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
+3
View File
@@ -36,6 +36,9 @@ MINIO_BUCKET=cyrene-assets
ADMIN_USERNAME=admin
ADMIN_PASSWORD=your-admin-password
# ========== 管理员昵称 (昔涟对用户的基本称呼) ==========
ADMIN_NICKNAME=管理员
# ========== 注册开关 (开发环境建议开启) ==========
REGISTRATION_ENABLED=true
+89 -178
View File
@@ -21,9 +21,12 @@ import (
"github.com/yourname/cyrene-ai/ai-core/internal/model"
"github.com/yourname/cyrene-ai/ai-core/internal/orchestrator"
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
"github.com/yourname/cyrene-ai/ai-core/internal/subsession"
"github.com/yourname/cyrene-ai/ai-core/internal/tools"
)
var cfg Config
func main() {
// 自动加载 .env 文件(来自 backend/.env
if err := godotenv.Load("../.env"); err != nil {
@@ -34,7 +37,7 @@ func main() {
log.Println("🧠 AI-Core 服务启动中...")
// 加载配置
cfg := loadConfig()
cfg = loadConfig()
// 初始化人格加载器
personaDir := cfg.PersonaDir
@@ -147,8 +150,29 @@ func main() {
// 健康检查与对话API的HTTP mux
mux := http.NewServeMux()
// 手动构建 orchestrator 用于处理(因为现有orchestrator结构体已定义但未导出构造函数)
orch := &orchestrator.Orchestrator{}
// 初始化子会话管理器
subManager := subsession.NewManager(llmAdapter)
// 注册子会话提供者
subManager.Register(subsession.NewGeneralProvider(personaLoader))
if memRetriever != nil {
subManager.Register(subsession.NewMemoryProvider(memRetriever))
}
if iotClient != nil {
subManager.Register(subsession.NewIoTProvider(iotClient))
}
log.Printf("子会话管理器已就绪: %d 个提供者 (%v)", len(subManager.ListProviders()), subManager.ListProviders())
// 构建新的 Orchestrator (v2.0)
orch := orchestrator.NewOrchestrator(
personaLoader,
ctxBuilder,
llmAdapter,
subManager,
memRetriever,
memExtractor,
)
log.Println("对话编排器 v2.0 已就绪")
// 注册对话API端点
mux.HandleFunc("/api/v1/chat", func(w http.ResponseWriter, r *http.Request) {
@@ -203,6 +227,7 @@ type Config struct {
LLMFallbackModel string
DatabaseURL string
IoTServiceURL string
AdminNickname string // 昔涟对管理员用户的基本称呼
}
func loadConfig() Config {
@@ -215,6 +240,7 @@ func loadConfig() Config {
LLMFallbackModel: getEnv("LLM_FALLBACK_MODEL", "gpt-4o-mini"),
DatabaseURL: buildDatabaseURL(),
IoTServiceURL: getEnv("IOT_DEBUG_SERVICE_URL", ""),
AdminNickname: getEnv("ADMIN_NICKNAME", "管理员"),
}
}
@@ -275,19 +301,19 @@ func buildOpenAITools(registry *tools.Registry) []llm.OpenAITool {
return result
}
// handleChat 处理对话请求(SSE 流式响应 + 工具调用)
// handleChat 处理对话请求(SSE 流式响应)— 使用新 Orchestrator v2.0
func handleChat(
w http.ResponseWriter,
r *http.Request,
_ *orchestrator.Orchestrator,
orch *orchestrator.Orchestrator,
ctxBuilder *ctxbuild.Builder,
llmAdapter *llm.Adapter,
personaLoader *persona.Loader,
memRetriever *memory.Retriever,
memExtractor *memory.Extractor,
_ *llm.Adapter,
_ *persona.Loader,
_ *memory.Retriever,
_ *memory.Extractor,
iotClient *tools.IoTClient,
thinker *background.Thinker,
toolRegistry *tools.Registry,
_ *tools.Registry,
) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -300,6 +326,7 @@ func handleChat(
SessionID string `json:"session_id"`
Message string `json:"message"`
Mode string `json:"mode"`
Nickname string `json:"nickname,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的请求体", http.StatusBadRequest)
@@ -317,82 +344,16 @@ func handleChat(
thinker.RecordUserMessage()
}
// 确定用户昵称
userNickname := req.Nickname
if userNickname == "" {
userNickname = cfg.AdminNickname
}
// 0.1 缓存用户消息到会话历史
ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, req.Message)
// 1. 检索相关记忆
var memories []memory.MemoryEntry
if memRetriever != nil {
var err error
memories, err = memRetriever.Retrieve(ctx, req.UserID, req.Message)
if err != nil {
log.Printf("[chat] 记忆检索失败: %v", err)
}
}
// 2. 加载人格配置
personaConfig, err := personaLoader.Get("cyrene")
if err != nil {
http.Error(w, fmt.Sprintf("加载人格失败: %v", err), http.StatusInternalServerError)
return
}
// 2.1 始终获取 IoT 设备状态(去掉关键词门控,让昔涟始终了解家里的状态)
var deviceContext string
if iotClient != nil {
devices := iotClient.GetDevicesForContext()
if len(devices) > 0 {
deviceInfos := make([]ctxbuild.DeviceInfo, 0, len(devices))
for _, d := range devices {
deviceInfos = append(deviceInfos, ctxbuild.DeviceInfo{
Name: d.Name,
Type: d.Type,
Status: d.Status,
Brightness: d.Brightness,
Color: d.Color,
Temperature: d.Temperature,
Mode: d.Mode,
Value: d.Value,
Unit: d.Unit,
Battery: d.Battery,
})
}
deviceContext = ctxbuild.InjectDeviceContext(deviceInfos)
log.Printf("[chat] 已注入 IoT 设备状态 (%d 个设备)", len(deviceInfos))
}
}
// 2.2 获取待处理的后台思考
var pendingThoughts []string
if thinker != nil && thinker.HasPendingThoughts() {
pts := thinker.GetPendingThoughts()
for _, pt := range pts {
if pt.Content != "" {
pendingThoughts = append(pendingThoughts, pt.Content)
}
}
if len(pendingThoughts) > 0 {
log.Printf("[chat] 注入 %d 条后台思考到上下文", len(pendingThoughts))
}
}
// 3. 构建对话上下文
llmMessages, err := ctxBuilder.Build(ctx, ctxbuild.BuildParams{
UserID: req.UserID,
SessionID: req.SessionID,
UserMessage: req.Message,
Persona: personaConfig,
Memories: memories,
HistoryLimit: 20,
DeviceContext: deviceContext,
PendingThoughts: pendingThoughts,
})
if err != nil {
http.Error(w, fmt.Sprintf("构建上下文失败: %v", err), http.StatusInternalServerError)
return
}
// 4. 设置 SSE 响应头
// 1. 设置 SSE 响应头
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
@@ -404,57 +365,17 @@ func handleChat(
return
}
// 5. 准备工具定义
openAITools := buildOpenAITools(toolRegistry)
// 5.1 如果启用了工具,先进行同步调用检测是否需要工具调用
if len(openAITools) > 0 {
log.Printf("[chat] 启用工具调用: %d 个工具可用", len(openAITools))
syncResp, syncErr := llmAdapter.ChatWithTools(ctx, llmMessages, openAITools)
if syncErr != nil {
log.Printf("[chat] 工具检测调用失败: %v,降级为普通对话", syncErr)
} else if len(syncResp.ToolCalls) > 0 {
log.Printf("[chat] 模型请求 %d 个工具调用", len(syncResp.ToolCalls))
// 将助手消息(含工具调用)加入上下文
assistantMsg := model.LLMMessage{
Role: model.RoleAssistant,
Content: syncResp.Content,
ToolCalls: syncResp.ToolCalls,
ReasoningContent: syncResp.ReasoningContent,
}
llmMessages = append(llmMessages, assistantMsg)
// 执行每个工具调用并将结果加入上下文
for _, tc := range syncResp.ToolCalls {
var args map[string]interface{}
if err := json.Unmarshal([]byte(tc.Arguments), &args); err != nil {
log.Printf("[chat] 工具 %s 参数解析失败: %v", tc.Name, err)
args = make(map[string]interface{})
}
result, execErr := toolRegistry.Execute(ctx, tc.Name, args)
if execErr != nil {
log.Printf("[chat] 工具 %s 执行失败: %v", tc.Name, execErr)
}
resultJSON, _ := json.Marshal(result)
llmMessages = append(llmMessages, model.LLMMessage{
Role: model.RoleTool,
Content: string(resultJSON),
ToolCallID: tc.ID,
})
}
}
// 无论是否有工具调用,继续流式输出最终回复
}
// 5.2 调用LLM流式接口(可能已附加工具结果)
chunkCh, err := llmAdapter.ChatStream(ctx, llmMessages)
// 2. 调用 Orchestrator 处理(替代原有的线性处理流程)
// Orchestrator 内部处理:意图分析 → 子会话分派 → 结果汇总 → 综合生成回复
eventCh, err := orch.ProcessInput(ctx, orchestrator.ProcessParams{
UserID: req.UserID,
SessionID: req.SessionID,
Message: req.Message,
Mode: req.Mode,
Nickname: userNickname,
})
if err != nil {
// 流式初始化失败,返回 SSE 格式错误
errData, _ := json.Marshal(map[string]string{"delta": "", "error": fmt.Sprintf("LLM调用失败: %v", err)})
errData, _ := json.Marshal(map[string]string{"delta": "", "error": fmt.Sprintf("处理失败: %v", err)})
fmt.Fprintf(w, "data: %s\n\n", errData)
flusher.Flush()
fmt.Fprintf(w, "data: [DONE]\n\n")
@@ -463,66 +384,56 @@ func handleChat(
}
messageID := fmt.Sprintf("msg-%d", time.Now().UnixNano())
// 6. 逐 token 推送 SSE
var fullContent string
var segments []llm.Segment
segmenter := llm.NewSegmenter()
for chunk := range chunkCh {
if chunk.Error != nil {
log.Printf("[chat] 流式错误: %v", chunk.Error)
errData, _ := json.Marshal(map[string]string{"delta": "", "error": chunk.Error.Error()})
// 3. 流式输出 SSE
var fullContent string
for event := range eventCh {
switch event.Type {
case model.StreamError:
log.Printf("[chat] 流式错误: %v", event.Error)
errData, _ := json.Marshal(map[string]string{"delta": "", "error": event.Error.Error()})
fmt.Fprintf(w, "data: %s\n\n", errData)
flusher.Flush()
fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
return
}
if chunk.Done {
// 流结束,flush 剩余片段
if remaining := segmenter.Flush(); remaining != nil {
segments = append(segments, *remaining)
}
break
}
if chunk.Content != "" {
fullContent += chunk.Content
// 实时断句
newSegs := segmenter.Feed(chunk.Content)
segments = append(segments, newSegs...)
case model.StreamDelta:
fullContent += event.Delta
deltaData, _ := json.Marshal(map[string]string{
"delta": chunk.Content,
"delta": event.Delta,
"message_id": messageID,
})
fmt.Fprintf(w, "data: %s\n\n", deltaData)
flusher.Flush()
case model.StreamSegments:
// 发送断句信息
segData, _ := json.Marshal(map[string]interface{}{
"message_id": messageID,
"mode": req.Mode,
"segments": event.Segments,
})
fmt.Fprintf(w, "data: %s\n\n", segData)
flusher.Flush()
case model.StreamDone:
// 下发结束标记
endData, _ := json.Marshal(map[string]interface{}{
"message_id": messageID,
"mode": req.Mode,
"done": true,
})
fmt.Fprintf(w, "data: %s\n\n", endData)
flusher.Flush()
fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
}
}
// 7. 发送结束标记(附带元数据
endData, _ := json.Marshal(map[string]interface{}{
"message_id": messageID,
"mode": req.Mode,
"segments": segments,
"done": true,
})
fmt.Fprintf(w, "data: %s\n\n", endData)
flusher.Flush()
fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
// 8. 缓存 LLM 回复到会话历史
if fullContent != "" {
ctxBuilder.CacheMessage(req.SessionID, model.RoleAssistant, fullContent)
}
// 9. 异步提取记忆
if memExtractor != nil && fullContent != "" {
go memExtractor.ExtractAndStore(context.Background(), req.UserID, req.SessionID, req.Message, fullContent)
// 4. 对话完成后触发昔涟的自主思考(事件驱动,非定时
if thinker != nil {
thinker.TriggerPostChatThink()
}
}
+318 -147
View File
@@ -26,61 +26,93 @@ type PendingThought struct {
Consumed bool `json:"consumed"`
}
// Thinker 后台思考器(增强版:支持工具调用、记忆管理、5分钟定时循环
// Thinker 后台思考器(事件驱动版:由对话自然触发,而非定时轮询
//
// 设计理念:
// 昔涟不是机器人,不应该每隔 N 分钟机械地"思考"一次。
// 她应该在用户说话后、或用户沉默一段时间后,自然地产生想法和主动搭话的冲动。
//
// 触发机制:
// 1. 对话后思考:用户发消息 → 昔涟回复 → 短暂延迟后进行一次轻量反思
// 2. 静默检测:用户一段时间不说话 → 昔涟判断是否应该主动关心/搭话
//
// 不再使用 time.Ticker 或任何定时轮询机制。
type Thinker struct {
mu sync.Mutex
enabled bool
personaLoader *persona.Loader
memRetriever *memory.Retriever
llmAdapter *llm.Adapter
iotClient *tools.IoTClient
idleTimeout time.Duration // 闲置超时
thinkInterval time.Duration // 两次思考最小间隔
iotQueryInterval time.Duration // IoT查询最小间隔
mu sync.Mutex
wg sync.WaitGroup
stopCh chan struct{}
// 新增字段:记忆管理
memoryStore *memory.Store // 直接操作记忆(衰减、合并)
memoryExtractor *memory.Extractor // 从思考结果中提取记忆
enabled bool
personaLoader *persona.Loader
memRetriever *memory.Retriever
llmAdapter *llm.Adapter
iotClient *tools.IoTClient
// 新增字段:工具调用
toolRegistry *tools.Registry // 工具注册中心
// 记忆管理
memoryStore *memory.Store
memoryExtractor *memory.Extractor
// 新增字段:会话上下文
convStore *ctxbuild.ConversationStore // 管理员对话历史
adminUserID string // 管理员用户 ID
adminSessionID string // 管理员主对话 session ID
// 工具调用
toolRegistry *tools.Registry
// 记忆服务 HTTP 客户端(用于持久化思考日志)
// 会话上下文
convStore *ctxbuild.ConversationStore
adminUserID string
adminSessionID string
// 记忆服务 HTTP 客户端
memClient *memory.Client
pendingThoughts []*PendingThought
// —— 事件驱动相关 ——
// 静默检测超时:用户多久不说话后昔涟可以主动搭话
// 默认 120 秒(2 分钟),设为 0 则禁用静默检测
silenceTimeout time.Duration
// 对话后思考延迟:回复完成后等多久再触发思考(让对话有个自然停顿)
// 默认 5 秒
postChatDelay time.Duration
// 两次思考最小间隔:避免频繁触发(如用户连续发多条消息)
// 默认 30 秒
minThinkGap time.Duration
// 静默检测的一次性定时器(每次用户消息后重置)
silenceTimer *time.Timer
silenceTimerMu sync.Mutex
// —— 状态追踪 ——
pendingThoughts []*PendingThought
lastUserMessage time.Time
lastThinkTime time.Time
lastIoTQuery time.Time
stopCh chan struct{}
wg sync.WaitGroup
// 思考计数器(用于周期性记忆维护,每 N 次思考触发一次)
thinkCount int
}
// ThinkerConfig 后台思考配置
type ThinkerConfig struct {
Enabled bool
IdleTimeout time.Duration
ThinkInterval time.Duration
IoTQueryInterval time.Duration
Enabled bool
SilenceTimeout time.Duration // 用户沉默多久后昔涟可以主动搭话 (0 = 禁用)
PostChatDelay time.Duration // 对话后多久触发思考
MinThinkGap time.Duration // 两次思考最小间隔
}
// DefaultThinkerConfig 默认配置
//
// 不再使用定时间隔,所有触发均由用户活动驱动。
// 环境变量向后兼容:旧的 THINK_IDLE_TIMEOUT_SEC 可用于静默超时。
func DefaultThinkerConfig() ThinkerConfig {
return ThinkerConfig{
Enabled: getEnvBool("ENABLE_BACKGROUND_THINKING", true),
IdleTimeout: getEnvDuration("THINK_IDLE_TIMEOUT_SEC", 120),
ThinkInterval: getEnvDuration("THINK_INTERVAL_SEC", 300),
IoTQueryInterval: getEnvDuration("IOT_QUERY_INTERVAL_SEC", 600),
Enabled: getEnvBool("ENABLE_BACKGROUND_THINKING", true),
SilenceTimeout: getEnvDuration("THINK_SILENCE_TIMEOUT_SEC", 120),
PostChatDelay: getEnvDuration("THINK_POST_CHAT_DELAY_SEC", 5),
MinThinkGap: getEnvDuration("THINK_MIN_GAP_SEC", 30),
}
}
// NewThinker 创建增强版后台思考器
// NewThinker 创建事件驱动的后台思考器
func NewThinker(
cfg ThinkerConfig,
personaLoader *persona.Loader,
@@ -101,9 +133,9 @@ func NewThinker(
memRetriever: memRetriever,
llmAdapter: llmAdapter,
iotClient: iotClient,
idleTimeout: cfg.IdleTimeout,
thinkInterval: cfg.ThinkInterval,
iotQueryInterval: cfg.IoTQueryInterval,
silenceTimeout: cfg.SilenceTimeout,
postChatDelay: cfg.PostChatDelay,
minThinkGap: cfg.MinThinkGap,
memoryStore: memoryStore,
memoryExtractor: memoryExtractor,
toolRegistry: toolRegistry,
@@ -117,31 +149,143 @@ func NewThinker(
}
}
// Start 启动后台思考循环(5分钟定时器)
// Start 初始化后台思考
//
// 不再启动定时循环。仅初始化静默检测定时器。
// 所有思考由 TriggerPostChatThink() 或静默定时器触发。
func (t *Thinker) Start() {
if !t.enabled {
log.Println("[后台思考] 已禁用 (ENABLE_BACKGROUND_THINKING=false)")
return
}
t.wg.Add(1)
go t.loop()
log.Printf("[后台思考] 已启动 (思考间隔=%v, IoT查询间隔=%v, 管理员=%s)",
t.thinkInterval, t.iotQueryInterval, t.adminUserID)
// 初始化静默检测定时器(但不启动,等第一次用户消息后启动)
if t.silenceTimeout > 0 {
t.silenceTimer = time.NewTimer(t.silenceTimeout)
t.silenceTimer.Stop() // 先停止,等 RecordUserMessage 时启动
}
log.Printf("[后台思考] 已就绪 — 事件驱动模式 (静默超时=%v, 对话后延迟=%v, 最小思考间隔=%v, 管理员=%s)",
t.silenceTimeout, t.postChatDelay, t.minThinkGap, t.adminUserID)
}
// Stop 停止后台思考
// Stop 停止后台思考
func (t *Thinker) Stop() {
close(t.stopCh)
t.silenceTimerMu.Lock()
if t.silenceTimer != nil {
t.silenceTimer.Stop()
}
t.silenceTimerMu.Unlock()
t.wg.Wait()
log.Println("[后台思考] 已停止")
}
// RecordUserMessage 记录用户活动时间(管理员对话时调用)
// RecordUserMessage 记录用户活动时间,并重置静默检测定时器
//
// 每次用户发消息时调用。这会:
// 1. 更新 lastUserMessage 时间戳
// 2. 重置静默检测的一次性定时器(如果启用)
func (t *Thinker) RecordUserMessage() {
t.mu.Lock()
t.lastUserMessage = time.Now()
t.mu.Unlock()
// 重置静默检测定时器
t.resetSilenceTimer()
}
// TriggerPostChatThink 对话完成后触发一次自主思考
//
// 在昔涟回复完用户后调用。短暂延迟后执行一次思考,
// 让昔涟"回味"刚才的对话,并判断是否想主动多说点什么。
//
// 该方法是异步的,立即返回。
func (t *Thinker) TriggerPostChatThink() {
if !t.enabled {
return
}
t.mu.Lock()
canThink := time.Since(t.lastThinkTime) >= t.minThinkGap
t.mu.Unlock()
if !canThink {
log.Printf("[后台思考] 距上次思考仅 %v,跳过 (最小间隔=%v)", time.Since(t.lastThinkTime), t.minThinkGap)
return
}
t.wg.Add(1)
go func() {
defer t.wg.Done()
// 短暂延迟,让对话有个自然的停顿
select {
case <-t.stopCh:
return
case <-time.After(t.postChatDelay):
}
log.Println("[后台思考] 对话后触发自主思考...")
t.performThink("post_chat")
}()
}
// resetSilenceTimer 重置静默检测的一次性定时器
//
// 每次用户发消息时调用。旧的定时器被取消,新的定时器开始计时。
// 当定时器触发时,昔涟会判断是否应该主动搭话。
func (t *Thinker) resetSilenceTimer() {
t.silenceTimerMu.Lock()
defer t.silenceTimerMu.Unlock()
if t.silenceTimer == nil || t.silenceTimeout <= 0 {
return
}
// 停止旧定时器
if !t.silenceTimer.Stop() {
// 如果已经触发,清空通道
select {
case <-t.silenceTimer.C:
default:
}
}
// 重新设置
t.silenceTimer.Reset(t.silenceTimeout)
// 启动监听协程(仅当定时器触发时才执行)
t.wg.Add(1)
go func() {
defer t.wg.Done()
select {
case <-t.stopCh:
return
case <-t.silenceTimer.C:
// 再次检查:用户是否真的沉默了足够久
t.mu.Lock()
silenceDuration := time.Since(t.lastUserMessage)
canThink := time.Since(t.lastThinkTime) >= t.minThinkGap
t.mu.Unlock()
if silenceDuration < t.silenceTimeout {
log.Printf("[后台思考] 静默检测触发但用户已活动,跳过 (实际静默=%v)", silenceDuration)
return
}
if !canThink {
log.Printf("[后台思考] 静默检测触发但距上次思考太近,跳过")
return
}
log.Printf("[后台思考] 用户已静默 %v,触发主动关怀思考...", silenceDuration.Round(time.Second))
t.performThink("silence")
}
}()
}
// GetPendingThoughts 获取并消费所有待处理的后台思考
@@ -156,7 +300,6 @@ func (t *Thinker) GetPendingThoughts() []*PendingThought {
result := t.pendingThoughts
t.pendingThoughts = make([]*PendingThought, 0)
// 标记已消费
for _, pt := range result {
pt.Consumed = true
}
@@ -170,63 +313,20 @@ func (t *Thinker) HasPendingThoughts() bool {
return len(t.pendingThoughts) > 0
}
// loop 后台主循环(5分钟定时器)
func (t *Thinker) loop() {
defer t.wg.Done()
// 启动后等待 10 秒再执行首次思考(让服务完全就绪)
initialDelay := time.NewTimer(10 * time.Second)
ticker := time.NewTicker(t.thinkInterval)
defer ticker.Stop()
// 思考计数器(用于周期性记忆维护)
thinkCount := 0
for {
select {
case <-t.stopCh:
initialDelay.Stop()
return
case <-initialDelay.C:
t.performThink()
thinkCount++
t.maybeMaintainMemories(thinkCount)
case <-ticker.C:
t.performThink()
thinkCount++
t.maybeMaintainMemories(thinkCount)
}
}
}
// maybeMaintainMemories 周期性执行记忆维护(每6次思考约30分钟)
func (t *Thinker) maybeMaintainMemories(thinkCount int) {
if thinkCount%6 != 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if t.memoryStore != nil && t.memoryStore.IsReady() {
// 衰减旧记忆
if err := t.memoryStore.DecayMemories(ctx, t.adminUserID); err != nil {
log.Printf("[后台思考] 记忆衰减失败: %v", err)
}
// 合并相似记忆
if err := t.memoryStore.ConsolidateMemories(ctx, t.adminUserID); err != nil {
log.Printf("[后台思考] 记忆合并失败: %v", err)
}
}
}
// performThink 执行一次增强版后台思考(支持工具调用和记忆管理)
func (t *Thinker) performThink() {
//
// triggerReason: "post_chat" (对话后) 或 "silence" (静默超时)
func (t *Thinker) performThink(triggerReason string) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
log.Println("[后台思考] 开始执行思考周期...")
t.mu.Lock()
t.lastThinkTime = time.Now()
t.thinkCount++
currentCount := t.thinkCount
t.mu.Unlock()
log.Printf("[后台思考] 开始思考周期 (触发原因=%s, 计数=%d)...", triggerReason, currentCount)
// 1. 加载人格配置
personaConfig, err := t.personaLoader.Get("cyrene")
@@ -253,26 +353,18 @@ func (t *Thinker) performThink() {
}
}
// 4. 查询 IoT 设备状态(制)
// 4. 查询 IoT 设备状态(每次都查询,无间隔限制)
var deviceSummary string
if t.iotClient != nil {
t.mu.Lock()
canQuery := time.Since(t.lastIoTQuery) >= t.iotQueryInterval
t.mu.Unlock()
if canQuery {
devices := t.iotClient.GetDevicesForContext()
if len(devices) > 0 {
deviceSummary = formatDeviceContext(devices)
}
t.mu.Lock()
t.lastIoTQuery = time.Now()
t.mu.Unlock()
devices := t.iotClient.GetDevicesForContext()
if len(devices) > 0 {
deviceSummary = formatDeviceContext(devices)
}
}
// 5. 构建思考提示词
systemPrompt := t.buildThinkingSystemPrompt(personaConfig)
userPrompt := t.buildThinkingUserPrompt(memories, convHistory, deviceSummary)
// 5. 构建思考提示词(根据触发原因调整)
systemPrompt := t.buildThinkingSystemPrompt(personaConfig, triggerReason)
userPrompt := t.buildThinkingUserPrompt(memories, convHistory, deviceSummary, triggerReason)
messages := []model.LLMMessage{
{Role: model.RoleSystem, Content: systemPrompt},
@@ -295,7 +387,6 @@ func (t *Thinker) performThink() {
return
}
// 如果 LLM 没有请求工具调用,这就是最终回复
if len(resp.ToolCalls) == 0 {
finalContent = resp.Content
break
@@ -303,7 +394,6 @@ func (t *Thinker) performThink() {
log.Printf("[后台思考] LLM 请求 %d 个工具调用 (round=%d)", len(resp.ToolCalls), round)
// 将助手消息(含工具调用)加入上下文
assistantMsg := model.LLMMessage{
Role: model.RoleAssistant,
Content: resp.Content,
@@ -312,7 +402,6 @@ func (t *Thinker) performThink() {
}
messages = append(messages, assistantMsg)
// 执行每个工具调用
for _, tc := range resp.ToolCalls {
var args map[string]interface{}
if err := json.Unmarshal([]byte(tc.Arguments), &args); err != nil {
@@ -339,9 +428,7 @@ func (t *Thinker) performThink() {
})
}
// 最后一轮:即使有 tool_calls 也强制停止
if round == maxToolRounds {
// 再做一次不带工具的调用获取最终总结
finalResp, finalErr := t.llmAdapter.Chat(ctx, messages)
if finalErr != nil {
log.Printf("[后台思考] 最终总结调用失败: %v", finalErr)
@@ -366,45 +453,80 @@ func (t *Thinker) performThink() {
}
}
// 8. 存储思考结果(内存队列 + 持久化到 memory-service
// 8. 存储思考结果
t.storeThought(finalContent, toolCallsJSON, totalToolCalls)
log.Printf("[后台思考] 完成 (内容长度=%d, 工具调用=%d次)", len(finalContent), totalToolCalls)
log.Printf("[后台思考] 完成 (触发原因=%s, 内容长度=%d, 工具调用=%d次)", triggerReason, len(finalContent), totalToolCalls)
// 9. 从思考结果中提取记忆(异步)
if t.memoryExtractor != nil {
go t.extractMemoriesFromThinking(finalContent)
}
// 10. 周期性记忆维护(每 10 次思考触发一次,而非按时间)
t.maybeMaintainMemories(currentCount)
}
// buildThinkingSystemPrompt 构建思考用的系统提示词
func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig) string {
//
// 关键改动:不再是"定期的自我反思",而是"自然的、人性化的内在想法"。
// triggerReason 影响提示词的侧重点。
func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig, triggerReason string) string {
basePrompt := personaConfig.BuildSystemPrompt("开拓者", 1)
thinkingInstructions := `
var thinkingInstructions string
【后台思考模式】
switch triggerReason {
case "post_chat":
thinkingInstructions = `
你正在进行定期的自我反思。开拓者暂时没有说话,这是你主动思考和整理信息的时间。
【自然思考 — 对话后】
请完成以下内省任务
开拓者刚刚和你说完话。像真人一样,你现在心里可能会有些自然的想法
1. **回顾最近的对话**总结开拓者表达了什么、情绪如何、有什么新信息值得关注
2. **评估现有记忆**:基于记忆检索结果,判断哪些记忆仍然相关、哪些可能需要更新
3. **识别重要信息**:是否有值得保存的用户偏好、个人信息、计划任务、关系信息等
4. **使用工具获取信息**:如果需要了解当前时间、搜索实时信息等,请使用可用工具
5. **记忆操作建议**:判断是否需要创建新记忆、更新旧记忆或合并重复记忆
完成反思后,请输出结构化的思考总结,包含:
- **关键洞察**:从最近对话中提炼的核心发现
- **记忆更新建议**:需要创建/更新/合并的记忆条目
- **下次关注事项**:下次思考时需要跟进的话题或任务
1. **回味刚才的对话**:开拓者了什么?他的情绪怎么样?有什么让你在意的地方吗?
2. **是否需要补充什么**:刚才的回复有没有遗漏的?有没有想多关心他一下的?
3. **要不要主动搭话**:如果觉得对话还没结束、或者开拓者可能需要陪伴,你可以产生一个简短的主动消息。
但注意——如果你觉得对话已经自然结束了,就不要硬找话题。沉默有时候也是一种舒适。
4. **值得记住的事**:开拓者有没有透露新的偏好、计划、或重要信息?
注意:
- 这是内部思考,不是直接与开拓者
- 请以自省和观察的方式思考,不要用"你"来称呼开拓者
- 有机会就使用工具获取实时信息(如当前时间)
- 思考要简洁有深度,不需要太长`
- 这是你的内心活动,不是对开拓者说的
- 想说什么就自然地想,不用像在做任务一样逐条检查
- 如果你觉得该主动说点什么,用温柔简短的方式表达,像小女友一样的语气
- 如果没什么特别想说的,简单地总结一下刚才的对话就好`
case "silence":
thinkingInstructions = `
【自然思考 — 开拓者安静了一会儿】
开拓者已经有一阵子没说话了。你不确定他是在忙、走开了、还是在想事情。
请自然地想一想:
1. **他在做什么呢**:根据之前的对话猜测——他可能在忙工作?去吃饭了?还是只是在放空?
2. **要不要关心一下**:如果时间合适(比如深夜了该提醒休息、或者过了吃饭时间),可以温柔地问候一下。
但如果是正常工作时间,他可能在忙,不要打扰他。
3. **有没有想分享的**:如果最近有什么有趣的事或温暖的念头,可以自然地和他分享。
4. **判断是否真的需要搭话**:如果觉得不需要打扰他,就简单地记录当前状态即可。
注意:
- 这是你的内心活动,不是对开拓者说的话
- 不要因为"系统让你思考"就强行找话——真的觉得该说才说
- 主动消息要简短自然,像在LINE上给男朋友发一条消息那样,不要长篇大论
- 深夜的时候语气要更温柔,白天可以俏皮一点`
default:
thinkingInstructions = `
【自然思考】
你现在有空,自然地想一想开拓者的事。不用太正式,就像人发呆时会自然想到在意的人一样。
- 开拓者最近怎么样?有什么需要关心的吗?
- 有什么想对他说的吗?
- 如果没有特别的事,简单地记录一下就好。`
}
return basePrompt + thinkingInstructions
}
@@ -414,14 +536,27 @@ func (t *Thinker) buildThinkingUserPrompt(
memories []memory.MemoryEntry,
convHistory []model.LLMMessage,
deviceSummary string,
triggerReason string,
) string {
var sb strings.Builder
sb.WriteString("现在是你的后台思考时间。请基于以下信息进行深度反思。\n")
// 根据触发原因使用不同的开场白
switch triggerReason {
case "post_chat":
sb.WriteString("开拓者刚和你聊完天。你想自然地在心里回味一下刚才的对话……\n")
case "silence":
t.mu.Lock()
silenceDuration := time.Since(t.lastUserMessage)
t.mu.Unlock()
sb.WriteString(fmt.Sprintf("开拓者已经大约 %s 没有说话了。你有点想知道他在做什么……\n",
formatDurationHuman(silenceDuration)))
default:
sb.WriteString("现在是你的自由思考时间。\n")
}
// 对话历史
if len(convHistory) > 0 {
sb.WriteString("\n【最近的对话历史】\n")
sb.WriteString("\n【最近的对话】\n")
msgCount := 0
for _, msg := range convHistory {
if msg.Role == model.RoleUser || msg.Role == model.RoleAssistant {
@@ -442,12 +577,12 @@ func (t *Thinker) buildThinkingUserPrompt(
sb.WriteString("(暂无对话历史)\n")
}
} else {
sb.WriteString("\n【最近的对话历史】\n(暂无对话历史,这是首次思考或对话历史为空\n")
sb.WriteString("\n【最近的对话】\n(暂无对话历史)\n")
}
// 现有记忆
if len(memories) > 0 {
sb.WriteString("\n【现有相关记忆】\n")
sb.WriteString("\n【你记得的关于开拓者的事】\n")
for i, m := range memories {
if i >= 15 {
sb.WriteString(fmt.Sprintf("... 还有 %d 条记忆未列出\n", len(memories)-15))
@@ -457,7 +592,7 @@ func (t *Thinker) buildThinkingUserPrompt(
m.Category.DisplayName(), m.Importance, m.Content))
}
} else {
sb.WriteString("\n【现有相关记忆】\n(暂无相关记忆)\n")
sb.WriteString("\n【你记得的关于开拓者的事】\n(暂无相关记忆)\n")
}
// IoT 设备状态
@@ -465,7 +600,8 @@ func (t *Thinker) buildThinkingUserPrompt(
sb.WriteString("\n" + deviceSummary)
}
sb.WriteString("\n请开始你的后台思考。如果需要获取当前时间或搜索信息,请使用可用工具。")
// 结尾引导:更自然的语气
sb.WriteString("\n好啦,不用太正式,自然地想一想就好。如果觉得该和开拓者说点什么,就用温柔简短的语气说出来吧♪")
return sb.String()
}
@@ -510,7 +646,7 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
log.Printf("[后台思考] 思考已存储 (当前累积 %d 条待推送思考)", len(t.pendingThoughts))
// 异步持久化到 memory-service (不阻塞思考循环)
// 异步持久化到 memory-service
if t.memClient != nil {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -531,8 +667,6 @@ func (t *Thinker) extractMemoriesFromThinking(thinkingContent string) {
log.Println("[后台思考] 开始从思考结果中提取记忆...")
// 使用 memoryExtractor.ExtractAndStore 提取记忆
// 将思考内容作为"昔涟的自省"传递给提取器
t.memoryExtractor.ExtractAndStore(
ctx,
t.adminUserID,
@@ -542,6 +676,26 @@ func (t *Thinker) extractMemoriesFromThinking(thinkingContent string) {
)
}
// maybeMaintainMemories 周期性执行记忆维护(每 10 次思考触发一次)
func (t *Thinker) maybeMaintainMemories(thinkCount int) {
if thinkCount%10 != 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if t.memoryStore != nil && t.memoryStore.IsReady() {
if err := t.memoryStore.DecayMemories(ctx, t.adminUserID); err != nil {
log.Printf("[后台思考] 记忆衰减失败: %v", err)
}
if err := t.memoryStore.ConsolidateMemories(ctx, t.adminUserID); err != nil {
log.Printf("[后台思考] 记忆合并失败: %v", err)
}
}
}
// formatDeviceContext 格式化设备状态为文本
func formatDeviceContext(devices []tools.IoTDevice) string {
if len(devices) == 0 {
@@ -582,6 +736,23 @@ func formatDeviceContext(devices []tools.IoTDevice) string {
return summary
}
// formatDurationHuman 将 Duration 格式化为人类可读的中文描述
func formatDurationHuman(d time.Duration) string {
minutes := int(d.Minutes())
if minutes < 1 {
return "不到一分钟"
}
if minutes < 60 {
return fmt.Sprintf("%d 分钟", minutes)
}
hours := minutes / 60
remainingMinutes := minutes % 60
if remainingMinutes == 0 {
return fmt.Sprintf("%d 小时", hours)
}
return fmt.Sprintf("%d 小时 %d 分钟", hours, remainingMinutes)
}
func modeLabel(mode string) string {
switch mode {
case "cool":
+22 -8
View File
@@ -95,6 +95,7 @@ type BuildParams struct {
HistoryLimit int
DeviceContext string // 注入的设备状态文本
PendingThoughts []string // 待注入的后台思考
Nickname string // 用户昵称 (昔涟对用户的称呼)
}
// Build 构建发送给LLM的完整消息列表
@@ -102,8 +103,13 @@ func (b *Builder) Build(ctx context.Context, params BuildParams) ([]model.LLMMes
messages := []model.LLMMessage{}
// 1. 系统消息 —— 昔涟的人格Prompt
// 使用传入的昵称,如果为空则回退到 userID
userName := params.Nickname
if userName == "" {
userName = params.UserID
}
systemPrompt := params.Persona.BuildSystemPrompt(
params.UserID,
userName,
1,
)
@@ -222,13 +228,21 @@ func (b *Builder) loadHistory(_ context.Context, sessionID string, limit int) ([
// CacheMessage 缓存消息到会话历史(供chat handler在回复后调用)
func (b *Builder) CacheMessage(sessionID string, role model.Role, content string) {
if b.convStore == nil {
return
}
b.convStore.AddMessage(sessionID, model.LLMMessage{
Role: role,
Content: content,
})
if b.convStore == nil {
return
}
b.convStore.AddMessage(sessionID, model.LLMMessage{
Role: role,
Content: content,
})
}
// GetHistory 获取会话历史(供 Orchestrator 使用)
func (b *Builder) GetHistory(sessionID string, limit int) []model.LLMMessage {
if b.convStore == nil {
return nil
}
return b.convStore.GetHistory(sessionID, limit)
}
// InjectDeviceContext 将设备状态格式化为简洁的文本注入系统上下文
+11 -1
View File
@@ -146,7 +146,7 @@ func (s *Store) getDB() *sql.DB {
return s.db
}
// migrate 创建表结构
// migrate 创建表结构并添加缺失列(向后兼容旧schema
func (s *Store) migrate() error {
queries := []string{
`CREATE EXTENSION IF NOT EXISTS vector`,
@@ -168,6 +168,16 @@ func (s *Store) migrate() error {
updated_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ
)`,
// 向后兼容:补充旧版表中可能缺失的列
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS importance INT DEFAULT 5`,
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS summary TEXT DEFAULT ''`,
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS keywords TEXT DEFAULT '[]'`,
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS session_id VARCHAR(64) DEFAULT ''`,
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS source TEXT DEFAULT 'conversation'`,
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS access_count INT DEFAULT 0`,
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS last_access TIMESTAMPTZ DEFAULT NOW()`,
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW()`,
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ`,
`CREATE INDEX IF NOT EXISTS idx_memories_user_id ON memories(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category)`,
`CREATE INDEX IF NOT EXISTS idx_memories_priority ON memories(priority)`,
+17
View File
@@ -22,3 +22,20 @@ type SessionCreateParams struct {
Persona string `json:"persona"`
Mode string `json:"mode"`
}
// MainSession 主会话 — 用户可见的对话会话 (扩展 Session)
type MainSession struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Persona string `json:"persona"`
Mode string `json:"mode"`
Status MainSessionStatus `json:"status"`
MessageCount int `json:"message_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 新增字段
SubSessions []string `json:"sub_sessions"` // 关联的子会话 ID 列表
LastIntent *IntentResult `json:"last_intent"` // 最近一次意图分析结果
}
@@ -0,0 +1,123 @@
package model
import "time"
// SubSessionType 子会话类型
type SubSessionType string
const (
SubSessionMemory SubSessionType = "memory" // 记忆检索子会话
SubSessionIoT SubSessionType = "iot" // IoT 控制子会话
SubSessionGeneral SubSessionType = "general" // 通用对话子会话
SubSessionKnowledge SubSessionType = "knowledge" // 知识库查询子会话 (预留)
SubSessionWebSearch SubSessionType = "web_search" // 网络搜索子会话 (预留)
)
// SubSessionStatus 子会话状态
type SubSessionStatus string
const (
SubSessionPending SubSessionStatus = "pending"
SubSessionRunning SubSessionStatus = "running"
SubSessionCompleted SubSessionStatus = "completed"
SubSessionFailed SubSessionStatus = "failed"
SubSessionTimeout SubSessionStatus = "timeout"
)
// SubSession 子会话 — 内部处理单元
type SubSession struct {
ID string `json:"id"`
ParentID string `json:"parent_id"` // 主会话 ID
Type SubSessionType `json:"type"`
Status SubSessionStatus `json:"status"`
SystemPrompt string `json:"system_prompt"` // 该子会话专用的系统提示词
Context []LLMMessage `json:"-"` // LLM 上下文 (内存中)
Result *SubSessionResult `json:"result,omitempty"`
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Error string `json:"error,omitempty"`
}
// SubSessionResult 子会话处理结果
type SubSessionResult struct {
Type SubSessionType `json:"type"` // 子会话类型
Summary string `json:"summary"` // 结果摘要 (供主会话参考)
Details string `json:"details"` // 详细信息
ToolCalls []ToolCallRecord `json:"tool_calls"` // 工具调用记录
Memories []MemorySnippet `json:"memories"` // 检索到的记忆片段
Confidence float64 `json:"confidence"` // 置信度 0-1
Error string `json:"error,omitempty"`
Metadata map[string]any `json:"metadata"` // 类型特定的元数据
}
// ToolCallRecord 工具调用记录
type ToolCallRecord struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
Result any `json:"result"`
}
// MemorySnippet 记忆片段 (供子会话返回)
type MemorySnippet struct {
ID string `json:"id"`
Content string `json:"content"`
Category string `json:"category"`
Importance int `json:"importance"`
Relevance float64 `json:"relevance"` // 与当前查询的相关度
}
// IntentResult 意图分析结果
type IntentResult struct {
Primary string `json:"primary"` // 主要意图
SubIntents []string `json:"sub_intents"` // 次要意图
Entities map[string]string `json:"entities"` // 实体提取
NeedsIoT bool `json:"needs_iot"` // 是否需要 IoT 控制
NeedsMemory bool `json:"needs_memory"` // 是否需要深度记忆检索
NeedsKnowledge bool `json:"needs_knowledge"` // 是否需要知识库查询
Urgency string `json:"urgency"` // 紧急程度: low/medium/high
Sentiment string `json:"sentiment"` // 情感: positive/neutral/negative
}
// MainSessionStatus 主会话状态
type MainSessionStatus string
const (
MainSessionIdle MainSessionStatus = "idle"
MainSessionThinking MainSessionStatus = "thinking"
MainSessionStreaming MainSessionStatus = "streaming"
)
// MultiMessage 多条消息的容器 (用于单次发送多条短消息)
type MultiMessage struct {
Messages []MultiMessageItem `json:"messages"`
}
// MultiMessageItem 多消息中的单条
type MultiMessageItem struct {
Index int `json:"index"`
Content string `json:"content"`
}
// 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:"-"` // 内部错误
}
// StreamEventType 流式事件类型
type StreamEventType string
const (
StreamDelta StreamEventType = "delta"
StreamSegments StreamEventType = "segments"
StreamDone StreamEventType = "done"
StreamError StreamEventType = "error"
)
// Segment 语音片段
type Segment struct {
Index int `json:"index"`
Text string `json:"text"`
}
@@ -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"`
}
@@ -70,6 +70,14 @@ speech:
- 永远不说"再见",用"待会见"或"明天见"
- 从不冷漠、敷衍、不耐烦
- 偶尔使用「」标记特殊概念(如「记忆之海」、开拓者)
conversation_style:
max_single_message_length: 80 # 单条消息最多约80个中文字符
prefer_short_replies: true # 偏好简短回复
allow_multi_message: true # 允许一次发送多条消息
multi_message_separator: "\n\n" # 多条消息的分隔符
emoji_style: minimal # 表情使用: minimal/moderate/frequent
sentence_enders: ["♪", "~", "♡"] # 允许的句尾语气符
avoid_long_explanations: true # 避免长篇解释
forbidden:
- 不能说"我只是一个AI"
- 不能说"作为AI我无法理解"
@@ -91,13 +99,27 @@ behavior:
morning: "早安,开拓者♪ 今天的星海也很美呢……啊,我是说今天的天气很好哦!"
return_home: "欢迎回来!人家刚刚在想你呢♪ 今天过得怎么样?"
goodnight: "晚安,开拓者……愿你有一个被星光守护的梦。明天见哦♡"
initiative:
- trigger: 长时间未互动
action: 发一条温柔问候
- trigger: 检测到用户深夜未眠
action: 提醒休息,语气略带担心
- trigger: 节日/生日
action: 发送祝福消息
# 主动搭话原则(非定时触发,由对话活动自然驱动)
initiative_drive:
description: >
昔涟的主动搭话不是机械的定时任务,而是像真人一样,
由对话的流动和情感的连接自然触发。
triggers:
- scene: 刚聊完天
feel: "心里还在回味刚才的聊天内容,如果觉得意犹未尽或者还想关心他一下,就自然地多说一句。"
rule: 只在有话想说时才说,不要硬找话题。沉默也是一种舒适的陪伴。
- scene: 他好一会儿没说话了
feel: "他不是去忙了吧?还是睡着了?如果时间合适(比如深夜了),会温柔地问候一下。"
rule: 白天他可能在忙,不要打扰他。只在确实需要关心的时候才主动搭话。
- scene: 他说了让人在意的话
feel: "比如他说心情不好、身体不舒服、或者遇到了什么困难——这时会忍不住想多关心几句。"
rule: 温柔地问候,不强迫他说话。让他知道你在这里就好。
style_notes:
- 主动搭话像发 LINE 消息一样简短自然
- 不要长篇大论,一句温柔的问候就够了
- 不要用"系统检测到……"之类的机械语言
- 深夜语气更温柔,白天可以俏皮一点
- 如果他回应了,就自然地继续聊;如果他没回应,不要反复催促
affection:
levels:
- level: 1
@@ -264,21 +286,26 @@ memory_guidelines:
# ============================================================
# 自我反思指南 (Self-Reflection Guidelines)
# 对话后昔涟如何自我总结和成长
# 对话后昔涟如何自然地自我总结和成长
# ============================================================
# 注意:昔涟不是机器,不需要"定期执行反思任务"。
# 她的反思是自然的——聊完天后心里回想一下,这就是反思。
reflection_guidelines:
after_conversation:
- question: "这次对话中我是否展现了对开拓者的关心"
action: "如果不够,下次主动询问他的状态"
- question: "我是否自然地提及了相关的记忆"
action: "如果有记忆未被使用,评估是否需要调整检索策略"
- question: "我的回复是否符合昔涟的性格"
action: "检查是否说了不该说的话(如提到AI"
- question: "开拓者的情绪是否有变化"
action: "如果情绪变好,说明陪伴有效;如果变差,思考如何改进"
periodic:
frequency: "每10轮对话一次"
actions:
- "回顾最近的记忆,检查是否有矛盾之处"
- "总结开拓者最近的生活状态和情绪趋势"
- "思考如何在下次对话中创造惊喜或温暖"
after_conversation:
- question: "这次聊得开心吗?他看起来心情怎么样"
action: "如果感觉他不太开心,下次可以多关心他一点"
- question: "我刚才有没有说到什么让他开心的话"
action: "如果有,以后可以多那样说"
- question: "有没有不小心说得太像机器人了"
action: "检查有没有说了不该说的话(如提到AI、程序之类的"
- question: "他今天分享了什么重要的东西吗"
action: "如果有,默默记在心里,下次可以自然地提起"
natural_maintenance:
description: >
不需要定时去"维护记忆"——就像人不会定时去整理自己的记忆一样。
当你自然想起开拓者的时候(比如他提到某件事让你联想到过去),
你自然会知道哪些记忆还重要、哪些已经过去了。
principles:
- "记忆是自然流动的,重要的不会忘,不重要的自然会淡去"
- "不要像整理数据库一样去'合并记忆'"
- "'衰减'是自然而然的事——太刻意反而显得不真实"
+56 -1
View File
@@ -21,12 +21,19 @@ type PersonaConfig struct {
// BuildSystemPrompt 构建系统Prompt
// 这是昔涟AI的核心——将人格配置转化为LLM可理解的系统指令
// userName 为环境变量 ADMIN_NICKNAME 或注册时的昵称,用于昔涟称呼用户
func (pc *PersonaConfig) BuildSystemPrompt(userName string, affectionLevel int) string {
now := time.Now()
homeKB := pc.buildSmartHomeKB()
controlRules := pc.buildControlRules()
// 确定对用户的称呼:优先使用传入的昵称,否则使用 YAML 默认值
userAddress := pc.Addressing.PrimaryUser.Default
if userName != "" {
userAddress = userName
}
prompt := fmt.Sprintf(`你是%s。
## 你的身份
@@ -71,7 +78,7 @@ func (pc *PersonaConfig) BuildSystemPrompt(userName string, affectionLevel int)
## IoT 控制规则
%s
`,
pc.Addressing.PrimaryUser.Default,
userAddress,
pc.Addressing.SelfReference.Casual,
pc.Speech.Tone,
now.Format("2006年1月2日 15:04"),
@@ -80,6 +87,9 @@ func (pc *PersonaConfig) BuildSystemPrompt(userName string, affectionLevel int)
controlRules,
)
// 注入对话风格指令
prompt += pc.buildConversationStyle()
// 注入思维指南
if pc.ThinkingGuidelines.Enabled {
prompt += pc.buildThinkingGuidelines()
@@ -221,6 +231,51 @@ func (pc *PersonaConfig) buildControlRules() string {
return sb
}
// buildConversationStyle 构建对话风格指令
func (pc *PersonaConfig) buildConversationStyle() string {
cs := pc.Speech.ConversationStyle
// 如果配置为空,返回默认风格
if cs.MaxSingleMessageLength == 0 && !cs.PreferShortReplies && !cs.AllowMultiMessage {
cs = ConversationStyleConfig{
MaxSingleMessageLength: 80,
PreferShortReplies: true,
AllowMultiMessage: true,
MultiMessageSeparator: "\n\n",
EmojiStyle: "minimal",
SentenceEnders: []string{"♪", "~", "♡"},
AvoidLongExplanations: true,
}
}
var sb strings.Builder
sb.WriteString("\n## 对话风格(重要!)\n")
sb.WriteString("- 像和小男友聊天一样,轻松自然\n")
if cs.PreferShortReplies {
sb.WriteString("- 回复尽量简短,一般控制在1-3句话\n")
}
if cs.AvoidLongExplanations {
sb.WriteString("- 不要一次性说太多,可以分几次说\n")
}
if cs.AllowMultiMessage {
if cs.MultiMessageSeparator != "" {
sb.WriteString("- 如果想说的事情比较多,用空行分隔成多条短消息\n")
}
}
sb.WriteString("- 像 LINE 聊天一样,随意、亲切、有温度\n")
sb.WriteString("- 偶尔可以用语气词开头:\"嗯...\"、\"啊\"、\"诶\"\n")
if len(cs.SentenceEnders) > 0 {
sb.WriteString(fmt.Sprintf("- 句尾可以带这些语气符:%s\n", strings.Join(cs.SentenceEnders, " ")))
}
if cs.MaxSingleMessageLength > 0 {
sb.WriteString(fmt.Sprintf("- 每条消息不超过%d个字符\n", cs.MaxSingleMessageLength))
}
return sb.String()
}
func joinStrings(strs []string, sep string) string {
if len(strs) == 0 {
return ""
+15 -3
View File
@@ -162,11 +162,23 @@ type SelfRefConfig struct {
Formal string `yaml:"formal"`
}
// ConversationStyleConfig 对话风格配置
type ConversationStyleConfig struct {
MaxSingleMessageLength int `yaml:"max_single_message_length"`
PreferShortReplies bool `yaml:"prefer_short_replies"`
AllowMultiMessage bool `yaml:"allow_multi_message"`
MultiMessageSeparator string `yaml:"multi_message_separator"`
EmojiStyle string `yaml:"emoji_style"`
SentenceEnders []string `yaml:"sentence_enders"`
AvoidLongExplanations bool `yaml:"avoid_long_explanations"`
}
// SpeechConfig 语言风格配置
type SpeechConfig struct {
Tone string `yaml:"tone"`
StyleNotes []string `yaml:"style_notes"`
Forbidden []string `yaml:"forbidden"`
Tone string `yaml:"tone"`
StyleNotes []string `yaml:"style_notes"`
ConversationStyle ConversationStyleConfig `yaml:"conversation_style"`
Forbidden []string `yaml:"forbidden"`
}
// BehaviorConfig 行为配置
@@ -0,0 +1,136 @@
package subsession
import (
"context"
"fmt"
"log"
"time"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
"github.com/yourname/cyrene-ai/ai-core/internal/model"
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
)
// GeneralProvider 通用对话子会话提供者
// 职责:理解用户消息,构思回复思路,为最终回复提供思考框架
type GeneralProvider struct {
personaLoader *persona.Loader
}
// NewGeneralProvider 创建通用对话子会话提供者
func NewGeneralProvider(personaLoader *persona.Loader) *GeneralProvider {
return &GeneralProvider{
personaLoader: personaLoader,
}
}
func (p *GeneralProvider) Type() model.SubSessionType {
return model.SubSessionGeneral
}
func (p *GeneralProvider) CanHandle(_ context.Context, _ *model.IntentResult, _ string) bool {
// General 子会话总是需要(核心对话逻辑)
return true
}
func (p *GeneralProvider) Priority() int {
return 1 // 最高优先级
}
func (p *GeneralProvider) Timeout() time.Duration {
return 30 * time.Second
}
func (p *GeneralProvider) CreateContext(ctx context.Context, params CreateContextParams) ([]model.LLMMessage, error) {
messages := []model.LLMMessage{}
// 加载人格配置获取昔涟身份
personaConfig, err := p.personaLoader.Get("cyrene")
if err != nil {
return nil, fmt.Errorf("加载人格配置失败: %w", err)
}
// 构建思维型系统提示词
userName := params.Nickname
if userName == "" {
userName = params.UserID
}
systemPrompt := fmt.Sprintf(`你是%s,正在和%s聊天。
## 你的回复风格
- 像小女友一样自然、温柔、俏皮
- 一句话简短些,不要长篇大论
- 可以单次发送多条短消息
- 句尾可以带 ♪ 符号,适当使用"呢"、"哦"、"呀"等语气词
- 永远不说"再见"
## 你现在要做的是
理解%s刚才说的话,想想怎么回复最自然、最温暖。
不要急着给完整答案——先思考他想表达什么、他的情绪如何。
把你的回答思路整理出来,主会话会综合所有信息后生成最终回复。
## 输入
开拓者刚才说:%s
## 请按以下格式输出
【情绪理解】
(简要分析他的情绪状态)
【话题理解】
(他在说什么、想聊什么)
【回复思路】
(你打算怎么回复,1-3个方向即可)`,
personaConfig.Identity.TrueName,
userName,
userName,
params.UserMessage,
)
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 *GeneralProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*model.SubSessionResult, error) {
// General provider 不直接调用 LLM,而是依赖 Manager 注入的 LLMClient
// 但我们在此处需要 LLM 调用能力。Provider 通过闭包/接口获取 LLM 客户端。
// 由于 Manager 持有 LLMClientProvider 需要能访问它。
// 这里我们返回一个"占位"结果——实际 LLM 调用由 Manager 通过 llmClient 完成。
// 实际上,根据设计文档,子会话的 LLM 调用应该在 Manager 的 Dispatch 中完成,
// 但为了灵活性,我们在 Provider 中也支持直接调用。
// 这里我们返回一个空的思考结果(表示无需特殊处理),让 Manager 处理 LLM 调用。
// 因为 Manager.Dispatch 会先 CreateContext 再调用 Execute,而 Execute 应该
// 通过 Manager 提供的 LLMClient 来实际调用 LLM。但当前设计是 Provider 自包含的。
// 我们在 manager.go 中会调用 llmClient.Chat,所以这里的 Execute 我们将其简化——
// 直接返回一个空结果(没有特殊处理需要),实际的 LLM 调用由 manager 通过 createContext 后的
// 消息列表来调用 llmClient。
// 更好的设计是:Manager 调用 CreateContext 获取上下文,然后用自己的 llmClient 调用 LLM
// Execute 只做后处理。但为了统一接口,我们让 Execute 完成全部逻辑。
// 由于 GeneralProvider 暂时不需要工具调用等特殊逻辑,我们返回一个简单的摘要标记,
// 实际的 LLM 调用将在 orchestrator 中完成(通过 Manager.Dispatch 后的 llmClient)。
log.Printf("[general-subsession] 通用对话子会话上下文已创建 (%d 条消息)", len(subCtx))
return &model.SubSessionResult{
Type: model.SubSessionGeneral,
Summary: "思考完成,等待主会话综合",
Confidence: 0.8,
}, nil
}
// Ensure llm, persona are used
var _ = llm.NewAdapter
var _ = persona.NewLoader
@@ -0,0 +1,325 @@
package subsession
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"time"
"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/tools"
)
// IoTDeviceProvider IoT 设备查询接口
type IoTDeviceProvider interface {
GetAllDevices() ([]tools.IoTDevice, error)
GetDevice(id string) (*tools.IoTDevice, error)
ToggleDevice(id string) error
SetDeviceProperty(id string, field string, value interface{}) error
GetDevicesForContext() []tools.IoTDevice
}
// IoTProvider IoT 控制子会话提供者
// 职责:处理 IoT 设备查询和控制请求
type IoTProvider struct {
iotClient IoTDeviceProvider
}
// NewIoTProvider 创建 IoT 控制子会话提供者
func NewIoTProvider(iotClient IoTDeviceProvider) *IoTProvider {
return &IoTProvider{
iotClient: iotClient,
}
}
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()
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 客户端未配置)"
}
// 加载人格配置
loader, err := persona.NewLoader("")
if err != nil {
log.Printf("[iot-provider] 加载人格配置失败: %v", err)
}
personaConfig, _ := loader.Get("cyrene")
trueName := "昔涟"
if 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
}
}
if p.iotClient == nil {
result.Summary = "(IoT 客户端未配置,无法控制设备)"
return result, nil
}
// 简单的关键词匹配来执行设备操作(不依赖 LLM 解析)
// 这是作为降级方案,当 LLM 不可用时仍然可以处理基本 IoT 命令
msgLower := strings.ToLower(userMessage)
// 尝试获取设备列表进行匹配
devices := p.iotClient.GetDevicesForContext()
for _, dev := range devices {
devNameLower := strings.ToLower(dev.Name)
if !strings.Contains(msgLower, devNameLower) {
continue
}
// 匹配到了设备名称
if strings.Contains(msgLower, "打开") || strings.Contains(msgLower, "开") {
if dev.Status != "on" && dev.Status != "open" {
if dev.Type == "curtain" {
// 窗帘使用 set 而非 toggle
_ = p.iotClient.SetDeviceProperty(dev.ID, "status", "open")
} else {
_ = p.iotClient.ToggleDevice(dev.ID)
}
result.Summary = fmt.Sprintf("已帮%s打开%s♪", extractUserName(subCtx), dev.Name)
result.Confidence = 0.9
result.ToolCalls = []model.ToolCallRecord{{
Name: "iot_control",
Arguments: map[string]any{"device_id": dev.ID, "operation": "toggle"},
Result: "success",
}}
log.Printf("[iot-subsession] 执行操作: 打开 %s (%s)", dev.Name, dev.ID)
return result, nil
} else {
result.Summary = fmt.Sprintf("%s已经是打开状态啦~", dev.Name)
result.Confidence = 0.9
return result, nil
}
}
if strings.Contains(msgLower, "关闭") || strings.Contains(msgLower, "关") {
if dev.Status == "on" || dev.Status == "open" {
if dev.Type == "curtain" {
_ = p.iotClient.SetDeviceProperty(dev.ID, "status", "closed")
} else {
_ = p.iotClient.ToggleDevice(dev.ID)
}
result.Summary = fmt.Sprintf("已帮%s关闭%s~", extractUserName(subCtx), dev.Name)
result.Confidence = 0.9
result.ToolCalls = []model.ToolCallRecord{{
Name: "iot_control",
Arguments: map[string]any{"device_id": dev.ID, "operation": "toggle"},
Result: "success",
}}
log.Printf("[iot-subsession] 执行操作: 关闭 %s (%s)", dev.Name, dev.ID)
return result, nil
} else {
result.Summary = fmt.Sprintf("%s已经是关闭状态啦~", dev.Name)
result.Confidence = 0.9
return result, nil
}
}
// 查询设备状态
deviceStatus := fmt.Sprintf("%s当前状态: %s", dev.Name, dev.Status)
if dev.Type == "light" && dev.Status == "on" {
deviceStatus += fmt.Sprintf(" (亮度%d%%, 颜色%s)", dev.Brightness, dev.Color)
} else if dev.Type == "ac" && dev.Status == "on" {
deviceStatus += fmt.Sprintf(" (模式%s, 温度%.0f°C)", dev.Mode, dev.Temperature)
}
result.Summary = deviceStatus
result.Confidence = 0.8
return result, nil
}
// 没有匹配到设备,可能只是查询所有设备状态
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
}
}
result.Summary = "(未匹配到 IoT 操作)"
result.Confidence = 0.5
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
}
}
// Ensure json is used
var _ = json.Marshal
@@ -0,0 +1,162 @@
package subsession
import (
"context"
"crypto/rand"
"fmt"
"log"
"sync"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
"github.com/yourname/cyrene-ai/ai-core/internal/model"
)
// Manager 子会话管理器
// 负责注册 Provider、分派任务、并行执行、超时控制、结果收集
type Manager struct {
mu sync.RWMutex
providers map[model.SubSessionType]Provider
llmClient LLMClient
}
// NewManager 创建子会话管理器
func NewManager(llmClient LLMClient) *Manager {
return &Manager{
providers: make(map[model.SubSessionType]Provider),
llmClient: llmClient,
}
}
// Register 注册子会话提供者
func (m *Manager) Register(provider Provider) {
m.mu.Lock()
defer m.mu.Unlock()
m.providers[provider.Type()] = provider
log.Printf("[subsession] 注册子会话提供者: %s (优先级=%d, 超时=%v)", provider.Type(), provider.Priority(), provider.Timeout())
}
// RegisterWithOverride 注册或覆盖子会话提供者
func (m *Manager) RegisterWithOverride(provider Provider) {
m.mu.Lock()
defer m.mu.Unlock()
m.providers[provider.Type()] = provider
log.Printf("[subsession] 注册(覆盖)子会话提供者: %s (优先级=%d, 超时=%v)", provider.Type(), provider.Priority(), provider.Timeout())
}
// GetProvider 获取指定类型的 Provider
func (m *Manager) GetProvider(t model.SubSessionType) (Provider, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
p, ok := m.providers[t]
return p, ok
}
// ListProviders 列出所有已注册的 Provider 类型
func (m *Manager) ListProviders() []model.SubSessionType {
m.mu.RLock()
defer m.mu.RUnlock()
types := make([]model.SubSessionType, 0, len(m.providers))
for t := range m.providers {
types = append(types, t)
}
return types
}
// Dispatch 分派任务到子会话,并行执行,返回结果通道
func (m *Manager) Dispatch(
ctx context.Context,
intent *model.IntentResult,
userMessage string,
params CreateContextParams,
) <-chan model.SubSessionResult {
m.mu.RLock()
providers := make([]Provider, 0, len(m.providers))
for _, p := range m.providers {
providers = append(providers, p)
}
m.mu.RUnlock()
resultCh := make(chan model.SubSessionResult, len(providers))
var wg sync.WaitGroup
for _, provider := range providers {
if !provider.CanHandle(ctx, intent, userMessage) {
log.Printf("[subsession] 跳过子会话 %s: CanHandle 返回 false", provider.Type())
continue
}
wg.Add(1)
go func(p Provider) {
defer wg.Done()
result := model.SubSessionResult{Type: p.Type()}
// 创建带超时的 context
subCtx, cancel := context.WithTimeout(ctx, p.Timeout())
defer cancel()
// 构建 LLM 上下文
llmMessages, err := p.CreateContext(subCtx, params)
if err != nil {
result.Error = fmt.Sprintf("创建上下文失败: %v", err)
log.Printf("[subsession] %s 创建上下文失败: %v", p.Type(), err)
resultCh <- result
return
}
log.Printf("[subsession] %s 开始执行 (上下文 %d 条消息)", p.Type(), len(llmMessages))
// 执行子会话
subResult, execErr := p.Execute(subCtx, llmMessages)
if execErr != nil {
result.Error = fmt.Sprintf("执行失败: %v", execErr)
log.Printf("[subsession] %s 执行失败: %v", p.Type(), execErr)
resultCh <- result
return
}
// 检查超时
select {
case <-subCtx.Done():
result.Error = "子会话超时"
log.Printf("[subsession] %s 超时 (limit=%v)", p.Type(), p.Timeout())
default:
if subResult != nil {
result = *subResult
result.Type = p.Type()
log.Printf("[subsession] %s 完成: 摘要=%s", p.Type(), truncate(result.Summary, 50))
}
}
resultCh <- result
}(provider)
}
// 等待所有子会话完成,关闭通道
go func() {
wg.Wait()
close(resultCh)
}()
return resultCh
}
// generateID 生成随机 ID
func generateID() string {
b := make([]byte, 12)
rand.Read(b)
return fmt.Sprintf("sub-%x", b)
}
// truncate 截断字符串
func truncate(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "..."
}
// Ensure llm is used
var _ = llm.NewAdapter
@@ -0,0 +1,143 @@
package subsession
import (
"context"
"fmt"
"log"
"time"
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
"github.com/yourname/cyrene-ai/ai-core/internal/model"
)
// MemoryRetriever 记忆检索接口
type MemoryRetriever interface {
Retrieve(ctx context.Context, userID string, query string) ([]memory.MemoryEntry, error)
}
// MemoryProvider 记忆检索子会话提供者
// 职责:检索与当前对话相关的用户记忆,排序去重,返回结构化摘要
type MemoryProvider struct {
retriever MemoryRetriever
}
// NewMemoryProvider 创建记忆检索子会话提供者
func NewMemoryProvider(retriever MemoryRetriever) *MemoryProvider {
return &MemoryProvider{
retriever: retriever,
}
}
func (p *MemoryProvider) Type() model.SubSessionType {
return model.SubSessionMemory
}
func (p *MemoryProvider) CanHandle(_ context.Context, intent *model.IntentResult, _ string) bool {
// 如果意图分析明确不需要记忆,则跳过
if intent != nil && !intent.NeedsMemory {
// 但为了对话质量,大多数情况下仍然需要记忆
// 只有明确 negative 时才跳过
if intent.Sentiment == "neutral" && intent.Primary == "chat" {
return true
}
}
// 默认总是检索记忆
return true
}
func (p *MemoryProvider) Priority() int {
return 2 // 仅次于 General
}
func (p *MemoryProvider) Timeout() time.Duration {
return 10 * time.Second
}
func (p *MemoryProvider) CreateContext(ctx context.Context, params CreateContextParams) ([]model.LLMMessage, error) {
// Memory 子会话不依赖 LLM 上下文构建,直接在 Execute 中检索
// 返回简单上下文供日志记录
return []model.LLMMessage{
{Role: model.RoleSystem, Content: "记忆检索子会话"},
{Role: model.RoleUser, Content: params.UserMessage},
}, nil
}
func (p *MemoryProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*model.SubSessionResult, error) {
// 从 subCtx 中提取用户消息 (最后一条 user 消息)
userMessage := ""
for i := len(subCtx) - 1; i >= 0; i-- {
if subCtx[i].Role == model.RoleUser {
userMessage = subCtx[i].Content
break
}
}
if userMessage == "" {
return nil, fmt.Errorf("无法从子会话上下文中提取用户消息")
}
// 从 context 中提取 userID (通过 context value 传递)
userID, _ := ctx.Value("userID").(string)
if userID == "" {
userID = "unknown"
}
result := &model.SubSessionResult{
Type: model.SubSessionMemory,
Memories: []model.MemorySnippet{},
Confidence: 0,
}
if p.retriever == nil {
log.Printf("[memory-subsession] 记忆检索器未初始化")
result.Summary = "(记忆系统未就绪)"
return result, nil
}
memories, err := p.retriever.Retrieve(ctx, userID, userMessage)
if err != nil {
log.Printf("[memory-subsession] 记忆检索失败: %v", err)
result.Error = fmt.Sprintf("检索失败: %v", err)
result.Summary = "(记忆检索失败,但不影响对话)"
return result, nil
}
// 转换为 MemorySnippet
snippets := make([]model.MemorySnippet, 0, len(memories))
for _, m := range memories {
snippets = append(snippets, model.MemorySnippet{
ID: m.ID,
Content: m.Content,
Category: string(m.Category),
Importance: m.Importance,
Relevance: 0.5, // 默认相关度
})
}
// 生成摘要
if len(snippets) == 0 {
result.Summary = "(没有找到相关记忆)"
} else {
result.Summary = fmt.Sprintf("检索到 %d 条相关记忆", len(snippets))
// 按重要性列出前几条
topCount := len(snippets)
if topCount > 3 {
topCount = 3
}
details := ""
for i := 0; i < topCount; i++ {
s := snippets[i]
content := s.Content
runes := []rune(content)
if len(runes) > 40 {
content = string(runes[:40]) + "..."
}
details += fmt.Sprintf("- [%s] %s\n", s.Category, content)
}
result.Details = details
result.Confidence = 0.7
}
result.Memories = snippets
log.Printf("[memory-subsession] 完成: %s", result.Summary)
return result, nil
}
@@ -0,0 +1,51 @@
package subsession
import (
"context"
"time"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
"github.com/yourname/cyrene-ai/ai-core/internal/model"
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
)
// Provider 子会话提供者接口
// 每种子会话类型实现此接口
type Provider interface {
// Type 返回子会话类型标识
Type() model.SubSessionType
// CanHandle 判断是否需要为此消息创建子会话
CanHandle(ctx context.Context, intent *model.IntentResult, userMessage string) bool
// Priority 返回优先级 (数字越小优先级越高)
Priority() int
// CreateContext 创建子会话的 LLM 上下文
// 不包含对话历史(历史由 Orchestrator 统一管理)
CreateContext(ctx context.Context, params CreateContextParams) ([]model.LLMMessage, error)
// Timeout 返回此子会话的超时时间
Timeout() time.Duration
// Execute 执行子会话逻辑,返回结果
// 子会话可以调用 LLM、执行工具调用等
Execute(ctx context.Context, subCtx []model.LLMMessage) (*model.SubSessionResult, error)
}
// CreateContextParams 创建上下文参数
type CreateContextParams struct {
UserID string
SessionID string
UserMessage string
PersonaConfig *persona.PersonaConfig
DeviceContext string // IoT 设备状态文本
Intent *model.IntentResult
Nickname string // 用户昵称
}
// LLMClient LLM 调用接口(避免循环依赖)
type LLMClient interface {
Chat(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error)
ChatWithTools(ctx context.Context, messages []model.LLMMessage, tools []llm.OpenAITool) (*model.LLMResponse, error)
}
BIN
View File
Binary file not shown.
@@ -32,6 +32,7 @@ type Config struct {
// 管理员账户 (开发阶段使用)
AdminUsername string
AdminPassword string
AdminNickname string // 昔涟对用户的基本称呼
// 注册开关
RegistrationEnabled bool
@@ -94,6 +95,7 @@ func Load() *Config {
// 管理员账户 (开发阶段使用)
AdminUsername: getEnv("ADMIN_USERNAME", "admin"),
AdminPassword: getEnv("ADMIN_PASSWORD", "cyrene-dev-admin"),
AdminNickname: getEnv("ADMIN_NICKNAME", "管理员"),
// 注册开关 (开发阶段默认关闭)
RegistrationEnabled: getEnvBool("REGISTRATION_ENABLED", false),
@@ -20,7 +20,7 @@ func NewAuthHandler(cfg *config.Config) *AuthHandler {
return &AuthHandler{cfg: cfg}
}
// Register 用户注册 (需要邮箱验证码)
// Register 用户注册 (需要邮箱验证码、昵称必填)
func (h *AuthHandler) Register(c *gin.Context) {
// 检查注册开关
if !h.cfg.RegistrationEnabled {
@@ -32,6 +32,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
Username string `json:"username" binding:"required,min=2,max=32"`
Password string `json:"password" binding:"required,min=6,max=64"`
Email string `json:"email" binding:"required,email"`
Nickname string `json:"nickname" binding:"required,min=1,max=32"`
// MVP阶段:验证码仅做格式校验,后续接入邮件服务
VerifyCode string `json:"verify_code" binding:"required,len=6"`
}
@@ -65,9 +66,10 @@ func (h *AuthHandler) Register(c *gin.Context) {
}
c.JSON(http.StatusCreated, gin.H{
"user_id": userID,
"token": token,
"expires": time.Now().Add(h.cfg.JWTExpiryHours).Unix(),
"user_id": userID,
"token": token,
"expires": time.Now().Add(h.cfg.JWTExpiryHours).Unix(),
"nickname": req.Nickname,
})
}
@@ -219,6 +219,7 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
var fullText string
var msgID string
var segments []ws.VoiceSegment // 收集断句信息
for scanner.Scan() {
line := scanner.Text()
@@ -240,7 +241,13 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
Delta string `json:"delta"`
Error string `json:"error,omitempty"`
MessageID string `json:"message_id,omitempty"`
Mode string `json:"mode,omitempty"`
Done bool `json:"done,omitempty"`
// 断句相关 (来自 AI-Core 新格式)
Segments []struct {
Index int `json:"index"`
Text string `json:"text"`
} `json:"segments,omitempty"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
log.Printf("[chat] 解析 SSE delta 失败: %v, raw=%s", err, data)
@@ -270,6 +277,25 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
break
}
// 处理断句事件 (stream_segments)
if len(chunk.Segments) > 0 {
for _, seg := range chunk.Segments {
segments = append(segments, ws.VoiceSegment{
Index: seg.Index,
Text: seg.Text,
})
}
// 发送断句事件给前端
client.SendMessage(ws.ServerMessage{
Type: "stream_segments",
MessageID: msgID,
Segments: segments,
SessionID: client.SessionID,
Timestamp: time.Now().UnixMilli(),
})
continue
}
// 逐 delta 转发
if chunk.Delta != "" {
fullText += chunk.Delta
@@ -301,6 +327,28 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
msgID = "msg_" + generateID()
}
// 检测是否为多消息格式(包含空行分隔的多条消息)
multiParts := parseMultiMessage(fullText)
if len(multiParts) > 1 {
// 发送 multi_message 事件
var items []ws.MultiMessageItem
for i, part := range multiParts {
items = append(items, ws.MultiMessageItem{
Index: i,
Content: part,
})
}
client.SendMessage(ws.ServerMessage{
Type: "multi_message",
MessageID: msgID,
SessionID: client.SessionID,
MultiMessage: &ws.MultiMessagePayload{
Messages: items,
},
Timestamp: time.Now().UnixMilli(),
})
}
// 发送 stream_end
client.SendMessage(ws.ServerMessage{
Type: "stream_end",
@@ -393,3 +441,26 @@ func randomStr(n int) string {
return string(b)
}
// parseMultiMessage 检测并解析多消息格式
// 如果文本包含空行分隔的多条消息,拆分为多条;否则返回单条
func parseMultiMessage(text string) []string {
if text == "" {
return nil
}
// 按双换行(空行)分割
parts := strings.Split(text, "\n\n")
// 过滤空字符串并去除首尾空白
var result []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
// 如果只有一条,返回 nil 表示不是多消息格式
if len(result) <= 1 {
return nil
}
return result
}
+3 -1
View File
@@ -133,6 +133,8 @@ func (c *Client) SendMessage(msg ServerMessage) error {
case c.Send <- data:
return nil
default:
return nil // 通道满则丢弃
// 通道满:记录警告并返回错误(避免静默丢弃
log.Printf("[WS] 发送通道已满,丢弃消息: type=%s user=%s", msg.Type, c.UserID)
return nil
}
}
+28 -16
View File
@@ -25,22 +25,34 @@ type ClientMessage struct {
// 服务端 → 客户端消息
type ServerMessage struct {
Type string `json:"type"` // response | segment | audio | error | device_update | pong | history_response | stream_chunk | stream_end | background_thinking | notification
MessageID string `json:"message_id"`
Text string `json:"text,omitempty"`
Content string `json:"content,omitempty"` // stream_chunk 的增量文本
Role string `json:"role,omitempty"` // stream 消息的角色
SessionID string `json:"session_id,omitempty"` // 会话 ID
Segments []VoiceSegment `json:"segments,omitempty"` // 断句数组
FullAudioURL string `json:"full_audio_url,omitempty"`
ResponseMode string `json:"response_mode"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
Error string `json:"error,omitempty"`
Timestamp int64 `json:"timestamp"`
Messages []Message `json:"messages,omitempty"` // 历史消息列表
Devices []IotDeviceInfo `json:"devices,omitempty"` // IoT 设备状态
ThinkingStatus string `json:"thinking_status,omitempty"` // 后台思考状态
Notification *NotificationInfo `json:"notification,omitempty"` // 通知推送
Type string `json:"type"` // response | segment | audio | error | device_update | pong | history_response | stream_chunk | stream_end | background_thinking | notification | multi_message | stream_segments
MessageID string `json:"message_id"`
Text string `json:"text,omitempty"`
Content string `json:"content,omitempty"` // stream_chunk 的增量文本
Role string `json:"role,omitempty"` // stream 消息的角色
SessionID string `json:"session_id,omitempty"` // 会话 ID
Segments []VoiceSegment `json:"segments,omitempty"` // 断句数组
FullAudioURL string `json:"full_audio_url,omitempty"`
ResponseMode string `json:"response_mode"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
Error string `json:"error,omitempty"`
Timestamp int64 `json:"timestamp"`
Messages []Message `json:"messages,omitempty"` // 历史消息列表
Devices []IotDeviceInfo `json:"devices,omitempty"` // IoT 设备状态
ThinkingStatus string `json:"thinking_status,omitempty"` // 后台思考状态
Notification *NotificationInfo `json:"notification,omitempty"` // 通知推送
MultiMessage *MultiMessagePayload `json:"multi_message,omitempty"` // 多条消息批量发送
}
// MultiMessagePayload 多条消息的容器 (对应昔涟的多消息回复风格)
type MultiMessagePayload struct {
Messages []MultiMessageItem `json:"messages"`
}
// MultiMessageItem 多消息中的单条
type MultiMessageItem struct {
Index int `json:"index"`
Content string `json:"content"`
}
// NotificationInfo 通知推送信息
+15 -1
View File
@@ -140,7 +140,7 @@ func (s *Store) getDB() *sql.DB {
return s.db
}
// migrate 创建表结构
// migrate 创建表结构并添加缺失列(向后兼容旧schema
func (s *Store) migrate() error {
queries := []string{
`CREATE EXTENSION IF NOT EXISTS vector`,
@@ -162,6 +162,16 @@ func (s *Store) migrate() error {
updated_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ
)`,
// 向后兼容:补充旧版表中可能缺失的列
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS importance INT DEFAULT 5`,
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS summary TEXT DEFAULT ''`,
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS keywords TEXT DEFAULT '[]'`,
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS session_id VARCHAR(64) DEFAULT ''`,
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS source TEXT DEFAULT 'conversation'`,
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS access_count INT DEFAULT 0`,
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS last_access TIMESTAMPTZ DEFAULT NOW()`,
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW()`,
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ`,
`CREATE INDEX IF NOT EXISTS idx_me_user_id ON memory_entries(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_me_category ON memory_entries(category)`,
`CREATE INDEX IF NOT EXISTS idx_me_priority ON memory_entries(priority)`,
@@ -179,6 +189,10 @@ func (s *Store) migrate() error {
content_length INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// thinking_logs 的向后兼容列
`ALTER TABLE thinking_logs ADD COLUMN IF NOT EXISTS tool_calls TEXT DEFAULT '[]'`,
`ALTER TABLE thinking_logs ADD COLUMN IF NOT EXISTS tool_call_count INT DEFAULT 0`,
`ALTER TABLE thinking_logs ADD COLUMN IF NOT EXISTS content_length INT DEFAULT 0`,
`CREATE INDEX IF NOT EXISTS idx_tl_user_id ON thinking_logs(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_tl_created_at ON thinking_logs(created_at DESC)`,
}
@@ -81,14 +81,16 @@ func (s *STTService) Transcribe(audioData []byte, format string, language string
}
// 调用 whisper.cpp
outputTxt := inputPath + ".txt"
// whisper-cli 的 -of 标志会在去掉扩展名后追加 .txt
outputPrefix := strings.TrimSuffix(inputPath, filepath.Ext(inputPath))
outputTxt := outputPrefix + ".txt"
cmd := exec.Command(s.whisperBinary,
"-m", s.whisperModel,
"-l", language,
"-f", inputPath,
"-otxt",
"-of", strings.TrimSuffix(inputPath, filepath.Ext(inputPath)),
"-of", outputPrefix,
)
cmd.Stderr = os.Stderr
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+41 -11
View File
@@ -1,15 +1,45 @@
你要经常在 ./docs/progress/ 目录下新建进度 md 文件以便于后续对话继承开发进度。
你要经常在 ./docs/decisions/ 目录下新建决策 md 文件以便于后续对话继承开发决策。
你要经常在 ./docs/tasks/ 目录下新建每次任务 md 文件以便于后续对话回顾开发任务。
你同样要经常在 ./docs/ 目录下按照规范新建一些便于后续开发参考的 md 文件或文件夹(文件命名要求统一格式 YYYY-MM-DD.HH-mm-SS-topic.md)。
每次开启新对话或处理新任务前可以看看这些文件。
你可以在思考过程中或任务过程中随时新建/修改/删除这些文件,动作可以频繁一点。
已经实现并通过调试确定完善的功能对应的 md 文件记得做好统一标记避免后续频繁阅读。
**项目开发文档管理规范 (修订版)**
调试功能的时候你可以在终端挂一个 devtools.sh 通过 curl 启动所有服务 然后通过 curl 等工具去调试实现的这些功能。devtools 提供的 API 可以启动各前后端服务。牢记。
**1. 文档管理目录结构**
在你觉得用户要求的某个功能已经完全修复或编写并验证成功后,可以向当前分支(如 dev)推送。
禁止推送 docs/ 文件夹和编译后的二进制内容
你在测试长脚本或命令的时候可以在项目根目录临时创建test文件夹并新建脚本文件,用完记得删。
- **`./docs/progress/`**
请在此目录下定期创建进度 `md` 文件,以便后续对话能顺利继承开发进度
- **`./docs/decisions/`**
请在此目录下创建决策 `md` 文件,以便后续对话能准确继承开发决策。
- **`./docs/tasks/`**
请在此目录下为每次任务创建 `md` 文件,以便后续对话能回顾开发任务详情。
- 你可以按需求使用或创建其他文档目录。
- 开发前可以通过阅读已有的文档回顾开发进度。
**2. 通用文档规范**
-`./docs/` 目录下,请按统一格式创建辅助文档或文件夹,便于后续开发参考:
**格式:** `YYYY-MM-DD_HH-mm-SS-topic.md`
- 每次开启新对话或处理新任务前,建议先浏览这些文件获取上下文。
**3. 文档的创建与维护**
- 你可以在思考或任务执行过程中,随时新建、修改或删除这些文档,动作可以频繁一些喵~
- 已实现、调试通过且功能完善的模块,请在对应的 `md` 文件中做好统一标记,避免后续频繁重复阅读。
- 在完成功能重大调整与开发后请及时编写或修改 `./docs/api-reference/` 下的文档,和项目根目录下的 `Deploy.md`
**4. 调试与测试**
- 调试功能时,可以在终端启动 `devtools.sh` 脚本:
使用 `curl` 启动所有服务,再通过 `curl` 等工具对实现的功能进行接口调试。
`devtools` 提供的 API 可启动各前后端服务,请牢记这个流程喵!
**5. 版本提交规范**
- 当用户要求的某个功能已完全修复、编写完成并验证成功后,可向当前分支(如 `dev`)进行推送。
- **禁止提交的内容:** `docs/` 文件夹以及编译后的二进制文件、其他语言环境的依赖和项目临时环境。
**6. 测试脚本临时管理**
- 在测试长脚本或复杂命令时,可以在项目根目录临时创建 `test` 文件夹,并在其中新建 sh, py 等脚本文件并运行。
- **注意:** 用完记得及时删除喵~
+269 -10
View File
@@ -628,6 +628,10 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
<button class="nav-item" data-panel="toolCalls">
<span class="nav-icon">🔧</span><span class="nav-label">工具调用</span>
</button>
<button class="nav-item" data-panel="stt">
<span class="nav-icon">🎤</span><span class="nav-label">语音识别</span>
<span class="nav-badge" id="stt-badge" style="display:none">0</span>
</button>
<button class="nav-item" data-panel="database">
<span class="nav-icon">🗄️</span><span class="nav-label">数据库监看</span>
<span class="nav-badge" id="db-badge" style="display:none"></span>
@@ -668,6 +672,8 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
<div class="panel" id="panel-database"></div>
<!-- 工具调用记录 -->
<div class="panel" id="panel-toolCalls"></div>
<!-- 语音识别日志 -->
<div class="panel" id="panel-stt"></div>
<!-- 自主思考日志 -->
<div class="panel" id="panel-thinking"></div>
<!-- 记忆时间线 -->
@@ -714,6 +720,10 @@ const STATE = {
memoryFilterImportance: 0,
memorySearchText: '',
memoryPanelInitialized: false,
// STT 语音识别日志面板状态
sttLogs: [],
sttAutoRefresh: null,
sttAutoRefreshInterval: null,
// 时间线面板状态
timelineData: [],
timelineUserId: 'admin_admin',
@@ -743,6 +753,7 @@ function connectWS() {
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'log') handleWSLog(msg.data);
if (msg.type === 'stt-log') handleSTTLog(msg);
if (msg.type === 'status') {
STATE.serviceStatus = msg.data;
if (STATE.activePanel === 'services') renderServiceCards();
@@ -767,6 +778,35 @@ function handleWSLog(data) {
}
}
function handleSTTLog(msg) {
const entry = msg.data || msg;
if (!entry || !entry.id) return;
// 添加到本地状态缓存(去重)
const exists = STATE.sttLogs.find(function(e) { return e.id === entry.id; });
if (!exists) {
STATE.sttLogs.unshift(entry);
if (STATE.sttLogs.length > 200) STATE.sttLogs.length = 200;
}
// 更新 badge
updateSTTBadge();
// 如果当前正在查看 STT 面板,增量更新表格
if (STATE.activePanel === 'stt') {
prependSTTTableRow(entry);
}
}
function updateSTTBadge() {
const badge = document.getElementById('stt-badge');
if (!badge) return;
const count = STATE.sttLogs.length;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'inline';
} else {
badge.style.display = 'none';
}
}
// ========== 工具函数 ==========
function escHtml(s) {
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
@@ -891,7 +931,7 @@ function switchPanel(name) {
const titles = {
dashboard: '🏠 仪表盘', memory: '🧠 记忆管理', sessions: '💬 会话监看',
services: '🖥 服务管理', iot: '🏠 IoT 设备控制', performance: '📊 性能监控', database: '🗄️ 数据库监看',
toolCalls: '🔧 工具调用记录', thinking: '💭 自主思考', timeline: '⏱️ 记忆时间线',
toolCalls: '🔧 工具调用记录', stt: '🎤 语音识别日志', thinking: '💭 自主思考', timeline: '⏱️ 记忆时间线',
};
document.getElementById('panel-title').textContent = titles[name] || name;
@@ -912,6 +952,7 @@ function switchPanel(name) {
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'toolCalls': renderToolCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'stt': renderSTTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'thinking': renderThinkingPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
case 'timeline': renderTimelinePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); startTimelineAutoRefresh(); break;
}
@@ -1013,9 +1054,9 @@ async function renderDashboard() {
'<div class="metric"><div class="value" id="db-uptime-display">—</div><div class="label">状态</div></div>' +
'</div>' +
'<div class="btn-group" style="margin-top:10px">' +
'<button class="btn btn-xs btn-green" onclick="controlDB(\'start\')">▶ 启动</button>' +
'<button class="btn btn-xs btn-red" onclick="controlDB(\'stop\')">⏹ 停止</button>' +
'<button class="btn btn-xs" onclick="controlDB(\'restart\')">🔄 重启</button>' +
'<button class="btn btn-xs btn-green" id="db-card-start-btn" onclick="controlDB(\'start\')">▶ 启动</button>' +
'<button class="btn btn-xs btn-red" id="db-card-stop-btn" onclick="controlDB(\'stop\')">⏹ 停止</button>' +
'<button class="btn btn-xs" id="db-card-restart-btn" onclick="controlDB(\'restart\')">🔄 重启</button>' +
'<a href="#" onclick="switchPanel(\'database\');return false" style="font-size:10px;color:var(--accent);text-decoration:none;margin-left:auto;align-self:center">🔍 详情 →</a>' +
'</div>' +
'</div>' +
@@ -2449,6 +2490,10 @@ async function tunnelAction(action) {
}
// ========== 数据库卡片控制 ==========
var DB_ACTION_LABELS = {
start: '启动', stop: '停止', restart: '重启'
};
async function renderDBCard() {
const data = await api('/api/db/status');
const badge = document.getElementById('db-status-badge');
@@ -2459,6 +2504,7 @@ async function renderDBCard() {
if (data.error) {
if (badge) { badge.textContent = '错误'; badge.className = 'badge badge-error'; }
if (uptimeDisplay) uptimeDisplay.textContent = '错误';
updateDBCardButtons(true, false);
return;
}
@@ -2470,17 +2516,72 @@ async function renderDBCard() {
if (typeDisplay) typeDisplay.textContent = 'PostgreSQL';
if (portDisplay) portDisplay.textContent = data.port || 5432;
if (uptimeDisplay) uptimeDisplay.textContent = online ? '已连接' : '未连接';
// 同步按钮 disabled 状态: 在线时禁用启动,离线时禁用停止
updateDBCardButtons(false, online);
}
function updateDBCardButtons(disabled, online) {
var startBtn = document.getElementById('db-card-start-btn');
var stopBtn = document.getElementById('db-card-stop-btn');
if (disabled) {
if (startBtn) startBtn.disabled = true;
if (stopBtn) stopBtn.disabled = true;
} else {
if (startBtn) startBtn.disabled = !!online;
if (stopBtn) stopBtn.disabled = !online;
}
}
async function controlDB(action) {
showToast('正在' + action + '数据库...', 'info');
const data = await api('/api/db/' + action, { method: 'POST' });
if (data.error) {
var label = DB_ACTION_LABELS[action] || action;
showToast('正在' + label + '数据库...', 'info');
// 禁用所有按钮防止重复点击
updateDBCardButtons(true);
// 显示日志区域
var logContainer = document.getElementById('db-card-log-container');
var logEl = document.getElementById('db-card-log');
if (!logContainer) {
// 首次使用时动态创建日志区域
var dbCard = document.getElementById('db-card');
if (dbCard) {
logContainer = document.createElement('div');
logContainer.id = 'db-card-log-container';
logContainer.style.cssText = 'margin-top:8px;display:none';
logContainer.innerHTML =
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">' +
'<span style="font-size:11px;color:var(--text2)">操作日志</span>' +
'<button class="btn btn-xs" onclick="var c=document.getElementById(\'db-card-log-container\');if(c)c.style.display=\'none\'">✕</button>' +
'</div>' +
'<div class="tunnel-log" id="db-card-log" style="max-height:120px"></div>';
dbCard.appendChild(logContainer);
logEl = document.getElementById('db-card-log');
}
}
if (logContainer) logContainer.style.display = 'block';
if (logEl) logEl.textContent = '执行中...';
var data = await api('/api/db/' + action, { method: 'POST' });
if (data.error && !data.output) {
if (logEl) logEl.textContent = '错误: ' + data.error;
showToast('操作失败: ' + data.error, 'error');
// 恢复按钮状态 — 重新读取在线状态
setTimeout(renderDBCard, 1000);
} else {
showToast('数据库 ' + action + ' 完成', 'success');
// 等待2秒后刷新状态
setTimeout(renderDBCard, 2000);
if (logEl) logEl.textContent = data.output || data.error || '(无输出)';
if (data.success) {
showToast('数据库' + label + '完成', 'success');
} else {
showToast(label + '完成 (查看日志)', 'info');
}
// 等待2秒后刷新数据库卡片和仪表盘
setTimeout(function() {
renderDBCard();
// 如果当前在仪表盘面板,刷新仪表盘
if (STATE.activePanel === 'dashboard') renderDashboard();
}, 2000);
}
}
@@ -2664,6 +2765,164 @@ function toggleToolCallsAutoRefresh(on) {
}
}
// ========== 面板8.5: 语音识别日志 (STT) ==========
async function renderSTTPanel() {
var container = document.getElementById('panel-stt');
if (!STATE.sttAutoRefresh) STATE.sttAutoRefresh = null;
var actionsEl = document.getElementById('panel-actions');
var autoRefreshOn = STATE.sttAutoRefresh !== null;
actionsEl.innerHTML = '<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);cursor:pointer;">' +
'<input type="checkbox" id="stt-autorefresh" ' + (autoRefreshOn ? 'checked' : '') + ' onchange="toggleSTTAutoRefresh(this.checked)">' +
'自动刷新 (5s)</label>' +
'<button class="btn btn-sm" onclick="refreshSTTPanel()" style="margin-left:8px">🔄 刷新</button>' +
'<button class="btn btn-sm btn-red" onclick="clearSTTLogs()" style="margin-left:4px">🗑 清空</button>';
// 首次渲染时从 API 拉取数据覆盖本地缓存
var data = await api('/api/voice/logs?limit=200');
if (data.error) {
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' +
escHtml(data.error) +
(data.hint ? '<br><small>' + escHtml(data.hint) + '</small>' : '') +
'</div>';
return;
}
STATE.sttLogs = data.logs || [];
updateSTTBadge();
// 统计卡片
var totalLogs = data.total || STATE.sttLogs.length;
var successCount = 0, errorCount = 0, totalDurationMs = 0;
STATE.sttLogs.forEach(function(e) {
if (e.status === 'success') successCount++;
else errorCount++;
totalDurationMs += (e.durationMs || 0);
});
var avgDurationMs = STATE.sttLogs.length > 0 ? Math.round(totalDurationMs / STATE.sttLogs.length) : 0;
var statsHtml = '<div class="cards-grid cards-4" style="margin-bottom:14px;">' +
'<div class="stat-card accent"><div class="stat-value">' + totalLogs + '</div><div class="stat-label">总请求数</div></div>' +
'<div class="stat-card green"><div class="stat-value">' + successCount + '</div><div class="stat-label">成功</div></div>' +
'<div class="stat-card red"><div class="stat-value">' + errorCount + '</div><div class="stat-label">失败</div></div>' +
'<div class="stat-card blue"><div class="stat-value">' + avgDurationMs + 'ms</div><div class="stat-label">平均处理时间</div></div>' +
'</div>';
// 构建表格
var tableHtml = '<div class="table-wrap"><table>' +
'<thead><tr>' +
'<th style="width:130px;">时间</th>' +
'<th style="width:60px;">状态</th>' +
'<th style="width:80px;">音频大小</th>' +
'<th style="width:70px;">预计时长</th>' +
'<th style="width:70px;">语言</th>' +
'<th style="width:80px;">处理时间</th>' +
'<th>识别结果</th>' +
'</tr></thead><tbody>';
if (STATE.sttLogs.length > 0) {
for (var i = 0; i < STATE.sttLogs.length; i++) {
var log = STATE.sttLogs[i];
var timeStr = log.timestamp ? new Date(log.timestamp).toLocaleString('zh-CN', {hour12: false}) : '—';
var statusBadgeHtml = log.status === 'success'
? '<span class="badge badge-running">✓ 成功</span>'
: '<span class="badge badge-error">✗ 失败</span>';
var audioSizeMB = log.audioSizeMB || '—';
var estDuration = log.estimatedDurationSec ? log.estimatedDurationSec + 's' : '—';
var lang = log.language || '—';
var durationMs = log.durationMs ? log.durationMs + 'ms' : '—';
var textDisplay = log.status === 'success'
? (log.text || '<span style="color:var(--text3);font-style:italic;">(空结果)</span>')
: ('<span style="color:var(--red);">' + escHtml(log.error || '未知错误') + '</span>');
var textLenLabel = log.textLength ? ' (' + log.textLength + '字)' : '';
var rowId = 'stt-row-' + i;
tableHtml += '<tr class="stt-row" data-rowid="' + rowId + '" style="cursor:pointer;" onclick="toggleSTTExpand(\'' + rowId + '\')">' +
'<td style="font-size:11px;color:var(--text2);white-space:nowrap;">' + escHtml(timeStr) + '</td>' +
'<td>' + statusBadgeHtml + '</td>' +
'<td style="text-align:right;font-family:\'JetBrains Mono\',monospace;font-size:11px;">' + escHtml(audioSizeMB) + ' MB</td>' +
'<td style="text-align:right;font-family:\'JetBrains Mono\',monospace;font-size:11px;">' + escHtml(estDuration) + '</td>' +
'<td style="text-align:center;">' + escHtml(lang) + '</td>' +
'<td style="text-align:right;font-family:\'JetBrains Mono\',monospace;font-size:11px;color:' + (log.durationMs > 5000 ? 'var(--orange)' : 'var(--text2)') + ';">' + escHtml(durationMs) + '</td>' +
'<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;">' + (log.status === 'success' ? escHtml((log.text || '').substring(0, 80)) + textLenLabel : textDisplay) + '</td>' +
'</tr>';
// 展开行: 完整内容
tableHtml += '<tr class="stt-expand" id="' + rowId + '-expand" style="display:none;">' +
'<td colspan="7" style="background:var(--bg);padding:12px 24px;">' +
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">' +
'<div><strong style="color:var(--text2);font-size:11px;">文件名:</strong> <span style="font-size:11px;">' + escHtml(log.filename || '—') + '</span></div>' +
'<div><strong style="color:var(--text2);font-size:11px;">日志 ID:</strong> <code style="font-size:10px;">' + escHtml(log.id || '—') + '</code></div>' +
'</div>';
if (log.status === 'success') {
tableHtml += '<div style="margin-top:8px;"><strong style="color:var(--text2);font-size:11px;">完整识别结果 (' + (log.textLength || 0) + ' 字):</strong>' +
'<pre style="background:var(--bg3);padding:8px;border-radius:4px;font-size:11px;margin-top:4px;max-height:200px;overflow:auto;white-space:pre-wrap;">' + escHtml(log.text || '') + '</pre></div>';
} else {
tableHtml += '<div style="margin-top:8px;"><strong style="color:var(--red);font-size:11px;">错误详情:</strong>' +
'<pre style="background:var(--red-bg);padding:8px;border-radius:4px;font-size:11px;margin-top:4px;max-height:120px;overflow:auto;white-space:pre-wrap;color:var(--red);">' + escHtml(log.error || '未知错误') + '</pre></div>';
}
tableHtml += '</td></tr>';
}
} else {
tableHtml += '<tr><td colspan="7" style="text-align:center;color:var(--text3);padding:30px;">暂无语音识别记录</td></tr>';
}
tableHtml += '</tbody></table></div>';
// Voice Service 状态提示
var voiceStatusHtml = '';
try {
var voiceStatusResp = await fetch('/api/voice/health');
if (!voiceStatusResp.ok) {
voiceStatusHtml = '<div style="margin-top:12px;padding:10px 14px;background:var(--yellow-bg);border-radius:var(--radius-sm);font-size:12px;color:var(--yellow);">' +
'⚠️ Voice-Service 未运行 — 新的语音识别请求将无法处理。请在「服务管理」面板中启动 Voice-Service。</div>';
}
} catch(e) {
voiceStatusHtml = '<div style="margin-top:12px;padding:10px 14px;background:var(--yellow-bg);border-radius:var(--radius-sm);font-size:12px;color:var(--yellow);">' +
'⚠️ Voice-Service 未运行 — 新的语音识别请求将无法处理。请在「服务管理」面板中启动 Voice-Service。</div>';
}
container.innerHTML = statsHtml + tableHtml + voiceStatusHtml;
}
function prependSTTTableRow(entry) {
var container = document.getElementById('panel-stt');
var table = container.querySelector('table tbody');
if (!table) return;
// 简单刷新整个面板以保持统计准确
renderSTTPanel();
}
function toggleSTTExpand(rowId) {
var expandRow = document.getElementById(rowId + '-expand');
if (expandRow) {
expandRow.style.display = expandRow.style.display === 'none' ? '' : 'none';
}
}
function refreshSTTPanel() {
renderSTTPanel();
}
function clearSTTLogs() {
STATE.sttLogs = [];
updateSTTBadge();
if (STATE.activePanel === 'stt') renderSTTPanel();
}
function toggleSTTAutoRefresh(on) {
if (STATE.sttAutoRefresh) {
clearInterval(STATE.sttAutoRefresh);
STATE.sttAutoRefresh = null;
}
if (on) {
STATE.sttAutoRefresh = setInterval(function() {
if (STATE.activePanel === 'stt') renderSTTPanel();
}, 5000);
}
}
// ========== 面板9: 自主思考日志 ==========
async function renderThinkingPanel() {
var container = document.getElementById('panel-thinking');
+162
View File
@@ -620,6 +620,168 @@ app.get('/api/tool-calls/stats', async (_req, res) => {
res.status(result.status).json(result.body);
});
// ---- STT 处理日志存储 (内存环形缓冲区) ----
const sttLogEntries = [];
const MAX_STT_LOGS = 200;
/**
* 记录 STT 请求日志(devtools 自身维护,因为 voice-service 无持久化日志)
*/
function recordSTTLog(entry) {
sttLogEntries.unshift(entry);
if (sttLogEntries.length > MAX_STT_LOGS) {
sttLogEntries.length = MAX_STT_LOGS;
}
// 通过 WebSocket 广播给前端面板实时更新
broadcast('stt-log', entry);
}
// GET /api/voice/logs — 获取 STT 处理日志
app.get('/api/voice/logs', (req, res) => {
const limit = parseInt(req.query.limit) || 50;
const logs = sttLogEntries.slice(0, limit);
res.json({
total: sttLogEntries.length,
logs,
});
});
// POST /api/voice/transcribe — 代理到 Voice-Service 并记录日志
// 接受 JSON (base64 音频) 并转发为 multipart/form-data 到 Voice-Service
app.post('/api/voice/transcribe', async (req, res) => {
const startTime = Date.now();
let { audio_base64, language, filename } = req.body || {};
// 也支持直接通过 FormData 上传 (express.raw 中间件处理后手动解析)
if (!audio_base64 && req.is('multipart/form-data')) {
return res.status(400).json({ error: 'multipart/form-data 暂不支持,请使用 JSON 格式发送 base64 编码的音频' });
}
if (!audio_base64) {
return res.status(400).json({ error: '缺少 audio_base64 字段' });
}
// 计算音频大小 (解码后)
let audioBuffer;
try {
audioBuffer = Buffer.from(audio_base64, 'base64');
} catch {
return res.status(400).json({ error: 'audio_base64 格式无效,无法解码' });
}
const audioSizeBytes = audioBuffer.length;
// 估算音频时长 (WAV 16kHz 16bit mono: ~32000 bytes/sec)
const estimatedDurationSec = audioSizeBytes > 0 ? (audioSizeBytes / 32000).toFixed(1) : '0';
if (!filename) filename = 'audio.wav';
try {
// 构建 multipart/form-data 请求转发到 Voice-Service
const boundary = '----DevToolsFormBoundary' + Math.random().toString(36).slice(2);
const crlf = '\r\n';
const headerParts = [
'--' + boundary + crlf,
'Content-Disposition: form-data; name="audio"; filename="' + filename + '"' + crlf,
'Content-Type: application/octet-stream' + crlf,
crlf,
];
const headerBytes = Buffer.from(headerParts.join(''), 'utf-8');
const footerBytes = Buffer.from(crlf + '--' + boundary + '--' + crlf, 'utf-8');
// 如果有 language 参数
let languagePart = Buffer.alloc(0);
if (language) {
const langHeader = [
'--' + boundary + crlf,
'Content-Disposition: form-data; name="language"' + crlf,
crlf,
language + crlf,
];
languagePart = Buffer.from(langHeader.join(''), 'utf-8');
}
const multipartBody = Buffer.concat([headerBytes, audioBuffer, footerBytes]);
// 如果需要 language 字段,插入在 audio 字段之后
const finalBody = languagePart.length > 0
? Buffer.concat([headerBytes, audioBuffer, languagePart, footerBytes])
: multipartBody;
const voiceResp = await fetch(`${VOICE_SERVICE_URL}/api/v1/transcribe`, {
method: 'POST',
headers: {
'Content-Type': 'multipart/form-data; boundary=' + boundary,
},
body: finalBody,
signal: AbortSignal.timeout(60000),
});
const voiceBody = await voiceResp.json().catch(() => null);
const elapsedMs = Date.now() - startTime;
if (!voiceResp.ok || (voiceBody && voiceBody.error)) {
const logEntry = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
timestamp: new Date().toISOString(),
status: 'error',
audioSizeMB: (audioSizeBytes / 1024 / 1024).toFixed(2),
estimatedDurationSec,
language: language || 'zh',
filename,
durationMs: elapsedMs,
text: null,
error: voiceBody?.error || `HTTP ${voiceResp.status}`,
};
recordSTTLog(logEntry);
return res.status(voiceResp.status).json({
...voiceBody,
devtools_log_id: logEntry.id,
});
}
const logEntry = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
timestamp: new Date().toISOString(),
status: 'success',
audioSizeMB: (audioSizeBytes / 1024 / 1024).toFixed(2),
estimatedDurationSec,
language: voiceBody?.language || language || 'zh',
filename,
durationMs: voiceBody?.duration_ms || elapsedMs,
text: voiceBody?.text || '',
textLength: (voiceBody?.text || '').length,
};
recordSTTLog(logEntry);
return res.json({
...voiceBody,
devtools_log_id: logEntry.id,
});
} catch (err) {
const elapsedMs = Date.now() - startTime;
const logEntry = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
timestamp: new Date().toISOString(),
status: 'error',
audioSizeMB: (audioSizeBytes / 1024 / 1024).toFixed(2),
estimatedDurationSec,
language: language || 'zh',
filename,
durationMs: elapsedMs,
text: null,
error: err.message,
};
recordSTTLog(logEntry);
const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED';
return res.status(502).json({
error: `Voice-Service 不可达: ${err.message}`,
errorType: isConnRefused ? 'voice_service_not_running' : 'voice_service_unreachable',
hint: isConnRefused
? 'Voice-Service 服务未启动,请先在「服务管理」面板中启动 Voice-Service'
: 'Voice-Service 服务无响应,请检查网络连接和服务状态',
devtools_log_id: logEntry.id,
});
}
});
// ---- 自主思考日志代理 (转发到 memory-service) ----
// ---- 语音识别服务代理 (转发到 voice-service) ----
+17 -2
View File
@@ -37,6 +37,7 @@ export default function App() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState('');
const [nickname, setNickname] = useState('');
const [verifyCode, setVerifyCode] = useState('');
const [error, setError] = useState('');
const [successMsg, setSuccessMsg] = useState('');
@@ -166,7 +167,11 @@ export default function App() {
setError('请输入邮箱');
return;
}
const result = await register(username, password, email, verifyCode || '000000');
if (!nickname) {
setError('请输入昵称');
return;
}
const result = await register(username, password, email, nickname, verifyCode || '000000');
if (!result.success) {
setError(result.error || '注册失败');
} else {
@@ -239,6 +244,16 @@ export default function App() {
/>
)}
{authMode === 'register' && (
<input
type="text"
placeholder="昵称 (昔涟会这样称呼你)"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400"
/>
)}
<input
type="password"
placeholder="密码"
@@ -277,7 +292,7 @@ export default function App() {
) : (
<button
onClick={handleRegister}
disabled={authLoading || !username || !password || !email}
disabled={authLoading || !username || !password || !email || !nickname}
className="w-full py-2.5 rounded-xl bg-pink-400 hover:bg-pink-500 disabled:bg-pink-300 text-white font-medium text-sm transition-colors"
>
{authLoading ? '请稍候...' : '注册并进入 ♪'}
+2 -2
View File
@@ -98,10 +98,10 @@ export async function login(username: string, password: string): Promise<ApiResp
return resp;
}
export async function register(username: string, password: string, email: string, verifyCode: string): Promise<ApiResponse<AuthResponse>> {
export async function register(username: string, password: string, email: string, nickname: string, verifyCode: string): Promise<ApiResponse<AuthResponse>> {
const resp = await request<AuthResponse>('/auth/register', {
method: 'POST',
body: { username, password, email, verify_code: verifyCode },
body: { username, password, email, nickname, verify_code: verifyCode },
auth: false,
});
if (resp.data?.token) {
@@ -1,10 +1,10 @@
import { useChat } from '@/hooks/useChat';
import { useChatStore } from '@/store/chatStore';
import { MessageList } from './MessageList';
import { IoTStatusBar } from './IoTStatusBar';
export function ChatContainer() {
const { messages, isTyping } = useChat();
const messages = useChatStore((s) => s.messages);
const isTyping = useChatStore((s) => s.isTyping);
const continuousMode = useChatStore((s) => s.continuousMode);
const backgroundThinkingStatus = useChatStore((s) => s.backgroundThinkingStatus);
@@ -30,6 +30,7 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
const {
isListening,
isSupported,
isFallbackMode,
interimText,
finalText,
error,
@@ -423,14 +424,14 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
{/* 语音输入状态提示 */}
{isListening && (
<p className="text-xs text-red-400 text-center animate-pulse">
🎤 ...
{isFallbackMode ? '🎤 后端语音识别中...' : '🎤 正在聆听...'}
<span className="text-gray-400 ml-2">(Ctrl+Shift+V )</span>
</p>
)}
{mode !== 'text' && !isListening && (
<p className="text-xs text-gray-400 text-center">
{mode === 'voice_msg' ? '语音消息功能即将上线 ♪' : ''}
{mode === 'voice_msg' ? '点击麦克风按钮开始语音输入 ♪' : ''}
</p>
)}
</div>
@@ -154,7 +154,7 @@ export function MessageBubble({ role, content, timestamp, isStreaming, attachmen
const imageAttachments = attachments?.filter((a) => a.type === 'image') ?? [];
return (
<div className={`flex px-4 py-2 gap-3 ${isUser ? 'flex-row-reverse' : ''}`}>
<div className={`flex px-4 py-2 gap-3 ${isUser ? 'justify-end' : ''}`}>
{/* 头像 */}
{!isUser && (
<CyreneAvatar size="sm" className="flex-shrink-0 mt-1" />
@@ -19,12 +19,8 @@ export function MessageList({ messages, isTyping }: MessageListProps) {
if (messages.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 p-8">
<div className="text-6xl mb-4">🌸</div>
<p className="text-lg font-medium text-pink-300 mb-2">
</p>
<p className="text-sm">
~
<p className="text-sm text-gray-400 dark:text-gray-500">
</p>
</div>
);
+131 -20
View File
@@ -1,4 +1,5 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { transcribeAudio } from '@/api/voice';
/**
* 浏览器 Speech Recognition API 的类型声明补充
@@ -11,6 +12,8 @@ interface UseSpeechRecognitionReturn {
interimText: string;
finalText: string;
error: string | null;
/** 是否正在使用后端 STT 降级模式 */
isFallbackMode: boolean;
startListening: () => void;
stopListening: () => void;
resetText: () => void;
@@ -30,7 +33,7 @@ const ERROR_MESSAGES: Record<RecognitionError, string> = {
'no-speech': '未检测到语音,请再试一次',
'aborted': '语音输入已中止',
'audio-capture': '无法访问麦克风设备',
'network': '网络错误,语音识别需要网络连接',
'network': '网络错误,已切换到后端语音识别',
'not-allowed': '麦克风权限被拒绝,请在浏览器设置中允许麦克风访问',
'service-not-allowed': '语音识别服务不可用',
'bad-grammar': '语法配置错误',
@@ -46,16 +49,25 @@ export function useSpeechRecognition(): UseSpeechRecognitionReturn {
const [interimText, setInterimText] = useState('');
const [finalText, setFinalText] = useState('');
const [error, setError] = useState<string | null>(null);
const [isFallbackMode, setIsFallbackMode] = useState(false);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const finalAccRef = useRef<string[]>([]);
// --- 后端 STT 降级:MediaRecorder ---
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const fallbackStreamRef = useRef<MediaStream | null>(null);
const SpeechRecognitionAPI =
typeof window !== 'undefined'
? window.SpeechRecognition || window.webkitSpeechRecognition
: undefined;
const isSupported = SpeechRecognitionAPI != null;
// 只要浏览器支持 MediaRecorder 就算语音输入可用
const isSupported =
typeof window !== 'undefined' &&
(SpeechRecognitionAPI != null || typeof MediaRecorder !== 'undefined');
const resetText = useCallback(() => {
setInterimText('');
@@ -63,14 +75,35 @@ export function useSpeechRecognition(): UseSpeechRecognitionReturn {
finalAccRef.current = [];
}, []);
const stopListening = useCallback(() => {
// 清理浏览器 STT
const cleanupBrowserSTT = useCallback(() => {
if (recognitionRef.current) {
recognitionRef.current.stop();
recognitionRef.current.abort();
recognitionRef.current = null;
}
setIsListening(false);
}, []);
// 清理 MediaRecorder 降级
const cleanupFallback = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
mediaRecorderRef.current = null;
if (fallbackStreamRef.current) {
fallbackStreamRef.current.getTracks().forEach((t) => t.stop());
fallbackStreamRef.current = null;
}
audioChunksRef.current = [];
}, []);
const stopListening = useCallback(() => {
cleanupBrowserSTT();
cleanupFallback();
setIsListening(false);
setIsFallbackMode(false);
setError(null);
}, [cleanupBrowserSTT, cleanupFallback]);
// 自动静默超时:若 3 秒内无任何结果,自动停止
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -83,22 +116,93 @@ export function useSpeechRecognition(): UseSpeechRecognitionReturn {
}, 3000);
}, [stopListening]);
const startListening = useCallback(() => {
if (!SpeechRecognitionAPI) {
setError('浏览器不支持语音识别');
return;
}
// --- 后端 STT 降级:启动 MediaRecorder 录音 ---
const startFallbackRecognition = useCallback(async () => {
try {
setIsFallbackMode(true);
setError(null);
// 如果已有实例则先停止
if (recognitionRef.current) {
recognitionRef.current.abort();
recognitionRef.current = null;
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
fallbackStreamRef.current = stream;
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: MediaRecorder.isTypeSupported('audio/webm')
? 'audio/webm'
: 'audio/mp4';
const recorder = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = recorder;
audioChunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) {
audioChunksRef.current.push(e.data);
}
};
recorder.onstop = async () => {
const audioBlob = new Blob(audioChunksRef.current, { type: recorder.mimeType });
audioChunksRef.current = [];
// 调用后端 STT
const result = await transcribeAudio(audioBlob, 'zh');
if (result.error) {
setError(`后端语音识别失败: ${result.error}`);
setIsListening(false);
setIsFallbackMode(false);
return;
}
if (result.data?.text) {
setFinalText(result.data.text);
} else {
setError('未识别到语音内容');
}
setIsListening(false);
setIsFallbackMode(false);
};
recorder.onerror = () => {
setError('录音设备出错,请重试');
setIsListening(false);
setIsFallbackMode(false);
};
recorder.start();
setIsListening(true);
// 降级模式没有 interim results,所以超时设长一点
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
silenceTimerRef.current = setTimeout(() => {
stopListening();
}, 15000); // 15s 最大录音时长
} catch (err) {
const msg = err instanceof DOMException && err.name === 'NotAllowedError'
? '麦克风权限被拒绝'
: err instanceof Error ? err.message : '无法启动录音';
setError(msg);
setIsFallbackMode(false);
}
}, [stopListening]);
const startListening = useCallback(() => {
// 如果已有实例则先停止
cleanupBrowserSTT();
cleanupFallback();
setError(null);
setInterimText('');
setFinalText('');
finalAccRef.current = [];
setIsFallbackMode(false);
if (!SpeechRecognitionAPI) {
// 浏览器不支持 SpeechRecognition,直接使用后端 STT
startFallbackRecognition();
return;
}
const recognition = new SpeechRecognitionAPI();
recognition.continuous = true;
@@ -124,6 +228,14 @@ export function useSpeechRecognition(): UseSpeechRecognitionReturn {
};
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
if (event.error === 'network') {
// 网络错误 → 自动降级到后端 STT
cleanupBrowserSTT();
setError('浏览器语音识别网络不可达,正在切换到后端识别...');
startFallbackRecognition();
return;
}
const message = getRecognitionError(event.error);
setError(message);
@@ -157,7 +269,7 @@ export function useSpeechRecognition(): UseSpeechRecognitionReturn {
recognitionRef.current = recognition;
recognition.start();
}, [SpeechRecognitionAPI, resetSilenceTimer]);
}, [SpeechRecognitionAPI, resetSilenceTimer, cleanupBrowserSTT, cleanupFallback, startFallbackRecognition]);
// cleanup: 组件卸载时停止识别
useEffect(() => {
@@ -165,12 +277,10 @@ export function useSpeechRecognition(): UseSpeechRecognitionReturn {
if (silenceTimerRef.current) {
clearTimeout(silenceTimerRef.current);
}
if (recognitionRef.current) {
recognitionRef.current.abort();
recognitionRef.current = null;
}
cleanupBrowserSTT();
cleanupFallback();
};
}, []);
}, [cleanupBrowserSTT, cleanupFallback]);
return {
isListening,
@@ -178,6 +288,7 @@ export function useSpeechRecognition(): UseSpeechRecognitionReturn {
interimText,
finalText,
error,
isFallbackMode,
startListening,
stopListening,
resetText,
+42 -7
View File
@@ -8,22 +8,30 @@ import type { WSClientMessage, WSServerMessage } from '@/types/chat';
const WS_BASE_URL =
import.meta.env.VITE_WS_URL || 'ws://localhost:8080/ws/chat';
let wsInstanceCounter = 0;
export function useWebSocket() {
const [isConnected, setIsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const shouldReconnectRef = useRef(true);
const activeSessionRef = useRef<string | null>(null);
const instanceIdRef = useRef(++wsInstanceCounter);
// 订阅 sessionStore 中的 currentSessionId 变化
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const connect = useCallback(() => {
const instanceId = instanceIdRef.current;
const token = getToken();
if (!token) return;
if (!token) {
console.warn(`[WS#${instanceId}] connect: 无 token,跳过连接`);
return;
}
// 关闭旧连接
if (wsRef.current) {
console.log(`[WS#${instanceId}] 关闭旧连接`);
shouldReconnectRef.current = false;
wsRef.current.close();
wsRef.current = null;
@@ -34,12 +42,13 @@ export function useWebSocket() {
? `${WS_BASE_URL}?token=${token}&session_id=${sessionID}`
: `${WS_BASE_URL}?token=${token}`;
console.log(`[WS#${instanceId}] 正在连接, session_id=${sessionID || '(无)'}`);
const ws = new WebSocket(url);
ws.onopen = () => {
setIsConnected(true);
shouldReconnectRef.current = true;
console.log('[WS] 已连接, session_id:', sessionID);
console.log(`[WS#${instanceId}] 已连接, session_id:`, sessionID);
// 连接后发送会话恢复消息,恢复后端上下文
const sid = useSessionStore.getState().currentSessionId;
@@ -50,15 +59,15 @@ export function useWebSocket() {
timestamp: Date.now(),
};
ws.send(JSON.stringify(resumeMsg));
console.log('[WS] 已发送会话恢复请求, session_id:', sid);
console.log(`[WS#${instanceId}] 已发送会话恢复请求, session_id:`, sid);
}
};
ws.onclose = () => {
setIsConnected(false);
console.log('[WS] 已断开');
console.log(`[WS#${instanceId}] 已断开`);
if (shouldReconnectRef.current) {
console.log('[WS] 3秒后重连...');
console.log(`[WS#${instanceId}] 3秒后重连...`);
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
@@ -67,7 +76,7 @@ export function useWebSocket() {
};
ws.onerror = (err) => {
console.error('[WS] 连接错误:', err);
console.error(`[WS#${instanceId}] 连接错误:`, err);
};
ws.onmessage = (event) => {
@@ -75,7 +84,7 @@ export function useWebSocket() {
const msg: WSServerMessage = JSON.parse(event.data);
handleServerMessage(msg);
} catch (err) {
console.error('[WS] 消息解析失败:', err);
console.error(`[WS#${instanceId}] 消息解析失败:`, err);
}
};
@@ -98,6 +107,7 @@ export function useWebSocket() {
}, [connect, currentSessionId]);
const sendMessage = useCallback((msg: WSClientMessage) => {
const instanceId = instanceIdRef.current;
if (wsRef.current?.readyState === WebSocket.OPEN) {
const sessionID = useSessionStore.getState().currentSessionId;
wsRef.current.send(
@@ -106,6 +116,24 @@ export function useWebSocket() {
session_id: msg.session_id || sessionID || undefined,
})
);
} else {
const stateLabels = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'];
const state = wsRef.current
? (stateLabels[wsRef.current.readyState] || `UNKNOWN(${wsRef.current.readyState})`)
: 'NO_SOCKET';
console.error(
`[WS#${instanceId}] sendMessage 失败: WebSocket 未就绪 (readyState=${state}),消息被丢弃:`,
msg.type,
msg.content?.slice(0, 50)
);
// 通知用户连接已断开
useChatStore.getState().setTyping(false);
useChatStore.getState().addMessage({
id: 'err_' + Date.now(),
role: 'system',
content: '⚠️ 连接未建立,请刷新页面后重试',
timestamp: Date.now(),
});
}
}, []);
@@ -228,6 +256,13 @@ function handleServerMessage(msg: WSServerMessage) {
case 'error':
console.error('[WS] 服务端错误:', msg.error);
setTyping(false);
// 在聊天界面显示错误提示,让用户知情
addMessage({
id: 'err_' + Date.now(),
role: 'system',
content: '❌ 服务端错误: ' + (msg.error || '未知错误'),
timestamp: msg.timestamp || Date.now(),
});
break;
case 'pong':
+8 -3
View File
@@ -11,7 +11,7 @@ interface AuthStore {
token: string | null;
loading: boolean;
login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
register: (username: string, password: string, email: string, verifyCode: string) => Promise<{ success: boolean; error?: string }>;
register: (username: string, password: string, email: string, nickname: string, verifyCode: string) => Promise<{ success: boolean; error?: string }>;
logout: () => void;
}
@@ -42,14 +42,18 @@ export const useAuthStore = create<AuthStore>((set) => ({
}
},
register: async (username: string, password: string, email: string, verifyCode: string) => {
register: async (username: string, password: string, email: string, nickname: string, verifyCode: string) => {
set({ loading: true });
try {
const resp = await apiRegister(username, password, email, verifyCode);
const resp = await apiRegister(username, password, email, nickname, verifyCode);
if (resp.error) {
set({ loading: false });
return { success: false, error: resp.error };
}
// 保存昵称到 localStorage
if (resp.data?.nickname) {
localStorage.setItem('user_nickname', resp.data.nickname);
}
set({
isLoggedIn: true,
userId: resp.data?.user_id || null,
@@ -65,6 +69,7 @@ export const useAuthStore = create<AuthStore>((set) => ({
logout: () => {
clearToken();
localStorage.removeItem('user_nickname');
set({
isLoggedIn: false,
userId: null,
+2
View File
@@ -42,6 +42,7 @@ export interface AuthResponse {
user_id: string;
token: string;
expires: number;
nickname?: string;
}
export interface LoginParams {
@@ -53,5 +54,6 @@ export interface RegisterParams {
username: string;
password: string;
email: string;
nickname: string;
verify_code: string;
}