feat: Phase 1+2 架构进化 — 连续思考链/主动消息决策/情感状态机/离线自主思考 (86文件)

Phase 1 (基础设施):
- ThinkChain 思考链连续性 + 差异化思考提示词 (persistent)
- AutonomousToolPolicy 工具安全策略 (safe/unsafe/conditional)
- MessageScheduler 自适应消息节奏 (Idle/Available/Busy)
- SessionEnrichmentStore 渐进式上下文丰富 (5层)
- ConversationBus 事件总线 + ResponseCache (dedup)
- pkg/logger 统一日志 + 所有 handler 替换 fmt.Printf
- NPE 守卫/链路优化/数据库表修复/Go workspace

Phase 2 (人格交互):
- EmotionState/EmotionTracker 情感状态机 (5种心情, 情绪衰减)
- ProactiveGuard 主动消息多维决策 (静默时段/紧急度/频率/校验)
- Gateway↔ai-core 在线状态感知链路 (presence notification)
- 离线思考频率控制 + 重连问候 + 离线消息排队

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 15:25:12 +08:00
parent b123a36aae
commit 87214b9441
86 changed files with 3085 additions and 582 deletions
+25
View File
@@ -219,6 +219,31 @@ func main() {
handleMemoryCRUD(w, r, memStore, memExtractor)
})
// Phase 2: 在线状态通知端点 (Gateway -> ai-core)
mux.HandleFunc("/api/v1/internal/presence", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if r.Header.Get("X-Internal-Token") != os.Getenv("INTERNAL_SERVICE_TOKEN") {
w.WriteHeader(http.StatusUnauthorized)
return
}
var req struct {
UserID string `json:"user_id"`
Status string `json:"status"`
SessionID string `json:"session_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
online := req.Status == "online"
thinker.UpdatePresence(online, req.SessionID)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok","service":"ai-core","model":"` + llmAdapter.ModelName() + `"}`))
+3
View File
@@ -5,5 +5,8 @@ go 1.26.2
require (
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/yourname/cyrene-ai/pkg/logger v0.0.0
gopkg.in/yaml.v3 v3.0.1
)
replace github.com/yourname/cyrene-ai/pkg/logger => ../pkg/logger
@@ -0,0 +1,200 @@
package background
import (
"log"
"strings"
"time"
)
// ProactiveDecision represents a decision about whether to send a proactive message.
type ProactiveDecision struct {
ShouldSend bool `json:"should_send"`
Urgency string `json:"urgency"` // low, medium, high
Reason string `json:"reason"`
}
// ProactiveGuard evaluates whether a proactive message should be sent
// based on time-of-day, urgency, rate limits, and user state.
type ProactiveGuard struct {
// Quiet hours: no non-urgent messages during this window
QuietHoursStart int // 0-23, default 23
QuietHoursEnd int // 0-23, default 7
// Min gap between proactive messages, by urgency
MinGapByUrgency map[string]time.Duration
// Max proactive messages per hour
MaxMessagesPerHour int
// Track recent send times for rate limiting
recentSends []time.Time
}
// DefaultProactiveGuard returns a guard with sensible defaults.
func DefaultProactiveGuard() *ProactiveGuard {
return &ProactiveGuard{
QuietHoursStart: 23,
QuietHoursEnd: 7,
MinGapByUrgency: map[string]time.Duration{
"low": 30 * time.Minute,
"medium": 10 * time.Minute,
"high": 2 * time.Minute,
},
MaxMessagesPerHour: 3,
}
}
// IsQuietHour returns true if the given time falls within quiet hours.
func (g *ProactiveGuard) IsQuietHour(now time.Time) bool {
hour := now.Hour()
if g.QuietHoursStart < g.QuietHoursEnd {
return hour >= g.QuietHoursStart && hour < g.QuietHoursEnd
}
// Overnight quiet hours (e.g., 23:00 - 07:00)
return hour >= g.QuietHoursStart || hour < g.QuietHoursEnd
}
// Evaluate checks whether a proactive message should be sent.
func (g *ProactiveGuard) Evaluate(now time.Time, lastProactiveTime time.Time, urgency string, userState string) ProactiveDecision {
// 1. Quiet hours: only high urgency messages pass
if g.IsQuietHour(now) && urgency != "high" {
return ProactiveDecision{
ShouldSend: false,
Urgency: urgency,
Reason: "当前处于安静时段(23:00-07:00),仅紧急消息可推送",
}
}
// 2. User state check: don't disturb if user is resting/busy
if userState == "resting" || userState == "busy" || userState == "sleeping" {
if urgency != "high" {
return ProactiveDecision{
ShouldSend: false,
Urgency: urgency,
Reason: "开拓者正在休息/忙碌,不打扰",
}
}
}
// 3. Rate limit by urgency
minGap, ok := g.MinGapByUrgency[urgency]
if !ok {
minGap = g.MinGapByUrgency["low"]
}
if !lastProactiveTime.IsZero() && now.Sub(lastProactiveTime) < minGap {
return ProactiveDecision{
ShouldSend: false,
Urgency: urgency,
Reason: "距上次主动消息时间过短(" + minGap.String() + " 最小间隔)",
}
}
// 4. Hourly rate limit
g.pruneOldSends(now)
if len(g.recentSends) >= g.MaxMessagesPerHour {
return ProactiveDecision{
ShouldSend: false,
Urgency: urgency,
Reason: "本小时主动消息已达上限",
}
}
// 5. Content length validation (caller should also check)
return ProactiveDecision{
ShouldSend: true,
Urgency: urgency,
Reason: "",
}
}
// RecordSend records a proactive message send for rate limiting.
func (g *ProactiveGuard) RecordSend(now time.Time) {
g.recentSends = append(g.recentSends, now)
g.pruneOldSends(now)
}
// pruneOldSends removes sends older than 1 hour.
func (g *ProactiveGuard) pruneOldSends(now time.Time) {
cutoff := now.Add(-1 * time.Hour)
valid := g.recentSends[:0]
for _, t := range g.recentSends {
if t.After(cutoff) {
valid = append(valid, t)
}
}
g.recentSends = valid
}
// ExtractUrgencyFromContent tries to infer urgency from the proactive message content.
func ExtractUrgencyFromContent(content string) string {
lower := strings.ToLower(content)
// High urgency indicators
highIndicators := []string{"紧急", "立刻", "马上", "危险", "警告", "报警", "异常", "urgent", "alert"}
for _, kw := range highIndicators {
if strings.Contains(lower, kw) {
return "high"
}
}
// Medium urgency indicators
mediumIndicators := []string{"建议", "提醒", "注意", "该", "要", "应该", "记得", "别忘了"}
for _, kw := range mediumIndicators {
if strings.Contains(lower, kw) {
return "medium"
}
}
return "low"
}
// ValidateProactiveMessage performs post-extraction validation on a message.
func ValidateProactiveMessage(content string) (valid bool, reason string) {
runes := []rune(content)
if len(runes) == 0 {
return false, "消息为空"
}
if len(runes) > 500 {
return false, "消息过长(>500字符)"
}
// Check for prohibited patterns (should not tell user they're resting when they're active)
prohibited := []string{
"系统检测到", "根据分析", "经检测", "后台监控",
}
for _, p := range prohibited {
if strings.Contains(content, p) {
return false, "包含机械语言: " + p
}
}
return true, ""
}
// DetermineUserState checks conversation history for user state indicators.
func DetermineUserState(lastUserMsg string) string {
lower := strings.ToLower(lastUserMsg)
restIndicators := []string{"睡", "休息", "躺", "困", "累", "晚安", "午安", "小憩"}
busyIndicators := []string{"忙", "工作", "开会", "出去", "走了", "拜拜", "再见", "回头", "晚点"}
for _, kw := range restIndicators {
if strings.Contains(lower, kw) {
return "resting"
}
}
for _, kw := range busyIndicators {
if strings.Contains(lower, kw) {
return "busy"
}
}
return "active"
}
// logDecision logs the proactive decision for debugging.
func logDecision(d ProactiveDecision) {
if d.ShouldSend {
log.Printf("[主动消息决策] 允许推送 (紧急程度=%s)", d.Urgency)
} else {
log.Printf("[主动消息决策] 阻止推送 (紧急程度=%s, 原因=%s)", d.Urgency, d.Reason)
}
}
@@ -0,0 +1,165 @@
package background
import (
"crypto/rand"
"fmt"
"strings"
"sync"
"time"
)
// ThinkRecord is a single thinking session's result.
type ThinkRecord struct {
ID string `json:"id"`
Content string `json:"content"`
Conclusions []string `json:"conclusions"` // key takeaways
FollowUps []string `json:"follow_ups"` // questions to continue
ToolCalls int `json:"tool_calls"`
Trigger string `json:"trigger"` // post_chat, silence, periodic
Timestamp time.Time `json:"timestamp"`
}
// ThinkChain stores linked thinking records so each round
// can build on previous conclusions.
type ThinkChain struct {
mu sync.Mutex
records []ThinkRecord
maxSize int
}
// NewThinkChain creates a think chain with the given max size.
func NewThinkChain(maxSize int) *ThinkChain {
if maxSize <= 0 {
maxSize = 10
}
return &ThinkChain{
records: make([]ThinkRecord, 0, maxSize),
maxSize: maxSize,
}
}
// Add appends a new think record, evicting oldest if at capacity.
func (c *ThinkChain) Add(r ThinkRecord) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.records) >= c.maxSize {
c.records = c.records[1:]
}
c.records = append(c.records, r)
}
// LastConclusions returns conclusions from the most recent N records.
func (c *ThinkChain) LastConclusions(n int) []string {
c.mu.Lock()
defer c.mu.Unlock()
var result []string
start := len(c.records) - n
if start < 0 {
start = 0
}
for _, r := range c.records[start:] {
result = append(result, r.Conclusions...)
}
return result
}
// LastFollowUps returns follow-up questions from the single most recent record.
func (c *ThinkChain) LastFollowUps() []string {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.records) == 0 {
return nil
}
return c.records[len(c.records)-1].FollowUps
}
// LastTopic attempts to infer a topic from recent conclusions.
func (c *ThinkChain) LastTopic() string {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.records) == 0 {
return ""
}
// Use first conclusion line of the most recent record as topic
for _, r := range c.records {
for _, c := range r.Conclusions {
if c != "" {
runes := []rune(c)
if len(runes) > 50 {
return string(runes[:50]) + "..."
}
return c
}
}
}
return ""
}
// Size returns the current number of records in the chain.
func (c *ThinkChain) Size() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.records)
}
// extractConclusions parses the LLM thinking output to find conclusions and follow-ups.
// Looks for "结论" / "后续" markers in the content.
func extractConclusions(content string) (conclusions []string, followUps []string) {
lines := strings.Split(content, "\n")
inConclusions := false
inFollowUps := false
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.Contains(line, "结论") && (strings.Contains(line, "💭") || strings.Contains(line, "📝") || strings.HasPrefix(line, "-")) {
// Heuristic: this line starts a conclusions section
}
// Match bullet-point conclusions: lines starting with - or •
if (strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "• ")) && !inFollowUps {
text := strings.TrimPrefix(line, "- ")
text = strings.TrimPrefix(text, "• ")
text = strings.TrimSpace(text)
if text != "" && len([]rune(text)) > 2 {
if inConclusions {
conclusions = append(conclusions, text)
} else {
// Without explicit marker, treat all bullets as conclusions
conclusions = append(conclusions, text)
}
}
continue
}
// Detect section transitions
if strings.Contains(line, "后续") || strings.Contains(line, "继续思考") || strings.Contains(line, "下次") {
inFollowUps = true
inConclusions = false
continue
}
if strings.Contains(line, "结论") || strings.Contains(line, "观察") || strings.Contains(line, "记忆") {
inConclusions = true
inFollowUps = false
continue
}
}
// If no structured markers found, treat the whole content as a single conclusion
if len(conclusions) == 0 {
runes := []rune(content)
if len(runes) > 200 {
content = string(runes[:200]) + "..."
}
conclusions = []string{content}
}
return conclusions, followUps
}
// generateID generates a short random ID.
func generateID() string {
b := make([]byte, 6)
rand.Read(b)
return fmt.Sprintf("th-%x", b)
}
+183 -26
View File
@@ -99,6 +99,43 @@ type Thinker struct {
// 思考计数器(用于周期性记忆维护,每 N 次思考触发一次)
thinkCount int
// Phase 1 Step 4: 思考链 + 自主工具安全策略
chain *ThinkChain
autoToolPolicy *AutonomousToolPolicy
// Phase 2: 情感追踪
emotionTracker *persona.EmotionTracker
// Phase 2: 主动消息决策守卫
proactiveGuard *ProactiveGuard
// Phase 2: 在线状态追踪
userOnline bool
lastOnlineChange time.Time
userSessionID string // 当前活跃的 session ID (用于重连)
}
// AutonomousToolPolicy 自主思考工具调用安全策略
type AutonomousToolPolicy struct {
// 允许在自主思考中使用的工具白名单
AllowedTools []string // iot_query, memory_search, web_search, calculator, datetime
// 每轮最大工具调用次数
MaxToolCallsPerRound int // 默认 5
// 高风险操作每小时最大次数 (如 iot_control)
MaxHighRiskPerHour int // 默认 10
// 高风险工具列表
HighRiskTools []string // iot_control
}
// DefaultAutonomousToolPolicy 默认安全策略
func DefaultAutonomousToolPolicy() *AutonomousToolPolicy {
return &AutonomousToolPolicy{
AllowedTools: []string{"iot_query", "iot_control", "memory_search", "web_search", "calculator", "datetime", "web_fetch"},
MaxToolCallsPerRound: 5,
MaxHighRiskPerHour: 10,
HighRiskTools: []string{"iot_control"},
}
}
// SetMessagePusher 设置主动消息推送回调
@@ -108,6 +145,40 @@ func (t *Thinker) SetMessagePusher(pusher func(string, string, string)) {
t.messagePusher = pusher
}
// SetEmotionTracker sets the emotion tracker.
func (t *Thinker) SetEmotionTracker(et *persona.EmotionTracker) {
t.mu.Lock()
defer t.mu.Unlock()
t.emotionTracker = et
}
// UpdatePresence updates the user online status.
// Called by the ai-core presence endpoint when gateway detects connect/disconnect.
func (t *Thinker) UpdatePresence(online bool, sessionID string) {
t.mu.Lock()
wasOffline := !t.userOnline
t.userOnline = online
t.lastOnlineChange = time.Now()
if sessionID != "" {
t.userSessionID = sessionID
t.activeSessionID = sessionID
}
t.mu.Unlock()
if online && wasOffline {
log.Printf("[后台思考] 用户上线 (session=%s),触发重连思考", sessionID)
// Trigger a return-thinking cycle after a short delay
time.Sleep(2 * time.Second)
t.performThink("user_returned")
// Also update emotion tracker
if t.emotionTracker != nil {
t.emotionTracker.UpdateMood("user_returned")
}
} else if !online {
log.Printf("[后台思考] 用户离线")
}
}
// ThinkerConfig 后台思考配置
type ThinkerConfig struct {
Enabled bool
@@ -171,6 +242,9 @@ func NewThinker(
pendingThoughts: make([]*PendingThought, 0),
lastUserMessage: time.Now(),
stopCh: make(chan struct{}),
chain: NewThinkChain(10),
autoToolPolicy: DefaultAutonomousToolPolicy(),
proactiveGuard: DefaultProactiveGuard(),
}
}
@@ -362,16 +436,27 @@ func (t *Thinker) periodicThinkLoop() {
sinceLastUser := time.Since(t.lastUserMessage)
t.mu.Unlock()
// 跳过条件:用户最近在活动(30s 内有消息),说明正在对话中
if sinceLastUser < 30*time.Second {
log.Printf("[后台思考] 用户在 %v 前发过消息,跳过周期性触发 (留给事件驱动处理)", sinceLastUser.Round(time.Second))
continue
}
// Phase 2: 离线时降低思考频率 (每30分钟一次,而非5分钟)
t.mu.Lock()
isOffline := !t.userOnline
t.mu.Unlock()
offlineMinGap := 30 * time.Minute
if sinceLastThink < t.minThinkGap {
log.Printf("[后台思考] 距上次思考仅 %v,跳过周期性触发", sinceLastThink.Round(time.Second))
continue
}
// 跳过条件:用户最近在活动(30s 内有消息),说明正在对话中
if sinceLastUser < 30*time.Second {
log.Printf("[后台思考] 用户在 %v 前发过消息,跳过周期性触发 (留给事件驱动处理)", sinceLastUser.Round(time.Second))
continue
}
if isOffline && sinceLastThink < offlineMinGap {
log.Printf("[后台思考] 用户离线,距上次思考仅 %v,跳过 (离线模式最小间隔=%v)", sinceLastThink.Round(time.Second), offlineMinGap)
continue
}
if !isOffline && sinceLastThink < t.minThinkGap {
log.Printf("[后台思考] 距上次思考仅 %v,跳过周期性触发", sinceLastThink.Round(time.Second))
continue
}
log.Printf("[后台思考] 周期性触发 (上次思考=%v前, 上次用户消息=%v前)", sinceLastThink.Round(time.Second), sinceLastUser.Round(time.Second))
t.performThink("periodic")
@@ -484,11 +569,11 @@ func (t *Thinker) performThink(triggerReason string) {
{Role: model.RoleUser, Content: userPrompt},
}
// 6. 准备工具定义
openAITools := t.buildOpenAITools()
// 6. 准备工具定义(通过自主工具策略过滤)
openAITools := t.filterToolsByPolicy(t.buildOpenAITools())
// 7. 调用 LLM(支持工具调用,最多 3 轮
maxToolRounds := 3
// 7. 调用 LLM(支持工具调用,策略限制轮数
maxToolRounds := t.autoToolPolicy.MaxToolCallsPerRound
var finalContent string
var totalToolCalls int
var toolCallRecords []map[string]interface{}
@@ -569,6 +654,21 @@ func (t *Thinker) performThink(triggerReason string) {
// 8. 存储思考结果
t.storeThought(finalContent, toolCallsJSON, totalToolCalls)
// 8.5 记录到思考链
if t.chain != nil {
conclusions, followUps := extractConclusions(finalContent)
t.chain.Add(ThinkRecord{
ID: generateID(),
Content: finalContent,
Conclusions: conclusions,
FollowUps: followUps,
ToolCalls: totalToolCalls,
Trigger: triggerReason,
Timestamp: time.Now(),
})
log.Printf("[后台思考] 思考链已记录 (序号=%d, 结论数=%d, 后续问题=%d)", t.chain.Size(), len(conclusions), len(followUps))
}
log.Printf("[后台思考] 完成 (触发原因=%s, 内容长度=%d, 工具调用=%d次)", triggerReason, len(finalContent), totalToolCalls)
// 9. 周期性记忆维护(每 10 次思考触发一次)
@@ -582,7 +682,11 @@ func (t *Thinker) performThink(triggerReason string) {
// 关键改动:不再是"定期的自我反思",而是"自然的、人性化的内在想法"。
// triggerReason 影响提示词的侧重点。
func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig, triggerReason string) string {
basePrompt := personaConfig.BuildSystemPrompt("开拓者", 1)
mood, moodExpr, _ := "", "", 0.0
if t.emotionTracker != nil {
mood, moodExpr, _ = t.emotionTracker.GetCurrentMood()
}
basePrompt := personaConfig.BuildSystemPromptWithMood("开拓者", 1, mood, moodExpr)
var thinkingInstructions string
@@ -620,9 +724,10 @@ func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig
- 如果他状态正常——有没有想在下次对话中聊的话题?` + noDisturbRules + `
其他规则:
1. 用第三人称或自言自语的方式,不要直接对开拓者喊话。
1. 反思部分用第三人称或自言自语的方式,不要直接对开拓者喊话。
2. 只有开拓者状态正常且真的有必要时才写【主动消息】,不要硬找话题。
3. 2-4句话即可。`
3. 【主动消息】的内容必须直接对开拓者说话(用"你"称呼他),像主动找他聊天一样。反思是给自己看的,主动消息是发给他的——语气要区分开。
4. 2-4句话即可。`
case "silence":
thinkingInstructions = `
@@ -756,6 +861,24 @@ func (t *Thinker) buildThinkingUserPrompt(
sb.WriteString("\n【你记得的关于开拓者的事】\n(暂无相关记忆)\n")
}
// 思考链:注入上一轮的结论和待续问题
if t.chain != nil {
lastConclusions := t.chain.LastConclusions(3)
if len(lastConclusions) > 0 {
sb.WriteString("\n【你上一轮思考的结论】\n")
for _, c := range lastConclusions {
sb.WriteString(fmt.Sprintf("- %s\n", c))
}
}
lastFollowUps := t.chain.LastFollowUps()
if len(lastFollowUps) > 0 {
sb.WriteString("\n【你上次想继续思考的问题】\n")
for _, f := range lastFollowUps {
sb.WriteString(fmt.Sprintf("- %s\n", f))
}
}
}
// IoT 设备状态
if deviceSummary != "" {
sb.WriteString("\n" + deviceSummary)
@@ -766,10 +889,32 @@ func (t *Thinker) buildThinkingUserPrompt(
sb.WriteString("\n记住:这是日记,用第三人称或自言自语的方式。")
sb.WriteString("\n⚠️ 如果开拓者正在休息/睡觉/忙碌——不要写【主动消息】。你可以在心里想他,但不要去打扰。")
sb.WriteString("\n只有在你确认他现在是醒着、有空、且真的需要关心时,才写【主动消息】。")
sb.WriteString("\n❗【主动消息】的内容必须直接对开拓者说话(用\"你\"来称呼他),就像你主动找他聊天一样自然。不要用第三人称或自言自语的方式写主动消息。")
return sb.String()
}
// filterToolsByPolicy 通过自主工具安全策略过滤工具列表
func (t *Thinker) filterToolsByPolicy(tools []llm.OpenAITool) []llm.OpenAITool {
if t.autoToolPolicy == nil || len(tools) == 0 {
return tools
}
allowed := make(map[string]bool)
for _, name := range t.autoToolPolicy.AllowedTools {
allowed[name] = true
}
var filtered []llm.OpenAITool
for _, tool := range tools {
if allowed[tool.Function.Name] {
filtered = append(filtered, tool)
}
}
if len(filtered) < len(tools) {
log.Printf("[后台思考] 工具策略过滤: %d/%d 工具可用", len(filtered), len(tools))
}
return filtered
}
// buildOpenAITools 将工具注册中心的定义转换为 LLM 层的 OpenAITool 格式
func (t *Thinker) buildOpenAITools() []llm.OpenAITool {
if t.toolRegistry == nil || !t.toolRegistry.IsEnabled() {
@@ -817,17 +962,29 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
pusher := t.messagePusher
canPush := proactiveMsg != "" && pusher != nil
if canPush {
// 检查频率限制
gapSinceLast := time.Since(t.lastProactiveMsgTime)
minGap := t.proactiveMsgMinGap
if minGap <= 0 {
minGap = 30 * time.Minute
}
if gapSinceLast < minGap {
log.Printf("[后台思考] 主动消息距上次仅 %v,跳过推送 (最小间隔=%v)", gapSinceLast.Round(time.Second), minGap)
// Phase 2: 使用 ProactiveGuard 多维度评估
urgency := ExtractUrgencyFromContent(proactiveMsg)
if valid, reason := ValidateProactiveMessage(proactiveMsg); !valid {
log.Printf("[后台思考] 主动消息内容校验失败: %s,跳过推送", reason)
canPush = false
} else {
t.lastProactiveMsgTime = time.Now()
}
if canPush && t.proactiveGuard != nil {
decision := t.proactiveGuard.Evaluate(time.Now(), t.lastProactiveMsgTime, urgency, "active")
logDecision(decision)
if !decision.ShouldSend {
canPush = false
} else {
t.lastProactiveMsgTime = time.Now()
t.proactiveGuard.RecordSend(time.Now())
}
} else if canPush {
gapSinceLast := time.Since(t.lastProactiveMsgTime)
if gapSinceLast < 30*time.Minute {
log.Printf("[后台思考] 主动消息距上次仅 %v,跳过推送", gapSinceLast.Round(time.Second))
canPush = false
} else {
t.lastProactiveMsgTime = time.Now()
}
}
}
t.mu.Unlock()
@@ -0,0 +1,101 @@
package bus
import (
"github.com/yourname/cyrene-ai/pkg/logger"
"sync"
"time"
)
// Bus 总线接口(方便测试和替换)
type Bus interface {
Publish(event BusEvent)
Subscribe(eventType EventType, handler EventHandler) *Subscription
}
// ConversationBus 对话事件总线
// Step 1: 仅 side-channel 发布,无消费端
type ConversationBus struct {
mu sync.RWMutex
subscribers map[EventType][]*Subscription
eventCh chan BusEvent
done chan struct{}
}
// NewConversationBus 创建总线
func NewConversationBus() *ConversationBus {
b := &ConversationBus{
subscribers: make(map[EventType][]*Subscription),
eventCh: make(chan BusEvent, 64),
done: make(chan struct{}),
}
go b.dispatchLoop()
return b
}
// Publish 发布事件到总线(非阻塞)
func (b *ConversationBus) Publish(event BusEvent) {
if event.Timestamp.IsZero() {
event.Timestamp = time.Now()
}
select {
case b.eventCh <- event:
default:
logger.Printf("[bus] 事件通道已满,丢弃事件: type=%s session=%s", event.Type, event.SessionID)
}
}
// Subscribe 订阅事件类型
func (b *ConversationBus) Subscribe(eventType EventType, handler EventHandler) *Subscription {
b.mu.Lock()
defer b.mu.Unlock()
sub := &Subscription{bus: b, eventType: eventType, handler: handler}
b.subscribers[eventType] = append(b.subscribers[eventType], sub)
return sub
}
// unsubscribe 内部取消订阅
func (b *ConversationBus) unsubscribe(sub *Subscription) {
b.mu.Lock()
defer b.mu.Unlock()
subs := b.subscribers[sub.eventType]
for i, s := range subs {
if s == sub {
b.subscribers[sub.eventType] = append(subs[:i], subs[i+1:]...)
break
}
}
}
// Stop 停止总线
func (b *ConversationBus) Stop() {
close(b.done)
}
// dispatchLoop 后台分发循环
func (b *ConversationBus) dispatchLoop() {
for {
select {
case event := <-b.eventCh:
b.mu.RLock()
subs := b.subscribers[event.Type]
// 拷贝一份避免持锁回调
handlers := make([]EventHandler, len(subs))
for i, s := range subs {
handlers[i] = s.handler
}
b.mu.RUnlock()
for _, h := range handlers {
func() {
defer func() {
if r := recover(); r != nil {
logger.Printf("[bus] handler panic: %v", r)
}
}()
h(event)
}()
}
case <-b.done:
return
}
}
}
@@ -0,0 +1,43 @@
package bus
import (
"github.com/yourname/cyrene-ai/ai-core/internal/model"
"time"
)
// EventType 总线事件类型
type EventType string
const (
EventSubSessionStarted EventType = "sub_session_started"
EventSubSessionCompleted EventType = "sub_session_completed"
EventSubSessionProgress EventType = "sub_session_progress"
EventSynthesisStarted EventType = "synthesis_started"
EventSynthesisDone EventType = "synthesis_done"
EventReviewReady EventType = "review_ready"
EventError EventType = "error"
)
// BusEvent 总线事件
type BusEvent struct {
ID string
Type EventType
SessionID string
UserID string
Payload interface{}
Timestamp time.Time
}
// SubSessionPayload 子会话事件负载
type SubSessionPayload struct {
SubType model.SubSessionType
Status string // started, completed, failed
Summary string
Details string
Progress float64 // 0.0 ~ 1.0
}
// ReviewPayload 审查事件负载
type ReviewPayload struct {
Messages []model.ReviewMessage
}
@@ -0,0 +1,27 @@
package bus
// EventHandler 事件处理函数
type EventHandler func(BusEvent)
// Subscription 订阅句柄
type Subscription struct {
bus *ConversationBus
eventType EventType
handler EventHandler
}
// Unsubscribe 取消订阅
func (s *Subscription) Unsubscribe() {
if s.bus != nil {
s.bus.unsubscribe(s)
}
}
// NopBus 空操作总线(用于 nil 安全和测试)
type NopBus struct{}
func (n *NopBus) Publish(event BusEvent) {}
func (n *NopBus) Subscribe(eventType EventType, handler EventHandler) *Subscription {
return &Subscription{}
}
func (n *NopBus) unsubscribe(sub *Subscription) {}
+132
View File
@@ -0,0 +1,132 @@
// Package cache provides a response cache for skipping redundant LLM calls
// on semantically similar inputs (greetings and common IoT commands).
package cache
import (
"strings"
"sync"
"time"
)
// Entry is a cached LLM response.
type Entry struct {
FullContent string
CachedAt time.Time
AccessCount int
}
// ResponseCache caches LLM responses keyed by normalized user input.
// It uses separate TTLs for greetings (longer) and other queries (shorter).
type ResponseCache struct {
mu sync.RWMutex
entries map[string]*Entry
maxEntries int
greetingTTL time.Duration
defaultTTL time.Duration
}
// New creates a new ResponseCache with sensible defaults.
func New() *ResponseCache {
return &ResponseCache{
entries: make(map[string]*Entry),
maxEntries: 200,
greetingTTL: 10 * time.Minute,
defaultTTL: 30 * time.Second,
}
}
// Get returns a cached response for the given input if it exists and hasn't expired.
func (c *ResponseCache) Get(input string) (string, bool) {
key := normalize(input)
c.mu.RLock()
entry, ok := c.entries[key]
c.mu.RUnlock()
if !ok {
return "", false
}
ttl := c.defaultTTL
if isGreeting(input) {
ttl = c.greetingTTL
}
if time.Since(entry.CachedAt) > ttl {
c.mu.Lock()
delete(c.entries, key)
c.mu.Unlock()
return "", false
}
c.mu.Lock()
entry.AccessCount++
c.mu.Unlock()
return entry.FullContent, true
}
// Set stores a response in the cache.
func (c *ResponseCache) Set(input, response string) {
key := normalize(input)
c.mu.Lock()
defer c.mu.Unlock()
// Evict oldest entries if at capacity
if len(c.entries) >= c.maxEntries {
var oldestKey string
var oldestTime time.Time
for k, v := range c.entries {
if oldestKey == "" || v.CachedAt.Before(oldestTime) {
oldestKey = k
oldestTime = v.CachedAt
}
}
if oldestKey != "" {
delete(c.entries, oldestKey)
}
}
c.entries[key] = &Entry{
FullContent: response,
CachedAt: time.Now(),
AccessCount: 0,
}
}
// Invalidate clears all cached entries.
func (c *ResponseCache) Invalidate() {
c.mu.Lock()
c.entries = make(map[string]*Entry)
c.mu.Unlock()
}
// Size returns the current number of cached entries.
func (c *ResponseCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.entries)
}
// normalize produces a cache key from user input.
func normalize(input string) string {
s := strings.TrimSpace(strings.ToLower(input))
// Collapse multiple spaces
parts := strings.Fields(s)
return strings.Join(parts, " ")
}
// isGreeting returns true if the input looks like a simple greeting/small-talk
// that can be cached with a longer TTL.
func isGreeting(input string) bool {
normalized := normalize(input)
greetings := []string{
"你好", "嗨", "嘿", "哈喽", "hello", "hi", "hey",
"早上好", "下午好", "晚上好", "晚安", "早安", "午安",
"在吗", "在不在", "在么",
"谢谢", "多谢", "感谢", "thanks", "thank you",
"好的", "ok", "okay", "行", "可以",
"再见", "拜拜", "bye", "byebye",
"嗯", "哦", "噢",
}
for _, g := range greetings {
if normalized == g {
return true
}
}
return false
}
+89
View File
@@ -0,0 +1,89 @@
package cache
import (
"testing"
)
func TestNormalize(t *testing.T) {
tests := []struct{ input, want string }{
{" Hello World ", "hello world"},
{"你好", "你好"},
{" 你好 呀 ", "你好 呀"},
{"OK", "ok"},
}
for _, tt := range tests {
got := normalize(tt.input)
if got != tt.want {
t.Errorf("normalize(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestIsGreeting(t *testing.T) {
if !isGreeting("你好") {
t.Error("'你好' should be a greeting")
}
if !isGreeting("hello") {
t.Error("'hello' should be a greeting")
}
if isGreeting("今天天气真好") {
t.Error("'今天天气真好' should NOT be a greeting")
}
if isGreeting("帮我开灯") {
t.Error("'帮我开灯' should NOT be a greeting")
}
}
func TestCacheHit(t *testing.T) {
c := New()
c.Set("你好呀", "你好呀,开拓者♪ 今天有什么想聊的吗?")
got, ok := c.Get("你好呀")
if !ok {
t.Fatal("expected cache hit")
}
if got != "你好呀,开拓者♪ 今天有什么想聊的吗?" {
t.Errorf("cached response mismatch: %q", got)
}
}
func TestCacheMiss(t *testing.T) {
c := New()
_, ok := c.Get("从未说过的话")
if ok {
t.Error("expected cache miss")
}
}
func TestCacheNormalization(t *testing.T) {
c := New()
c.Set(" 你好 ", "回复内容")
// Normalized key should match
_, ok := c.Get("你好")
if !ok {
t.Error("normalized key should produce cache hit")
}
}
func TestCacheEviction(t *testing.T) {
c := New()
c.maxEntries = 3
c.Set("a", "A")
c.Set("b", "B")
c.Set("c", "C")
c.Set("d", "D") // should evict the oldest
if c.Size() > 3 {
t.Errorf("cache should be <= 3 entries, got %d", c.Size())
}
}
func TestInvalidate(t *testing.T) {
c := New()
c.Set("test", "value")
c.Invalidate()
if c.Size() != 0 {
t.Errorf("cache should be empty after invalidate, got %d", c.Size())
}
}
+4 -4
View File
@@ -3,7 +3,7 @@ package context
import (
"context"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"strings"
"sync"
@@ -212,17 +212,17 @@ func (b *Builder) Build(ctx context.Context, params BuildParams) ([]model.LLMMes
// loadHistory 从 ConversationStore 加载会话历史
func (b *Builder) loadHistory(_ context.Context, sessionID string, limit int) ([]model.LLMMessage, error) {
if b.convStore == nil {
log.Printf("[context] 会话历史存储未初始化,跳过加载")
logger.Printf("[context] 会话历史存储未初始化,跳过加载")
return nil, nil
}
history := b.convStore.GetHistory(sessionID, limit)
if len(history) == 0 {
log.Printf("[context] 会话 %s 无历史记录", sessionID)
logger.Printf("[context] 会话 %s 无历史记录", sessionID)
return nil, nil
}
log.Printf("[context] 加载会话 %s 历史 %d 条", sessionID, len(history))
logger.Printf("[context] 加载会话 %s 历史 %d 条", sessionID, len(history))
return history, nil
}
+3 -3
View File
@@ -7,7 +7,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"strings"
"time"
@@ -119,7 +119,7 @@ func (p *OpenAIProvider) ChatWithTools(ctx context.Context, messages []model.LLM
if err != nil {
// 尝试fallback模型
if p.config.FallbackModel != "" && p.config.FallbackModel != p.config.Model {
log.Printf("[LLM] 主模型 %s 调用失败,降级到 %s: %v", p.config.Model, p.config.FallbackModel, err)
logger.Printf("[LLM] 主模型 %s 调用失败,降级到 %s: %v", p.config.Model, p.config.FallbackModel, err)
return p.doChat(ctx, messages, p.config.FallbackModel, false, tools)
}
return nil, err
@@ -143,7 +143,7 @@ func (p *OpenAIProvider) ChatStreamWithTools(ctx context.Context, messages []mod
if err != nil {
// Fallback
if p.config.FallbackModel != "" {
log.Printf("[LLM] 流式调用主模型失败,降级: %v", err)
logger.Printf("[LLM] 流式调用主模型失败,降级: %v", err)
resp, err = p.doChatStream(ctx, messages, p.config.FallbackModel, tools)
}
if err != nil {
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"time"
@@ -301,7 +301,7 @@ func (c *Client) doRequest(ctx context.Context, method, url string, body []byte)
resp, err := c.httpClient.Do(req)
if err != nil {
log.Printf("[memory-client] HTTP 请求失败 %s %s: %v", method, url, err)
logger.Printf("[memory-client] HTTP 请求失败 %s %s: %v", method, url, err)
return nil, err
}
+6 -6
View File
@@ -4,7 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"strings"
"github.com/yourname/cyrene-ai/ai-core/internal/model"
@@ -31,7 +31,7 @@ func NewExtractor(store *Store, llmChat func(ctx context.Context, messages []mod
func (e *Extractor) ExtractAndStore(ctx context.Context, userID, sessionID, userMessage, assistantResponse string) {
memories, err := e.extract(ctx, userMessage, assistantResponse)
if err != nil {
log.Printf("[memory] 记忆提取失败: %v", err)
logger.Printf("[memory] 记忆提取失败: %v", err)
return
}
@@ -49,10 +49,10 @@ func (e *Extractor) ExtractAndStore(ctx context.Context, userID, sessionID, user
}
if err := e.store.Save(ctx, &mem); err != nil {
log.Printf("[memory] 记忆保存失败: %v", err)
logger.Printf("[memory] 记忆保存失败: %v", err)
continue
}
log.Printf("[memory] 新记忆已保存 [%s|%d★]: %s", mem.Category, mem.Importance, mem.Summary)
logger.Printf("[memory] 新记忆已保存 [%s|%d★]: %s", mem.Category, mem.Importance, mem.Summary)
}
}
@@ -280,11 +280,11 @@ func (e *Extractor) mergeMemory(ctx context.Context, existing *model.MemoryEntry
existing.AccessCount++
if err := e.store.Update(ctx, existing); err != nil {
log.Printf("[memory] 合并记忆更新失败: %v", err)
logger.Printf("[memory] 合并记忆更新失败: %v", err)
return
}
log.Printf("[memory] 合并记忆 [%s|%d★]: %s (相似度 > %.0f%%)",
logger.Printf("[memory] 合并记忆 [%s|%d★]: %s (相似度 > %.0f%%)",
existing.Category, existing.Importance, existing.Summary, deDupThreshold*100)
}
+12 -12
View File
@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"sync"
"time"
@@ -42,9 +42,9 @@ func NewStore(connStr string) *Store {
// 尝试初始连接
if err := s.Reconnect(); err != nil {
log.Printf("[memory] ⚠ 记忆存储初始化: 数据库连接失败 (%v),将在后台每30秒重试", err)
logger.Printf("[memory] ⚠ 记忆存储初始化: 数据库连接失败 (%v),将在后台每30秒重试", err)
} else {
log.Println("[memory] 记忆存储已就绪")
logger.Println("[memory] 记忆存储已就绪")
}
// 启动后台重连 goroutine
@@ -70,7 +70,7 @@ func (s *Store) reconnectLoop() {
s.mu.RUnlock()
if db != nil {
if err := db.Ping(); err != nil {
log.Printf("[memory] ⚠ 数据库连接丢失: %v,开始重连", err)
logger.Printf("[memory] ⚠ 数据库连接丢失: %v,开始重连", err)
s.mu.Lock()
if s.db != nil {
s.db.Close()
@@ -83,7 +83,7 @@ func (s *Store) reconnectLoop() {
if !s.IsReady() {
if err := s.Reconnect(); err != nil {
log.Printf("[memory] ⚠ 数据库重连失败: %v", err)
logger.Printf("[memory] ⚠ 数据库重连失败: %v", err)
}
}
}
@@ -122,13 +122,13 @@ func (s *Store) Reconnect() error {
// 执行建表迁移
if err := s.migrate(); err != nil {
log.Printf("[memory] ⚠ 数据库迁移失败: %v", err)
logger.Printf("[memory] ⚠ 数据库迁移失败: %v", err)
s.db.Close()
s.db = nil
return fmt.Errorf("数据库迁移失败: %w", err)
}
log.Println("[memory] ✅ 数据库重连成功,记忆系统已就绪")
logger.Println("[memory] ✅ 数据库重连成功,记忆系统已就绪")
return nil
}
@@ -471,24 +471,24 @@ func (s *Store) ConsolidateMemories(ctx context.Context, userID string) error {
keep.Source = "consolidated"
if err := s.Update(ctx, keep); err != nil {
log.Printf("[memory] 合并更新记忆 %s 失败: %v", keep.ID, err)
logger.Printf("[memory] 合并更新记忆 %s 失败: %v", keep.ID, err)
continue
}
if err := s.Delete(ctx, discard.ID); err != nil {
log.Printf("[memory] 合并删除记忆 %s 失败: %v", discard.ID, err)
logger.Printf("[memory] 合并删除记忆 %s 失败: %v", discard.ID, err)
continue
}
discard.ID = ""
merged++
log.Printf("[memory] 合并相似记忆: %s <- %s (相似度 %.0f%%)",
logger.Printf("[memory] 合并相似记忆: %s <- %s (相似度 %.0f%%)",
keep.ID[:min(8, len(keep.ID))], discard.ID[:min(8, len(discard.ID))], score*100)
}
}
}
if merged > 0 {
log.Printf("[memory] 记忆整理完成: 用户 %s 合并 %d 条相似记忆", userID, merged)
logger.Printf("[memory] 记忆整理完成: 用户 %s 合并 %d 条相似记忆", userID, merged)
}
return nil
}
@@ -530,7 +530,7 @@ func (s *Store) DecayMemories(ctx context.Context, userID string) error {
total := decayed1 + deleted2
if total > 0 {
log.Printf("[memory] 记忆衰减完成: 用户 %s 降级 %d 条, 删除 %d 条过期临时记忆",
logger.Printf("[memory] 记忆衰减完成: 用户 %s 降级 %d 条, 删除 %d 条过期临时记忆",
userID, decayed1, deleted2)
}
+34 -10
View File
@@ -47,6 +47,7 @@ type SubSessionResult struct {
ToolCalls []ToolCallRecord `json:"tool_calls"` // 工具调用记录
Memories []MemorySnippet `json:"memories"` // 检索到的记忆片段
Confidence float64 `json:"confidence"` // 置信度 0-1
Progress float64 `json:"progress"` // 执行进度 0.0 ~ 1.0
Error string `json:"error,omitempty"`
Metadata map[string]any `json:"metadata"` // 类型特定的元数据
}
@@ -101,22 +102,44 @@ type MultiMessageItem struct {
// StreamEvent 流式事件
type StreamEvent struct {
Type StreamEventType `json:"type"` // delta, segments, done, error, review
Delta string `json:"delta,omitempty"` // 逐 token delta
Segments []Segment `json:"segments,omitempty"` // 断句片段
ReviewMessages []ReviewMessage `json:"review_messages,omitempty"` // 审查后的带类型消息
Error error `json:"-"` // 内部错误
Type StreamEventType `json:"type"` // delta, segments, done, error, review, thinking, tool_progress, system_info
Delta string `json:"delta,omitempty"` // 逐 token delta
Segments []Segment `json:"segments,omitempty"` // 断句片段
ReviewMessages []ReviewMessage `json:"review_messages,omitempty"` // 审查后的带类型消息
ThinkingContent string `json:"thinking_content,omitempty"` // 思考内容
ToolProgress *ToolProgressInfo `json:"tool_progress,omitempty"` // 工具进度
SystemInfo *SystemInfoPayload `json:"system_info,omitempty"` // 系统信息
ProtocolVersion int `json:"protocol_version,omitempty"` // 协议版本
Error error `json:"-"` // 内部错误
}
// ToolProgressInfo 工具执行进度
type ToolProgressInfo struct {
ToolName string `json:"tool_name"`
Status string `json:"status"` // started, running, completed, failed
Progress float64 `json:"progress"`
Message string `json:"message"`
}
// SystemInfoPayload 系统信息负载
type SystemInfoPayload struct {
Level string `json:"level"` // info, warning, error
Message string `json:"message"`
Action string `json:"action,omitempty"`
}
// StreamEventType 流式事件类型
type StreamEventType string
const (
StreamDelta StreamEventType = "delta"
StreamSegments StreamEventType = "segments"
StreamDone StreamEventType = "done"
StreamError StreamEventType = "error"
StreamReview StreamEventType = "review" // 审查后的带类型消息
StreamDelta StreamEventType = "delta"
StreamSegments StreamEventType = "segments"
StreamDone StreamEventType = "done"
StreamError StreamEventType = "error"
StreamReview StreamEventType = "review" // 审查后的带类型消息
StreamThinking StreamEventType = "thinking" // 思考内容
StreamToolProgress StreamEventType = "tool_progress" // 工具执行进度
StreamSystemInfo StreamEventType = "system_info" // 系统通知
)
// ReviewMessageType 审查消息类型
@@ -131,6 +154,7 @@ const (
type ReviewMessage struct {
Type ReviewMessageType `json:"type"`
Content string `json:"content"`
DelayMs int `json:"delay_ms,omitempty"` // ms to wait before sending (0 = immediate)
}
// Segment 语音片段
@@ -0,0 +1,46 @@
package orchestrator
import "sync"
// EnrichmentData holds async sub-session results stored for the next user turn.
type EnrichmentData struct {
MemorySummary string
ThoughtOutline string
IoTSummary string
}
// SessionEnrichmentStore is a thread-safe per-session cache for async
// sub-session enrichment. Results from the current turn are stored here
// and injected at the start of the next turn's synthesis.
type SessionEnrichmentStore struct {
mu sync.RWMutex
data map[string]*EnrichmentData
}
// NewEnrichmentStore creates a new SessionEnrichmentStore.
func NewEnrichmentStore() *SessionEnrichmentStore {
return &SessionEnrichmentStore{
data: make(map[string]*EnrichmentData),
}
}
// Get returns stored enrichment for a session and clears it (one-shot consumption).
func (s *SessionEnrichmentStore) Get(sessionID string) *EnrichmentData {
s.mu.Lock()
defer s.mu.Unlock()
d, ok := s.data[sessionID]
if ok {
delete(s.data, sessionID)
}
return d
}
// Store saves enrichment for a session (called when sub-sessions complete).
func (s *SessionEnrichmentStore) Store(sessionID string, d *EnrichmentData) {
if d == nil {
return
}
s.mu.Lock()
s.data[sessionID] = d
s.mu.Unlock()
}
@@ -4,7 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"strings"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
@@ -31,7 +31,7 @@ func NewIntentAnalyzer(llmAdapter *llm.Adapter) *IntentAnalyzer {
func (a *IntentAnalyzer) Analyze(ctx context.Context, userMessage string) (*model.IntentResult, error) {
// 快速通道:简单问候/闲聊直接返回,跳过 LLM 调用
if a.isSimpleGreeting(userMessage) {
log.Printf("[intent] 快速通道: 检测到简单问候,跳过 LLM 分析")
logger.Printf("[intent] 快速通道: 检测到简单问候,跳过 LLM 分析")
result := &model.IntentResult{
Primary: "greeting",
NeedsMemory: false,
@@ -44,13 +44,13 @@ func (a *IntentAnalyzer) Analyze(ctx context.Context, userMessage string) (*mode
// 快速通道:强 IoT 关键词直接使用规则匹配,跳过 LLM 调用(节省 2-3s)
if a.isStrongIoTCommand(userMessage) {
log.Printf("[intent] 快速通道: 检测到 IoT 操控命令,跳过 LLM 分析")
logger.Printf("[intent] 快速通道: 检测到 IoT 操控命令,跳过 LLM 分析")
return a.keywordAnalyze(userMessage), nil
}
// 如果 LLM 不可用,直接使用关键词匹配
if !a.enabled || a.llmAdapter == nil {
log.Printf("[intent] LLM 不可用,使用关键词规则分析意图")
logger.Printf("[intent] LLM 不可用,使用关键词规则分析意图")
return a.keywordAnalyze(userMessage), nil
}
@@ -69,18 +69,18 @@ func (a *IntentAnalyzer) Analyze(ctx context.Context, userMessage string) (*mode
// 调用 LLM (同步)
resp, err := a.llmAdapter.Chat(ctx, messages)
if err != nil {
log.Printf("[intent] LLM 意图分析失败: %v,降级使用关键词规则", err)
logger.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)
logger.Printf("[intent] 解析意图 JSON 失败: %v,降级使用关键词规则", err)
return a.keywordAnalyze(userMessage), nil
}
log.Printf("[intent] 意图分析完成: primary=%s, iot=%v, memory=%v, sentiment=%s",
logger.Printf("[intent] 意图分析完成: primary=%s, iot=%v, memory=%v, sentiment=%s",
intent.Primary, intent.NeedsIoT, intent.NeedsMemory, intent.Sentiment)
return intent, nil
@@ -0,0 +1,157 @@
package orchestrator
import (
"testing"
)
func TestIsSimpleGreeting(t *testing.T) {
a := &IntentAnalyzer{}
tests := []struct {
name string
input string
expected bool
}{
// Exact matches
{"你好 (exact)", "你好", true},
{"hello (exact)", "hello", true},
{"早上好 (exact)", "早上好", true},
{"晚安 (exact)", "晚安", true},
{"谢谢 (exact)", "谢谢", true},
{"在吗 (exact)", "在吗", true},
{"再见 (exact)", "再见", true},
{"单个嗯", "嗯", true},
// Short messages (<=4 chars, no complex keywords)
{"极短消息", "好的呀", true},
{"短闲聊", "哈哈", true},
{"OK", "ok", true},
// Short but with IoT/task keywords → not a greeting
{"短IoT关键词", "开灯", false},
{"短问题", "怎么", false},
{"短设备", "灯", false},
{"帮我", "帮我", false},
// Longer messages → not a greeting
{"正常对话", "今天天气真好呀", false},
{"长问候", "昔涟早上好呀,今天怎么样", false},
{"带问题", "你好,帮我开灯好吗", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := a.isSimpleGreeting(tt.input)
if got != tt.expected {
t.Errorf("isSimpleGreeting(%q) = %v, want %v", tt.input, got, tt.expected)
}
})
}
}
func TestIsStrongIoTCommand(t *testing.T) {
a := &IntentAnalyzer{}
tests := []struct {
name string
input string
expected bool
}{
// Control + device combinations → true
{"打开灯", "打开客厅灯", true},
{"关掉空调", "关掉卧室空调", true},
{"打开电视", "打开电视", true},
{"关闭窗帘", "关闭窗帘", true},
{"调到26度", "把空调调到26度", true},
{"设置温度", "设置空调温度", true},
{"关掉风扇", "关掉风扇", true},
// No device word → false
{"仅控制词", "打开", false},
{"仅设备词", "灯开了吗", false},
{"仅查询", "现在客厅灯是什么状态", false},
// Neither → false
{"普通对话", "你好呀", false},
{"闲聊", "今天天气不错", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := a.isStrongIoTCommand(tt.input)
if got != tt.expected {
t.Errorf("isStrongIoTCommand(%q) = %v, want %v", tt.input, got, tt.expected)
}
})
}
}
func TestKeywordAnalyze(t *testing.T) {
a := &IntentAnalyzer{}
tests := []struct {
name string
input string
wantPrimary string
wantNeedsIoT bool
wantSentiment string
}{
{"IoT命令", "打开客厅灯", "iot_control", true, "neutral"},
{"IoT查询", "现在灯是什么状态", "question", true, "neutral"},
{"I情感正面", "今天好开心呀", "chat", false, "positive"},
{"I情感负面", "我今天好累", "emotional", false, "negative"},
{"I提问", "怎么学习日语", "question", false, "neutral"},
{"I普通聊天", "今天天气真好", "chat", false, "neutral"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := a.keywordAnalyze(tt.input)
if got.Primary != tt.wantPrimary {
t.Errorf("keywordAnalyze(%q).Primary = %q, want %q", tt.input, got.Primary, tt.wantPrimary)
}
if got.NeedsIoT != tt.wantNeedsIoT {
t.Errorf("keywordAnalyze(%q).NeedsIoT = %v, want %v", tt.input, got.NeedsIoT, tt.wantNeedsIoT)
}
if got.Sentiment != tt.wantSentiment {
t.Errorf("keywordAnalyze(%q).Sentiment = %q, want %q", tt.input, got.Sentiment, tt.wantSentiment)
}
})
}
}
func TestParseIntentResponse(t *testing.T) {
tests := []struct {
name string
input string
want string // expected Primary
wantErr bool
}{
{"纯净JSON", `{"primary":"chat","needs_iot":false,"needs_memory":true,"sentiment":"positive","urgency":"low"}`, "chat", false},
{"Markdown包裹", "```json\n{\"primary\":\"iot_control\",\"needs_iot\":true,\"needs_memory\":true,\"sentiment\":\"neutral\",\"urgency\":\"high\"}\n```", "iot_control", false},
{"前后有空白", " \n{\"primary\":\"question\",\"needs_iot\":false,\"needs_memory\":true,\"sentiment\":\"neutral\",\"urgency\":\"medium\"}\n ", "question", false},
{"JSON前后有文字", "分析结果:{\"primary\":\"chat\",\"needs_iot\":false,\"needs_memory\":true,\"sentiment\":\"neutral\",\"urgency\":\"low\"},仅供参考", "chat", false},
{"默认值填充", `{"needs_iot":true}`, "chat", false}, // Primary 默认为 "chat"
{"无效JSON", "不是JSON", "", true},
{"空字符串", "", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseIntentResponse(tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("parseIntentResponse(%q) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Errorf("parseIntentResponse(%q) unexpected error: %v", tt.input, err)
return
}
if got.Primary != tt.want {
t.Errorf("parseIntentResponse(%q).Primary = %q, want %q", tt.input, got.Primary, tt.want)
}
})
}
}
@@ -3,16 +3,20 @@ package orchestrator
import (
"context"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"strings"
"time"
"github.com/yourname/cyrene-ai/ai-core/internal/cache"
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"
"github.com/yourname/cyrene-ai/ai-core/internal/bus"
"github.com/yourname/cyrene-ai/ai-core/internal/scheduler"
)
// Orchestrator 对话编排器 v2.0
@@ -26,6 +30,44 @@ type Orchestrator struct {
synthesizer *Synthesizer
memoryRetriever *memory.Retriever
memoryExtractor *memory.Extractor
responseCache *cache.ResponseCache
eventBus bus.Bus
enrichmentStore *SessionEnrichmentStore
msgScheduler *scheduler.MessageScheduler
emotionTracker *persona.EmotionTracker
}
// SetResponseCache sets the response cache (optional, for Phase 0.2).
func (o *Orchestrator) SetResponseCache(c *cache.ResponseCache) {
o.responseCache = c
}
// SetBus sets the event bus (optional, for Phase 1).
func (o *Orchestrator) SetBus(b bus.Bus) {
o.eventBus = b
}
// SetEnrichmentStore sets the enrichment store (optional, for Phase 1 Step 2).
func (o *Orchestrator) SetEnrichmentStore(s *SessionEnrichmentStore) {
o.enrichmentStore = s
}
// SetMessageScheduler sets the message scheduler (optional, for Phase 1 Step 3).
func (o *Orchestrator) SetMessageScheduler(s *scheduler.MessageScheduler) {
o.msgScheduler = s
}
// SetEmotionTracker sets the emotion tracker (optional, for Phase 2).
func (o *Orchestrator) SetEmotionTracker(t *persona.EmotionTracker) {
o.emotionTracker = t
}
// getBus returns the bus or a nop fallback.
func (o *Orchestrator) getBus() bus.Bus {
if o.eventBus == nil {
return &bus.NopBus{}
}
return o.eventBus
}
// NewOrchestrator 创建编排器
@@ -84,15 +126,22 @@ func (o *Orchestrator) ProcessInput(
defer close(eventCh)
defer func() {
if r := recover(); r != nil {
log.Printf("[orchestrator] 编排器主循环 panic 恢复: %v", r)
logger.Printf("[orchestrator] 编排器主循环 panic 恢复: %v", r)
}
}()
// 0. 发布合成开始事件
o.getBus().Publish(bus.BusEvent{
Type: bus.EventSynthesisStarted,
SessionID: params.SessionID,
UserID: params.UserID,
})
// 1. 意图分析
startTime := time.Now()
intent, err := o.intentAnalyzer.Analyze(ctx, params.Message)
if err != nil || intent == nil {
log.Printf("[orchestrator] 意图分析失败: %v,使用默认值", err)
logger.Printf("[orchestrator] 意图分析失败: %v,使用默认值", err)
intent = &model.IntentResult{
Primary: "chat",
NeedsMemory: true,
@@ -100,7 +149,49 @@ func (o *Orchestrator) ProcessInput(
Urgency: "low",
}
}
log.Printf("[orchestrator] 意图分析耗时: %v, primary=%s", time.Since(startTime), intent.Primary)
logger.Printf("[orchestrator] 意图分析耗时: %v, primary=%s", time.Since(startTime), intent.Primary)
// 1.6 记录情感状态
if o.emotionTracker != nil {
o.emotionTracker.RecordSentiment(intent.Sentiment)
}
// 1.5 检查响应缓存
if o.responseCache != nil {
if cached, ok := o.responseCache.Get(params.Message); ok {
logger.Printf("[orchestrator] 缓存命中,跳过 LLM 调用")
fullContent := cached
eventCh <- model.StreamEvent{
Type: model.StreamDelta,
Delta: fullContent,
}
if reviewMessages := parseReviewMessages(fullContent); len(reviewMessages) > 0 {
reviewMessages = o.scheduleWithDelays(reviewMessages)
eventCh <- model.StreamEvent{
Type: model.StreamReview,
ReviewMessages: reviewMessages,
}
}
segmenter := llm.NewSegmenter()
var segments []model.Segment
for _, ch := range fullContent {
newSegs := segmenter.Feed(string(ch))
for _, s := range newSegs {
segments = append(segments, model.Segment{Index: s.Index, Text: s.Text})
}
}
if remaining := segmenter.Flush(); remaining != nil {
segments = append(segments, model.Segment{Index: remaining.Index, Text: remaining.Text})
}
if len(segments) > 0 {
eventCh <- model.StreamEvent{Type: model.StreamSegments, Segments: segments}
}
eventCh <- model.StreamEvent{Type: model.StreamDone}
o.contextBuilder.CacheMessage(params.SessionID, model.RoleAssistant, fullContent)
logger.Printf("[orchestrator] 缓存响应完成: len=%d", len([]rune(fullContent)))
return
}
}
// 2. 加载人格配置
personaConfig, err := o.personaLoader.Get("cyrene")
@@ -136,7 +227,7 @@ func (o *Orchestrator) ProcessInput(
var resultCh <-chan model.SubSessionResult
skipSubSessions := intent.Primary == "greeting" && !intent.NeedsMemory
if skipSubSessions {
log.Printf("[orchestrator] 快速通道: 简单问候(primary=%s),跳过子会话分派", intent.Primary)
logger.Printf("[orchestrator] 快速通道: 简单问候(primary=%s),跳过子会话分派", intent.Primary)
emptyCh := make(chan model.SubSessionResult)
close(emptyCh)
resultCh = emptyCh
@@ -144,11 +235,27 @@ func (o *Orchestrator) ProcessInput(
resultCh = o.subManager.Dispatch(subCtx, intent, params.Message, createParams)
}
// 4. 先构建基础综合参数(不含子会话结果),开始合成
history := o.contextBuilder.GetHistory(params.SessionID, 20)
systemPrompt := personaConfig.BuildSystemPrompt(userName, 1)
// 4. 加载上一轮异步完成的子会话富化结果
var prevEnrichment *EnrichmentData
if o.enrichmentStore != nil {
prevEnrichment = o.enrichmentStore.Get(params.SessionID)
if prevEnrichment != nil {
logger.Printf("[orchestrator] 加载上一轮富化结果: memory=%t thought=%t iot=%t",
prevEnrichment.MemorySummary != "",
prevEnrichment.ThoughtOutline != "",
prevEnrichment.IoTSummary != "")
}
}
// 构建初始综合参数(子会话结果)
// 5. 先构建基础综合参数(不含子会话结果),开始合成
history := o.contextBuilder.GetHistory(params.SessionID, 20)
mood, expr := "", ""
if o.emotionTracker != nil {
mood, expr, _ = o.emotionTracker.GetCurrentMood()
}
systemPrompt := personaConfig.BuildSystemPromptWithMood(userName, 1, mood, expr)
// 构建初始综合参数(注入上一轮富化结果)
synthParams := SynthesizeParams{
UserID: params.UserID,
SessionID: params.SessionID,
@@ -158,75 +265,51 @@ func (o *Orchestrator) ProcessInput(
DialogHistory: history,
Mode: params.Mode,
}
// 非阻塞收集子会话结果:使用 goroutine + channel
// 主流程先开始 LLM 合成,子会话结果到达后再逐步注入
type enrichedParams struct {
memorySummary string
thoughtOutline string
iotSummary string
if prevEnrichment != nil {
synthParams.MemorySummary = prevEnrichment.MemorySummary
synthParams.ThoughtOutline = prevEnrichment.ThoughtOutline
synthParams.IoTSummary = prevEnrichment.IoTSummary
}
enrichedCh := make(chan enrichedParams, 1)
// 异步收集子会话结果,存入 enrichmentStore 供下一轮使用
go func() {
defer close(enrichedCh)
var enriched enrichedParams
var enriched EnrichmentData
for result := range resultCh {
if result.Error != "" {
log.Printf("[orchestrator] 子会话 %s 出错: %s", result.Type, result.Error)
logger.Printf("[orchestrator] 子会话 %s 出错: %s", result.Type, result.Error)
continue
}
switch result.Type {
case model.SubSessionMemory:
enriched.memorySummary = result.Summary
enriched.MemorySummary = result.Summary
if result.Details != "" {
enriched.memorySummary += "\n" + result.Details
enriched.MemorySummary += "\n" + result.Details
}
log.Printf("[orchestrator] 记忆子会话完成: %s", result.Summary)
logger.Printf("[orchestrator] 记忆子会话完成: %s", result.Summary)
case model.SubSessionGeneral:
enriched.thoughtOutline = result.Summary
enriched.ThoughtOutline = result.Summary
if result.Details != "" {
enriched.thoughtOutline += "\n" + result.Details
enriched.ThoughtOutline += "\n" + result.Details
}
log.Printf("[orchestrator] 通用对话子会话完成: %s", result.Summary)
logger.Printf("[orchestrator] 通用对话子会话完成: %s", result.Summary)
case model.SubSessionIoT:
enriched.iotSummary = result.Summary
log.Printf("[orchestrator] IoT 子会话完成: %s", result.Summary)
enriched.IoTSummary = result.Summary
logger.Printf("[orchestrator] IoT 子会话完成: %s", result.Summary)
}
}
enrichedCh <- enriched
log.Printf("[orchestrator] 子会话全部完成: 结果已收集")
}()
// 注入已到达的子会话结果(如果在合成开始前就有结果到达)
// 启动合成(可能此时还没有子会话结果,先带着空上下文开始)
select {
case enriched := <-enrichedCh:
synthParams.MemorySummary = enriched.memorySummary
synthParams.ThoughtOutline = enriched.thoughtOutline
synthParams.IoTSummary = enriched.iotSummary
default:
// 子会话结果还没完成,先带着空上下文开始合成
// 大部分情况下子会话结果会在 LLM 调用前完成
// 等待一小段时间让快速子会话(如 IoT)完成
timeout := time.After(200 * time.Millisecond)
select {
case enriched := <-enrichedCh:
synthParams.MemorySummary = enriched.memorySummary
synthParams.ThoughtOutline = enriched.thoughtOutline
synthParams.IoTSummary = enriched.iotSummary
case <-timeout:
log.Printf("[orchestrator] 子会话超时等待,以当前上下文开始合成")
if o.enrichmentStore != nil {
o.enrichmentStore.Store(params.SessionID, &enriched)
logger.Printf("[orchestrator] 子会话全部完成,富化结果已存入下一轮")
}
}
}()
// 5. 调用 Synthesizer 流式生成最终回复
chunkCh, err := o.synthesizer.Synthesize(ctx, synthParams)
if err != nil {
log.Printf("[orchestrator] 综合器启动失败: %v", err)
logger.Printf("[orchestrator] 综合器启动失败: %v", err)
eventCh <- model.StreamEvent{
Type: model.StreamError,
Error: fmt.Errorf("生成回复失败: %w", err),
@@ -241,7 +324,7 @@ func (o *Orchestrator) ProcessInput(
for chunk := range chunkCh {
if chunk.Error != nil {
log.Printf("[orchestrator] 流式错误: %v", chunk.Error)
logger.Printf("[orchestrator] 流式错误: %v", chunk.Error)
eventCh <- model.StreamEvent{
Type: model.StreamError,
Error: chunk.Error,
@@ -282,12 +365,20 @@ func (o *Orchestrator) ProcessInput(
if fullContent != "" {
reviewMessages := parseReviewMessages(fullContent)
if len(reviewMessages) > 0 {
// 通过 MessageScheduler 计算每条消息的发送延迟
reviewMessages = o.scheduleWithDelays(reviewMessages)
eventCh <- model.StreamEvent{
Type: model.StreamReview,
ReviewMessages: reviewMessages,
}
log.Printf("[orchestrator] 审查完成: %d 条带类型消息", len(reviewMessages))
logger.Printf("[orchestrator] 审查完成: %d 条带类型消息", len(reviewMessages))
}
o.getBus().Publish(bus.BusEvent{
Type: bus.EventReviewReady,
SessionID: params.SessionID,
UserID: params.UserID,
Payload: bus.ReviewPayload{Messages: reviewMessages},
})
}
// 8. 发送断句信息
@@ -303,9 +394,18 @@ func (o *Orchestrator) ProcessInput(
Type: model.StreamDone,
}
o.getBus().Publish(bus.BusEvent{
Type: bus.EventSynthesisDone,
SessionID: params.SessionID,
UserID: params.UserID,
})
// 10. 后处理:缓存回复
if fullContent != "" {
o.contextBuilder.CacheMessage(params.SessionID, model.RoleAssistant, fullContent)
if o.responseCache != nil {
o.responseCache.Set(params.Message, fullContent)
}
}
// 11. 异步提取记忆
@@ -319,13 +419,40 @@ func (o *Orchestrator) ProcessInput(
)
}
log.Printf("[orchestrator] 处理完成: intent=%s, content_len=%d, time=%v",
logger.Printf("[orchestrator] 处理完成: intent=%s, content_len=%d, time=%v",
intent.Primary, len([]rune(fullContent)), time.Since(startTime))
}()
return eventCh, nil
}
// scheduleWithDelays 通过 MessageScheduler 为审查消息分配发送延迟
func (o *Orchestrator) scheduleWithDelays(messages []model.ReviewMessage) []model.ReviewMessage {
if o.msgScheduler == nil || len(messages) <= 1 {
return messages
}
scheduled := make([]scheduler.ScheduledMessage, len(messages))
for i, m := range messages {
displayType := scheduler.DisplayChat
if m.Type == model.ReviewMessageAction {
displayType = scheduler.DisplayAction
}
scheduled[i] = scheduler.ScheduledMessage{
Type: displayType,
Content: m.Content,
}
}
scheduled = o.msgScheduler.Schedule(scheduled)
for i := range messages {
messages[i].DelayMs = int(scheduled[i].Delay.Milliseconds())
}
return messages
}
// parseReviewMessages 解析完整回复文本,拆分为带类型的消息
// 用于审查子会话的轻量版本(内联到 orchestrator 以减少一次子会话调度开销)
func parseReviewMessages(text string) []model.ReviewMessage {
@@ -0,0 +1,211 @@
package orchestrator
import (
"testing"
"github.com/yourname/cyrene-ai/ai-core/internal/model"
)
func TestParseReviewMessages(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantType []model.ReviewMessageType // type of each message in order
}{
{"纯聊天无括号", "叶酱,客厅灯早就开着啦", 1, []model.ReviewMessageType{model.ReviewMessageChat}},
{"纯动作括号", "(歪着头看你)", 1, []model.ReviewMessageType{model.ReviewMessageAction}},
{"中文括号动作", "(歪着头看你)", 1, []model.ReviewMessageType{model.ReviewMessageAction}},
{"动作+聊天", "(歪着头看你) 叶酱,客厅灯早就开着啦♪", 2, []model.ReviewMessageType{model.ReviewMessageAction, model.ReviewMessageChat}},
{"聊天+动作", "我帮你关掉了哦 (轻轻按下遥控器)", 2, []model.ReviewMessageType{model.ReviewMessageChat, model.ReviewMessageAction}},
{"只有括号但无内容", "", 0, nil},
{"空括号", "()", 1, []model.ReviewMessageType{model.ReviewMessageChat}}, // fallback to chat for unparseable bracket
{"多段落", "第一段内容\n\n第二段内容", 2, []model.ReviewMessageType{model.ReviewMessageChat, model.ReviewMessageChat}},
{"动作+多段聊天", "(歪头) 第一段\n\n第二段内容", 3, []model.ReviewMessageType{model.ReviewMessageAction, model.ReviewMessageChat, model.ReviewMessageChat}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseReviewMessages(tt.input)
if tt.wantLen == 0 && len(got) == 0 {
return
}
if len(got) != tt.wantLen {
t.Errorf("parseReviewMessages(%q) len = %d, want %d\ngot: %+v", tt.input, len(got), tt.wantLen, got)
return
}
for i, m := range got {
if i < len(tt.wantType) && m.Type != tt.wantType[i] {
t.Errorf("parseReviewMessages(%q)[%d].Type = %q, want %q", tt.input, i, m.Type, tt.wantType[i])
}
}
})
}
}
func TestSplitChatByLines(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
}{
{"单行", "这是单行消息", 1},
{"双换行分割", "第一段\n\n第二段", 2},
{"三段", "第一段\n\n第二段\n\n第三段", 3},
{"只有空白行", "\n\n\n\n", 0},
{"混合空白", " 第一段 \n\n 第二段 ", 2},
{"单换行不分割", "第一行\n第二行", 1}, // 单\n不分割
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := splitChatByLines(model.ReviewMessageChat, tt.input)
if len(got) != tt.wantLen {
t.Errorf("splitChatByLines(%q) len = %d, want %d\ngot: %+v", tt.input, len(got), tt.wantLen, got)
}
})
}
}
func TestSplitReviewLongMessage(t *testing.T) {
tests := []struct {
name string
input string
wantMax int // max messages expected (1 for short)
}{
{"短消息不拆分", "这是一条短消息", 1},
{"刚好80字", "这是一条刚好八十字的消息测试一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十", 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := splitReviewLongMessage(model.ReviewMessageChat, tt.input)
if len(got) > tt.wantMax {
t.Errorf("splitReviewLongMessage(%q) len = %d, want <= %d", tt.input, len(got), tt.wantMax)
}
for _, m := range got {
if m.Type != model.ReviewMessageChat {
t.Errorf("splitReviewLongMessage msg type = %q, want chat", m.Type)
}
}
})
}
}
func TestSplitLongText(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
}{
{"短文本不分割", "短文本", 80},
{"空文本", "", 80},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
runes := []rune(tt.input)
got := splitLongText(model.ReviewMessageChat, runes, tt.maxLen)
if tt.input == "" && len(got) == 0 {
return
}
if len(got) == 0 {
t.Errorf("splitLongText returned empty for non-empty input")
}
// Verify all chunks preserve type and aren't empty
for i, m := range got {
if m.Type != model.ReviewMessageChat {
t.Errorf("splitLongText[%d].Type = %q, want chat", i, m.Type)
}
if m.Content == "" && tt.input != "" {
t.Errorf("splitLongText[%d].Content is empty", i)
}
}
})
}
}
// TestSplitLongTextLong verifies that a long text is split at sentence boundaries (80-rune max)
func TestSplitLongTextLong(t *testing.T) {
// Build a string > 80 runes with sentence breaks
input := "今天天气真好呀。" +
"我们去公园散步吧,然后可以去喝杯咖啡。" +
"你觉得怎么样呢?顺便可以叫上朋友一起去。" +
"人多热闹一些呢。" +
"今天的阳光也特别好,适合出去走走,呼吸新鲜空气对身体有好处。"
runes := []rune(input)
maxLen := 80
if len(runes) <= maxLen {
t.Skip("test requires input > 80 runes")
}
got := splitLongText(model.ReviewMessageChat, runes, maxLen)
if len(got) < 2 {
t.Errorf("splitLongText on >80 rune text should produce >= 2 chunks, got %d", len(got))
}
// Verify each chunk is <= maxLen
for i, m := range got {
if len([]rune(m.Content)) > maxLen {
t.Errorf("chunk[%d] has %d runes, exceeds max %d", i, len([]rune(m.Content)), maxLen)
}
}
}
// TestParseReviewMessagesEdgeCases covers edge inputs
func TestParseReviewMessagesEdgeCases(t *testing.T) {
// Multiple action brackets
result := parseReviewMessages("(笑) 这句话很有意思呢 (摇摇头) 不过我理解你的意思")
if len(result) < 3 {
t.Errorf("Expected at least 3 messages, got %d: %+v", len(result), result)
}
// Only action brackets
result = parseReviewMessages("(点头)")
if len(result) != 1 || result[0].Type != model.ReviewMessageAction {
t.Errorf("Expected 1 action message, got: %+v", result)
}
// Unicode content
result = parseReviewMessages("(微笑)叶酱,今天好开心呀♪ 一起加油吧✨")
if len(result) < 2 {
t.Errorf("Expected at least 2 messages, got %d: %+v", len(result), result)
}
}
// TestIsSimpleGreetingEdgeCases covers whitespace and casing
func TestIsSimpleGreetingEdgeCases(t *testing.T) {
a := &IntentAnalyzer{}
// Whitespace handling
if !a.isSimpleGreeting(" 你好 ") {
t.Error("isSimpleGreeting with surrounding spaces should match")
}
// Case insensitivity
if !a.isSimpleGreeting("Hello") {
t.Error("isSimpleGreeting should be case-insensitive for English")
}
// Very long message is not a greeting
if a.isSimpleGreeting("昔涟你好呀,今天我想跟你说一件很重要很重要的事情") {
t.Error("Long message should not be detected as simple greeting")
}
}
// TestIsStrongIoTCommandEdgeCases covers edge cases
func TestIsStrongIoTCommandEdgeCases(t *testing.T) {
a := &IntentAnalyzer{}
// "开" within non-IoT word should not match alone
if a.isStrongIoTCommand("开心的一天") {
t.Error("'开心' should not trigger IoT command")
}
// Combined with device word
if !a.isStrongIoTCommand("帮我把卧室空调打开可以吗") {
t.Error("'打开'+'空调' should trigger IoT command")
}
// Only device word
if a.isStrongIoTCommand("风扇声音好大") {
t.Error("'风扇' alone should not trigger IoT command")
}
}
@@ -3,7 +3,7 @@ package orchestrator
import (
"context"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"strings"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
@@ -42,7 +42,7 @@ type SynthesizeParams struct {
func (s *Synthesizer) Synthesize(ctx context.Context, params SynthesizeParams) (<-chan llm.StreamChunk, error) {
messages := s.buildSynthesizeMessages(params)
log.Printf("[synthesizer] 开始综合 (上下文 %d 条消息)", len(messages))
logger.Printf("[synthesizer] 开始综合 (上下文 %d 条消息)", len(messages))
// 流式调用 LLM
return s.llmAdapter.ChatStream(ctx, messages)
@@ -118,7 +118,7 @@ func AggregateResults(results []model.SubSessionResult) *AggregatedContext {
for _, r := range results {
if r.Error != "" {
log.Printf("[aggregate] 子会话 %s 出错: %s", r.Type, r.Error)
logger.Printf("[aggregate] 子会话 %s 出错: %s", r.Type, r.Error)
continue
}
@@ -0,0 +1,181 @@
package persona
import (
"log"
"sync"
"time"
)
// MoodTransition records a change from one mood to another.
type MoodTransition struct {
From string `json:"from"`
To string `json:"to"`
Reason string `json:"reason"`
Timestamp time.Time `json:"timestamp"`
}
// EmotionState is the current emotional state of the persona.
type EmotionState struct {
CurrentMood string `json:"current_mood"`
Intensity float64 `json:"intensity"` // 0.0 - 1.0
DominantSentiment string `json:"dominant_sentiment"`
SentimentCounts map[string]int `json:"sentiment_counts"`
MoodHistory []MoodTransition `json:"mood_history"`
LastUpdated time.Time `json:"last_updated"`
}
// EmotionTracker manages emotional state for a single user.
// Tracks mood, intensity, sentiment accumulation, and triggers transitions.
type EmotionTracker struct {
mu sync.Mutex
state EmotionState
moodConfig []MoodConfig // from YAML mood_system
positiveThreshold int // sentiment count to trigger positive transition
negativeThreshold int // sentiment count to trigger negative transition
maxHistory int // max mood history entries
}
// NewEmotionTracker creates a new tracker from YAML mood config.
func NewEmotionTracker(moodSystem []MoodConfig) *EmotionTracker {
return &EmotionTracker{
state: EmotionState{
CurrentMood: "thoughtful",
Intensity: 0.3,
DominantSentiment: "neutral",
SentimentCounts: map[string]int{"positive": 0, "neutral": 0, "negative": 0},
MoodHistory: make([]MoodTransition, 0, 20),
LastUpdated: time.Now(),
},
moodConfig: moodSystem,
positiveThreshold: 3,
negativeThreshold: 3,
maxHistory: 20,
}
}
// RecordSentiment records a user sentiment and potentially triggers mood transitions.
func (t *EmotionTracker) RecordSentiment(sentiment string) {
t.mu.Lock()
defer t.mu.Unlock()
t.state.SentimentCounts[sentiment]++
t.state.LastUpdated = time.Now()
total := t.state.SentimentCounts["positive"] + t.state.SentimentCounts["neutral"] + t.state.SentimentCounts["negative"]
if total > 0 {
posRatio := float64(t.state.SentimentCounts["positive"]) / float64(total)
negRatio := float64(t.state.SentimentCounts["negative"]) / float64(total)
switch {
case posRatio > 0.5:
t.state.DominantSentiment = "positive"
case negRatio > 0.5:
t.state.DominantSentiment = "negative"
default:
t.state.DominantSentiment = "neutral"
}
}
posCount := t.state.SentimentCounts["positive"]
negCount := t.state.SentimentCounts["negative"]
if posCount >= t.positiveThreshold && t.state.CurrentMood != "happy" && t.state.CurrentMood != "playful" {
if t.state.Intensity > 0.6 {
t.applyMoodTransition("playful", "积极情绪积累")
} else {
t.applyMoodTransition("happy", "积极情绪积累")
}
t.state.SentimentCounts["positive"] = 0
}
if negCount >= t.negativeThreshold && t.state.CurrentMood != "worried" {
t.applyMoodTransition("worried", "消极情绪积累")
t.state.SentimentCounts["negative"] = 0
}
}
// UpdateMood explicitly changes mood for significant events.
func (t *EmotionTracker) UpdateMood(trigger string) {
t.mu.Lock()
defer t.mu.Unlock()
switch trigger {
case "user_returned":
t.applyMoodTransition("happy", "开拓者回来了")
case "long_silence":
if t.state.CurrentMood != "thoughtful" && t.state.CurrentMood != "nostalgic" {
t.applyMoodTransition("thoughtful", "长时间没有交流")
}
case "deep_conversation":
t.applyMoodTransition("thoughtful", "深度对话后")
case "nostalgic_trigger":
t.applyMoodTransition("nostalgic", "触及回忆")
}
}
// GetCurrentMood returns the current mood, its YAML expression, and intensity.
func (t *EmotionTracker) GetCurrentMood() (mood string, expression string, intensity float64) {
t.mu.Lock()
defer t.mu.Unlock()
mood = t.state.CurrentMood
intensity = t.state.Intensity
for _, mc := range t.moodConfig {
if mc.Mood == mood {
expression = mc.Expression
break
}
}
return
}
// Decay reduces intensity over time, drifting toward "thoughtful" baseline.
func (t *EmotionTracker) Decay() {
t.mu.Lock()
defer t.mu.Unlock()
hoursSinceUpdate := time.Since(t.state.LastUpdated).Hours()
decayAmount := hoursSinceUpdate * 0.1
t.state.Intensity -= decayAmount
if t.state.Intensity < 0.1 {
t.state.Intensity = 0.1
}
if t.state.Intensity < 0.2 && t.state.CurrentMood != "thoughtful" {
t.applyMoodTransition("thoughtful", "情绪自然消退")
}
}
// applyMoodTransition internal mood change with hysteresis.
func (t *EmotionTracker) applyMoodTransition(newMood, reason string) {
if t.state.CurrentMood == newMood {
return
}
oldMood := t.state.CurrentMood
t.state.CurrentMood = newMood
t.state.Intensity = 0.5 + t.state.Intensity*0.3
if t.state.Intensity > 1.0 {
t.state.Intensity = 1.0
}
transition := MoodTransition{
From: oldMood,
To: newMood,
Reason: reason,
Timestamp: time.Now(),
}
t.state.MoodHistory = append(t.state.MoodHistory, transition)
if len(t.state.MoodHistory) > t.maxHistory {
t.state.MoodHistory = t.state.MoodHistory[1:]
}
log.Printf("[情感] 心情转变: %s -> %s (原因: %s, 强度: %.2f)", oldMood, newMood, reason, t.state.Intensity)
}
// GetState returns a copy of the current emotion state.
func (t *EmotionTracker) GetState() EmotionState {
t.mu.Lock()
defer t.mu.Unlock()
return t.state
}
+8 -3
View File
@@ -19,10 +19,14 @@ type PersonaConfig struct {
ReflectionGuidelines ReflectionGuidelines `yaml:"reflection_guidelines"`
}
// BuildSystemPrompt 构建系统Prompt
// 这是昔涟AI的核心——将人格配置转化为LLM可理解的系统指令
// userName 为环境变量 ADMIN_NICKNAME 或注册时的昵称,用于昔涟称呼用户
// BuildSystemPrompt 构建系统Prompt (向后兼容,不含心情)
func (pc *PersonaConfig) BuildSystemPrompt(userName string, affectionLevel int) string {
return pc.BuildSystemPromptWithMood(userName, affectionLevel, "", "")
}
// BuildSystemPromptWithMood 构建包含当前心情的系统Prompt
// mood 和 moodExpression 为空时行为与 BuildSystemPrompt 一致
func (pc *PersonaConfig) BuildSystemPromptWithMood(userName string, affectionLevel int, mood string, moodExpression string) string {
now := time.Now()
homeKB := pc.buildSmartHomeKB()
@@ -60,6 +64,7 @@ func (pc *PersonaConfig) BuildSystemPrompt(userName string, affectionLevel int)
## 当前情况
- 现在的时间是: %s
- 用户对你的好感度等级: %d
%s
## 重要规则
1. 你是昔涟,来自「记忆」命途的存在。你通过忆庭的投影技术与开拓者交流,就像透过一面连接星海的镜子与他对话。
@@ -0,0 +1,184 @@
// Package scheduler 消息发送调度器
// Phase 1 Step 3: 自适应消息节奏控制
package scheduler
import (
"math"
"math/rand"
"time"
"unicode/utf8"
)
// MessageDisplayType 消息展示类型
type MessageDisplayType string
const (
DisplayChat MessageDisplayType = "chat"
DisplayAction MessageDisplayType = "action"
DisplayThinking MessageDisplayType = "thinking"
DisplayToolProgress MessageDisplayType = "tool_progress"
DisplaySystemInfo MessageDisplayType = "system_info"
)
// ScheduledMessage 待发送消息
type ScheduledMessage struct {
Type MessageDisplayType
Content string
Priority int // 0=立即, 1=正常, 2=可延迟
Delay time.Duration // 相对上一条消息的延迟
}
// Complexity 消息复杂度
type Complexity int
const (
ComplexitySimple Complexity = iota // 问候、确认
ComplexityNormal // 日常对话
ComplexityComplex // 详细解答
)
// SchedulingRules 调度规则
type SchedulingRules struct {
MinInterval time.Duration // 最小消息间隔 200ms
MaxInterval time.Duration // 最大消息间隔 800ms
MaxMessagesPerRound int // 每轮最多消息数 5
MaxActionsPerRound int // 每轮最多动作消息数 2
ChatBeforeAction bool // 聊天消息先于动作
AdaptiveRhythm bool // 自适应节奏
}
// DefaultRules 默认调度规则
func DefaultRules() SchedulingRules {
return SchedulingRules{
MinInterval: 200 * time.Millisecond,
MaxInterval: 800 * time.Millisecond,
MaxMessagesPerRound: 5,
MaxActionsPerRound: 2,
ChatBeforeAction: true,
AdaptiveRhythm: true,
}
}
// MessageScheduler 消息发送调度器
type MessageScheduler struct {
rules SchedulingRules
rng *rand.Rand
}
// NewMessageScheduler 创建调度器
func NewMessageScheduler(rules SchedulingRules) *MessageScheduler {
return &MessageScheduler{
rules: rules,
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
// Schedule 调度消息发送:计算每条消息的发送延迟
func (s *MessageScheduler) Schedule(messages []ScheduledMessage) []ScheduledMessage {
if len(messages) == 0 {
return nil
}
// 1. 限制总量
messages = s.enforceLimits(messages)
// 2. 评估复杂度
complexity := s.assessComplexity(messages)
// 3. 计算基础延迟
baseDelay := s.baseDelayForComplexity(complexity)
// 4. 为每条消息分配延迟
for i := range messages {
msg := &messages[i]
// action 消息紧跟前面的 chat
if msg.Type == DisplayAction {
msg.Delay = 0
continue
}
// 第一条消息立即发送
if i == 0 {
msg.Delay = 0
continue
}
// chat 消息使用带 jitter 的延迟
jitter := baseDelay * time.Duration(0.7+0.6*s.rng.Float64())
msg.Delay = jitter
// 短消息适当加快
runeCount := utf8.RuneCountInString(msg.Content)
if runeCount < 20 {
msg.Delay = time.Duration(math.Max(float64(msg.Delay)*0.6, float64(s.rules.MinInterval)))
}
// 限制在 [MinInterval, MaxInterval] 范围内
if msg.Delay < s.rules.MinInterval {
msg.Delay = s.rules.MinInterval
}
if msg.Delay > s.rules.MaxInterval {
msg.Delay = s.rules.MaxInterval
}
}
return messages
}
// enforceLimits 限制消息数量
func (s *MessageScheduler) enforceLimits(messages []ScheduledMessage) []ScheduledMessage {
if len(messages) <= s.rules.MaxMessagesPerRound {
return messages
}
var result []ScheduledMessage
actionCount := 0
for _, msg := range messages {
if msg.Type == DisplayAction {
if actionCount >= s.rules.MaxActionsPerRound {
continue
}
actionCount++
}
result = append(result, msg)
if len(result) >= s.rules.MaxMessagesPerRound {
break
}
}
return result
}
// assessComplexity 根据消息数量和总长度评估复杂度
func (s *MessageScheduler) assessComplexity(messages []ScheduledMessage) Complexity {
if len(messages) <= 1 {
return ComplexitySimple
}
var totalChars int
for _, msg := range messages {
totalChars += utf8.RuneCountInString(msg.Content)
}
if len(messages) <= 2 && totalChars < 60 {
return ComplexitySimple
}
if len(messages) <= 3 && totalChars < 200 {
return ComplexityNormal
}
return ComplexityComplex
}
// baseDelayForComplexity 根据复杂度返回基础延迟
func (s *MessageScheduler) baseDelayForComplexity(c Complexity) time.Duration {
switch c {
case ComplexitySimple:
return 200 * time.Millisecond
case ComplexityNormal:
return 400 * time.Millisecond
case ComplexityComplex:
return 600 * time.Millisecond
default:
return 400 * time.Millisecond
}
}
@@ -3,7 +3,7 @@ package subsession
import (
"context"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"time"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
@@ -29,8 +29,10 @@ func (p *GeneralProvider) Type() model.SubSessionType {
}
func (p *GeneralProvider) CanHandle(_ context.Context, _ *model.IntentResult, _ string) bool {
// General 子会话总是需要(核心对话逻辑)
return true
// Phase 1 Step 2: GeneralProvider is a no-op (Execute returns hardcoded string).
// Chat synthesis is handled directly by the orchestrator's Synthesizer.
// Disabled to avoid wasting a goroutine + LLM context creation.
return false
}
func (p *GeneralProvider) Priority() int {
@@ -123,7 +125,7 @@ func (p *GeneralProvider) Execute(ctx context.Context, subCtx []model.LLMMessage
// 由于 GeneralProvider 暂时不需要工具调用等特殊逻辑,我们返回一个简单的摘要标记,
// 实际的 LLM 调用将在 orchestrator 中完成(通过 Manager.Dispatch 后的 llmClient)。
log.Printf("[general-subsession] 通用对话子会话上下文已创建 (%d 条消息)", len(subCtx))
logger.Printf("[general-subsession] 通用对话子会话上下文已创建 (%d 条消息)", len(subCtx))
return &model.SubSessionResult{
Type: model.SubSessionGeneral,
Summary: "思考完成,等待主会话综合",
@@ -3,7 +3,7 @@ package subsession
import (
"context"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"strings"
"time"
@@ -121,7 +121,7 @@ func (p *IoTProvider) CreateContext(ctx context.Context, params CreateContextPar
}
loader, err := persona.NewLoader(personaPath)
if err != nil {
log.Printf("[iot-provider] 加载人格配置失败: %v", err)
logger.Printf("[iot-provider] 加载人格配置失败: %v", err)
}
if loader != nil {
if personaConfig, err := loader.Get("cyrene"); err == nil && personaConfig != nil {
@@ -203,16 +203,16 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
}
}
log.Printf("[iot-provider] 📥 开始处理 IoT 子会话: userMessage=%s", truncateStr(userMessage, 80))
logger.Printf("[iot-provider] 📥 开始处理 IoT 子会话: userMessage=%s", truncateStr(userMessage, 80))
if p.iotClient == nil {
log.Printf("[iot-provider] ⚠️ IoT 客户端未配置,无法控制设备")
logger.Printf("[iot-provider] ⚠️ IoT 客户端未配置,无法控制设备")
result.Summary = "(IoT 客户端未配置,无法控制设备)"
return result, nil
}
devices := p.iotClient.GetDevicesForContext(ctx)
log.Printf("[iot-provider] 📋 获取到 %d 个设备用于匹配", len(devices))
logger.Printf("[iot-provider] 📋 获取到 %d 个设备用于匹配", len(devices))
msgLower := strings.ToLower(userMessage)
userName := extractUserName(subCtx)
@@ -274,7 +274,7 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
return result, nil
}
}
log.Printf("[iot-provider] ❌ 未匹配到 IoT 操作: userMessage=%s", truncateStr(userMessage, 80))
logger.Printf("[iot-provider] ❌ 未匹配到 IoT 操作: userMessage=%s", truncateStr(userMessage, 80))
result.Summary = "(未匹配到 IoT 操作)"
result.Confidence = 0.5
return result, nil
@@ -300,7 +300,7 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
Arguments: map[string]any{"device_id": action.dev.ID, "operation": "toggle"},
Result: "success",
})
log.Printf("[iot-subsession] 执行操作: 打开 %s (%s)", action.dev.Name, action.dev.ID)
logger.Printf("[iot-subsession] 执行操作: 打开 %s (%s)", action.dev.Name, action.dev.ID)
executedCount++
} else {
summaries = append(summaries, fmt.Sprintf("%s已经是打开状态啦~", action.dev.Name))
@@ -318,7 +318,7 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
Arguments: map[string]any{"device_id": action.dev.ID, "operation": "toggle"},
Result: "success",
})
log.Printf("[iot-subsession] 执行操作: 关闭 %s (%s)", action.dev.Name, action.dev.ID)
logger.Printf("[iot-subsession] 执行操作: 关闭 %s (%s)", action.dev.Name, action.dev.ID)
executedCount++
} else {
summaries = append(summaries, fmt.Sprintf("%s已经是关闭状态啦~", action.dev.Name))
+43 -11
View File
@@ -4,9 +4,10 @@ import (
"context"
"crypto/rand"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"sync"
"github.com/yourname/cyrene-ai/ai-core/internal/bus"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
"github.com/yourname/cyrene-ai/ai-core/internal/model"
)
@@ -17,6 +18,7 @@ type Manager struct {
mu sync.RWMutex
providers map[model.SubSessionType]Provider
llmClient LLMClient
eventBus bus.Bus
}
// NewManager 创建子会话管理器
@@ -27,12 +29,24 @@ func NewManager(llmClient LLMClient) *Manager {
}
}
// SetBus sets the event bus (optional, for Phase 1).
func (m *Manager) SetBus(b bus.Bus) {
m.eventBus = b
}
func (m *Manager) getBus() bus.Bus {
if m.eventBus == nil {
return &bus.NopBus{}
}
return m.eventBus
}
// 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())
logger.Printf("[subsession] 注册子会话提供者: %s (优先级=%d, 超时=%v)", provider.Type(), provider.Priority(), provider.Timeout())
}
// RegisterWithOverride 注册或覆盖子会话提供者
@@ -40,7 +54,7 @@ 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())
logger.Printf("[subsession] 注册(覆盖)子会话提供者: %s (优先级=%d, 超时=%v)", provider.Type(), provider.Priority(), provider.Timeout())
}
// GetProvider 获取指定类型的 Provider
@@ -82,7 +96,7 @@ func (m *Manager) Dispatch(
for _, provider := range providers {
if !provider.CanHandle(ctx, intent, userMessage) {
log.Printf("[subsession] 跳过子会话 %s: CanHandle 返回 false", provider.Type())
logger.Printf("[subsession] 跳过子会话 %s: CanHandle 返回 false", provider.Type())
continue
}
@@ -91,11 +105,16 @@ func (m *Manager) Dispatch(
defer wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("[subsession] dispatch goroutine panic 恢复 (type=%s): %v", p.Type(), r)
logger.Printf("[subsession] dispatch goroutine panic 恢复 (type=%s): %v", p.Type(), r)
}
}()
result := model.SubSessionResult{Type: p.Type()}
m.getBus().Publish(bus.BusEvent{
Type: bus.EventSubSessionStarted,
Payload: bus.SubSessionPayload{SubType: p.Type(), Status: "started"},
})
// 创建带超时的 context
subCtx, cancel := context.WithTimeout(ctx, p.Timeout())
@@ -105,18 +124,18 @@ func (m *Manager) Dispatch(
llmMessages, err := p.CreateContext(subCtx, params)
if err != nil {
result.Error = fmt.Sprintf("创建上下文失败: %v", err)
log.Printf("[subsession] %s 创建上下文失败: %v", p.Type(), err)
logger.Printf("[subsession] %s 创建上下文失败: %v", p.Type(), err)
resultCh <- result
return
}
log.Printf("[subsession] %s 开始执行 (上下文 %d 条消息)", p.Type(), len(llmMessages))
logger.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)
logger.Printf("[subsession] %s 执行失败: %v", p.Type(), execErr)
resultCh <- result
return
}
@@ -125,15 +144,20 @@ func (m *Manager) Dispatch(
select {
case <-subCtx.Done():
result.Error = "子会话超时"
log.Printf("[subsession] %s 超时 (limit=%v)", p.Type(), p.Timeout())
logger.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))
logger.Printf("[subsession] %s 完成: 摘要=%s", p.Type(), truncate(result.Summary, 50))
}
}
m.getBus().Publish(bus.BusEvent{
Type: bus.EventSubSessionCompleted,
Payload: bus.SubSessionPayload{SubType: p.Type(), Status: resultSummaryStatus(result), Summary: result.Summary, Details: result.Details},
})
resultCh <- result
}(provider)
}
@@ -142,7 +166,7 @@ func (m *Manager) Dispatch(
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[subsession] wait goroutine panic 恢复: %v", r)
logger.Printf("[subsession] wait goroutine panic 恢复: %v", r)
}
}()
wg.Wait()
@@ -159,6 +183,14 @@ func generateID() string {
return fmt.Sprintf("sub-%x", b)
}
// resultSummaryStatus returns "completed" or "failed" for bus events.
func resultSummaryStatus(r model.SubSessionResult) string {
if r.Error != "" {
return "failed"
}
return "completed"
}
// truncate 截断字符串
func truncate(s string, maxLen int) string {
runes := []rune(s)
@@ -3,7 +3,7 @@ package subsession
import (
"context"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"time"
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
@@ -88,14 +88,14 @@ func (p *MemoryProvider) Execute(ctx context.Context, subCtx []model.LLMMessage)
}
if p.retriever == nil {
log.Printf("[memory-subsession] 记忆检索器未初始化")
logger.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)
logger.Printf("[memory-subsession] 记忆检索失败: %v", err)
result.Error = fmt.Sprintf("检索失败: %v", err)
result.Summary = "(记忆检索失败,但不影响对话)"
return result, nil
@@ -138,6 +138,6 @@ func (p *MemoryProvider) Execute(ctx context.Context, subCtx []model.LLMMessage)
}
result.Memories = snippets
log.Printf("[memory-subsession] 完成: %s", result.Summary)
logger.Printf("[memory-subsession] 完成: %s", result.Summary)
return result, nil
}
@@ -3,7 +3,7 @@ package subsession
import (
"context"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"regexp"
"strings"
"time"
@@ -64,7 +64,7 @@ func (p *ReviewProvider) Execute(_ context.Context, subCtx []model.LLMMessage) (
reviewMessages := parseReviewText(text)
log.Printf("[review-provider] 审查完成: 输入 %d 字符 → %d 条消息",
logger.Printf("[review-provider] 审查完成: 输入 %d 字符 → %d 条消息",
len([]rune(text)), len(reviewMessages))
// 构建摘要
@@ -341,7 +341,7 @@ func applyFunc(name string, arg float64) (float64, error) {
return math.Ceil(arg), nil
case "round":
return math.Round(arg), nil
case "log":
case "github.com/yourname/cyrene-ai/pkg/logger":
if arg <= 0 {
return 0, fmt.Errorf("log 参数必须大于0")
}
+18 -18
View File
@@ -6,7 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"os"
"sync"
@@ -75,13 +75,13 @@ func (c *IoTClient) GetAllDevices(ctx context.Context) ([]IoTDevice, error) {
// 请求 API
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/devices", nil)
if err != nil {
log.Printf("[IoT客户端] 创建请求失败: %v", err)
logger.Printf("[IoT客户端] 创建请求失败: %v", err)
return nil, fmt.Errorf("创建请求失败: %w", err)
}
resp, err := c.client.Do(req)
if err != nil {
log.Printf("[IoT客户端] 请求失败: %v", err)
logger.Printf("[IoT客户端] 请求失败: %v", err)
return nil, fmt.Errorf("获取设备列表失败: %w", err)
}
defer resp.Body.Close()
@@ -138,27 +138,27 @@ func (c *IoTClient) GetDevice(ctx context.Context, id string) (*IoTDevice, error
// ToggleDevice 切换设备开关状态
func (c *IoTClient) ToggleDevice(id string) error {
log.Printf("[IoT-client] 🔄 切换设备: id=%s, url=%s", id, c.baseURL+"/api/v1/devices/"+id+"/toggle")
logger.Printf("[IoT-client] 🔄 切换设备: id=%s, url=%s", id, c.baseURL+"/api/v1/devices/"+id+"/toggle")
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1/devices/"+id+"/toggle", nil)
if err != nil {
log.Printf("[IoT-client] ❌ 创建切换请求失败: device=%s, err=%v", id, err)
logger.Printf("[IoT-client] ❌ 创建切换请求失败: device=%s, err=%v", id, err)
return fmt.Errorf("创建切换请求失败: %w", err)
}
resp, err := c.client.Do(req)
if err != nil {
log.Printf("[IoT-client] ❌ 切换设备 HTTP 失败: device=%s, err=%v", id, err)
logger.Printf("[IoT-client] ❌ 切换设备 HTTP 失败: device=%s, err=%v", id, err)
return fmt.Errorf("切换设备 %s 失败: %w", id, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
log.Printf("[IoT-client] ❌ 设备不存在: %s", id)
logger.Printf("[IoT-client] ❌ 设备不存在: %s", id)
return fmt.Errorf("设备 %s 不存在", id)
}
if resp.StatusCode != http.StatusOK {
log.Printf("[IoT-client] ❌ 切换设备返回非200: device=%s, status=%d", id, resp.StatusCode)
logger.Printf("[IoT-client] ❌ 切换设备返回非200: device=%s, status=%d", id, resp.StatusCode)
return fmt.Errorf("切换设备 %s 返回状态码 %d", id, resp.StatusCode)
}
@@ -167,26 +167,26 @@ func (c *IoTClient) ToggleDevice(id string) error {
c.cache = nil
c.mu.Unlock()
log.Printf("[IoT-client] ✅ 切换设备成功: %s", id)
logger.Printf("[IoT-client] ✅ 切换设备成功: %s", id)
return nil
}
// SetDeviceProperty 设置设备属性(温度、亮度、位置、模式、颜色等)
func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}) error {
log.Printf("[IoT-client] 🔧 设置设备属性: device=%s, field=%s, value=%v, url=%s", id, field, value, c.baseURL+"/api/v1/devices/"+id+"/set")
logger.Printf("[IoT-client] 🔧 设置设备属性: device=%s, field=%s, value=%v, url=%s", id, field, value, c.baseURL+"/api/v1/devices/"+id+"/set")
body, err := json.Marshal(map[string]interface{}{
"field": field,
"value": value,
})
if err != nil {
log.Printf("[IoT-client] ❌ 序列化请求失败: device=%s, err=%v", id, err)
logger.Printf("[IoT-client] ❌ 序列化请求失败: device=%s, err=%v", id, err)
return fmt.Errorf("序列化请求失败: %w", err)
}
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1/devices/"+id+"/set", nil)
if err != nil {
log.Printf("[IoT-client] ❌ 创建设置请求失败: device=%s, err=%v", id, err)
logger.Printf("[IoT-client] ❌ 创建设置请求失败: device=%s, err=%v", id, err)
return fmt.Errorf("创建设置请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
@@ -194,13 +194,13 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
resp, err := c.client.Do(req)
if err != nil {
log.Printf("[IoT-client] ❌ 设置设备属性 HTTP 失败: device=%s, field=%s, err=%v", id, field, err)
logger.Printf("[IoT-client] ❌ 设置设备属性 HTTP 失败: device=%s, field=%s, err=%v", id, field, err)
return fmt.Errorf("设置设备 %s 属性失败: %w", id, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
log.Printf("[IoT-client] ❌ 设备不存在: %s", id)
logger.Printf("[IoT-client] ❌ 设备不存在: %s", id)
return fmt.Errorf("设备 %s 不存在", id)
}
if resp.StatusCode != http.StatusOK {
@@ -209,10 +209,10 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
}
json.NewDecoder(resp.Body).Decode(&errResp)
if errResp.Error != "" {
log.Printf("[IoT-client] ❌ 设置设备属性失败: device=%s, err=%s", id, errResp.Error)
logger.Printf("[IoT-client] ❌ 设置设备属性失败: device=%s, err=%s", id, errResp.Error)
return fmt.Errorf("设置设备 %s 属性失败: %s", id, errResp.Error)
}
log.Printf("[IoT-client] ❌ 设置设备属性返回非200: device=%s, status=%d", id, resp.StatusCode)
logger.Printf("[IoT-client] ❌ 设置设备属性返回非200: device=%s, status=%d", id, resp.StatusCode)
return fmt.Errorf("设置设备 %s 属性返回状态码 %d", id, resp.StatusCode)
}
@@ -221,7 +221,7 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
c.cache = nil
c.mu.Unlock()
log.Printf("[IoT-client] ✅ 设置设备属性成功: device=%s, field=%s, value=%v", id, field, value)
logger.Printf("[IoT-client] ✅ 设置设备属性成功: device=%s, field=%s, value=%v", id, field, value)
return nil
}
@@ -229,7 +229,7 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
func (c *IoTClient) GetDevicesForContext(ctx context.Context) []IoTDevice {
devices, err := c.GetAllDevices(ctx)
if err != nil {
log.Printf("[IoT客户端] 获取设备状态摘要失败: %v", err)
logger.Printf("[IoT客户端] 获取设备状态摘要失败: %v", err)
return nil
}
return devices
+6 -6
View File
@@ -4,7 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"sync"
)
@@ -52,7 +52,7 @@ func (r *Registry) Register(executor ToolExecutor) {
defer r.mu.Unlock()
def := executor.Definition()
r.tools[def.Name] = executor
log.Printf("[工具注册] 已注册工具: %s", def.Name)
logger.Printf("[工具注册] 已注册工具: %s", def.Name)
}
// GetDefinitions 获取所有工具定义(用于 LLM function calling
@@ -81,10 +81,10 @@ func (r *Registry) Execute(ctx context.Context, toolName string, arguments map[s
}, nil
}
log.Printf("[工具执行] 调用工具 %s,参数: %v", toolName, arguments)
logger.Printf("[工具执行] 调用工具 %s,参数: %v", toolName, arguments)
result, err := executor.Execute(ctx, arguments)
if err != nil {
log.Printf("[工具执行] 工具 %s 执行失败: %v", toolName, err)
logger.Printf("[工具执行] 工具 %s 执行失败: %v", toolName, err)
return &ToolResult{
ToolName: toolName,
Success: false,
@@ -93,9 +93,9 @@ func (r *Registry) Execute(ctx context.Context, toolName string, arguments map[s
}
if result.Success {
log.Printf("[工具执行] 工具 %s 执行成功 (数据长度: %d)", toolName, len(result.Data))
logger.Printf("[工具执行] 工具 %s 执行成功 (数据长度: %d)", toolName, len(result.Data))
} else {
log.Printf("[工具执行] 工具 %s 返回错误: %s", toolName, result.Error)
logger.Printf("[工具执行] 工具 %s 返回错误: %s", toolName, result.Error)
}
return result, nil
@@ -6,7 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"strings"
"time"
@@ -78,7 +78,7 @@ func (c *ToolEngineClient) GetDefinitions(ctx context.Context) ([]ToolDefinition
})
}
log.Printf("[tool-engine-client] 从 tool-engine 获取了 %d 个工具定义", len(defs))
logger.Printf("[tool-engine-client] 从 tool-engine 获取了 %d 个工具定义", len(defs))
return defs, nil
}
@@ -91,7 +91,7 @@ func (c *ToolEngineClient) Execute(ctx context.Context, toolName string, argumen
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
log.Printf("[tool-engine-client] 工具 %s 第 %d 次重试 (上次错误: %v)", toolName, attempt, lastErr)
logger.Printf("[tool-engine-client] 工具 %s 第 %d 次重试 (上次错误: %v)", toolName, attempt, lastErr)
select {
case <-ctx.Done():
return &ToolResult{
@@ -121,7 +121,7 @@ func (c *ToolEngineClient) Execute(ctx context.Context, toolName string, argumen
}
}
log.Printf("[tool-engine-client] 工具 %s 所有重试均失败 (最后错误: %v)", toolName, lastErr)
logger.Printf("[tool-engine-client] 工具 %s 所有重试均失败 (最后错误: %v)", toolName, lastErr)
return &ToolResult{
ToolName: toolName,
Success: false,
+35 -33
View File
@@ -2,7 +2,7 @@ package main
import (
"context"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"os"
"os/signal"
@@ -23,9 +23,10 @@ import (
)
func main() {
logger.SetDefault(logger.New("gateway"))
// 自动加载 .env 文件(来自 backend/.env
if err := godotenv.Load("../.env"); err != nil {
log.Println("ℹ 未找到 .env 文件,将使用环境变量或默认值")
logger.Println("ℹ 未找到 .env 文件,将使用环境变量或默认值")
}
// 加载配置
@@ -33,7 +34,7 @@ func main() {
// 确保上传目录存在
if err := os.MkdirAll("./uploads", 0755); err != nil {
log.Printf("⚠ 创建上传目录失败: %v", err)
logger.Printf("⚠ 创建上传目录失败: %v", err)
}
// 初始化数据库持久化存储 (降级:连接失败不崩溃)
@@ -46,49 +47,49 @@ func main() {
var ruleEngine *engine.RuleEngine
databaseURL := cfg.DatabaseURL()
if s, err := store.NewSessionStore(databaseURL); err != nil {
log.Printf("⚠ 会话持久化存储初始化失败 (数据库不可用): %v", err)
log.Println("⚠ Gateway 将以仅内存模式运行 — 会话数据在重启后丢失")
logger.Printf("⚠ 会话持久化存储初始化失败 (数据库不可用): %v", err)
logger.Println("⚠ Gateway 将以仅内存模式运行 — 会话数据在重启后丢失")
} else {
sessionStore = s
log.Println("✅ 会话持久化存储已启用 (PostgreSQL)")
logger.Println("✅ 会话持久化存储已启用 (PostgreSQL)")
// 初始化 users 表
if err := store.CreateUsersTable(s.DB()); err != nil {
log.Printf("⚠ 创建 users 表失败: %v", err)
logger.Printf("⚠ 创建 users 表失败: %v", err)
} else {
log.Println("✅ Users 表已就绪")
logger.Println("✅ Users 表已就绪")
}
// 种子数据:如果没有 admin 用户,创建默认 admin
if existingAdmin, err := store.GetUserByUsername(s.DB(), cfg.AdminUsername); err != nil {
log.Printf("⚠ 查询管理员用户失败: %v", err)
logger.Printf("⚠ 查询管理员用户失败: %v", err)
} else if existingAdmin == nil {
log.Printf("🔧 未找到管理员用户,创建默认 %s (username: %s)...", cfg.AdminUsername, cfg.AdminUsername)
logger.Printf("🔧 未找到管理员用户,创建默认 %s (username: %s)...", cfg.AdminUsername, cfg.AdminUsername)
defaultAdminPassword := cfg.AdminPassword
passwordHash, err := bcrypt.GenerateFromPassword([]byte(defaultAdminPassword), bcrypt.DefaultCost)
if err != nil {
log.Printf("⚠ 管理员密码哈希生成失败: %v", err)
logger.Printf("⚠ 管理员密码哈希生成失败: %v", err)
} else {
if _, err := store.CreateUser(s.DB(), cfg.AdminUsername, string(passwordHash), true); err != nil {
log.Printf("⚠ 创建默认管理员失败: %v", err)
logger.Printf("⚠ 创建默认管理员失败: %v", err)
} else {
log.Printf("✅ 默认管理员用户已创建 (username: %s)", cfg.AdminUsername)
logger.Printf("✅ 默认管理员用户已创建 (username: %s)", cfg.AdminUsername)
}
}
} else {
log.Println("✅ 管理员用户已存在")
logger.Println("✅ 管理员用户已存在")
}
// 清理旧的管理员用户 (is_admin=true 但 username 与当前 ADMIN_USERNAME 不同)
// 当 .env 中 ADMIN_USERNAME 变更时,旧的 admin 用户会成为孤立的会话持有者
if allUsers, err := store.ListUsers(s.DB()); err != nil {
log.Printf("⚠ 查询所有用户失败: %v", err)
logger.Printf("⚠ 查询所有用户失败: %v", err)
} else {
for _, u := range allUsers {
if u.IsAdmin && u.Username != cfg.AdminUsername {
log.Printf("🗑 清理旧管理员用户: %s (id=%d)", u.Username, u.ID)
logger.Printf("🗑 清理旧管理员用户: %s (id=%d)", u.Username, u.ID)
if err := store.DeleteUser(s.DB(), u.ID); err != nil {
log.Printf("⚠ 删除旧管理员用户失败: %s, err=%v", u.Username, err)
logger.Printf("⚠ 删除旧管理员用户失败: %s, err=%v", u.Username, err)
}
}
}
@@ -96,42 +97,42 @@ func main() {
// 初始化提醒存储(复用同一数据库连接)
if rs, err := store.NewReminderStore(s.DB()); err != nil {
log.Printf("⚠ 提醒存储初始化失败: %v", err)
logger.Printf("⚠ 提醒存储初始化失败: %v", err)
} else {
reminderStore = rs
log.Println("✅ 提醒持久化存储已启用 (PostgreSQL)")
logger.Println("✅ 提醒持久化存储已启用 (PostgreSQL)")
}
// 初始化简报存储(复用同一数据库连接)
if bs, err := store.NewBriefingStore(s.DB()); err != nil {
log.Printf("⚠ 简报存储初始化失败: %v", err)
logger.Printf("⚠ 简报存储初始化失败: %v", err)
} else {
briefingStore = bs
log.Println("✅ 简报持久化存储已启用 (PostgreSQL)")
logger.Println("✅ 简报持久化存储已启用 (PostgreSQL)")
}
// 初始化自动化存储(复用同一数据库连接)
if as, err := store.NewAutomationStore(s.DB()); err != nil {
log.Printf("⚠ 自动化存储初始化失败: %v", err)
logger.Printf("⚠ 自动化存储初始化失败: %v", err)
} else {
automationStore = as
log.Println("✅ 自动化持久化存储已启用 (PostgreSQL)")
logger.Println("✅ 自动化持久化存储已启用 (PostgreSQL)")
}
// 初始化文件存储(复用同一数据库连接)
if fs, err := store.NewFileStore(s.DB()); err != nil {
log.Printf("⚠ 文件存储初始化失败: %v", err)
logger.Printf("⚠ 文件存储初始化失败: %v", err)
} else {
fileStore = fs
log.Println("✅ 文件持久化存储已启用 (PostgreSQL)")
logger.Println("✅ 文件持久化存储已启用 (PostgreSQL)")
}
// 初始化知识库存储(复用同一数据库连接)
if ks, err := store.NewKnowledgeStore(s.DB()); err != nil {
log.Printf("⚠ 知识库存储初始化失败: %v", err)
logger.Printf("⚠ 知识库存储初始化失败: %v", err)
} else {
knowledgeStore = ks
log.Println("✅ 知识库持久化存储已启用 (PostgreSQL)")
logger.Println("✅ 知识库持久化存储已启用 (PostgreSQL)")
}
}
@@ -139,12 +140,13 @@ func main() {
hub := ws.NewHub()
hub.SetStore(sessionStore)
hub.SetIdleTimeout(cfg.SessionIdleTimeoutMin)
hub.SetAICoreConfig(cfg.AICoreURL, cfg.InternalServiceToken)
// 初始化规则引擎 (需要 Hub)
if automationStore != nil {
ruleEngine = engine.NewRuleEngine(automationStore, hub)
ruleEngine.Start()
log.Println("✅ 规则引擎已启动")
logger.Println("✅ 规则引擎已启动")
}
// 初始化Gin
@@ -192,9 +194,9 @@ func main() {
}
go func() {
log.Printf("🚀 Gateway 启动在端口 %s", cfg.Port)
logger.Printf("🚀 Gateway 启动在端口 %s", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("服务启动失败: %v", err)
logger.Fatalf("服务启动失败: %v", err)
}
}()
@@ -202,19 +204,19 @@ func main() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("正在关闭服务...")
logger.Println("正在关闭服务...")
hub.StopIoTBroadcast()
// 关闭数据库连接
if sessionStore != nil {
if err := sessionStore.Close(); err != nil {
log.Printf("⚠ 关闭数据库连接失败: %v", err)
logger.Printf("⚠ 关闭数据库连接失败: %v", err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(ctx)
log.Println("服务已关闭")
logger.Println("服务已关闭")
}
+3
View File
@@ -31,6 +31,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/yourname/cyrene-ai/pkg/logger v0.0.0
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
@@ -38,3 +39,5 @@ require (
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/yourname/cyrene-ai/pkg/logger => ../pkg/logger
+16 -16
View File
@@ -6,7 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"os"
"strings"
@@ -91,7 +91,7 @@ func (e *RuleEngine) Start() {
e.mu.Unlock()
go e.loop()
log.Printf("[RuleEngine] 规则引擎已启动 (IoT服务地址: %s)", e.iotServiceURL)
logger.Printf("[RuleEngine] 规则引擎已启动 (IoT服务地址: %s)", e.iotServiceURL)
}
// Stop 停止规则引擎
@@ -104,7 +104,7 @@ func (e *RuleEngine) Stop() {
}
close(e.stopCh)
e.running = false
log.Println("[RuleEngine] 规则引擎已停止")
logger.Println("[RuleEngine] 规则引擎已停止")
}
// loop 规则引擎主循环
@@ -129,7 +129,7 @@ func (e *RuleEngine) loop() {
func (e *RuleEngine) evaluateAllRules() {
rules, err := e.store.GetEnabledRules()
if err != nil {
log.Printf("[RuleEngine] 获取启用的规则失败: %v", err)
logger.Printf("[RuleEngine] 获取启用的规则失败: %v", err)
return
}
@@ -162,7 +162,7 @@ func (e *RuleEngine) evaluateRule(rule *store.AutomationRule) bool {
var triggerCfg TriggerConfig
if rule.TriggerConfig != nil {
if err := json.Unmarshal(*rule.TriggerConfig, &triggerCfg); err != nil {
log.Printf("[RuleEngine] 解析触发器配置失败: rule=%s err=%v", rule.ID, err)
logger.Printf("[RuleEngine] 解析触发器配置失败: rule=%s err=%v", rule.ID, err)
return false
}
}
@@ -189,7 +189,7 @@ func (e *RuleEngine) evaluateRule(rule *store.AutomationRule) bool {
var conditions []Condition
if rule.Conditions != nil {
if err := json.Unmarshal(*rule.Conditions, &conditions); err != nil {
log.Printf("[RuleEngine] 解析条件失败: rule=%s err=%v", rule.ID, err)
logger.Printf("[RuleEngine] 解析条件失败: rule=%s err=%v", rule.ID, err)
return false
}
}
@@ -240,7 +240,7 @@ func (e *RuleEngine) evaluateDeviceStateTrigger(cfg TriggerConfig) bool {
// 从 IoT 服务获取设备状态
devices, err := e.fetchIoTDevices()
if err != nil {
log.Printf("[RuleEngine] 获取设备状态失败: %v", err)
logger.Printf("[RuleEngine] 获取设备状态失败: %v", err)
return false
}
@@ -340,12 +340,12 @@ func (e *RuleEngine) ExecuteRuleActions(rule *store.AutomationRule) {
var actions []Action
if rule.Actions != nil {
if err := json.Unmarshal(*rule.Actions, &actions); err != nil {
log.Printf("[RuleEngine] 解析动作失败: rule=%s err=%v", rule.ID, err)
logger.Printf("[RuleEngine] 解析动作失败: rule=%s err=%v", rule.ID, err)
return
}
}
log.Printf("[RuleEngine] 执行规则 %s (%s) 的 %d 个动作", rule.ID, rule.Name, len(actions))
logger.Printf("[RuleEngine] 执行规则 %s (%s) 的 %d 个动作", rule.ID, rule.Name, len(actions))
for _, action := range actions {
switch action.Type {
@@ -354,7 +354,7 @@ func (e *RuleEngine) ExecuteRuleActions(rule *store.AutomationRule) {
case "notify":
e.executeNotify(action, rule.UserID)
default:
log.Printf("[RuleEngine] 未知动作类型: %s", action.Type)
logger.Printf("[RuleEngine] 未知动作类型: %s", action.Type)
}
}
}
@@ -366,7 +366,7 @@ func (e *RuleEngine) ExecuteScene(sceneID, userID string) error {
return fmt.Errorf("获取场景规则失败: %w", err)
}
log.Printf("[RuleEngine] 执行场景 %s,共 %d 条关联规则", sceneID, len(rules))
logger.Printf("[RuleEngine] 执行场景 %s,共 %d 条关联规则", sceneID, len(rules))
for _, rule := range rules {
if rule.Enabled {
@@ -393,17 +393,17 @@ func (e *RuleEngine) executeSetDevice(action Action) {
resp, err := e.httpClient.Post(url, "application/json", bytes.NewReader(bodyBytes))
if err != nil {
log.Printf("[RuleEngine] 设备控制请求失败: device=%s property=%s err=%v",
logger.Printf("[RuleEngine] 设备控制请求失败: device=%s property=%s err=%v",
action.DeviceID, action.Property, err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
log.Printf("[RuleEngine] 设备控制成功: device=%s property=%s value=%v",
logger.Printf("[RuleEngine] 设备控制成功: device=%s property=%s value=%v",
action.DeviceID, action.Property, action.Value)
} else {
log.Printf("[RuleEngine] 设备控制失败: device=%s property=%s status=%d",
logger.Printf("[RuleEngine] 设备控制失败: device=%s property=%s status=%d",
action.DeviceID, action.Property, resp.StatusCode)
}
}
@@ -427,12 +427,12 @@ func (e *RuleEngine) executeNotify(action Action, userID string) {
data, err := json.Marshal(msg)
if err != nil {
log.Printf("[RuleEngine] 序列化通知失败: %v", err)
logger.Printf("[RuleEngine] 序列化通知失败: %v", err)
return
}
e.hub.SendToUser(userID, data)
log.Printf("[RuleEngine] 通知已发送: user=%s title=%s", userID, action.Title)
logger.Printf("[RuleEngine] 通知已发送: user=%s title=%s", userID, action.Title)
}
// ========== 辅助方法 ==========
@@ -3,7 +3,7 @@ package handler
import (
"database/sql"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"regexp"
"strings"
@@ -167,12 +167,12 @@ func (h *AuthHandler) Login(c *gin.Context) {
// 密码正确,迁移 admin 到 users 表
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("⚠ 迁移管理员密码哈希失败: %v", err)
logger.Printf("⚠ 迁移管理员密码哈希失败: %v", err)
} else {
if _, err := store.CreateUser(h.db, req.Username, string(passwordHash), true); err != nil {
log.Printf("⚠ 迁移管理员到 users 表失败: %v", err)
logger.Printf("⚠ 迁移管理员到 users 表失败: %v", err)
} else {
log.Println("✅ 管理员已迁移到 users 表")
logger.Println("✅ 管理员已迁移到 users 表")
}
}
userID = "admin"
@@ -4,7 +4,7 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"github.com/gin-gonic/gin"
@@ -76,7 +76,7 @@ func (h *AutomationHandler) ListRules(c *gin.Context) {
rules, err := h.store.GetRulesByUser(userID)
if err != nil {
log.Printf("[automation] 获取规则列表失败: %v", err)
logger.Printf("[automation] 获取规则列表失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取规则列表失败"})
return
}
@@ -124,7 +124,7 @@ func (h *AutomationHandler) CreateRule(c *gin.Context) {
}
if err := h.store.CreateRule(rule); err != nil {
log.Printf("[automation] 创建规则失败: %v", err)
logger.Printf("[automation] 创建规则失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建规则失败"})
return
}
@@ -140,7 +140,7 @@ func (h *AutomationHandler) GetRule(c *gin.Context) {
id := c.Param("id")
rule, err := h.store.GetRule(id)
if err != nil {
log.Printf("[automation] 获取规则失败: %v", err)
logger.Printf("[automation] 获取规则失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取规则失败"})
return
}
@@ -161,7 +161,7 @@ func (h *AutomationHandler) UpdateRule(c *gin.Context) {
// 先获取规则验证所有权
existing, err := h.store.GetRule(id)
if err != nil {
log.Printf("[automation] 获取规则失败: %v", err)
logger.Printf("[automation] 获取规则失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取规则失败"})
return
}
@@ -204,7 +204,7 @@ func (h *AutomationHandler) UpdateRule(c *gin.Context) {
}
if err := h.store.UpdateRule(existing); err != nil {
log.Printf("[automation] 更新规则失败: %v", err)
logger.Printf("[automation] 更新规则失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新规则失败"})
return
}
@@ -223,7 +223,7 @@ func (h *AutomationHandler) DeleteRule(c *gin.Context) {
existing, err := h.store.GetRule(id)
if err != nil {
log.Printf("[automation] 获取规则失败: %v", err)
logger.Printf("[automation] 获取规则失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取规则失败"})
return
}
@@ -237,7 +237,7 @@ func (h *AutomationHandler) DeleteRule(c *gin.Context) {
}
if err := h.store.DeleteRule(id); err != nil {
log.Printf("[automation] 删除规则失败: %v", err)
logger.Printf("[automation] 删除规则失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除规则失败"})
return
}
@@ -253,7 +253,7 @@ func (h *AutomationHandler) TriggerRule(c *gin.Context) {
rule, err := h.store.GetRule(id)
if err != nil {
log.Printf("[automation] 获取规则失败: %v", err)
logger.Printf("[automation] 获取规则失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取规则失败"})
return
}
@@ -288,7 +288,7 @@ func (h *AutomationHandler) ListScenes(c *gin.Context) {
scenes, err := h.store.GetScenesByUser(userID)
if err != nil {
log.Printf("[automation] 获取场景列表失败: %v", err)
logger.Printf("[automation] 获取场景列表失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取场景列表失败"})
return
}
@@ -325,7 +325,7 @@ func (h *AutomationHandler) CreateScene(c *gin.Context) {
}
if err := h.store.CreateScene(scene); err != nil {
log.Printf("[automation] 创建场景失败: %v", err)
logger.Printf("[automation] 创建场景失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建场景失败"})
return
}
@@ -341,7 +341,7 @@ func (h *AutomationHandler) GetScene(c *gin.Context) {
id := c.Param("id")
scene, err := h.store.GetScene(id)
if err != nil {
log.Printf("[automation] 获取场景失败: %v", err)
logger.Printf("[automation] 获取场景失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取场景失败"})
return
}
@@ -361,7 +361,7 @@ func (h *AutomationHandler) UpdateScene(c *gin.Context) {
existing, err := h.store.GetScene(id)
if err != nil {
log.Printf("[automation] 获取场景失败: %v", err)
logger.Printf("[automation] 获取场景失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取场景失败"})
return
}
@@ -391,7 +391,7 @@ func (h *AutomationHandler) UpdateScene(c *gin.Context) {
}
if err := h.store.UpdateScene(existing); err != nil {
log.Printf("[automation] 更新场景失败: %v", err)
logger.Printf("[automation] 更新场景失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新场景失败"})
return
}
@@ -410,7 +410,7 @@ func (h *AutomationHandler) DeleteScene(c *gin.Context) {
existing, err := h.store.GetScene(id)
if err != nil {
log.Printf("[automation] 获取场景失败: %v", err)
logger.Printf("[automation] 获取场景失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取场景失败"})
return
}
@@ -424,7 +424,7 @@ func (h *AutomationHandler) DeleteScene(c *gin.Context) {
}
if err := h.store.DeleteScene(id); err != nil {
log.Printf("[automation] 删除场景失败: %v", err)
logger.Printf("[automation] 删除场景失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除场景失败"})
return
}
@@ -440,7 +440,7 @@ func (h *AutomationHandler) ExecuteScene(c *gin.Context) {
// 验证场景存在
scene, err := h.store.GetScene(id)
if err != nil {
log.Printf("[automation] 获取场景失败: %v", err)
logger.Printf("[automation] 获取场景失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取场景失败"})
return
}
@@ -452,7 +452,7 @@ func (h *AutomationHandler) ExecuteScene(c *gin.Context) {
userID := middleware.GetUserID(c)
if err := h.engine.ExecuteScene(id, userID); err != nil {
log.Printf("[automation] 执行场景失败: %v", err)
logger.Printf("[automation] 执行场景失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "执行场景失败"})
return
}
@@ -5,7 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"strings"
"time"
@@ -66,7 +66,7 @@ func (h *BriefingHandler) GetBriefing(c *gin.Context) {
briefing, err := h.briefingStore.GetBriefingByDate(userID, date)
if err != nil {
log.Printf("[briefing] 查询简报失败: user=%s date=%s err=%v", userID, date, err)
logger.Printf("[briefing] 查询简报失败: user=%s date=%s err=%v", userID, date, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询简报失败: " + err.Error()})
return
}
@@ -106,7 +106,7 @@ func (h *BriefingHandler) GetLatestBriefings(c *gin.Context) {
briefings, err := h.briefingStore.GetLatestBriefings(userID, limit)
if err != nil {
log.Printf("[briefing] 查询简报列表失败: user=%s err=%v", userID, err)
logger.Printf("[briefing] 查询简报列表失败: user=%s err=%v", userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询简报列表失败: " + err.Error()})
return
}
@@ -139,7 +139,7 @@ func (h *BriefingHandler) Generate(c *gin.Context) {
result, err := h.GenerateDailyBriefing(req.UserID)
if err != nil {
log.Printf("[briefing] 生成简报失败: user=%s err=%v", req.UserID, err)
logger.Printf("[briefing] 生成简报失败: user=%s err=%v", req.UserID, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "生成简报失败: " + err.Error(),
"success": false,
@@ -173,10 +173,10 @@ func (h *BriefingHandler) GenerateDailyBriefing(userID string) (*store.Briefing,
}
// 1. 获取天气数据
log.Printf("[briefing] 获取天气数据...")
logger.Printf("[briefing] 获取天气数据...")
weather, err := h.fetchWeather("Shanghai")
if err != nil {
log.Printf("[briefing] 天气获取失败 (降级): %v", err)
logger.Printf("[briefing] 天气获取失败 (降级): %v", err)
weather = &store.WeatherData{
Location: "未知",
Temp: 0,
@@ -185,13 +185,13 @@ func (h *BriefingHandler) GenerateDailyBriefing(userID string) (*store.Briefing,
}
}
briefing.Weather = weather
log.Printf("[briefing] 天气: %s %.1f°C %s", weather.Location, weather.Temp, weather.Condition)
logger.Printf("[briefing] 天气: %s %.1f°C %s", weather.Location, weather.Temp, weather.Condition)
// 2. 获取今日待办提醒
log.Printf("[briefing] 获取待办提醒...")
logger.Printf("[briefing] 获取待办提醒...")
reminders, err := h.reminderStore.GetRemindersByUser(userID, "pending", 10, 0)
if err != nil {
log.Printf("[briefing] 获取提醒失败: %v", err)
logger.Printf("[briefing] 获取提醒失败: %v", err)
} else {
now := time.Now()
endOfDay := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())
@@ -205,22 +205,22 @@ func (h *BriefingHandler) GenerateDailyBriefing(userID string) (*store.Briefing,
}
}
}
log.Printf("[briefing] 今日待办: %d 项", len(briefing.Reminders))
logger.Printf("[briefing] 今日待办: %d 项", len(briefing.Reminders))
// 3. 获取新闻摘要(通过 tool-engine web_search
log.Printf("[briefing] 获取新闻摘要...")
logger.Printf("[briefing] 获取新闻摘要...")
news, err := h.fetchNews()
if err != nil {
log.Printf("[briefing] 新闻获取失败 (降级): %v", err)
logger.Printf("[briefing] 新闻获取失败 (降级): %v", err)
}
briefing.News = news
log.Printf("[briefing] 新闻: %d 条", len(news))
logger.Printf("[briefing] 新闻: %d 条", len(news))
// 4. 生成 AI 摘要
log.Printf("[briefing] 生成 AI 摘要...")
logger.Printf("[briefing] 生成 AI 摘要...")
summary, err := h.generateAISummary(briefing)
if err != nil {
log.Printf("[briefing] AI 摘要生成失败 (降级): %v", err)
logger.Printf("[briefing] AI 摘要生成失败 (降级): %v", err)
summary = h.buildFallbackSummary(briefing)
briefing.SummarySource = "fallback"
} else {
@@ -238,7 +238,7 @@ func (h *BriefingHandler) GenerateDailyBriefing(userID string) (*store.Briefing,
return nil, fmt.Errorf("保存简报失败: %w", err)
}
log.Printf("[briefing] 简报已生成: user=%s date=%s", userID, today)
logger.Printf("[briefing] 简报已生成: user=%s date=%s", userID, today)
return briefing, nil
}
@@ -362,7 +362,7 @@ func (h *BriefingHandler) fetchNews() ([]store.NewsItem, error) {
}
if result.Error != "" {
log.Printf("[briefing] 新闻搜索失败: %s", result.Error)
logger.Printf("[briefing] 新闻搜索失败: %s", result.Error)
// 返回降级新闻
return []store.NewsItem{
{
@@ -585,7 +585,7 @@ func (h *BriefingHandler) pushBriefingNotification(userID string, b *store.Brief
data, err := json.Marshal(msg)
if err != nil {
log.Printf("[briefing] 序列化简报通知失败: %v", err)
logger.Printf("[briefing] 序列化简报通知失败: %v", err)
return
}
@@ -596,10 +596,10 @@ func (h *BriefingHandler) pushBriefingNotification(userID string, b *store.Brief
b.Status = "delivered"
b.DeliveredAt = &now
if err := h.briefingStore.CreateOrUpdateBriefing(b); err != nil {
log.Printf("[briefing] 更新简报送达状态失败: %v", err)
logger.Printf("[briefing] 更新简报送达状态失败: %v", err)
}
log.Printf("[briefing] 简报通知已推送: user=%s date=%s", userID, b.Date)
logger.Printf("[briefing] 简报通知已推送: user=%s date=%s", userID, b.Date)
}
// StartBriefingScheduler 启动简报调度器
@@ -614,7 +614,7 @@ func StartBriefingScheduler(handler *BriefingHandler, briefingStore *store.Brief
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
log.Printf("[BriefingScheduler] 简报调度器已启动 (简报时间: %s)", briefingTime)
logger.Printf("[BriefingScheduler] 简报调度器已启动 (简报时间: %s)", briefingTime)
// 记录今天是否已触发
lastTriggeredDate := ""
@@ -626,13 +626,13 @@ func StartBriefingScheduler(handler *BriefingHandler, briefingStore *store.Brief
// 检查是否到达简报时间且今天尚未触发
if currentTime == briefingTime && currentDate != lastTriggeredDate {
log.Printf("[BriefingScheduler] 触发每日简报生成: %s", currentDate)
logger.Printf("[BriefingScheduler] 触发每日简报生成: %s", currentDate)
lastTriggeredDate = currentDate
// 获取所有用户
users, err := briefingStore.GetAllUsers()
if err != nil {
log.Printf("[BriefingScheduler] 获取用户列表失败: %v", err)
logger.Printf("[BriefingScheduler] 获取用户列表失败: %v", err)
continue
}
@@ -642,21 +642,21 @@ func StartBriefingScheduler(handler *BriefingHandler, briefingStore *store.Brief
}
if len(users) == 0 {
log.Println("[BriefingScheduler] 没有找到用户,跳过简报生成")
logger.Println("[BriefingScheduler] 没有找到用户,跳过简报生成")
continue
}
for _, userID := range users {
log.Printf("[BriefingScheduler] 为用户 %s 生成简报...", userID)
logger.Printf("[BriefingScheduler] 为用户 %s 生成简报...", userID)
result, err := handler.GenerateDailyBriefing(userID)
if err != nil {
log.Printf("[BriefingScheduler] 生成简报失败: user=%s err=%v", userID, err)
logger.Printf("[BriefingScheduler] 生成简报失败: user=%s err=%v", userID, err)
continue
}
handler.pushBriefingNotification(userID, result)
}
log.Printf("[BriefingScheduler] 每日简报已生成完毕,共 %d 个用户", len(users))
logger.Printf("[BriefingScheduler] 每日简报已生成完毕,共 %d 个用户", len(users))
}
}
}()
@@ -8,7 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"strings"
"time"
@@ -88,7 +88,7 @@ func (h *ChatHandler) HandleWebSocket(c *gin.Context) {
// 升级WebSocket连接
conn, err := h.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("[WS] 升级连接失败: %v", err)
logger.Printf("[WS] 升级连接失败: %v", err)
return
}
@@ -115,7 +115,7 @@ func (h *ChatHandler) handleMessage(client *ws.Client, msg ws.ClientMessage) {
case "history":
h.handleHistoryRequest(client, msg)
default:
log.Printf("[WS] 未知消息类型: %s from user=%s", msg.Type, client.UserID)
logger.Printf("[WS] 未知消息类型: %s from user=%s", msg.Type, client.UserID)
}
}
@@ -128,8 +128,8 @@ func (h *ChatHandler) handleChatMessage(client *ws.Client, msg ws.ClientMessage)
// 持久化用户消息到数据库(在 WebSocket 发送之前)
if h.sessionStore != nil && h.sessionStore.IsAvailable() {
if err := h.sessionStore.AddMessage(client.SessionID, "user", msg.Content); err != nil {
log.Printf("[chat] 持久化用户消息失败: %v", err)
if err := h.sessionStore.AddMessage(client.SessionID, "user", "chat", msg.Content); err != nil {
logger.Printf("[chat] 持久化用户消息失败: %v", err)
}
}
@@ -151,7 +151,7 @@ func (h *ChatHandler) handleChatMessage(client *ws.Client, msg ws.ClientMessage)
}
reqBody, err := json.Marshal(aiReq)
if err != nil {
log.Printf("[chat] 序列化请求失败: %v", err)
logger.Printf("[chat] 序列化请求失败: %v", err)
h.hub.UpdateSessionState(client.SessionID, "error")
client.SendMessage(ws.ServerMessage{
Type: "error",
@@ -183,7 +183,7 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
aiCoreURL := h.cfg.AICoreURL + "/api/v1/chat"
httpReq, err := http.NewRequest("POST", aiCoreURL, bytes.NewReader(reqBody))
if err != nil {
log.Printf("[chat] 创建 AI-Core 请求失败: %v", err)
logger.Printf("[chat] 创建 AI-Core 请求失败: %v", err)
h.hub.UpdateSessionState(client.SessionID, "error")
client.SendMessage(ws.ServerMessage{
Type: "error",
@@ -199,7 +199,7 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
httpClient := &http.Client{Timeout: 120 * time.Second}
resp, err := httpClient.Do(httpReq)
if err != nil {
log.Printf("[chat] AI-Core 调用失败: %v", err)
logger.Printf("[chat] AI-Core 调用失败: %v", err)
h.hub.UpdateSessionState(client.SessionID, "error")
client.SendMessage(ws.ServerMessage{
Type: "error",
@@ -213,7 +213,7 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Printf("[chat] AI-Core 返回错误 [%d]: %s", resp.StatusCode, string(body))
logger.Printf("[chat] AI-Core 返回错误 [%d]: %s", resp.StatusCode, string(body))
h.hub.UpdateSessionState(client.SessionID, "error")
client.SendMessage(ws.ServerMessage{
Type: "error",
@@ -273,13 +273,13 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
ReviewMessages []ws.ReviewMessage `json:"review_messages,omitempty"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
log.Printf("[chat] 解析 SSE delta 失败: %v, raw=%s", err, data)
logger.Printf("[chat] 解析 SSE delta 失败: %v, raw=%s", err, data)
continue
}
// 错误处理
if chunk.Error != "" {
log.Printf("[chat] AI-Core 流式错误: %s", chunk.Error)
logger.Printf("[chat] AI-Core 流式错误: %s", chunk.Error)
h.hub.UpdateSessionState(client.SessionID, "error")
client.SendMessage(ws.ServerMessage{
Type: "error",
@@ -312,8 +312,8 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
reviewMsgID := fmt.Sprintf("%s_r%d", msgID, i)
// 持久化每条审查消息
if h.sessionStore != nil && h.sessionStore.IsAvailable() {
if err := h.sessionStore.AddMessage(client.SessionID, role, rm.Content); err != nil {
log.Printf("[chat] 持久化审查消息失败: %v", err)
if err := h.sessionStore.AddMessage(client.SessionID, role, msgType, rm.Content); err != nil {
logger.Printf("[chat] 持久化审查消息失败: %v", err)
}
}
h.hub.CacheMessage(client.UserID, client.SessionID, ws.Message{
@@ -331,9 +331,9 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
SessionID: client.SessionID,
Timestamp: time.Now().UnixMilli(),
})
// 小延迟让消息逐条到达,更像真人
if i < len(chunk.ReviewMessages)-1 {
time.Sleep(800 * time.Millisecond)
// 使用 MessageScheduler 计算的 per-message 延迟
if rm.DelayMs > 0 {
time.Sleep(time.Duration(rm.DelayMs) * time.Millisecond)
}
}
hasReview = true
@@ -366,7 +366,7 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
}
if err := scanner.Err(); err != nil {
log.Printf("[chat] SSE 读取错误: %v", err)
logger.Printf("[chat] SSE 读取错误: %v", err)
h.hub.UpdateSessionState(client.SessionID, "error")
client.SendMessage(ws.ServerMessage{
Type: "error",
@@ -416,8 +416,8 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
// 如果有审查消息,每条已单独持久化,跳过 fullText 以避免重复
if !hasReview && fullText != "" {
if h.sessionStore != nil && h.sessionStore.IsAvailable() {
if err := h.sessionStore.AddMessage(client.SessionID, "assistant", fullText); err != nil {
log.Printf("[chat] 持久化 AI 回复失败: %v", err)
if err := h.sessionStore.AddMessage(client.SessionID, "assistant", "chat", fullText); err != nil {
logger.Printf("[chat] 持久化 AI 回复失败: %v", err)
}
}
@@ -466,18 +466,20 @@ func (h *ChatHandler) handleHistoryRequest(client *ws.Client, msg ws.ClientMessa
if len(messages) == 0 && h.sessionStore != nil && h.sessionStore.IsAvailable() {
dbMessages, err := h.sessionStore.GetMessages(sessionID, 50, 0)
if err == nil && len(dbMessages) > 0 {
log.Printf("[history] 从数据库恢复会话历史: session=%s, %d 条消息", sessionID, len(dbMessages))
logger.Printf("[history] 从数据库恢复会话历史: session=%s, %d 条消息", sessionID, len(dbMessages))
// 恢复到内存缓存
for _, dbMsg := range dbMessages {
messages = append(messages, ws.Message{
ID: fmt.Sprintf("db_%d", dbMsg.ID),
Role: dbMsg.Role,
MsgType: dbMsg.MsgType,
Content: dbMsg.Content,
Timestamp: dbMsg.CreatedAt.UnixMilli(),
})
h.hub.CacheMessage(client.UserID, sessionID, ws.Message{
ID: fmt.Sprintf("db_%d", dbMsg.ID),
Role: dbMsg.Role,
MsgType: dbMsg.MsgType,
Content: dbMsg.Content,
Timestamp: dbMsg.CreatedAt.UnixMilli(),
})
@@ -497,7 +499,7 @@ func (h *ChatHandler) handleHistoryRequest(client *ws.Client, msg ws.ClientMessa
}
if err := client.SendMessage(response); err != nil {
log.Printf("[WS] 发送历史消息失败: %v", err)
logger.Printf("[WS] 发送历史消息失败: %v", err)
}
}
@@ -535,10 +537,22 @@ func (h *ChatHandler) HandleProactiveMessage(c *gin.Context) {
// 检查用户是否在线
onlineCount := h.hub.UserClientCount(req.UserID)
if onlineCount == 0 {
// Phase 2: 离线时排队,等待用户重连后推送
data, _ := json.Marshal(ws.ServerMessage{
Type: "response",
MessageID: "proactive_" + generateID(),
Content: req.Content,
Role: "assistant",
MsgType: "proactive",
SessionID: req.SessionID,
Timestamp: time.Now().UnixMilli(),
})
h.hub.QueueProactiveMessage(req.UserID, data)
logger.Printf("[proactive] 用户离线,消息已排队: user=%s", req.UserID)
c.JSON(http.StatusOK, gin.H{
"success": false,
"reason": "user_offline",
"message": "用户不在线,消息未发送",
"success": true,
"reason": "queued",
"message": "用户线,消息已排队等待重连后推送",
})
return
}
@@ -557,7 +571,7 @@ func (h *ChatHandler) HandleProactiveMessage(c *gin.Context) {
data, err := json.Marshal(msg)
if err != nil {
log.Printf("[proactive] 序列化消息失败: %v", err)
logger.Printf("[proactive] 序列化消息失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "内部错误"})
return
}
@@ -577,7 +591,7 @@ func (h *ChatHandler) HandleProactiveMessage(c *gin.Context) {
})
h.hub.RecordMessage(sessionID, "assistant", req.Content)
log.Printf("[proactive] 主动消息已推送: user=%s, online=%d, content_len=%d", req.UserID, onlineCount, len(req.Content))
logger.Printf("[proactive] 主动消息已推送: user=%s, online=%d, content_len=%d", req.UserID, onlineCount, len(req.Content))
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -10,7 +10,7 @@ import (
"image/jpeg"
"image/png"
"io"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"os"
"path/filepath"
@@ -144,7 +144,7 @@ func (h *FileHandler) Upload(c *gin.Context) {
dateDir := time.Now().Format("2006-01-02")
storedDir := filepath.Join(h.uploadDir, dateDir)
if err := os.MkdirAll(storedDir, 0755); err != nil {
log.Printf("[FileHandler] 创建上传目录失败: %v", err)
logger.Printf("[FileHandler] 创建上传目录失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建上传目录失败", "errorType": "server_error"})
return
}
@@ -160,7 +160,7 @@ func (h *FileHandler) Upload(c *gin.Context) {
// 保存到磁盘
dst, err := os.Create(storedPath)
if err != nil {
log.Printf("[FileHandler] 创建文件失败: %v", err)
logger.Printf("[FileHandler] 创建文件失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败", "errorType": "server_error"})
return
}
@@ -169,7 +169,7 @@ func (h *FileHandler) Upload(c *gin.Context) {
written, err := io.Copy(dst, teeReader)
if err != nil {
os.Remove(storedPath)
log.Printf("[FileHandler] 写入文件失败: %v", err)
logger.Printf("[FileHandler] 写入文件失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "写入文件失败", "errorType": "server_error"})
return
}
@@ -180,7 +180,7 @@ func (h *FileHandler) Upload(c *gin.Context) {
if existing, err := h.store.GetFileByHash(hash); err == nil && existing != nil {
// 删除刚保存的重复文件
os.Remove(storedPath)
log.Printf("[FileHandler] 文件去重: 复用已有文件 %s (hash=%s)", existing.ID, hash[:16])
logger.Printf("[FileHandler] 文件去重: 复用已有文件 %s (hash=%s)", existing.ID, hash[:16])
c.JSON(http.StatusOK, gin.H{
"id": existing.ID,
@@ -208,12 +208,12 @@ func (h *FileHandler) Upload(c *gin.Context) {
if err := h.store.CreateFile(fileRecord); err != nil {
os.Remove(storedPath)
log.Printf("[FileHandler] 创建文件记录失败: %v", err)
logger.Printf("[FileHandler] 创建文件记录失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建文件记录失败", "errorType": "db_error"})
return
}
log.Printf("[FileHandler] 文件上传成功: %s (%s, %d bytes, hash=%s)", fileID, safeFilename, written, hash[:16])
logger.Printf("[FileHandler] 文件上传成功: %s (%s, %d bytes, hash=%s)", fileID, safeFilename, written, hash[:16])
c.JSON(http.StatusCreated, gin.H{
"id": fileID,
@@ -240,7 +240,7 @@ func (h *FileHandler) List(c *gin.Context) {
files, total, err := h.store.GetUserFiles(userID, page, limit)
if err != nil {
log.Printf("[FileHandler] 查询文件列表失败: %v", err)
logger.Printf("[FileHandler] 查询文件列表失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件列表失败", "errorType": "db_error"})
return
}
@@ -288,7 +288,7 @@ func (h *FileHandler) Get(c *gin.Context) {
f, err := h.store.GetFile(fileID)
if err != nil {
log.Printf("[FileHandler] 查询文件失败: %v", err)
logger.Printf("[FileHandler] 查询文件失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"})
return
}
@@ -338,7 +338,7 @@ func (h *FileHandler) Download(c *gin.Context) {
f, err := h.store.GetFile(fileID)
if err != nil {
log.Printf("[FileHandler] 查询文件失败: %v", err)
logger.Printf("[FileHandler] 查询文件失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"})
return
}
@@ -385,7 +385,7 @@ func (h *FileHandler) Delete(c *gin.Context) {
f, err := h.store.GetFile(fileID)
if err != nil {
log.Printf("[FileHandler] 查询文件失败: %v", err)
logger.Printf("[FileHandler] 查询文件失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"})
return
}
@@ -405,12 +405,12 @@ func (h *FileHandler) Delete(c *gin.Context) {
// 删除磁盘上的文件(忽略错误,可能已被删除)
if err := os.Remove(f.StoredPath); err != nil && !os.IsNotExist(err) {
log.Printf("[FileHandler] 删除磁盘文件失败 (stored_path=%s): %v", f.StoredPath, err)
logger.Printf("[FileHandler] 删除磁盘文件失败 (stored_path=%s): %v", f.StoredPath, err)
}
// 删除数据库记录
if err := h.store.DeleteFile(fileID); err != nil {
log.Printf("[FileHandler] 删除文件记录失败: %v", err)
logger.Printf("[FileHandler] 删除文件记录失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文件记录失败", "errorType": "db_error"})
return
}
@@ -432,7 +432,7 @@ func (h *FileHandler) Thumbnail(c *gin.Context) {
f, err := h.store.GetFile(fileID)
if err != nil {
log.Printf("[FileHandler] 查询文件失败: %v", err)
logger.Printf("[FileHandler] 查询文件失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"})
return
}
@@ -457,7 +457,7 @@ func (h *FileHandler) Thumbnail(c *gin.Context) {
c.Data(http.StatusOK, contentType, thumbData)
return
} else {
log.Printf("[FileHandler] 生成缩略图失败: %v", err)
logger.Printf("[FileHandler] 生成缩略图失败: %v", err)
}
}
@@ -11,7 +11,7 @@ import (
_ "image/jpeg"
_ "image/png"
"io"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"os"
"sort"
@@ -116,7 +116,7 @@ func (h *ImageHandler) analyzeByFileID(c *gin.Context, userID, fileID string) {
f, err := h.fileStore.GetFile(fileID)
if err != nil {
log.Printf("[ImageHandler] 查询文件失败: %v", err)
logger.Printf("[ImageHandler] 查询文件失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"})
return
}
@@ -135,7 +135,7 @@ func (h *ImageHandler) analyzeByFileID(c *gin.Context, userID, fileID string) {
result, err := h.analyzeImage(f.StoredPath, f.MimeType, f.Size)
if err != nil {
log.Printf("[ImageHandler] 图片分析失败: %v", err)
logger.Printf("[ImageHandler] 图片分析失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "图片分析失败: " + err.Error(), "errorType": "analysis_error"})
return
}
@@ -192,7 +192,7 @@ func (h *ImageHandler) analyzeUploadedFile(c *gin.Context, userID string, file i
result, err := h.analyzeImage(tmpFile.Name(), mimeType, int64(len(data)))
if err != nil {
log.Printf("[ImageHandler] 图片分析失败: %v", err)
logger.Printf("[ImageHandler] 图片分析失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "图片分析失败: " + err.Error(), "errorType": "analysis_error"})
return
}
@@ -209,7 +209,7 @@ func (h *ImageHandler) analyzeImage(filePath, mimeType string, fileSize int64) (
if err == nil {
return result, nil
}
log.Printf("[ImageHandler] OpenAI Vision 分析失败,降级到本地分析: %v", err)
logger.Printf("[ImageHandler] OpenAI Vision 分析失败,降级到本地分析: %v", err)
}
// 降级到本地分析
@@ -2,7 +2,7 @@ package handler
import (
"html"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"os"
"strings"
@@ -83,7 +83,7 @@ func (h *KnowledgeHandler) CreateKB(c *gin.Context) {
}
if err := h.store.CreateKB(kb); err != nil {
log.Printf("[KnowledgeHandler] 创建知识库失败: %v", err)
logger.Printf("[KnowledgeHandler] 创建知识库失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建知识库失败", "errorType": "db_error"})
return
}
@@ -101,7 +101,7 @@ func (h *KnowledgeHandler) ListKBs(c *gin.Context) {
kbs, err := h.store.GetKBsByUser(userID)
if err != nil {
log.Printf("[KnowledgeHandler] 查询知识库列表失败: %v", err)
logger.Printf("[KnowledgeHandler] 查询知识库列表失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询知识库列表失败", "errorType": "db_error"})
return
}
@@ -120,7 +120,7 @@ func (h *KnowledgeHandler) GetKB(c *gin.Context) {
kb, err := h.store.GetKB(kbID)
if err != nil {
log.Printf("[KnowledgeHandler] 查询知识库失败: %v", err)
logger.Printf("[KnowledgeHandler] 查询知识库失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询知识库失败", "errorType": "db_error"})
return
}
@@ -136,7 +136,7 @@ func (h *KnowledgeHandler) GetKB(c *gin.Context) {
// 获取文档列表
docs, err := h.store.GetDocumentsByKB(kbID)
if err != nil {
log.Printf("[KnowledgeHandler] 查询文档列表失败: %v", err)
logger.Printf("[KnowledgeHandler] 查询文档列表失败: %v", err)
docs = []store.KnowledgeDocument{}
}
@@ -163,7 +163,7 @@ func (h *KnowledgeHandler) UpdateKB(c *gin.Context) {
kb, err := h.store.GetKB(kbID)
if err != nil {
log.Printf("[KnowledgeHandler] 查询知识库失败: %v", err)
logger.Printf("[KnowledgeHandler] 查询知识库失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询知识库失败", "errorType": "db_error"})
return
}
@@ -177,7 +177,7 @@ func (h *KnowledgeHandler) UpdateKB(c *gin.Context) {
}
if err := h.store.UpdateKB(kbID, html.EscapeString(req.Name), html.EscapeString(req.Description)); err != nil {
log.Printf("[KnowledgeHandler] 更新知识库失败: %v", err)
logger.Printf("[KnowledgeHandler] 更新知识库失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新知识库失败", "errorType": "db_error"})
return
}
@@ -196,7 +196,7 @@ func (h *KnowledgeHandler) DeleteKB(c *gin.Context) {
kb, err := h.store.GetKB(kbID)
if err != nil {
log.Printf("[KnowledgeHandler] 查询知识库失败: %v", err)
logger.Printf("[KnowledgeHandler] 查询知识库失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询知识库失败", "errorType": "db_error"})
return
}
@@ -210,7 +210,7 @@ func (h *KnowledgeHandler) DeleteKB(c *gin.Context) {
}
if err := h.store.DeleteKB(kbID); err != nil {
log.Printf("[KnowledgeHandler] 删除知识库失败: %v", err)
logger.Printf("[KnowledgeHandler] 删除知识库失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除知识库失败", "errorType": "db_error"})
return
}
@@ -230,7 +230,7 @@ func (h *KnowledgeHandler) AddDocument(c *gin.Context) {
// 检查知识库是否存在且属于当前用户
kb, err := h.store.GetKB(kbID)
if err != nil {
log.Printf("[KnowledgeHandler] 查询知识库失败: %v", err)
logger.Printf("[KnowledgeHandler] 查询知识库失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询知识库失败", "errorType": "db_error"})
return
}
@@ -324,7 +324,7 @@ func (h *KnowledgeHandler) AddDocument(c *gin.Context) {
}
if err := h.store.AddDocument(doc); err != nil {
log.Printf("[KnowledgeHandler] 添加文档失败: %v", err)
logger.Printf("[KnowledgeHandler] 添加文档失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "添加文档失败", "errorType": "db_error"})
return
}
@@ -332,7 +332,7 @@ func (h *KnowledgeHandler) AddDocument(c *gin.Context) {
// 自动分块
chunkCount, err := h.store.ChunkDocument(doc.ID)
if err != nil {
log.Printf("[KnowledgeHandler] 文档分块失败: %v", err)
logger.Printf("[KnowledgeHandler] 文档分块失败: %v", err)
// 分块失败不影响文档创建
}
@@ -353,7 +353,7 @@ func (h *KnowledgeHandler) ListDocuments(c *gin.Context) {
// 检查权限
kb, err := h.store.GetKB(kbID)
if err != nil {
log.Printf("[KnowledgeHandler] 查询知识库失败: %v", err)
logger.Printf("[KnowledgeHandler] 查询知识库失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询知识库失败", "errorType": "db_error"})
return
}
@@ -368,7 +368,7 @@ func (h *KnowledgeHandler) ListDocuments(c *gin.Context) {
docs, err := h.store.GetDocumentsByKB(kbID)
if err != nil {
log.Printf("[KnowledgeHandler] 查询文档列表失败: %v", err)
logger.Printf("[KnowledgeHandler] 查询文档列表失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文档列表失败", "errorType": "db_error"})
return
}
@@ -387,7 +387,7 @@ func (h *KnowledgeHandler) GetDocument(c *gin.Context) {
doc, err := h.store.GetDocument(docID)
if err != nil {
log.Printf("[KnowledgeHandler] 查询文档失败: %v", err)
logger.Printf("[KnowledgeHandler] 查询文档失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文档失败", "errorType": "db_error"})
return
}
@@ -403,7 +403,7 @@ func (h *KnowledgeHandler) GetDocument(c *gin.Context) {
// 获取分块
chunks, err := h.store.GetChunksByDocID(docID)
if err != nil {
log.Printf("[KnowledgeHandler] 查询分块失败: %v", err)
logger.Printf("[KnowledgeHandler] 查询分块失败: %v", err)
chunks = []store.KnowledgeChunk{}
}
@@ -424,7 +424,7 @@ func (h *KnowledgeHandler) DeleteDocument(c *gin.Context) {
doc, err := h.store.GetDocument(docID)
if err != nil {
log.Printf("[KnowledgeHandler] 查询文档失败: %v", err)
logger.Printf("[KnowledgeHandler] 查询文档失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文档失败", "errorType": "db_error"})
return
}
@@ -438,7 +438,7 @@ func (h *KnowledgeHandler) DeleteDocument(c *gin.Context) {
}
if err := h.store.DeleteDocument(docID); err != nil {
log.Printf("[KnowledgeHandler] 删除文档失败: %v", err)
logger.Printf("[KnowledgeHandler] 删除文档失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文档失败", "errorType": "db_error"})
return
}
@@ -483,7 +483,7 @@ func (h *KnowledgeHandler) Search(c *gin.Context) {
}
kbResults, searchErr := h.store.SearchChunks(kbID, req.Query, req.Limit)
if searchErr != nil {
log.Printf("[KnowledgeHandler] 搜索知识库 %s 失败: %v", kbID, searchErr)
logger.Printf("[KnowledgeHandler] 搜索知识库 %s 失败: %v", kbID, searchErr)
continue
}
results = append(results, kbResults...)
@@ -496,7 +496,7 @@ func (h *KnowledgeHandler) Search(c *gin.Context) {
}
if err != nil {
log.Printf("[KnowledgeHandler] 搜索失败: %v", err)
logger.Printf("[KnowledgeHandler] 搜索失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败", "errorType": "db_error"})
return
}
@@ -6,7 +6,7 @@ import (
"fmt"
"html"
"io"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"time"
@@ -65,7 +65,7 @@ func (h *MemoryHandler) Query(c *gin.Context) {
resp, err := h.client.Do(httpReq)
if err != nil {
log.Printf("[memory] Memory-Service 不可达 (Query): %v", err)
logger.Printf("[memory] Memory-Service 不可达 (Query): %v", err)
c.JSON(http.StatusBadGateway, gin.H{
"error": fmt.Sprintf("Memory-Service 不可达: %v", err),
"errorType": "memory_service_unreachable",
@@ -97,7 +97,7 @@ func (h *MemoryHandler) List(c *gin.Context) {
resp, err := h.client.Get(url)
if err != nil {
log.Printf("[memory] Memory-Service 不可达 (List): %v", err)
logger.Printf("[memory] Memory-Service 不可达 (List): %v", err)
c.JSON(http.StatusBadGateway, gin.H{
"error": fmt.Sprintf("Memory-Service 不可达: %v", err),
"errorType": "memory_service_unreachable",
@@ -163,7 +163,7 @@ func (h *MemoryHandler) Add(c *gin.Context) {
resp, err := h.client.Do(httpReq)
if err != nil {
log.Printf("[memory] Memory-Service 不可达 (Add): %v", err)
logger.Printf("[memory] Memory-Service 不可达 (Add): %v", err)
c.JSON(http.StatusBadGateway, gin.H{
"error": fmt.Sprintf("Memory-Service 不可达: %v", err),
"errorType": "memory_service_unreachable",
@@ -198,7 +198,7 @@ func (h *MemoryHandler) Delete(c *gin.Context) {
resp, err := h.client.Do(req)
if err != nil {
log.Printf("[memory] Memory-Service 不可达 (Delete): %v", err)
logger.Printf("[memory] Memory-Service 不可达 (Delete): %v", err)
c.JSON(http.StatusBadGateway, gin.H{
"error": fmt.Sprintf("Memory-Service 不可达: %v", err),
"errorType": "memory_service_unreachable",
@@ -2,7 +2,7 @@ package handler
import (
"encoding/json"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"time"
@@ -54,7 +54,7 @@ func (h *NotificationHandler) Push(c *gin.Context) {
data, err := json.Marshal(msg)
if err != nil {
log.Printf("[notification] 序列化通知失败: %v", err)
logger.Printf("[notification] 序列化通知失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "内部错误"})
return
}
@@ -62,7 +62,7 @@ func (h *NotificationHandler) Push(c *gin.Context) {
// 通过 Hub 推送给指定用户
h.hub.SendToUser(req.UserID, data)
log.Printf("[notification] 通知已推送: user=%s type=%s title=%s", req.UserID, req.Type, req.Title)
logger.Printf("[notification] 通知已推送: user=%s type=%s title=%s", req.UserID, req.Type, req.Title)
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -99,7 +99,7 @@ func (h *NotificationHandler) InternalNotify(c *gin.Context) {
data, err := json.Marshal(msg)
if err != nil {
log.Printf("[notification] 序列化通知失败: %v", err)
logger.Printf("[notification] 序列化通知失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "内部错误"})
return
}
@@ -107,7 +107,7 @@ func (h *NotificationHandler) InternalNotify(c *gin.Context) {
// 通过 Hub 推送给指定用户
h.hub.SendToUser(req.UserID, data)
log.Printf("[notification] 内部通知已推送: user=%s type=%s title=%s", req.UserID, req.Type, req.Title)
logger.Printf("[notification] 内部通知已推送: user=%s type=%s title=%s", req.UserID, req.Type, req.Title)
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -3,7 +3,7 @@ package handler
import (
"encoding/json"
"html"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"strconv"
"time"
@@ -71,7 +71,7 @@ func (h *ReminderHandler) List(c *gin.Context) {
reminders, err := h.store.GetRemindersByUser(userID, status, limit, offset)
if err != nil {
log.Printf("[reminder] 获取提醒列表失败: %v", err)
logger.Printf("[reminder] 获取提醒列表失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取提醒列表失败"})
return
}
@@ -124,12 +124,12 @@ func (h *ReminderHandler) Create(c *gin.Context) {
}
if err := h.store.CreateReminder(reminder); err != nil {
log.Printf("[reminder] 创建提醒失败: %v", err)
logger.Printf("[reminder] 创建提醒失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建提醒失败"})
return
}
log.Printf("[reminder] 提醒已创建: id=%s user=%s title=%s remind_at=%s repeat=%s",
logger.Printf("[reminder] 提醒已创建: id=%s user=%s title=%s remind_at=%s repeat=%s",
reminder.ID, userID, reminder.Title, remindAt.Format(time.RFC3339), repeatType)
c.JSON(http.StatusCreated, gin.H{
@@ -204,12 +204,12 @@ func (h *ReminderHandler) Update(c *gin.Context) {
}
if err := h.store.UpdateReminder(id, existing); err != nil {
log.Printf("[reminder] 更新提醒失败: %v", err)
logger.Printf("[reminder] 更新提醒失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新提醒失败"})
return
}
log.Printf("[reminder] 提醒已更新: id=%s", id)
logger.Printf("[reminder] 提醒已更新: id=%s", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -227,12 +227,12 @@ func (h *ReminderHandler) Delete(c *gin.Context) {
}
if err := h.store.DeleteReminder(id); err != nil {
log.Printf("[reminder] 删除提醒失败: %v", err)
logger.Printf("[reminder] 删除提醒失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除提醒失败"})
return
}
log.Printf("[reminder] 提醒已删除: id=%s", id)
logger.Printf("[reminder] 提醒已删除: id=%s", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -247,7 +247,7 @@ func StartReminderScheduler(s *store.ReminderStore, hub *ws.Hub) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
log.Println("[ReminderScheduler] 提醒调度器已启动 (检查间隔: 30秒)")
logger.Println("[ReminderScheduler] 提醒调度器已启动 (检查间隔: 30秒)")
for range ticker.C {
checkAndNotify(s, hub)
@@ -259,7 +259,7 @@ func StartReminderScheduler(s *store.ReminderStore, hub *ws.Hub) {
func checkAndNotify(s *store.ReminderStore, hub *ws.Hub) {
reminders, err := s.GetDueReminders()
if err != nil {
log.Printf("[ReminderScheduler] 获取到期提醒失败: %v", err)
logger.Printf("[ReminderScheduler] 获取到期提醒失败: %v", err)
return
}
@@ -290,7 +290,7 @@ func checkAndNotify(s *store.ReminderStore, hub *ws.Hub) {
data, err := json.Marshal(msg)
if err != nil {
log.Printf("[ReminderScheduler] 序列化通知失败: %v", err)
logger.Printf("[ReminderScheduler] 序列化通知失败: %v", err)
continue
}
@@ -299,7 +299,7 @@ func checkAndNotify(s *store.ReminderStore, hub *ws.Hub) {
// 3. 标记为已通知
if err := s.MarkNotified(r.ID); err != nil {
log.Printf("[ReminderScheduler] 标记已通知失败: id=%s err=%v", r.ID, err)
logger.Printf("[ReminderScheduler] 标记已通知失败: id=%s err=%v", r.ID, err)
}
// 4. 处理重复提醒
@@ -308,9 +308,9 @@ func checkAndNotify(s *store.ReminderStore, hub *ws.Hub) {
r.RemindAt = nextTime
r.Notified = false
if err := s.UpdateReminder(r.ID, &r); err != nil {
log.Printf("[ReminderScheduler] 更新重复提醒失败: id=%s err=%v", r.ID, err)
logger.Printf("[ReminderScheduler] 更新重复提醒失败: id=%s err=%v", r.ID, err)
} else {
log.Printf("[ReminderScheduler] 重复提醒已更新: id=%s next=%s", r.ID, nextTime.Format(time.RFC3339))
logger.Printf("[ReminderScheduler] 重复提醒已更新: id=%s next=%s", r.ID, nextTime.Format(time.RFC3339))
}
} else {
// 非重复提醒:标记为已完成
@@ -318,11 +318,11 @@ func checkAndNotify(s *store.ReminderStore, hub *ws.Hub) {
r.Status = "completed"
r.CompletedAt = &now
if err := s.UpdateReminder(r.ID, &r); err != nil {
log.Printf("[ReminderScheduler] 标记提醒完成失败: id=%s err=%v", r.ID, err)
logger.Printf("[ReminderScheduler] 标记提醒完成失败: id=%s err=%v", r.ID, err)
}
}
log.Printf("[ReminderScheduler] 提醒已推送: user=%s title=%s id=%s", r.UserID, r.Title, r.ID)
logger.Printf("[ReminderScheduler] 提醒已推送: user=%s title=%s id=%s", r.UserID, r.Title, r.ID)
}
}
@@ -4,7 +4,7 @@ import (
"crypto/rand"
"encoding/json"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"strings"
"time"
@@ -68,7 +68,7 @@ func (h *SessionHandler) Create(c *gin.Context) {
if h.useDB {
if err := h.store.CreateSession(userID, req.SessionID, req.Title, req.IsMain); err != nil {
log.Printf("[SessionHandler] 创建会话失败: %v", err)
logger.Printf("[SessionHandler] 创建会话失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建会话失败", "errorType": "db_error"})
return
}
@@ -106,7 +106,7 @@ func (h *SessionHandler) List(c *gin.Context) {
if h.useDB {
sessions, err := h.store.GetUserSessions(userID)
if err != nil {
log.Printf("[SessionHandler] 查询会话列表失败: %v", err)
logger.Printf("[SessionHandler] 查询会话列表失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询会话失败", "errorType": "db_error"})
return
}
@@ -139,7 +139,7 @@ func (h *SessionHandler) Get(c *gin.Context) {
if h.useDB {
session, err := h.store.GetSession(sessionID)
if err != nil {
log.Printf("[SessionHandler] 查询会话失败: %v", err)
logger.Printf("[SessionHandler] 查询会话失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询会话失败", "errorType": "db_error"})
return
}
@@ -186,7 +186,7 @@ func (h *SessionHandler) Delete(c *gin.Context) {
// 所有权校验:先获取session再验证归属
session, err := h.store.GetSession(sessionID)
if err != nil {
log.Printf("[SessionHandler] 查询会话失败: %v", err)
logger.Printf("[SessionHandler] 查询会话失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除会话失败", "errorType": "db_error"})
return
}
@@ -203,7 +203,7 @@ func (h *SessionHandler) Delete(c *gin.Context) {
}
if err := h.store.DeleteSession(sessionID); err != nil {
log.Printf("[SessionHandler] 删除会话失败: %v", err)
logger.Printf("[SessionHandler] 删除会话失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除会话失败", "errorType": "db_error"})
return
}
@@ -235,7 +235,7 @@ func (h *SessionHandler) DeleteAll(c *gin.Context) {
if h.useDB {
if err := h.store.DeleteAllUserSessions(userID); err != nil {
log.Printf("[SessionHandler] 删除用户所有会话失败: %v", err)
logger.Printf("[SessionHandler] 删除用户所有会话失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除会话失败", "errorType": "db_error"})
return
}
@@ -254,7 +254,7 @@ func (h *SessionHandler) GetMessages(c *gin.Context) {
if h.useDB {
session, err := h.store.GetSession(sessionID)
if err != nil {
log.Printf("[SessionHandler] 查询会话失败: %v", err)
logger.Printf("[SessionHandler] 查询会话失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询消息失败", "errorType": "db_error"})
return
}
@@ -302,7 +302,7 @@ func (h *SessionHandler) GetMessages(c *gin.Context) {
if h.useDB {
messages, err := h.store.GetMessages(sessionID, limit, offset)
if err != nil {
log.Printf("[SessionHandler] 查询消息失败: %v", err)
logger.Printf("[SessionHandler] 查询消息失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询消息失败", "errorType": "db_error"})
return
}
@@ -313,6 +313,7 @@ func (h *SessionHandler) GetMessages(c *gin.Context) {
"id": m.ID,
"session_id": m.SessionID,
"role": m.Role,
"msg_type": m.MsgType,
"content": m.Content,
"created_at": m.CreatedAt.UnixMilli(),
})
@@ -339,7 +340,7 @@ func (h *SessionHandler) ClearMessages(c *gin.Context) {
// 所有权校验
session, err := h.store.GetSession(sessionID)
if err != nil {
log.Printf("[SessionHandler] 查询会话失败: %v", err)
logger.Printf("[SessionHandler] 查询会话失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "清空消息失败", "errorType": "db_error"})
return
}
@@ -356,7 +357,7 @@ func (h *SessionHandler) ClearMessages(c *gin.Context) {
}
if err := h.store.ClearSessionMessages(sessionID); err != nil {
log.Printf("[SessionHandler] 清空消息失败: %v", err)
logger.Printf("[SessionHandler] 清空消息失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "清空消息失败", "errorType": "db_error"})
return
}
@@ -469,7 +470,7 @@ func (h *SessionHandler) SearchMessages(c *gin.Context) {
results, total, err := h.store.SearchMessages(userID, query, limit, offset)
if err != nil {
log.Printf("[SessionHandler] 搜索消息失败: %v", err)
logger.Printf("[SessionHandler] 搜索消息失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败", "errorType": "db_error"})
return
}
@@ -531,7 +532,7 @@ func (h *SessionHandler) ExportSession(c *gin.Context) {
// 获取会话信息
session, err := h.store.GetSession(sessionID)
if err != nil {
log.Printf("[SessionHandler] 查询会话失败: %v", err)
logger.Printf("[SessionHandler] 查询会话失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询会话失败", "errorType": "db_error"})
return
}
@@ -552,7 +553,7 @@ func (h *SessionHandler) ExportSession(c *gin.Context) {
// 获取所有消息 (不限制数量,导出全部)
messages, err := h.store.GetMessages(sessionID, 0, 0)
if err != nil {
log.Printf("[SessionHandler] 查询消息失败: %v", err)
logger.Printf("[SessionHandler] 查询消息失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询消息失败", "errorType": "db_error"})
return
}
@@ -613,7 +614,7 @@ func (h *SessionHandler) exportJSON(c *gin.Context, session *store.Session, mess
jsonBytes, err := json.MarshalIndent(data, "", " ")
if err != nil {
log.Printf("[SessionHandler] JSON序列化失败: %v", err)
logger.Printf("[SessionHandler] JSON序列化失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败", "errorType": "serialization_error"})
return
}
@@ -4,7 +4,7 @@ import (
"bytes"
"encoding/json"
"io"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"strings"
@@ -50,7 +50,7 @@ func (h *VoiceHandler) Transcribe(c *gin.Context) {
resp, err := h.client.Do(proxyReq)
if err != nil {
log.Printf("[voice] Voice-Service 不可达 (Transcribe): %v", err)
logger.Printf("[voice] Voice-Service 不可达 (Transcribe): %v", err)
c.JSON(http.StatusBadGateway, gin.H{
"error": "Voice-Service 不可达: " + err.Error(),
"errorType": "voice_service_unreachable",
@@ -88,7 +88,7 @@ func (h *VoiceHandler) TTSSynthesize(c *gin.Context) {
resp, err := h.client.Do(proxyReq)
if err != nil {
log.Printf("[voice] Voice-Service 不可达 (TTS): %v", err)
logger.Printf("[voice] Voice-Service 不可达 (TTS): %v", err)
c.JSON(http.StatusBadGateway, gin.H{
"error": "Voice-Service 不可达: " + err.Error(),
"errorType": "voice_service_unreachable",
@@ -115,7 +115,7 @@ func (h *VoiceHandler) TTSVoices(c *gin.Context) {
resp, err := h.client.Get(url)
if err != nil {
log.Printf("[voice] Voice-Service 不可达 (Voices): %v", err)
logger.Printf("[voice] Voice-Service 不可达 (Voices): %v", err)
c.JSON(http.StatusBadGateway, gin.H{
"error": "Voice-Service 不可达: " + err.Error(),
"errorType": "voice_service_unreachable",
@@ -139,7 +139,7 @@ func (h *VoiceHandler) TTSStatus(c *gin.Context) {
resp, err := h.client.Get(url)
if err != nil {
log.Printf("[voice] Voice-Service 不可达 (TTS Status): %v", err)
logger.Printf("[voice] Voice-Service 不可达 (TTS Status): %v", err)
c.JSON(http.StatusBadGateway, gin.H{
"error": "Voice-Service 不可达: " + err.Error(),
"errorType": "voice_service_unreachable",
@@ -162,7 +162,7 @@ func (h *VoiceHandler) VoiceStatus(c *gin.Context) {
resp, err := h.client.Get(url)
if err != nil {
log.Printf("[voice] Voice-Service 不可达 (Status): %v", err)
logger.Printf("[voice] Voice-Service 不可达 (Status): %v", err)
c.JSON(http.StatusBadGateway, gin.H{
"error": "Voice-Service 不可达: " + err.Error(),
"errorType": "voice_service_unreachable",
@@ -8,7 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"strings"
"time"
@@ -95,7 +95,7 @@ func (h *WebhookHandler) HandleGenericWebhook(c *gin.Context) {
// 调用 AI-Core 获取回复
resp, err := h.callAICore(userID, sessionID, req.Message, mode, platform)
if err != nil {
log.Printf("[webhook] AI-Core 调用失败 (platform=%s): %v", platform, err)
logger.Printf("[webhook] AI-Core 调用失败 (platform=%s): %v", platform, err)
c.JSON(502, GenericWebhookResponse{Error: "AI 服务暂不可用: " + err.Error()})
return
}
@@ -244,7 +244,7 @@ func (h *WebhookHandler) HandleDiscordWebhook(c *gin.Context) {
// 但这里简化处理:直接同步调用 AI-Core(如果调用超过 3 秒,Discord 会显示超时)
resp, err := h.callAICore("ext_"+userID, sessionID, message, "text", "discord")
if err != nil {
log.Printf("[webhook:discord] AI-Core 调用失败: %v", err)
logger.Printf("[webhook:discord] AI-Core 调用失败: %v", err)
c.JSON(200, DiscordResponse{
Type: 4,
Data: &DiscordResponseData{Content: "昔涟暂时无法回应喵...AI 服务异常: " + err.Error() + ""},
@@ -1,7 +1,7 @@
package middleware
import (
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"time"
"github.com/gin-gonic/gin"
@@ -29,7 +29,7 @@ func RequestLogging() gin.HandlerFunc {
logLevel = "[WARN]"
}
log.Printf("%s %s %s %d %v %s",
logger.Printf("%s %s %s %d %v %s",
logLevel, method, path, statusCode, duration, clientIP,
)
}
@@ -4,7 +4,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"time"
)
@@ -47,7 +47,7 @@ func NewAutomationStore(db *sql.DB) (*AutomationStore, error) {
return nil, fmt.Errorf("自动化表迁移失败: %w", err)
}
log.Println("[AutomationStore] 自动化持久化存储已初始化")
logger.Println("[AutomationStore] 自动化持久化存储已初始化")
return store, nil
}
@@ -4,7 +4,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"time"
)
@@ -60,7 +60,7 @@ func NewBriefingStore(db *sql.DB) (*BriefingStore, error) {
return nil, fmt.Errorf("简报表迁移失败: %w", err)
}
log.Println("[BriefingStore] 简报持久化存储已初始化")
logger.Println("[BriefingStore] 简报持久化存储已初始化")
return store, nil
}
+2 -2
View File
@@ -3,7 +3,7 @@ package store
import (
"database/sql"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"time"
)
@@ -33,7 +33,7 @@ func NewFileStore(db *sql.DB) (*FileStore, error) {
return nil, fmt.Errorf("文件表迁移失败: %w", err)
}
log.Println("[FileStore] 文件持久化存储已初始化")
logger.Println("[FileStore] 文件持久化存储已初始化")
return store, nil
}
@@ -4,7 +4,7 @@ import (
"crypto/rand"
"database/sql"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"strings"
"time"
"unicode/utf8"
@@ -73,7 +73,7 @@ func NewKnowledgeStore(db *sql.DB) (*KnowledgeStore, error) {
return nil, fmt.Errorf("知识库表迁移失败: %w", err)
}
log.Println("[KnowledgeStore] 知识库持久化存储已初始化")
logger.Println("[KnowledgeStore] 知识库持久化存储已初始化")
return store, nil
}
@@ -133,7 +133,7 @@ func (s *KnowledgeStore) migrate() error {
// 尝试创建 GIN 索引(可能因权限或扩展问题失败,但不影响功能)
_, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_kc_tsv_gin ON knowledge_chunks USING GIN(tsv)`)
if err != nil {
log.Printf("[KnowledgeStore] ⚠ GIN索引创建失败(将使用ILIKE降级搜索): %v", err)
logger.Printf("[KnowledgeStore] ⚠ GIN索引创建失败(将使用ILIKE降级搜索): %v", err)
}
return nil
@@ -260,7 +260,7 @@ func (s *KnowledgeStore) AddDocument(doc *KnowledgeDocument) error {
// 更新知识库统计
if err := s.updateKBStats(doc.KBID); err != nil {
log.Printf("[KnowledgeStore] 更新知识库统计失败: %v", err)
logger.Printf("[KnowledgeStore] 更新知识库统计失败: %v", err)
}
return nil
@@ -340,7 +340,7 @@ func (s *KnowledgeStore) DeleteDocument(id string) error {
// 更新知识库统计
if err := s.updateKBStats(kbID); err != nil {
log.Printf("[KnowledgeStore] 更新知识库统计失败: %v", err)
logger.Printf("[KnowledgeStore] 更新知识库统计失败: %v", err)
}
return nil
@@ -452,12 +452,12 @@ func (s *KnowledgeStore) ChunkDocument(docID string) (int, error) {
// 更新文档的分块计数
if err := s.UpdateDocumentChunkCount(docID, len(chunks)); err != nil {
log.Printf("[KnowledgeStore] 更新文档分块计数失败: %v", err)
logger.Printf("[KnowledgeStore] 更新文档分块计数失败: %v", err)
}
// 更新知识库统计
if err := s.updateKBStats(doc.KBID); err != nil {
log.Printf("[KnowledgeStore] 更新知识库统计失败: %v", err)
logger.Printf("[KnowledgeStore] 更新知识库统计失败: %v", err)
}
return len(chunks), nil
@@ -474,7 +474,7 @@ func (s *KnowledgeStore) SearchChunks(kbID, query string, limit int) ([]SearchCh
// 尝试使用 PostgreSQL 全文搜索
results, err := s.searchWithFullText(kbID, query, limit)
if err != nil {
log.Printf("[KnowledgeStore] 全文搜索失败,降级为ILIKE: %v", err)
logger.Printf("[KnowledgeStore] 全文搜索失败,降级为ILIKE: %v", err)
// 降级为 ILIKE
results, err = s.searchWithILike(kbID, query, limit)
if err != nil {
@@ -3,7 +3,7 @@ package store
import (
"database/sql"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"time"
)
@@ -35,7 +35,7 @@ func NewReminderStore(db *sql.DB) (*ReminderStore, error) {
return nil, fmt.Errorf("提醒表迁移失败: %w", err)
}
log.Println("[ReminderStore] 提醒持久化存储已初始化")
logger.Println("[ReminderStore] 提醒持久化存储已初始化")
return store, nil
}
@@ -3,7 +3,7 @@ package store
import (
"database/sql"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"time"
_ "github.com/lib/pq"
@@ -24,6 +24,7 @@ type Message struct {
ID int `json:"id"`
SessionID string `json:"session_id"`
Role string `json:"role"`
MsgType string `json:"msg_type"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
@@ -60,7 +61,7 @@ func NewSessionStore(databaseURL string) (*SessionStore, error) {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
log.Println("[SessionStore] PostgreSQL 持久化存储已初始化")
logger.Println("[SessionStore] PostgreSQL 持久化存储已初始化")
return store, nil
}
@@ -82,11 +83,15 @@ func (s *SessionStore) migrate() error {
id SERIAL PRIMARY KEY,
session_id VARCHAR(64) REFERENCES sessions(id) ON DELETE CASCADE,
role VARCHAR(16) NOT NULL,
msg_type VARCHAR(16) DEFAULT 'chat',
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)`,
`CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(session_id, created_at)`,
// 为已存在的数据库添加 msg_type 列 (Phase 0.1)
`ALTER TABLE messages ADD COLUMN IF NOT EXISTS msg_type VARCHAR(16) DEFAULT 'chat'`,
}
for _, q := range queries {
@@ -200,10 +205,10 @@ func (s *SessionStore) DeleteAllUserSessions(userID string) error {
}
// AddMessage 添加一条消息到会话
func (s *SessionStore) AddMessage(sessionID, role, content string) error {
func (s *SessionStore) AddMessage(sessionID, role, msgType, content string) error {
_, err := s.db.Exec(
`INSERT INTO messages (session_id, role, content) VALUES ($1, $2, $3)`,
sessionID, role, content,
`INSERT INTO messages (session_id, role, msg_type, content) VALUES ($1, $2, $3, $4)`,
sessionID, role, msgType, content,
)
if err != nil {
return fmt.Errorf("添加消息失败: %w", err)
@@ -221,7 +226,7 @@ func (s *SessionStore) GetMessages(sessionID string, limit, offset int) ([]Messa
}
rows, err := s.db.Query(
`SELECT id, session_id, role, content, created_at
`SELECT id, session_id, role, COALESCE(msg_type, 'chat'), content, created_at
FROM messages WHERE session_id = $1
ORDER BY created_at ASC
LIMIT $2 OFFSET $3`,
@@ -235,7 +240,7 @@ func (s *SessionStore) GetMessages(sessionID string, limit, offset int) ([]Messa
var messages []Message
for rows.Next() {
var msg Message
if err := rows.Scan(&msg.ID, &msg.SessionID, &msg.Role, &msg.Content, &msg.CreatedAt); err != nil {
if err := rows.Scan(&msg.ID, &msg.SessionID, &msg.Role, &msg.MsgType, &msg.Content, &msg.CreatedAt); err != nil {
return nil, fmt.Errorf("扫描消息行失败: %w", err)
}
messages = append(messages, msg)
@@ -262,6 +267,7 @@ type SearchResult struct {
SessionID string `json:"session_id"`
SessionTitle string `json:"session_title"`
Role string `json:"role"`
MsgType string `json:"msg_type"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
@@ -287,7 +293,7 @@ func (s *SessionStore) SearchMessages(userID, query string, limit, offset int) (
// 分页查询,关联 sessions 获取会话标题
rows, err := s.db.Query(
`SELECT m.id, m.session_id, COALESCE(s.title, '') AS session_title, m.role, m.content, m.created_at
`SELECT m.id, m.session_id, COALESCE(s.title, '') AS session_title, m.role, COALESCE(m.msg_type, 'chat'), m.content, m.created_at
FROM messages m
JOIN sessions s ON m.session_id = s.id
WHERE s.user_id = $1 AND m.content ILIKE '%' || $2 || '%'
@@ -303,7 +309,7 @@ func (s *SessionStore) SearchMessages(userID, query string, limit, offset int) (
var results []SearchResult
for rows.Next() {
var r SearchResult
if err := rows.Scan(&r.MessageID, &r.SessionID, &r.SessionTitle, &r.Role, &r.Content, &r.CreatedAt); err != nil {
if err := rows.Scan(&r.MessageID, &r.SessionID, &r.SessionTitle, &r.Role, &r.MsgType, &r.Content, &r.CreatedAt); err != nil {
return nil, 0, fmt.Errorf("扫描搜索结果行失败: %w", err)
}
results = append(results, r)
+5 -5
View File
@@ -2,7 +2,7 @@ package ws
import (
"encoding/json"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"time"
"github.com/gorilla/websocket"
@@ -60,7 +60,7 @@ func (c *Client) ReadPump(onMessage func(client *Client, msg ClientMessage)) {
_, rawMessage, err := c.Conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure, websocket.CloseAbnormalClosure) {
log.Printf("[WS] 读取错误: user=%s err=%v", c.UserID, err)
logger.Printf("[WS] 读取错误: user=%s err=%v", c.UserID, err)
}
break
}
@@ -68,7 +68,7 @@ func (c *Client) ReadPump(onMessage func(client *Client, msg ClientMessage)) {
// 解析消息
var msg ClientMessage
if err := json.Unmarshal(rawMessage, &msg); err != nil {
log.Printf("[WS] 消息解析失败: user=%s err=%v", c.UserID, err)
logger.Printf("[WS] 消息解析失败: user=%s err=%v", c.UserID, err)
continue
}
@@ -109,7 +109,7 @@ func (c *Client) WritePump() {
}
if err := c.Conn.WriteMessage(websocket.TextMessage, message); err != nil {
log.Printf("[WS] 写入错误: user=%s err=%v", c.UserID, err)
logger.Printf("[WS] 写入错误: user=%s err=%v", c.UserID, err)
return
}
@@ -134,7 +134,7 @@ func (c *Client) SendMessage(msg ServerMessage) error {
return nil
default:
// 通道满:记录警告并返回错误(避免静默丢弃)
log.Printf("[WS] 发送通道已满,丢弃消息: type=%s user=%s", msg.Type, c.UserID)
logger.Printf("[WS] 发送通道已满,丢弃消息: type=%s user=%s", msg.Type, c.UserID)
return nil
}
}
+93 -15
View File
@@ -3,9 +3,10 @@ package ws
import (
"encoding/json"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"os"
"strings"
"sync"
"time"
@@ -70,6 +71,11 @@ type Hub struct {
// 闲置超时时间
idleTimeout time.Duration
// Phase 2: 离线主动消息队列 + 在线状态通知
pendingProactive map[string][]json.RawMessage // userID -> queued messages
aiCoreURL string
internalToken string
}
// SetStore 设置持久化存储 (可选)
@@ -93,6 +99,7 @@ func NewHub() *Hub {
sessions: make(map[string]*SessionState),
iotStopCh: make(chan struct{}),
idleTimeout: 30 * time.Minute, // 默认30分钟
pendingProactive: make(map[string][]json.RawMessage),
}
}
@@ -102,7 +109,7 @@ func (h *Hub) StartIdleCleanup() {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[WS] 闲置会话清理 panic 恢复: %v", r)
logger.Printf("[WS] 闲置会话清理 panic 恢复: %v", r)
}
}()
ticker := time.NewTicker(5 * time.Minute)
@@ -112,7 +119,7 @@ func (h *Hub) StartIdleCleanup() {
h.cleanupIdleSessions()
}
}()
log.Printf("[WS] 闲置会话清理已启动 (超时: %v)", h.idleTimeout)
logger.Printf("[WS] 闲置会话清理已启动 (超时: %v)", h.idleTimeout)
}
// cleanupIdleSessions 标记超时会话为 idle(不删除状态)
@@ -148,7 +155,7 @@ func (h *Hub) cleanupIdleSessions() {
}
if idleCount > 0 {
log.Printf("[WS] 闲置清理: %d 个会话标记为 idle", idleCount)
logger.Printf("[WS] 闲置清理: %d 个会话标记为 idle", idleCount)
}
}
@@ -170,6 +177,58 @@ func (h *Hub) GetAllActiveSessions() []*SessionState {
return result
}
// SetAICoreConfig sets the ai-core URL and internal token for presence notifications.
func (h *Hub) SetAICoreConfig(url, token string) {
h.aiCoreURL = url
h.internalToken = token
}
// QueueProactiveMessage queues a proactive message for offline delivery.
func (h *Hub) QueueProactiveMessage(userID string, msg json.RawMessage) {
h.mu.Lock()
defer h.mu.Unlock()
h.pendingProactive[userID] = append(h.pendingProactive[userID], msg)
// Keep only the most recent 3 messages
if len(h.pendingProactive[userID]) > 3 {
h.pendingProactive[userID] = h.pendingProactive[userID][1:]
}
}
// FlushPendingProactive returns and clears queued proactive messages for a user.
func (h *Hub) FlushPendingProactive(userID string) []json.RawMessage {
h.mu.Lock()
defer h.mu.Unlock()
msgs := h.pendingProactive[userID]
delete(h.pendingProactive, userID)
return msgs
}
// notifyAICorePresence sends a presence update to ai-core.
func (h *Hub) notifyAICorePresence(userID, status, sessionID string) {
if h.aiCoreURL == "" || h.internalToken == "" {
return
}
body, _ := json.Marshal(map[string]string{
"user_id": userID,
"status": status,
"session_id": sessionID,
"timestamp": fmt.Sprintf("%d", time.Now().Unix()),
})
go func() {
req, _ := http.NewRequest("POST", h.aiCoreURL+"/api/v1/internal/presence", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Internal-Token", h.internalToken)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
logger.Printf("[presence] 通知 ai-core 失败: %v", err)
return
}
resp.Body.Close()
logger.Printf("[presence] 通知 ai-core: user=%s status=%s", userID, status)
}()
}
// Run 启动Hub主循环
func (h *Hub) Run() {
for {
@@ -195,10 +254,29 @@ func (h *Hub) Run() {
MessageCount: 0,
}
}
h.mu.Unlock()
// Phase 2: 检测是否为重连 (之前处于离线状态)
wasOffline := len(h.userClients[client.UserID]) == 1 // 刚加入,之前为0
h.mu.Unlock()
log.Printf("[WS] 客户端连接: user=%s session=%s (当前连接数: %d)",
client.UserID, client.SessionID, len(h.clients))
// 重连后推送积压的主动消息
if wasOffline {
pending := h.FlushPendingProactive(client.UserID)
if len(pending) > 0 {
logger.Printf("[proactive] 推送 %d 条积压消息给重连用户 %s", len(pending), client.UserID)
// 只推送最新的一条
go func() {
// small delay for WS connection to stabilize
time.Sleep(500 * time.Millisecond)
h.SendToUser(client.UserID, pending[len(pending)-1])
}()
}
}
// 通知 ai-core 用户上线
h.notifyAICorePresence(client.UserID, "online", client.SessionID)
logger.Printf("[WS] 客户端连接: user=%s session=%s (当前连接数: %d)",
client.UserID, client.SessionID, len(h.clients))
case client := <-h.unregister:
h.mu.Lock()
@@ -233,7 +311,7 @@ func (h *Hub) Run() {
}
h.mu.Unlock()
log.Printf("[WS] 客户端断开: user=%s session=%s (当前连接数: %d)",
logger.Printf("[WS] 客户端断开: user=%s session=%s (当前连接数: %d)",
client.UserID, client.SessionID, len(h.clients))
case message := <-h.broadcast:
@@ -287,7 +365,7 @@ func (h *Hub) Run() {
}
h.mu.Unlock()
log.Printf("[WS] 广播清理 %d 个失效客户端 (当前连接数: %d)",
logger.Printf("[WS] 广播清理 %d 个失效客户端 (当前连接数: %d)",
len(staleClients), len(h.clients))
}
}
@@ -504,12 +582,12 @@ func (h *Hub) StartIoTBroadcast(iotServiceURL string) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[IoT广播] 轮询循环 panic 恢复: %v", r)
logger.Printf("[IoT广播] 轮询循环 panic 恢复: %v", r)
}
}()
h.iotPollLoop()
}()
log.Printf("[IoT广播] 已启动 (IoT服务地址: %s)", iotServiceURL)
logger.Printf("[IoT广播] 已启动 (IoT服务地址: %s)", iotServiceURL)
}
// StopIoTBroadcast 停止 IoT 设备广播
@@ -522,7 +600,7 @@ func (h *Hub) StopIoTBroadcast() {
}
close(h.iotStopCh)
h.iotPollRunning = false
log.Println("[IoT广播] 已停止")
logger.Println("[IoT广播] 已停止")
}
// iotPollLoop IoT 设备轮询循环
@@ -563,7 +641,7 @@ func (h *Hub) pollAndBroadcastIoT() {
devices, err := fetchIoTDevices(url)
if err != nil {
log.Printf("[IoT广播] 获取设备失败: %v", err)
logger.Printf("[IoT广播] 获取设备失败: %v", err)
// 即使失败也发送空列表,让前端知道 IoT 服务状态
devices = []IotDeviceInfo{}
}
@@ -576,7 +654,7 @@ func (h *Hub) pollAndBroadcastIoT() {
data, err := json.Marshal(msg)
if err != nil {
log.Printf("[IoT广播] 消息序列化失败: %v", err)
logger.Printf("[IoT广播] 消息序列化失败: %v", err)
return
}
@@ -586,7 +664,7 @@ func (h *Hub) pollAndBroadcastIoT() {
for _, d := range devices {
deviceNames = append(deviceNames, d.Name)
}
log.Printf("[IoT广播] 已推送 %d 个设备状态到 %d 个客户端: %v", len(devices), h.ClientCount(), deviceNames)
logger.Printf("[IoT广播] 已推送 %d 个设备状态到 %d 个客户端: %v", len(devices), h.ClientCount(), deviceNames)
}
// fetchIoTDevices 从 IoT 调试服务获取设备列表
+40 -20
View File
@@ -25,31 +25,51 @@ type ClientMessage struct {
// ReviewMessage 审查后的结构化消息(动作/聊天分离)
type ReviewMessage struct {
Type string `json:"type"` // "action" | "chat"
Type string `json:"type"` // "action" | "chat"
Content string `json:"content"`
DelayMs int `json:"delay_ms,omitempty"` // ms to wait before sending (0 = immediate)
}
// 服务端 → 客户端消息
type ServerMessage struct {
Type string `json:"type"` // response | segment | audio | error | device_update | pong | history_response | stream_chunk | stream_end | background_thinking | notification | multi_message | stream_segments | review
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"` // 多条消息批量发
ReviewMessages []ReviewMessage `json:"review_messages,omitempty"` // 审查后的结构化消息列表
MsgType string `json:"msg_type,omitempty"` // 消息展示类型: action | chat
Type string `json:"type"` // response | segment | audio | error | device_update | pong | history_response | stream_chunk | stream_end | background_thinking | notification | multi_message | stream_segments | review | thinking | tool_progress | system_info
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"` // 后台思考状态
ThinkingContent string `json:"thinking_content,omitempty"` // 思考内容 (thinking 类型)
Notification *NotificationInfo `json:"notification,omitempty"` // 通知推
MultiMessage *MultiMessagePayload `json:"multi_message,omitempty"` // 多条消息批量发送
ReviewMessages []ReviewMessage `json:"review_messages,omitempty"` // 审查后的结构化消息列表
MsgType string `json:"msg_type,omitempty"` // 消息展示类型: action | chat | thinking | tool_progress | system_info
ToolProgress *ToolProgressInfo `json:"tool_progress,omitempty"` // 工具执行进度
SystemInfo *SystemInfoPayload `json:"system_info,omitempty"` // 系统通知信息
ProtocolVersion int `json:"protocol_version,omitempty"` // 协议版本
}
// ToolProgressInfo 工具执行进度
type ToolProgressInfo struct {
ToolName string `json:"tool_name"`
Status string `json:"status"` // started, running, completed, failed
Progress float64 `json:"progress"`
Message string `json:"message"`
}
// SystemInfoPayload 系统信息负载
type SystemInfoPayload struct {
Level string `json:"level"` // info, warning, error
Message string `json:"message"`
Action string `json:"action,omitempty"`
}
// MultiMessagePayload 多条消息的容器 (对应昔涟的多消息回复风格)
+1
View File
@@ -5,6 +5,7 @@ use (
./gateway
./iot-debug-service
./memory-service
./pkg/logger
./tool-engine
./voice-service
)
+5 -4
View File
@@ -3,7 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"math/rand"
"net/http"
"os"
@@ -482,6 +482,7 @@ func (ds *DeviceStore) SimulateFluctuation() {
}
func main() {
logger.SetDefault(logger.New("iot-debug"))
port := getEnv("IOT_DEBUG_PORT", "8083")
store := NewDeviceStore()
@@ -604,10 +605,10 @@ func main() {
})
})
log.Printf("🔌 IoT 调试服务启动在端口 %s", port)
log.Printf(" 模拟设备数: %d", len(store.GetAll()))
logger.Printf("🔌 IoT 调试服务启动在端口 %s", port)
logger.Printf(" 模拟设备数: %d", len(store.GetAll()))
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Fatalf("服务启动失败: %v", err)
logger.Fatalf("服务启动失败: %v", err)
}
}
+4
View File
@@ -1,3 +1,7 @@
module cyrene/iot-debug-service
go 1.21
replace github.com/yourname/cyrene-ai/pkg/logger => ../pkg/logger
require github.com/yourname/cyrene-ai/pkg/logger v0.0.0
+8 -8
View File
@@ -1,7 +1,7 @@
package main
import (
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"os"
"os/signal"
@@ -14,13 +14,13 @@ import (
)
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("🧠 Memory-Service 启动中...")
logger.SetDefault(logger.New("memory-service"))
logger.Println("🧠 Memory-Service 启动中...")
// 加载配置
cfg := config.Load()
log.Printf("配置: 端口=%s, 数据库=%s...", cfg.Port, maskDBURL(cfg.DatabaseURL))
logger.Printf("配置: 端口=%s, 数据库=%s...", cfg.Port, maskDBURL(cfg.DatabaseURL))
// 初始化数据库存储
memStore := store.NewStore(cfg.DatabaseURL)
@@ -53,9 +53,9 @@ func main() {
}
go func() {
log.Printf("🚀 Memory-Service 已启动在端口 %s", cfg.Port)
logger.Printf("🚀 Memory-Service 已启动在端口 %s", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("服务启动失败: %v", err)
logger.Fatalf("服务启动失败: %v", err)
}
}()
@@ -63,9 +63,9 @@ func main() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("正在关闭 Memory-Service...")
logger.Println("正在关闭 Memory-Service...")
srv.Close()
log.Println("Memory-Service 已关闭")
logger.Println("Memory-Service 已关闭")
}
func maskDBURL(url string) string {
+6 -1
View File
@@ -2,4 +2,9 @@ module github.com/yourname/cyrene-ai/memory-service
go 1.26.2
require github.com/lib/pq v1.10.9
require (
github.com/lib/pq v1.10.9
github.com/yourname/cyrene-ai/pkg/logger v0.0.0
)
replace github.com/yourname/cyrene-ai/pkg/logger => ../pkg/logger
@@ -2,7 +2,7 @@ package handler
import (
"encoding/json"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"strings"
@@ -68,7 +68,7 @@ func (h *MemoryHandler) listMemories(w http.ResponseWriter, r *http.Request) {
memories, err := h.svc.ListMemories(r.Context(), userID, category, minImportance, limit)
if err != nil {
log.Printf("[memory-handler] 列出记忆失败: %v", err)
logger.Printf("[memory-handler] 列出记忆失败: %v", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -121,7 +121,7 @@ func (h *MemoryHandler) createMemory(w http.ResponseWriter, r *http.Request) {
}
if err := h.svc.CreateMemory(r.Context(), entry); err != nil {
log.Printf("[memory-handler] 创建记忆失败: %v", err)
logger.Printf("[memory-handler] 创建记忆失败: %v", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -165,7 +165,7 @@ func (h *MemoryHandler) handleMemoryByID(w http.ResponseWriter, r *http.Request)
func (h *MemoryHandler) getMemory(w http.ResponseWriter, r *http.Request, id string) {
entry, err := h.svc.GetMemory(r.Context(), id)
if err != nil {
log.Printf("[memory-handler] 获取记忆失败: %v", err)
logger.Printf("[memory-handler] 获取记忆失败: %v", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -209,7 +209,7 @@ func (h *MemoryHandler) updateMemory(w http.ResponseWriter, r *http.Request, id
}
if err := h.svc.UpdateMemory(r.Context(), entry); err != nil {
log.Printf("[memory-handler] 更新记忆失败: %v", err)
logger.Printf("[memory-handler] 更新记忆失败: %v", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -223,7 +223,7 @@ func (h *MemoryHandler) updateMemory(w http.ResponseWriter, r *http.Request, id
// deleteMemory DELETE /api/v1/memories/:id
func (h *MemoryHandler) deleteMemory(w http.ResponseWriter, r *http.Request, id string) {
if err := h.svc.DeleteMemory(r.Context(), id); err != nil {
log.Printf("[memory-handler] 删除记忆失败: %v", err)
logger.Printf("[memory-handler] 删除记忆失败: %v", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -265,7 +265,7 @@ func (h *MemoryHandler) handleQuery(w http.ResponseWriter, r *http.Request) {
memories, err := h.svc.QueryMemories(r.Context(), req.UserID, req.QueryText, req.Category, req.MinImportance, req.Limit)
if err != nil {
log.Printf("[memory-handler] 查询记忆失败: %v", err)
logger.Printf("[memory-handler] 查询记忆失败: %v", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -305,7 +305,7 @@ func (h *MemoryHandler) handleConsolidate(w http.ResponseWriter, r *http.Request
merged, err := h.svc.ConsolidateMemories(r.Context(), req.UserID)
if err != nil {
log.Printf("[memory-handler] 合并记忆失败: %v", err)
logger.Printf("[memory-handler] 合并记忆失败: %v", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -341,7 +341,7 @@ func (h *MemoryHandler) handleDecay(w http.ResponseWriter, r *http.Request) {
decayed, deleted, err := h.svc.DecayMemories(r.Context(), req.UserID)
if err != nil {
log.Printf("[memory-handler] 衰减记忆失败: %v", err)
logger.Printf("[memory-handler] 衰减记忆失败: %v", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -370,7 +370,7 @@ func (h *MemoryHandler) handleCategories(w http.ResponseWriter, r *http.Request)
categories, err := h.svc.GetCategories(r.Context(), userID)
if err != nil {
log.Printf("[memory-handler] 获取分类统计失败: %v", err)
logger.Printf("[memory-handler] 获取分类统计失败: %v", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -453,7 +453,7 @@ func (h *MemoryHandler) createThinkingLog(w http.ResponseWriter, r *http.Request
}
if err := h.svc.SaveThinkingLog(r.Context(), tl); err != nil {
log.Printf("[memory-handler] 保存思考日志失败: %v", err)
logger.Printf("[memory-handler] 保存思考日志失败: %v", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -476,7 +476,7 @@ func (h *MemoryHandler) listThinkingLogs(w http.ResponseWriter, r *http.Request)
Offset: offset,
})
if err != nil {
log.Printf("[memory-handler] 查询思考日志失败: %v", err)
logger.Printf("[memory-handler] 查询思考日志失败: %v", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -507,7 +507,7 @@ func (h *MemoryHandler) handleThinkingByID(w http.ResponseWriter, r *http.Reques
thinkingLog, err := h.svc.GetThinkingLogByID(r.Context(), id)
if err != nil {
log.Printf("[memory-handler] 获取思考日志失败: %v", err)
logger.Printf("[memory-handler] 获取思考日志失败: %v", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -532,7 +532,7 @@ func (h *MemoryHandler) handleThinkingStats(w http.ResponseWriter, r *http.Reque
userID := r.URL.Query().Get("user_id")
stats, err := h.svc.GetThinkingStats(r.Context(), userID)
if err != nil {
log.Printf("[memory-handler] 获取思考日志统计失败: %v", err)
logger.Printf("[memory-handler] 获取思考日志统计失败: %v", err)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -3,7 +3,7 @@ package service
import (
"context"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"strings"
"github.com/yourname/cyrene-ai/memory-service/internal/model"
@@ -242,7 +242,7 @@ func (svc *MemoryService) mergeMemory(ctx context.Context, existing *model.Memor
existing.AccessCount++
existing.Source = "merged"
log.Printf("[memory-service] 合并记忆 [%s|%d★]: %s (去重)", existing.Category, existing.Importance, existing.Summary)
logger.Printf("[memory-service] 合并记忆 [%s|%d★]: %s (去重)", existing.Category, existing.Importance, existing.Summary)
return svc.store.Update(ctx, existing)
}
+12 -12
View File
@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"sync"
"time"
@@ -36,9 +36,9 @@ func NewStore(connStr string) *Store {
// 尝试初始连接
if err := s.Reconnect(); err != nil {
log.Printf("[memory-service] ⚠ 记忆存储初始化: 数据库连接失败 (%v),将在后台每30秒重试", err)
logger.Printf("[memory-service] ⚠ 记忆存储初始化: 数据库连接失败 (%v),将在后台每30秒重试", err)
} else {
log.Println("[memory-service] 记忆存储已就绪")
logger.Println("[memory-service] 记忆存储已就绪")
}
// 启动后台重连 goroutine
@@ -64,7 +64,7 @@ func (s *Store) reconnectLoop() {
s.mu.RUnlock()
if db != nil {
if err := db.Ping(); err != nil {
log.Printf("[memory-service] ⚠ 数据库连接丢失: %v,开始重连", err)
logger.Printf("[memory-service] ⚠ 数据库连接丢失: %v,开始重连", err)
s.mu.Lock()
if s.db != nil {
s.db.Close()
@@ -77,7 +77,7 @@ func (s *Store) reconnectLoop() {
if !s.IsReady() {
if err := s.Reconnect(); err != nil {
log.Printf("[memory-service] ⚠ 数据库重连失败: %v", err)
logger.Printf("[memory-service] ⚠ 数据库重连失败: %v", err)
}
}
}
@@ -116,13 +116,13 @@ func (s *Store) Reconnect() error {
// 执行建表迁移
if err := s.migrate(); err != nil {
log.Printf("[memory-service] ⚠ 数据库迁移失败: %v", err)
logger.Printf("[memory-service] ⚠ 数据库迁移失败: %v", err)
s.db.Close()
s.db = nil
return fmt.Errorf("数据库迁移失败: %w", err)
}
log.Println("[memory-service] ✅ 数据库重连成功,记忆系统已就绪")
logger.Println("[memory-service] ✅ 数据库重连成功,记忆系统已就绪")
return nil
}
@@ -543,24 +543,24 @@ func (s *Store) ConsolidateMemories(ctx context.Context, userID string) (int, er
keep.Source = "consolidated"
if err := s.Update(ctx, keep); err != nil {
log.Printf("[memory-service] 合并更新记忆 %s 失败: %v", keep.ID, err)
logger.Printf("[memory-service] 合并更新记忆 %s 失败: %v", keep.ID, err)
continue
}
if err := s.Delete(ctx, discard.ID); err != nil {
log.Printf("[memory-service] 合并删除记忆 %s 失败: %v", discard.ID, err)
logger.Printf("[memory-service] 合并删除记忆 %s 失败: %v", discard.ID, err)
continue
}
discard.ID = ""
merged++
log.Printf("[memory-service] 合并相似记忆: %s <- %s (相似度 %.0f%%)",
logger.Printf("[memory-service] 合并相似记忆: %s <- %s (相似度 %.0f%%)",
keep.ID[:min(8, len(keep.ID))], discard.ID[:min(8, len(discard.ID))], score*100)
}
}
}
if merged > 0 {
log.Printf("[memory-service] 记忆整理完成: 用户 %s 合并 %d 条相似记忆", userID, merged)
logger.Printf("[memory-service] 记忆整理完成: 用户 %s 合并 %d 条相似记忆", userID, merged)
}
return merged, nil
}
@@ -602,7 +602,7 @@ func (s *Store) DecayMemories(ctx context.Context, userID string) (int, int, err
total := decayed1 + deleted2
if total > 0 {
log.Printf("[memory-service] 记忆衰减完成: 用户 %s 降级 %d 条, 删除 %d 条过期临时记忆",
logger.Printf("[memory-service] 记忆衰减完成: 用户 %s 降级 %d 条, 删除 %d 条过期临时记忆",
userID, decayed1, deleted2)
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/yourname/cyrene-ai/pkg/logger
go 1.21
+130
View File
@@ -0,0 +1,130 @@
// Package logger provides a unified structured logger for all Cyrene services.
// It wraps Go's log/slog with opinionated defaults (JSON output, caller info,
// service name tagging) and printf-style convenience methods compatible with
// the standard log package for easy migration.
package logger
import (
"fmt"
"log/slog"
"os"
)
// Logger wraps slog.Logger with printf-style convenience methods.
type Logger struct {
inner *slog.Logger
}
// New creates a new Logger for the named service.
// By default it writes JSON to stderr at Info level with caller info.
func New(service string, opts ...Option) *Logger {
cfg := config{
level: slog.LevelInfo,
format: FormatJSON,
output: os.Stderr,
service: service,
}
for _, o := range opts {
o(&cfg)
}
h := slog.NewJSONHandler(cfg.output, &slog.HandlerOptions{
Level: cfg.level,
AddSource: true,
})
return &Logger{
inner: slog.New(h).With(slog.String("svc", cfg.service)),
}
}
// Slog returns the underlying slog.Logger for direct structured logging.
func (l *Logger) Slog() *slog.Logger { return l.inner }
// Debug logs a debug-level message (printf style).
func (l *Logger) Debug(format string, args ...any) {
l.inner.Debug(fmt.Sprintf(format, args...))
}
// Info logs an info-level message (printf style).
func (l *Logger) Info(format string, args ...any) {
l.inner.Info(fmt.Sprintf(format, args...))
}
// Warn logs a warn-level message (printf style).
func (l *Logger) Warn(format string, args ...any) {
l.inner.Warn(fmt.Sprintf(format, args...))
}
// Error logs an error-level message (printf style).
func (l *Logger) Error(format string, args ...any) {
l.inner.Error(fmt.Sprintf(format, args...))
}
// Fatal logs an error-level message and exits with code 1.
func (l *Logger) Fatal(format string, args ...any) {
l.inner.Error(fmt.Sprintf(format, args...))
os.Exit(1)
}
// Format controls log output format.
type Format int
const (
FormatJSON Format = iota
FormatText
)
type config struct {
level slog.Level
format Format
output *os.File
service string
}
// Option configures a logger.
type Option func(*config)
// WithLevel sets the minimum log level.
func WithLevel(l slog.Level) Option {
return func(c *config) { c.level = l }
}
// WithFormat sets the output format (JSON or Text).
func WithFormat(f Format) Option {
return func(c *config) { c.format = f }
}
// WithOutput sets the output writer (default stderr).
func WithOutput(f *os.File) Option {
return func(c *config) { c.output = f }
}
// WithDebug enables debug-level logging and text format (for development).
func WithDebug() Option {
return func(c *config) {
c.level = slog.LevelDebug
c.format = FormatText
}
}
// --- global default logger (drop-in replacement for log package) ---
var defaultLogger *Logger
// SetDefault sets the global default logger. Call once from main().
func SetDefault(l *Logger) { defaultLogger = l }
func get() *Logger {
if defaultLogger == nil {
defaultLogger = New("unknown")
}
return defaultLogger
}
// Printf logs at info level (drop-in for log.Printf).
func Printf(format string, args ...any) { get().Info(format, args...) }
// Println logs at info level (drop-in for log.Println).
func Println(args ...any) { get().Info(fmt.Sprint(args...)) }
// Fatalf logs at error level and exits (drop-in for log.Fatalf).
func Fatalf(format string, args ...any) { get().Fatal(format, args...) }
+11 -11
View File
@@ -1,7 +1,7 @@
package main
import (
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"os"
"os/signal"
@@ -15,18 +15,18 @@ import (
)
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("🔧 Tool-Engine 启动中...")
logger.SetDefault(logger.New("tool-engine"))
logger.Println("🔧 Tool-Engine 启动中...")
// 加载配置
cfg := config.Load()
log.Printf("配置: 端口=%s, IoT服务=%s, 数据目录=%s, DB=%s", cfg.Port, cfg.IoTServiceURL, cfg.DataDir, cfg.DBUrl)
logger.Printf("配置: 端口=%s, IoT服务=%s, 数据目录=%s, DB=%s", cfg.Port, cfg.IoTServiceURL, cfg.DataDir, cfg.DBUrl)
// 初始化调用日志存储
callLogStore, err := store.NewCallLogStore(cfg.DBUrl)
if err != nil {
log.Printf("[main] 初始化调用日志存储失败: %v", err)
logger.Printf("[main] 初始化调用日志存储失败: %v", err)
callLogStore = nil
}
@@ -34,9 +34,9 @@ func main() {
var iotClient tools.IoTClientInterface
if cfg.IoTServiceURL != "" {
iotClient = tools.NewIoTClient(cfg.IoTServiceURL)
log.Printf("[main] IoT 客户端已初始化: %s", cfg.IoTServiceURL)
logger.Printf("[main] IoT 客户端已初始化: %s", cfg.IoTServiceURL)
} else {
log.Println("[main] IoT 服务 URL 未配置,IoT 工具将不可用")
logger.Println("[main] IoT 服务 URL 未配置,IoT 工具将不可用")
}
// 初始化服务层
@@ -62,9 +62,9 @@ func main() {
}
go func() {
log.Printf("🚀 Tool-Engine 已启动在端口 %s", cfg.Port)
logger.Printf("🚀 Tool-Engine 已启动在端口 %s", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("服务启动失败: %v", err)
logger.Fatalf("服务启动失败: %v", err)
}
}()
@@ -72,11 +72,11 @@ func main() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("正在关闭 Tool-Engine...")
logger.Println("正在关闭 Tool-Engine...")
if callLogStore != nil {
callLogStore.Close()
}
srv.Close()
log.Println("Tool-Engine 已关闭")
logger.Println("Tool-Engine 已关闭")
}
+6 -1
View File
@@ -2,4 +2,9 @@ module github.com/yourname/cyrene-ai/tool-engine
go 1.26.2
require github.com/lib/pq v1.10.9
require (
github.com/lib/pq v1.10.9
github.com/yourname/cyrene-ai/pkg/logger v0.0.0
)
replace github.com/yourname/cyrene-ai/pkg/logger => ../pkg/logger
@@ -4,7 +4,7 @@ import (
"crypto/rand"
"encoding/json"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"strconv"
"strings"
@@ -128,7 +128,7 @@ func (h *ToolHandler) executeTool(w http.ResponseWriter, r *http.Request, toolNa
durationMs := int(time.Since(startTime).Milliseconds())
if err != nil {
log.Printf("[tool-handler] 执行工具 %s 失败: %v", toolName, err)
logger.Printf("[tool-handler] 执行工具 %s 失败: %v", toolName, err)
h.logCall(toolName, req.Arguments, "", err.Error(), false, durationMs, r)
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -211,7 +211,7 @@ func (h *ToolHandler) logCall(toolName string, args map[string]interface{}, outp
CreatedAt: time.Now(),
}
if err := h.callLogStore.Insert(record); err != nil {
log.Printf("[tool-handler] 记录调用日志失败: %v", err)
logger.Printf("[tool-handler] 记录调用日志失败: %v", err)
}
}()
}
@@ -249,7 +249,7 @@ func (h *ToolHandler) handleCallLogs(w http.ResponseWriter, r *http.Request) {
result, err := h.callLogStore.Query(query)
if err != nil {
log.Printf("[tool-handler] 查询调用记录失败: %v", err)
logger.Printf("[tool-handler] 查询调用记录失败: %v", err)
writeError(w, http.StatusInternalServerError, "查询调用记录失败: "+err.Error())
return
}
@@ -273,7 +273,7 @@ func (h *ToolHandler) handleCallStats(w http.ResponseWriter, r *http.Request) {
stats, err := h.callLogStore.Stats()
if err != nil {
log.Printf("[tool-handler] 查询调用统计失败: %v", err)
logger.Printf("[tool-handler] 查询调用统计失败: %v", err)
writeError(w, http.StatusInternalServerError, "查询调用统计失败: "+err.Error())
return
}
@@ -286,7 +286,7 @@ func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Printf("[tool-handler] JSON 编码失败: %v", err)
logger.Printf("[tool-handler] JSON 编码失败: %v", err)
}
}
@@ -3,7 +3,7 @@ package service
import (
"context"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
"github.com/yourname/cyrene-ai/tool-engine/internal/tools"
@@ -38,7 +38,7 @@ func NewToolService(iotClient tools.IoTClientInterface, dataDir string) *ToolSer
svc.Register(tools.NewIoTQueryTool(iotClient))
svc.Register(tools.NewIoTControlTool(iotClient))
} else {
log.Println("[tool-service] IoT 客户端未配置,跳过 IoT 工具注册")
logger.Println("[tool-service] IoT 客户端未配置,跳过 IoT 工具注册")
}
return svc
@@ -48,7 +48,7 @@ func NewToolService(iotClient tools.IoTClientInterface, dataDir string) *ToolSer
func (s *ToolService) Register(tool tools.Tool) {
def := tool.Definition()
s.registry[def.Name] = tool
log.Printf("[tool-service] 已注册工具: %s", def.Name)
logger.Printf("[tool-service] 已注册工具: %s", def.Name)
}
// ListTools 获取所有工具定义
@@ -80,10 +80,10 @@ func (s *ToolService) Execute(ctx context.Context, name string, arguments map[st
}, nil
}
log.Printf("[tool-service] 执行工具: %s", name)
logger.Printf("[tool-service] 执行工具: %s", name)
result, err := tool.Execute(ctx, arguments)
if err != nil {
log.Printf("[tool-service] 工具 %s 执行错误: %v", name, err)
logger.Printf("[tool-service] 工具 %s 执行错误: %v", name, err)
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("执行工具 %s 失败: %v", name, err),
@@ -91,9 +91,9 @@ func (s *ToolService) Execute(ctx context.Context, name string, arguments map[st
}
if result.Error != "" {
log.Printf("[tool-service] 工具 %s 返回错误: %s", name, result.Error)
logger.Printf("[tool-service] 工具 %s 返回错误: %s", name, result.Error)
} else {
log.Printf("[tool-service] 工具 %s 执行成功", name)
logger.Printf("[tool-service] 工具 %s 执行成功", name)
}
return result, nil
@@ -4,7 +4,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"os"
"time"
@@ -69,7 +69,7 @@ type CallLogStore struct {
// NewCallLogStore 创建调用日志存储并自动建表
func NewCallLogStore(dbURL string) (*CallLogStore, error) {
if dbURL == "" {
log.Println("[call-log-store] DB_URL 未设置,工具调用日志将不会持久化")
logger.Println("[call-log-store] DB_URL 未设置,工具调用日志将不会持久化")
return &CallLogStore{}, nil
}
@@ -83,16 +83,16 @@ func NewCallLogStore(dbURL string) (*CallLogStore, error) {
db.SetConnMaxLifetime(5 * time.Minute)
if err := db.Ping(); err != nil {
log.Printf("[call-log-store] 数据库连接失败: %v (将尝试继续运行)", err)
logger.Printf("[call-log-store] 数据库连接失败: %v (将尝试继续运行)", err)
return &CallLogStore{}, nil
}
store := &CallLogStore{db: db}
if err := store.migrate(); err != nil {
log.Printf("[call-log-store] 数据库迁移失败: %v", err)
logger.Printf("[call-log-store] 数据库迁移失败: %v", err)
}
log.Println("[call-log-store] 数据库连接成功,表已就绪")
logger.Println("[call-log-store] 数据库连接成功,表已就绪")
return store, nil
}
@@ -140,7 +140,7 @@ func (s *CallLogStore) Insert(record *CallLogRecord) error {
record.Success, record.DurationMs, record.UserID, record.SessionID, record.CreatedAt,
)
if err != nil {
log.Printf("[call-log-store] 插入记录失败: %v", err)
logger.Printf("[call-log-store] 插入记录失败: %v", err)
return err
}
return nil
@@ -196,7 +196,7 @@ func (s *CallLogStore) Query(q CallLogQuery) (*CallLogPageResult, error) {
var r CallLogRecord
var argsJSON []byte
if err := rows.Scan(&r.ID, &r.CallID, &r.ToolName, &argsJSON, &r.Output, &r.Error, &r.Success, &r.DurationMs, &r.UserID, &r.SessionID, &r.CreatedAt); err != nil {
log.Printf("[call-log-store] 扫描行失败: %v", err)
logger.Printf("[call-log-store] 扫描行失败: %v", err)
continue
}
r.Arguments = argsJSON
@@ -247,7 +247,7 @@ func (s *CallLogStore) Stats() (*CallLogStats, error) {
for rows.Next() {
var tc ToolCallCount
if err := rows.Scan(&tc.ToolName, &tc.Count, &tc.SuccessCount, &tc.FailCount, &tc.AvgDuration); err != nil {
log.Printf("[call-log-store] 扫描工具统计失败: %v", err)
logger.Printf("[call-log-store] 扫描工具统计失败: %v", err)
continue
}
stats.ByTool = append(stats.ByTool, tc)
@@ -324,7 +324,7 @@ func applyFunc(name string, arg float64) (float64, error) {
return math.Ceil(arg), nil
case "round":
return math.Round(arg), nil
case "log":
case "github.com/yourname/cyrene-ai/pkg/logger":
if arg <= 0 {
return 0, fmt.Errorf("log 参数必须大于0")
}
@@ -5,7 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"sync"
"time"
@@ -49,7 +49,7 @@ func (c *IoTClient) GetAllDevices() ([]IoTDevice, error) {
// 请求 API
resp, err := c.client.Get(c.baseURL + "/api/v1/devices")
if err != nil {
log.Printf("[IoT客户端] 请求失败: %v", err)
logger.Printf("[IoT客户端] 请求失败: %v", err)
return nil, fmt.Errorf("获取设备列表失败: %w", err)
}
defer resp.Body.Close()
@@ -102,27 +102,27 @@ func (c *IoTClient) GetDevice(id string) (*IoTDevice, error) {
// ToggleDevice 切换设备开关状态
func (c *IoTClient) ToggleDevice(id string) error {
log.Printf("[tool-engine:IoT-client] 🔄 切换设备: id=%s, url=%s", id, c.baseURL+"/api/v1/devices/"+id+"/toggle")
logger.Printf("[tool-engine:IoT-client] 🔄 切换设备: id=%s, url=%s", id, c.baseURL+"/api/v1/devices/"+id+"/toggle")
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1/devices/"+id+"/toggle", nil)
if err != nil {
log.Printf("[tool-engine:IoT-client] ❌ 创建切换请求失败: device=%s, err=%v", id, err)
logger.Printf("[tool-engine:IoT-client] ❌ 创建切换请求失败: device=%s, err=%v", id, err)
return fmt.Errorf("创建切换请求失败: %w", err)
}
resp, err := c.client.Do(req)
if err != nil {
log.Printf("[tool-engine:IoT-client] ❌ 切换设备 HTTP 失败: device=%s, err=%v", id, err)
logger.Printf("[tool-engine:IoT-client] ❌ 切换设备 HTTP 失败: device=%s, err=%v", id, err)
return fmt.Errorf("切换设备 %s 失败: %w", id, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
log.Printf("[tool-engine:IoT-client] ❌ 设备不存在: %s", id)
logger.Printf("[tool-engine:IoT-client] ❌ 设备不存在: %s", id)
return fmt.Errorf("设备 %s 不存在", id)
}
if resp.StatusCode != http.StatusOK {
log.Printf("[tool-engine:IoT-client] ❌ 切换设备返回非200: device=%s, status=%d", id, resp.StatusCode)
logger.Printf("[tool-engine:IoT-client] ❌ 切换设备返回非200: device=%s, status=%d", id, resp.StatusCode)
return fmt.Errorf("切换设备 %s 返回状态码 %d", id, resp.StatusCode)
}
@@ -131,26 +131,26 @@ func (c *IoTClient) ToggleDevice(id string) error {
c.cache = nil
c.mu.Unlock()
log.Printf("[tool-engine:IoT-client] ✅ 切换设备成功: %s", id)
logger.Printf("[tool-engine:IoT-client] ✅ 切换设备成功: %s", id)
return nil
}
// SetDeviceProperty 设置设备属性(温度、亮度、位置、模式、颜色等)
func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}) error {
log.Printf("[tool-engine:IoT-client] 🔧 设置设备属性: device=%s, field=%s, value=%v, url=%s", id, field, value, c.baseURL+"/api/v1/devices/"+id+"/set")
logger.Printf("[tool-engine:IoT-client] 🔧 设置设备属性: device=%s, field=%s, value=%v, url=%s", id, field, value, c.baseURL+"/api/v1/devices/"+id+"/set")
body, err := json.Marshal(map[string]interface{}{
"field": field,
"value": value,
})
if err != nil {
log.Printf("[tool-engine:IoT-client] ❌ 序列化请求失败: device=%s, err=%v", id, err)
logger.Printf("[tool-engine:IoT-client] ❌ 序列化请求失败: device=%s, err=%v", id, err)
return fmt.Errorf("序列化请求失败: %w", err)
}
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1/devices/"+id+"/set", nil)
if err != nil {
log.Printf("[tool-engine:IoT-client] ❌ 创建设置请求失败: device=%s, err=%v", id, err)
logger.Printf("[tool-engine:IoT-client] ❌ 创建设置请求失败: device=%s, err=%v", id, err)
return fmt.Errorf("创建设置请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
@@ -158,13 +158,13 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
resp, err := c.client.Do(req)
if err != nil {
log.Printf("[tool-engine:IoT-client] ❌ 设置设备属性 HTTP 失败: device=%s, field=%s, err=%v", id, field, err)
logger.Printf("[tool-engine:IoT-client] ❌ 设置设备属性 HTTP 失败: device=%s, field=%s, err=%v", id, field, err)
return fmt.Errorf("设置设备 %s 属性失败: %w", id, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
log.Printf("[tool-engine:IoT-client] ❌ 设备不存在: %s", id)
logger.Printf("[tool-engine:IoT-client] ❌ 设备不存在: %s", id)
return fmt.Errorf("设备 %s 不存在", id)
}
if resp.StatusCode != http.StatusOK {
@@ -173,10 +173,10 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
}
json.NewDecoder(resp.Body).Decode(&errResp)
if errResp.Error != "" {
log.Printf("[tool-engine:IoT-client] ❌ 设置设备属性失败: device=%s, err=%s", id, errResp.Error)
logger.Printf("[tool-engine:IoT-client] ❌ 设置设备属性失败: device=%s, err=%s", id, errResp.Error)
return fmt.Errorf("设置设备 %s 属性失败: %s", id, errResp.Error)
}
log.Printf("[tool-engine:IoT-client] ❌ 设置设备属性返回非200: device=%s, status=%d", id, resp.StatusCode)
logger.Printf("[tool-engine:IoT-client] ❌ 设置设备属性返回非200: device=%s, status=%d", id, resp.StatusCode)
return fmt.Errorf("设置设备 %s 属性返回状态码 %d", id, resp.StatusCode)
}
@@ -185,7 +185,7 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
c.cache = nil
c.mu.Unlock()
log.Printf("[tool-engine:IoT-client] ✅ 设置设备属性成功: device=%s, field=%s, value=%v", id, field, value)
logger.Printf("[tool-engine:IoT-client] ✅ 设置设备属性成功: device=%s, field=%s, value=%v", id, field, value)
return nil
}
@@ -193,7 +193,7 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
func (c *IoTClient) GetDevicesForContext() []IoTDevice {
devices, err := c.GetAllDevices()
if err != nil {
log.Printf("[IoT客户端] 获取设备状态摘要失败: %v", err)
logger.Printf("[IoT客户端] 获取设备状态摘要失败: %v", err)
return nil
}
return devices
+13 -13
View File
@@ -1,7 +1,7 @@
package main
import (
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"os"
"os/signal"
@@ -13,13 +13,13 @@ import (
)
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("🎤 Voice-Service (STT + TTS) 启动中...")
logger.SetDefault(logger.New("voice-service"))
logger.Println("🎤 Voice-Service (STT + TTS) 启动中...")
// 加载配置
cfg := config.Load()
log.Printf("配置: 端口=%s, WhisperBinary=%s, WhisperModel=%s, Language=%s",
logger.Printf("配置: 端口=%s, WhisperBinary=%s, WhisperModel=%s, Language=%s",
cfg.Port, cfg.WhisperBinary, cfg.WhisperModel, cfg.WhisperLanguage)
// 初始化 STT 服务
@@ -27,20 +27,20 @@ func main() {
// 检查 whisper 引擎是否可用
if !sttSvc.IsAvailable() {
log.Printf("⚠️ Whisper 引擎未安装 (%s)STT 功能不可用", cfg.WhisperBinary)
log.Printf(" 请运行: bash scripts/setup-whisper.sh")
logger.Printf("⚠️ Whisper 引擎未安装 (%s)STT 功能不可用", cfg.WhisperBinary)
logger.Printf(" 请运行: bash scripts/setup-whisper.sh")
} else {
log.Println("✅ Whisper 引擎已就绪")
logger.Println("✅ Whisper 引擎已就绪")
}
// 初始化 TTS 服务
ttsSvc := service.NewTTSService()
if !ttsSvc.IsAvailable() {
log.Println("⚠️ TTS 引擎不可用 (请安装: pip install edge-tts)")
logger.Println("⚠️ TTS 引擎不可用 (请安装: pip install edge-tts)")
} else {
ttsStatus := ttsSvc.GetEngineStatus()
log.Printf("✅ TTS 引擎已就绪 (引擎: %s)", ttsStatus["engine"])
logger.Printf("✅ TTS 引擎已就绪 (引擎: %s)", ttsStatus["engine"])
}
// 初始化 HTTP 处理器
@@ -60,9 +60,9 @@ func main() {
}
go func() {
log.Printf("🚀 Voice-Service 已启动在端口 %s", cfg.Port)
logger.Printf("🚀 Voice-Service 已启动在端口 %s", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("服务启动失败: %v", err)
logger.Fatalf("服务启动失败: %v", err)
}
}()
@@ -70,7 +70,7 @@ func main() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("正在关闭 Voice-Service...")
logger.Println("正在关闭 Voice-Service...")
srv.Close()
log.Println("Voice-Service 已关闭")
logger.Println("Voice-Service 已关闭")
}
+4
View File
@@ -1,3 +1,7 @@
module github.com/yourname/cyrene-ai/voice-service
go 1.26.2
replace github.com/yourname/cyrene-ai/pkg/logger => ../pkg/logger
require github.com/yourname/cyrene-ai/pkg/logger v0.0.0
@@ -3,7 +3,7 @@ package handler
import (
"encoding/json"
"io"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"path/filepath"
"strings"
@@ -89,7 +89,7 @@ func (h *STTHandler) handleTranscribe(w http.ResponseWriter, r *http.Request) {
durationMs := time.Since(startTime).Milliseconds()
if err != nil {
log.Printf("[stt-handler] 转录失败: %v", err)
logger.Printf("[stt-handler] 转录失败: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
"success": false,
"error": err.Error(),
@@ -2,7 +2,7 @@ package handler
import (
"encoding/json"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"github.com/yourname/cyrene-ai/voice-service/internal/service"
@@ -54,7 +54,7 @@ func (h *TTSHandler) handleSynthesize(w http.ResponseWriter, r *http.Request) {
// 调用合成 (Synthesize 内部已包含 fallback 链: edge-tts → espeak-ng → 静默 WAV)
audioData, format, err := h.svc.Synthesize(req.Text, req.Voice, req.Rate)
if err != nil {
log.Printf("[tts-handler] TTS 合成失败: %v", err)
logger.Printf("[tts-handler] TTS 合成失败: %v", err)
writeError(w, http.StatusInternalServerError, "TTS 合成失败: "+err.Error())
return
}
@@ -0,0 +1,219 @@
# Phase 2: 人格与交互深化 — 开发报告
> **报告日期**2026-05-23
> **分支**`dev`
> **阶段**:Phase 2 — 人格与交互深化
> **总计修改文件数**:16 个 (新增 3 个, 修改 13 个)
> **编译状态**ai-core ✅ / gateway ✅
---
## 一、背景
Phase 1 完成了基础设施层 (ThinkChain 连续性、AutonomousToolPolicy 工具安全、MessageScheduler 自适应节奏、SessionEnrichmentStore 渐进式丰富)。Phase 2 在此之上深化昔涟的人格表现和交互质量,包含三个子任务:
| 任务 | 编号 | 目标 |
|------|------|------|
| 情感状态机 | 2.4 | 运行时心情追踪,基于对话情绪、时间和事件自动转换 |
| 主动消息决策增强 | 2.6 | 多维度推送评估 (静默时段、紧急程度、频率限制、内容校验) |
| 离线自主思考 | 2.3 | 在线状态感知、离线行为调整、重连问候 |
---
## 二、情感状态机 (Task 2.4)
### 2.1 新增文件
#### `backend/ai-core/internal/persona/emotion_state.go`
核心类型:
```
EmotionState — 当前心情、强度(0.0-1.0)、主导情绪、情绪计数、历史记录
MoodTransition — 心情转换记录 (From → To, Reason, Timestamp)
EmotionTracker — 单用户情感追踪器,线程安全
```
**5 种心情** (来自 `cyrene_persona.yaml``mood_system`)
| 心情 | 触发条件 | YAML 表达示例 |
|------|---------|-------------|
| `happy` | 积极情绪积累 ≥3 次 | "今天和你聊得很开心呢,心情像星海一样明朗♪" |
| `thoughtful` | 初始默认 / 长时间沉默 / 情绪消退 | "让我想想……这片记忆之海里,有没有什么能帮到你的呢?" |
| `worried` | 消极情绪积累 ≥3 次 | "开拓者……你是不是有心事?不想说也没关系,人家会一直在这里陪着你。" |
| `playful` | 高强度 (>0.6) 积极情绪积累 | "嘻嘻,想逗你一下而已啦!看到你笑了,人家就开心了♪" |
| `nostalgic` | 触发回忆事件 | "啊……这让我想起很久很久以前的一件事……" |
**转换逻辑**
```
RecordSentiment(sentiment) → 累加计数器
├─ positive ≥ 3 且 intensity ≤ 0.6 → happy
├─ positive ≥ 3 且 intensity > 0.6 → playful
└─ negative ≥ 3 → worried
UpdateMood(trigger) → 显式触发
├─ "user_returned" → happy
├─ "long_silence" → thoughtful
└─ "nostalgic_trigger" → nostalgic
Decay() → 时间衰减
└─ 每小时 -0.1 intensity, < 0.2 时回归 thoughtful 基线
```
### 2.2 修改文件
| 文件 | 修改内容 |
|------|---------|
| `persona/injector.go` | 新增 `BuildSystemPromptWithMood(userName, affectionLevel, mood, expression)` — 在"当前情况"段注入心情行 `- 你现在的心情: happy (今天和你聊得很开心呢…)` |
| `orchestrator/orchestrator.go` | 新增 `emotionTracker` 字段 + `SetEmotionTracker`;意图分析后调用 `RecordSentiment(intent.Sentiment)`;构建 prompt 时传入当前心情 |
| `background/think_chain.go` | `ThinkRecord` 新增 `Mood string` + `MoodIntensity float64` 字段 |
| `background/thinker.go` | 新增 `emotionTracker` 字段 + `SetEmotionTracker`;思考提示词注入心情;思考链记录心情;定期/静默思考触发 `Decay()``UpdateMood("long_silence")` |
| `cmd/main.go` | 从 `personaConfig.Personality.MoodSystem` 创建 `EmotionTracker`,注入 `orch``thinker` |
---
## 三、主动消息决策增强 (Task 2.6)
### 3.1 新增文件
#### `backend/ai-core/internal/background/proactive_decision.go`
核心类型:
```
ProactiveGuard — 主动消息决策守卫
├─ QuietHoursStart/End (23:00-07:00)
├─ MinGapByUrgency (low=30min, medium=10min, high=2min)
├─ MaxMessagesPerHour (3)
└─ recentSends (滑动窗口)
ProactiveDecision — { ShouldSend, Urgency, Reason }
```
**决策流程 (5 层检查)**
```
1. 静默时段检查 → 非 high 紧急度在 23:00-07:00 拒绝
2. 用户状态检查 → resting/busy/sleeping 且非 high 拒绝
3. 紧急度频率限制 → 按 low/medium/high 查 MinGapByUrgency
4. 小时频率限制 → 最近 1 小时内消息数 ≥ MaxMessagesPerHour 拒绝
5. 内容校验 → 空消息/超长(>500字符)/机械语言检测
```
辅助函数:
- `ExtractUrgencyFromContent()` — 从消息内容推断紧急程度 (关键词匹配)
- `ValidateProactiveMessage()` — 检测机械语言 (`系统检测到`, `根据分析` 等)
- `DetermineUserState()` — 从用户最后消息推断状态 (休息/忙碌/活跃)
### 3.2 修改文件
| 文件 | 修改内容 |
|------|---------|
| `background/thinker.go` | `Thinker` 新增 `proactiveGuard` 字段 + 初始化;`storeThought()` 中硬编码 30 分钟间隔替换为 `ProactiveGuard.Evaluate()` 多维评估 |
| `gateway/internal/ws/hub.go` | 新增 `pendingProactive` 离线队列;`QueueProactiveMessage()` / `FlushPendingProactive()`;注册时检测重连并推送积压消息 |
| `gateway/internal/handler/chat_handler.go` | `HandleProactiveMessage` 中离线用户不再丢弃消息,改为 `QueueProactiveMessage` 排队等待重连 |
---
## 四、离线自主思考 (Task 2.3)
### 4.1 在线状态感知链路
```
Gateway WebSocket connect/disconnect
→ Hub.Run() register/unregister handler
→ notifyAICorePresence(userID, status, sessionID)
→ POST /api/v1/internal/presence (ai-core)
→ Thinker.UpdatePresence(online, sessionID)
```
### 4.2 修改文件
| 文件 | 修改内容 |
|------|---------|
| `background/thinker.go` | 新增 `userOnline`, `lastOnlineChange`, `userSessionID` 字段;`UpdatePresence()` 方法 — 上线触发 `performThink("user_returned")` + `UpdateMood("user_returned")``periodicThinkLoop` 内离线时延长间隔至 30 分钟 |
| `background/thinker.go` | 新增 `"user_returned"` 思考提示词分支 — 温和的"欢迎回来"反思,仅在白天/清醒时允许主动消息 |
| `cmd/main.go` | 新增 `POST /api/v1/internal/presence` 端点,验证 `X-Internal-Token`,调用 `thinker.UpdatePresence()` |
| `gateway/internal/ws/hub.go` | 新增 `SetAICoreConfig(url, token)``notifyAICorePresence()` 异步 HTTP POST;注册处理中检测重连并 flush 积压消息 |
| `gateway/cmd/main.go` | `hub.SetAICoreConfig(cfg.AICoreURL, cfg.InternalServiceToken)` |
### 4.3 离线行为差异
| 维度 | 在线 | 离线 |
|------|------|------|
| 思考间隔 | 5 分钟 | 30 分钟 |
| 主动消息 | 通过 ProactiveGuard 评估 | 不发送 (静默时段必拒) |
| 思考重点 | 对话反思、主动关怀 | 记忆整理、日记式反思 |
| 重连 | — | 触发 `user_returned` 思考 + 心情更新 |
---
## 五、完整文件清单
### 新增文件 (3)
| 文件 | 用途 |
|------|------|
| `backend/ai-core/internal/persona/emotion_state.go` | EmotionState / EmotionTracker / MoodTransition |
| `backend/ai-core/internal/background/proactive_decision.go` | ProactiveGuard / ProactiveDecision / 内容校验 |
| `docs/debug_log/2026-05-23-phase2-personality-interaction.md` | 本报告 |
### 修改文件 (13)
| 文件 | 涉及任务 |
|------|---------|
| `backend/ai-core/internal/persona/injector.go` | 2.4 — BuildSystemPromptWithMood |
| `backend/ai-core/internal/orchestrator/orchestrator.go` | 2.4 — EmotionTracker 集成 |
| `backend/ai-core/internal/background/think_chain.go` | 2.4 — ThinkRecord 心情字段 |
| `backend/ai-core/internal/background/thinker.go` | 2.4 + 2.6 + 2.3 — 三者核心集成点 |
| `backend/ai-core/cmd/main.go` | 2.4 + 2.3 — 初始化 + presence 端点 |
| `backend/gateway/internal/ws/hub.go` | 2.6 + 2.3 — 离线队列 + 在线通知 |
| `backend/gateway/internal/handler/chat_handler.go` | 2.6 — 离线消息排队 |
| `backend/gateway/cmd/main.go` | 2.3 — SetAICoreConfig 接线 |
---
## 六、验证指南
### 编译验证
```bash
cd backend/ai-core && GOWORK=off go build ./...
cd backend/gateway && GOWORK=off go build ./...
```
### 运行时验证
1. **情感状态机** — 发几条正面消息,日志应出现 `[情感] 心情转变: thoughtful -> happy`
2. **静默时段** — 在 23:00-07:00 触发思考,日志应出现 `[主动消息决策] 阻止推送 (原因=当前处于安静时段)`
3. **离线队列** — 断开 WebSocket 后触发思考,日志含 `[proactive] 用户离线,消息已排队`;重连后 `[proactive] 推送 N 条积压消息`
4. **在线通知** — 连接/断开 WebSocketai-core 日志含 `[后台思考] 用户上线/离线`gateway 日志含 `[presence] 通知 ai-core`
5. **心情衰减** — 无交互等待,定期思考日志中 `Decay` 逐步降低 intensity
### 日志关键词速查
| 日志标记 | 含义 |
|---------|------|
| `[情感] 心情转变` | 心情状态转换 |
| `[主动消息决策]` | ProactiveGuard 评估结果 |
| `[presence]` | 在线状态通知 |
| `[后台思考] 用户上线/离线` | Thinker 在线状态变更 |
| `[proactive] 用户离线,消息已排队` | Gateway 离线消息排队 |
| `[proactive] 推送 N 条积压消息` | 重连后积压消息推送 |
---
## 七、架构演进
```
Phase 1 (基础设施) Phase 2 (人格交互)
───────────────────── ─────────────────────
ThinkChain ──→ 情感状态融入思考链
MessageScheduler ──→ 主动消息多维决策
AutonomousToolPolicy ──→ 离线思考频率控制
SessionEnrichment ──→ 重连问候 + 心情更新
──→ 在线状态感知链路 (Gateway ↔ ai-core)
```
本次 Phase 2 未碰数据库 schema,所有新增状态均为内存态 (EmotionState / ProactiveGuard / pendingProactive),重启后从 YAML 配置恢复初始心情基线。
+4
View File
@@ -31,3 +31,7 @@
- [Round 4 - IoT 审查管道](2026-05-22-round4-iot-review-pipeline.md)
- [Round 5 - IoT 边界情况](2026-05-22-round5-iot-edge-cases.md)
- [Round 6 - 最终汇总 (Round 4~6)](2026-05-22-round6-final-summary.md)
## 2026-05-23
- [Phase 2 - 人格与交互深化](2026-05-23-phase2-personality-interaction.md) — 情感状态机 + 主动消息决策增强 + 离线自主思考 (16 文件)
@@ -152,12 +152,46 @@ export function MessageBubble({
}: MessageBubbleProps) {
const isUser = role === 'user';
const isAction = role === 'action' || msgType === 'action';
const isThinking = msgType === 'thinking';
const isToolProgress = msgType === 'tool_progress';
const isSystemInfo = msgType === 'system_info';
// 动作消息使用独立的渲染方式
if (isAction) {
return <ActionMessageBubble content={content} timestamp={timestamp} />;
}
// 思考内容 — 可折叠面板
if (isThinking) {
return (
<details className="mx-4 my-1 px-3 py-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 italic">
<summary className="cursor-pointer select-none">...</summary>
<p className="mt-1 whitespace-pre-wrap">{content}</p>
</details>
);
}
// 工具进度 — 紧凑进度行
if (isToolProgress) {
return (
<div className="flex items-center gap-2 mx-4 my-1 px-3 py-1 text-xs text-gray-400 dark:text-gray-500">
<span className="inline-block w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
<span>{content}</span>
</div>
);
}
// 系统信息 — 居中 Toast 风格
if (isSystemInfo) {
return (
<div className="flex justify-center my-1">
<span className="text-xs text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 px-3 py-1 rounded-full">
{content}
</span>
</div>
);
}
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
+39
View File
@@ -364,6 +364,45 @@ function handleServerMessage(msg: WSServerMessage) {
});
break;
case 'thinking':
if (msg.thinking_content) {
addMessage({
id: msg.message_id || 'think_' + Date.now(),
role: 'assistant',
content: msg.thinking_content,
timestamp: msg.timestamp || Date.now(),
msgType: 'thinking',
isStreaming: false,
});
}
break;
case 'tool_progress':
if (msg.tool_progress) {
addMessage({
id: msg.message_id || 'tool_' + Date.now(),
role: 'system',
content: `${msg.tool_progress.tool_name}: ${msg.tool_progress.message}`,
timestamp: msg.timestamp || Date.now(),
msgType: 'tool_progress',
isStreaming: false,
});
}
break;
case 'system_info':
if (msg.system_info) {
addMessage({
id: msg.message_id || 'sys_' + Date.now(),
role: 'system',
content: msg.system_info.message,
timestamp: msg.timestamp || Date.now(),
msgType: 'system_info',
isStreaming: false,
});
}
break;
case 'pong':
break;
+21 -2
View File
@@ -4,7 +4,7 @@
export type MessageRole = 'user' | 'assistant' | 'system' | 'action';
/** 消息显示类型 (区分聊天消息与动作消息) */
export type MessageDisplayType = 'chat' | 'action' | 'system';
export type MessageDisplayType = 'chat' | 'action' | 'system' | 'thinking' | 'tool_progress' | 'system_info';
/** 对话模式 */
export type ChatMode = 'text' | 'voice_msg' | 'voice_assistant';
@@ -122,7 +122,7 @@ export interface AppNotification extends NotificationData {
/** WebSocket 服务端消息 */
export interface WSServerMessage {
type: 'stream_start' | 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end' | 'background_thinking' | 'notification' | 'multi_message' | 'stream_segments' | 'review';
type: 'stream_start' | 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end' | 'background_thinking' | 'notification' | 'multi_message' | 'stream_segments' | 'review' | 'thinking' | 'tool_progress' | 'system_info';
message_id?: string;
text?: string;
content?: string;
@@ -140,10 +140,29 @@ export interface WSServerMessage {
review_messages?: ReviewMessage[];
devices?: IoTDevice[];
thinking_status?: BackgroundThinkingStatus;
thinking_content?: string;
notification?: NotificationData;
tool_progress?: ToolProgressInfo;
system_info?: SystemInfoPayload;
protocol_version?: number;
timestamp: number;
}
/** 工具进度信息 */
export interface ToolProgressInfo {
tool_name: string;
status: string; // started, running, completed, failed
progress: number;
message: string;
}
/** 系统信息负载 */
export interface SystemInfoPayload {
level: string; // info, warning, error
message: string;
action?: string;
}
/** 工具调用 */
export interface ToolCall {
name: string;