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:
@@ -36,6 +36,9 @@ MINIO_BUCKET=cyrene-assets
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=your-admin-password
|
||||
|
||||
# ========== 管理员昵称 (昔涟对用户的基本称呼) ==========
|
||||
ADMIN_NICKNAME=管理员
|
||||
|
||||
# ========== 注册开关 (开发环境建议开启) ==========
|
||||
REGISTRATION_ENABLED=true
|
||||
|
||||
|
||||
+89
-178
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 将设备状态格式化为简洁的文本注入系统上下文
|
||||
|
||||
@@ -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)`,
|
||||
|
||||
@@ -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:
|
||||
- "记忆是自然流动的,重要的不会忘,不重要的自然会淡去"
|
||||
- "不要像整理数据库一样去'合并记忆'"
|
||||
- "'衰减'是自然而然的事——太刻意反而显得不真实"
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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 持有 LLMClient,Provider 需要能访问它。
|
||||
// 这里我们返回一个"占位"结果——实际 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)
|
||||
}
|
||||
Executable
BIN
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 通知推送信息
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
+41
-11
@@ -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
@@ -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');
|
||||
|
||||
@@ -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) ----
|
||||
|
||||
@@ -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 ? '请稍候...' : '注册并进入 ♪'}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user