13 Commits

Author SHA1 Message Date
AskaEth 79b1b9a3fa docs: 更新项目文档反映6-7月功能升级
- 新增 dev-plan/15: 6-7月功能升级与修复汇总 (Platform Bridge/记忆/Thinker/群聊等)
- 更新 dev-plan README: 版本日期 + 新文档索引
- 更新架构分析文档: 日期、已知限制、服务端口加入Platform Bridge
- .gitignore: 开放 docs/dev-plan/ 和关键架构文档纳入版本管理

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-07 21:24:03 +08:00
AskaEth 355ab9ae4d docs: .env.example 添加 EMBEDDING_API_URL/EMBEDDING_MODEL 示例
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 20:55:10 +08:00
AskaEth 02852347d9 feat: n-gram文本相似度作为embedding降级方案
SimpleEmbedder重写为基于字符bigram+trigram的FNV哈希向量:
- 相似中文短语共享n-gram → 余弦相似度有意义
- 不需要任何外部API,纯本地计算
- 作为EMBEDDING_API_URL不可用时的自动降级

keywordSearch升级为滑动窗口分词匹配:
- 2-4字窗口切分查询词,分别匹配记忆内容/摘要/标签
- 按匹配分数降序排列,分数越高越相关
- 替代原来的完整字符串包含匹配,中文召回率大幅提升

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 20:46:40 +08:00
AskaEth 9b0c05f03d fix: 记忆嵌入兼容性 + 管理员称呼 + 记忆归因三大修复
记忆嵌入修复:
- 新增 EMBEDDING_API_URL/EMBEDDING_MODEL 环境变量,嵌入端点可独立配置
- 启动时测试嵌入API可用性,不可用则自动降级为关键词匹配并记录日志
- DeepSeek等不提供embedding的API不再静默失败

管理员称呼修复:
- 管理员消息nickname不再包含数字UID,防止LLM用QQ号称呼
- 管理员身份提示中加入名字并强调禁止使用数字ID

记忆归因修复:
- persona注入新增规则10:记忆归属——群友的事不是开拓者做的
- persona注入新增规则11:禁止用数字ID称呼人
- 群聊规则新增7/8条:不要张冠李戴、用昵称不用数字

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 20:42:40 +08:00
AskaEth d0fd1c06ec fix: 群聊多消息发送间隔期间重新检查禁言状态
每条消息发送前都检查IsGroupMuted,防止发送间隔期间被禁言
导致后续消息仍然发出。检测到禁言后break跳过所有剩余消息。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-05 19:53:04 +08:00
AskaEth 2499ca862d feat: 禁止在聊天平台生成代码/长文等大量文本
- persona注入规则新增第9条:严禁答应代码/长文生成请求
- 群聊规则新增第6条:严禁生成代码/长文(管理员也不破例)
- 明确告知后续本地工作助手插件会上线这些能力

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-05 14:29:18 +08:00
AskaEth 4ebdac42a1 fix: 【不发送】标签在sendProactive路径失效 + 群聊长消息截断防封控
- sendProactive端点新增【不发送】过滤(之前只有主回复路径有)
- sendProactive和主回复路径均增加群聊500字截断+最多2条限制
- 防止第三方平台适配器因过长消息触发封控

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-05 14:18:39 +08:00
AskaEth f723194a17 fix: group_ambient模式区分管理员和非管理员审查提示
管理员在群里说话不@bot时,group_ambient审查提示不应限制其工具使用权限。
现在管理员走「群聊审查模式-管理员」提示,允许正常使用工具;
普通群友仍然走严格审查,防止随意插话。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-05 13:40:43 +08:00
AskaEth 13a2c69d5e fix: 修复记忆力差和跨群聊上下文泄漏
记忆嵌入修复:
- 新增 memory.APIEmbedder 使用 text-embedding-3-small 替代 SimpleEmbedder
- Extractor 保存记忆时自动生成向量嵌入
- Embedder 接口增加 IsAvailable() 方法

跨群聊上下文隔离:
- Thinker 新增 thinkSessionID 字段,performThink 启动时绑定会话
- storeThought 优先使用绑定的 session 推送思考结果
- 防止思考过程中其他群消息改变 activeSessionID 导致串台

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 12:25:05 +08:00
AskaEth 197c86fa72 fix: 修复 web_search 搜索超时和噪音问题
- 移除baidu/360search引擎(安全验证拦截),保留bing+sogou
- 超时从10s增加到25s,SearXNG内部timeout=20s
- 添加User-Agent
- 跳过失败的工具结果推送,避免"百度被安全验证挡住"等噪音刷屏

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 12:02:46 +08:00
AskaEth e26501b25d fix: 强制简体中文输出,防止群友繁体昵称传染
- persona/injector.go: 对话风格规则中加入简体中文强制要求
- orchestrator/synthesizer.go: 群聊规则中加入简体中文强制要求

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 21:32:49 +08:00
AskaEth efcd97e98f fix: TLS连接池复用 + 禁言检测 + 降级回复静默
- llm/openai.go: 移除DisableKeepAlives,启用连接池(100idle/20perhost/90s)
  修复频繁TLS握手超时问题
- qq/adapter.go: 监听group_ban通知,发送前检查禁言状态
  防止向被禁言群发消息导致风控
- cmd/main.go: 降级回复仅管理员私聊,群聊静默失败避免怪话
- protocol.go: OBv11Message新增Duration字段
- bridge/router.go: 处理noticeToUnified返回nil(内部消费的通知)
2026-07-02 20:31:08 +08:00
AskaEth ddb1120ee1 fix: buildThinkingUserPrompt嵌套锁死锁 + 管理员群聊路由优化
- thinker.go:1662/1762 defer t.muUnlock→立即解锁,消除buildThinkingUserPrompt自死锁
  (1662持锁后1762再Lock→同goroutine自死锁→23h锁泄露→3769 goroutine泄漏)
- platform-bridge: 管理员在群里@别人走group_ambient而非normal,避免频繁插嘴
2026-06-30 12:41:24 +08:00
19 changed files with 1502 additions and 105 deletions
+7
View File
@@ -16,6 +16,13 @@ LLM_API_KEY=sk-xxxxx
LLM_MODEL=gpt-4o LLM_MODEL=gpt-4o
LLM_FALLBACK_MODEL=gpt-4o-mini LLM_FALLBACK_MODEL=gpt-4o-mini
# ========== 记忆嵌入 (Embedding) — 可选 ==========
# 嵌入 API 独立于对话 LLM,因为 DeepSeek 等厂商不提供 /embeddings 端点。
# 不配置时自动使用本地 n-gram 文本相似度(中文效果可用,无需外部依赖)。
# 如需更强检索质量,可指向 Ollama 等本地嵌入服务:
# EMBEDDING_API_URL=http://localhost:11434/v1
# EMBEDDING_MODEL=bge-m3
# ========== LLM 模型路由配置 (可选,优先于 LLM_* 环境变量) ========== # ========== LLM 模型路由配置 (可选,优先于 LLM_* 环境变量) ==========
# MODELS_CONFIG_PATH=./models.json # MODELS_CONFIG_PATH=./models.json
+4 -1
View File
@@ -61,10 +61,13 @@ platform_blocklist.json
*.exe~ *.exe~
.claude/ .claude/
# ========== 文档 (项目规范:docs/ 不纳入版本管理,docs/api/ 为例外) ========== # ========== 文档 (项目规范:docs/ 不纳入版本管理,以下为例外) ==========
docs/* docs/*
!docs/api/ !docs/api/
!docs/deploy/ !docs/deploy/
!docs/dev-plan/
!docs/cyrene-project-architecture-analysis.md
!docs/dev_must_read.md
# ========== 调试临时文件 (项目规范:debug/cache/ 为临时脚本目录) ========== # ========== 调试临时文件 (项目规范:debug/cache/ 为临时脚本目录) ==========
debug/cache/ debug/cache/
+62 -25
View File
@@ -119,12 +119,33 @@ func main() {
memStore = memory.NewStore(cfg.DatabaseURL) memStore = memory.NewStore(cfg.DatabaseURL)
defer memStore.Close() defer memStore.Close()
memRetriever = memory.NewRetriever(memStore, nil) // 嵌入 API 可独立配置(DeepSeek 等不提供 embedding 端点时,可指向其他服务)
embedURL := getEnv("EMBEDDING_API_URL", cfg.LLMBaseURL)
embedModel := getEnv("EMBEDDING_MODEL", "text-embedding-3-small")
memEmbedder := memory.NewAPIEmbedder(embedURL, cfg.LLMAPIKey, embedModel)
if memEmbedder.IsAvailable() {
// 测试嵌入端点是否真的可用
testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second)
_, testErr := memEmbedder.Embed(testCtx, "test")
testCancel()
if testErr != nil {
log.Printf("⚠ 嵌入 API 不可用 (%v),记忆检索将仅使用关键词匹配", testErr)
memEmbedder = nil
} else {
log.Printf("记忆嵌入服务已就绪 (%s @ %s)", embedModel, embedURL)
}
} else {
log.Println("⚠ 嵌入服务未配置,记忆检索将仅使用关键词匹配")
memEmbedder = nil
}
memRetriever = memory.NewRetriever(memStore, memEmbedder)
// 记忆提取器使用 memory purpose 适配器 // 记忆提取器使用 memory purpose 适配器
memExtractor = memory.NewExtractor(memStore, func(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error) { memExtractor = memory.NewExtractor(memStore, func(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error) {
return memoryAdapter.Chat(ctx, messages) return memoryAdapter.Chat(ctx, messages)
}) })
memExtractor.SetEmbedder(memEmbedder)
log.Println("记忆提取器已就绪") log.Println("记忆提取器已就绪")
} }
@@ -462,6 +483,14 @@ func main() {
if err := json.Unmarshal([]byte(result), &toolResult); err != nil { if err := json.Unmarshal([]byte(result), &toolResult); err != nil {
toolResult = map[string]interface{}{"output": result} toolResult = map[string]interface{}{"output": result}
} }
// 跳过失败的工具调用 — 错误结果不需要 LLM 跟进,避免噪音刷屏
if success, ok := toolResult["success"].(bool); ok && !success {
errMsg, _ := toolResult["error"].(string)
log.Printf("[tool-followup] 跳过失败工具 %s: %s", toolName, errMsg)
return
}
output, _ := toolResult["output"].(string) output, _ := toolResult["output"].(string)
if output == "" { if output == "" {
output = result output = result
@@ -1092,21 +1121,21 @@ func handleChat(
// 平台静默观察模式:只记录消息、提取记忆、触发后台思考,不生成回复。 // 平台静默观察模式:只记录消息、提取记忆、触发后台思考,不生成回复。
if req.Mode == "group_ambient" { if req.Mode == "group_ambient" {
// 群聊环境消息 — 审查是否值得回应 // 群聊环境消息 — 审查是否值得回应
message := req.Message message := req.Message
ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, message) ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, message)
if thinker != nil { if thinker != nil {
thinker.RecordUserMessage(req.SessionID) thinker.RecordUserMessage(req.SessionID)
}
// 在消息前加审查指令,让 LLM 自己判断是否插话。
// 关键规则:
// 1. 绝大多数群聊消息不需要你回复。只有话题直接涉及你、有人@你、或你真有特别相关的信息时才开口。
// 2. 如果有人说"别说话""闭嘴""先别说""别让ta说"之类让你安静的话,必须立刻闭嘴,后续几条消息都不要回复。
// 3. 不想说话时不要发消息。不要为了刷存在感而发言。
// 4. 每轮最多说 1-2 句话,不要长篇大论。
req.Message = "【群聊审查模式】以下是群聊里的一条消息。绝大多数群聊消息你不需要回复。只有以下情况才开口:(1)有人直接@你或叫你名字 (2)话题与你高度相关 (3)你有重要信息补充。如果有人让你闭嘴/别说话,必须严格遵守。每次回复最多1-2句话。如果你被唤醒了但觉得不该说话,在消息开头写 【不发送】——这条消息就不会被发出去,只在你心里想过。\n\n" + message
req.Mode = "text"
} }
// 管理员消息 vs 普通群友消息使用不同的审查提示。
// 管理员在群里说话(不@bot)也走 group_ambient,但不应限制其使用工具的权限。
if req.IsAdmin {
req.Message = "【群聊审查模式-管理员】以下是群聊里的一条消息,发送者是开拓者本人(管理员)。\n你可以正常回应他的指令和请求,包括使用各种工具。但如果他只是闲聊或对别人说话,你可以保持安静。\n每次回复最多1-2句话。如果你觉得不该说话,在消息开头写 【不发送】。\n\n" + message
} else {
req.Message = "【群聊审查模式】以下是群聊里的一条消息。绝大多数群聊消息你不需要回复。只有以下情况才开口:(1)有人直接@你或叫你名字 (2)话题与你高度相关 (3)你有重要信息补充。如果有人让你闭嘴/别说话,必须严格遵守。每次回复最多1-2句话。如果你被唤醒了但觉得不该说话,在消息开头写 【不发送】——这条消息就不会被发出去,只在你心里想过。\n\n" + message
}
req.Mode = "text"
}
if req.Mode == "platform_silent" { if req.Mode == "platform_silent" {
if thinker != nil { if thinker != nil {
thinker.RecordUserMessage(req.SessionID) thinker.RecordUserMessage(req.SessionID)
@@ -1221,11 +1250,16 @@ func handleChat(
IsAdmin: req.IsAdmin, IsAdmin: req.IsAdmin,
}) })
if err != nil { if err != nil {
fallback := getFallbackMessage() // 降级回复:仅对管理员私聊发送,群聊/频道静默失败避免怪话
log.Printf("[chat] ProcessInput 失败,使用降级回复: %v", err) if req.IsAdmin && req.Source.ChannelType == "direct" {
fallbackData, _ := json.Marshal(map[string]string{"delta": fallback, "status": "fallback"}) fallback := getFallbackMessage()
fmt.Fprintf(w, "data: %s\n\n", fallbackData) log.Printf("[chat] ProcessInput 失败,发送降级回复: %v", err)
flusher.Flush() fallbackData, _ := json.Marshal(map[string]string{"delta": fallback, "status": "fallback"})
fmt.Fprintf(w, "data: %s\n\n", fallbackData)
flusher.Flush()
} else {
log.Printf("[chat] ProcessInput 失败,群聊静默: %v", err)
}
return return
} }
@@ -1250,11 +1284,14 @@ func handleChat(
case model.StreamError: case model.StreamError:
log.Printf("[chat] 流式错误: %v", event.Error) log.Printf("[chat] 流式错误: %v", event.Error)
fallback := getFallbackMessage() // 降级回复:仅管理员私聊,群聊静默避免怪话
log.Printf("[chat] 流式事件错误,发送降级回复: %v", event.Error) if req.IsAdmin && req.Source.ChannelType == "direct" {
fallbackData, _ := json.Marshal(map[string]string{"delta": fallback, "status": "fallback"}) fallback := getFallbackMessage()
fmt.Fprintf(w, "data: %s\n\n", fallbackData) log.Printf("[chat] 流式错误,发送降级回复: %v", event.Error)
flusher.Flush() fallbackData, _ := json.Marshal(map[string]string{"delta": fallback, "status": "fallback"})
fmt.Fprintf(w, "data: %s\n\n", fallbackData)
flusher.Flush()
}
return return
case model.StreamDelta: case model.StreamDelta:
+15 -8
View File
@@ -229,6 +229,7 @@ type Thinker struct {
lastProactiveNs atomic.Int64 // UnixNano (replaces lastProactiveMsgTime) lastProactiveNs atomic.Int64 // UnixNano (replaces lastProactiveMsgTime)
lastOnlineChange time.Time lastOnlineChange time.Time
userSessionID string // 当前活跃的 session ID (用于重连) userSessionID string // 当前活跃的 session ID (用于重连)
thinkSessionID string // 当前思考周期绑定的 session(防止跨群串台)
// 时区设置 (默认 Asia/Shanghai,可通过 TZ 环境变量覆盖) // 时区设置 (默认 Asia/Shanghai,可通过 TZ 环境变量覆盖)
timeLocation *time.Location timeLocation *time.Location
@@ -1114,6 +1115,11 @@ func (t *Thinker) performThink(triggerReason string) {
log.Printf("[后台思考] 开始思考周期 (触发原因=%s, 计数=%d)...", triggerReason, currentCount) log.Printf("[后台思考] 开始思考周期 (触发原因=%s, 计数=%d)...", triggerReason, currentCount)
// 捕获当前活跃 session,防止思考过程中其他群的消息改变 activeSessionID 导致串台
t.muLock()
t.thinkSessionID = t.activeSessionID
t.muUnlock()
// 0. 让步于前台——如果用户最近有活动(非post_chat),跳过本次思考。 // 0. 让步于前台——如果用户最近有活动(非post_chat),跳过本次思考。
if triggerReason != "post_chat" { if triggerReason != "post_chat" {
t.muLock() t.muLock()
@@ -1133,15 +1139,13 @@ func (t *Thinker) performThink(triggerReason string) {
return return
} }
// 2. 获取当前活跃会话的对话历史(优先活跃会话,回退到管理员主会话 // 2. 获取思考周期绑定会话的对话历史,回退到管理员主会话
var convHistory []model.LLMMessage var convHistory []model.LLMMessage
if t.convStore != nil { if t.convStore != nil {
t.muLock() sessionID := t.thinkSessionID
sessionID := t.activeSessionID
if sessionID == "" { if sessionID == "" {
sessionID = t.adminSessionID sessionID = t.adminSessionID
} }
t.muUnlock()
if sessionID != "" { if sessionID != "" {
convHistory = t.convStore.GetHistory(sessionID, 30) convHistory = t.convStore.GetHistory(sessionID, 30)
@@ -1660,8 +1664,8 @@ func (t *Thinker) buildThinkingUserPrompt(
sb.WriteString("刚有人和你聊完天。你想自然地在心里回味一下刚才的对话……\n") sb.WriteString("刚有人和你聊完天。你想自然地在心里回味一下刚才的对话……\n")
case "silence": case "silence":
t.muLock() t.muLock()
defer t.muUnlock()
silenceDuration := time.Since(t.lastUserTime()) silenceDuration := time.Since(t.lastUserTime())
t.muUnlock()
sb.WriteString(fmt.Sprintf("已经大约 %s 没有说话了。你有点想知道大家在做什么……\n", sb.WriteString(fmt.Sprintf("已经大约 %s 没有说话了。你有点想知道大家在做什么……\n",
formatDurationHuman(silenceDuration))) formatDurationHuman(silenceDuration)))
@@ -1760,7 +1764,7 @@ func (t *Thinker) buildThinkingUserPrompt(
// OBv11 platform identity and available channels for proactive messaging. // OBv11 platform identity and available channels for proactive messaging.
t.muLock() t.muLock()
defer t.muUnlock() t.muUnlock()
qqChannels := t.platformChannels qqChannels := t.platformChannels
botUIDs := t.botUIDs botUIDs := t.botUIDs
activeSID := t.activeSessionID activeSID := t.activeSessionID
@@ -1922,8 +1926,11 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
// Extract proactive message and optional platform target. // Extract proactive message and optional platform target.
proactiveMsg, proactiveTarget := t.extractProactiveMessage(content) proactiveMsg, proactiveTarget := t.extractProactiveMessage(content)
// Prefer active session, fall back to admin main session. // 优先使用思考周期绑定的 session(防止跨群串台),其次活跃 session,最后管理员主会话
pushSessionID := t.activeSessionID pushSessionID := t.thinkSessionID
if pushSessionID == "" {
pushSessionID = t.activeSessionID
}
if pushSessionID == "" { if pushSessionID == "" {
pushSessionID = t.adminSessionID pushSessionID = t.adminSessionID
} }
+5 -2
View File
@@ -41,9 +41,12 @@ func NewOpenAIProvider(cfg OpenAIConfig) *OpenAIProvider {
cfg.Timeout = 60 * time.Second cfg.Timeout = 60 * time.Second
} }
// 克隆默认 Transport 并关闭 keep-alive,防止 context 取消后连接池脏连接导致全阻塞 // 使用连接池复用 TCP+TLS 连接,避免每次请求都重新 TLS 握手
tr := http.DefaultTransport.(*http.Transport).Clone() tr := http.DefaultTransport.(*http.Transport).Clone()
tr.DisableKeepAlives = true tr.MaxIdleConns = 100
tr.MaxIdleConnsPerHost = 20
tr.IdleConnTimeout = 90 * time.Second
tr.TLSHandshakeTimeout = 15 * time.Second
return &OpenAIProvider{ return &OpenAIProvider{
config: cfg, config: cfg,
+104
View File
@@ -0,0 +1,104 @@
package memory
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// APIEmbedder generates text embeddings via OpenAI-compatible API.
type APIEmbedder struct {
baseURL string
apiKey string
model string
httpClient *http.Client
}
// NewAPIEmbedder creates a new embedding service.
func NewAPIEmbedder(baseURL, apiKey, model string) *APIEmbedder {
return &APIEmbedder{
baseURL: baseURL,
apiKey: apiKey,
model: model,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
type embRequest struct {
Input []string `json:"input"`
Model string `json:"model"`
}
type embResponse struct {
Data []embData `json:"data"`
Error *embError `json:"error,omitempty"`
}
type embData struct {
Embedding []float64 `json:"embedding"`
}
type embError struct {
Message string `json:"message"`
}
// Embed generates an embedding vector for the given text.
func (e *APIEmbedder) Embed(ctx context.Context, text string) ([]float64, error) {
if !e.IsAvailable() {
return nil, fmt.Errorf("embedding service not available")
}
reqBody := embRequest{
Input: []string{text},
Model: e.model,
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal embedding request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", e.baseURL+"/embeddings", bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("create embedding request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+e.apiKey)
resp, err := e.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("embedding request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read embedding response: %w", err)
}
var embResp embResponse
if err := json.Unmarshal(body, &embResp); err != nil {
return nil, fmt.Errorf("parse embedding response: %w", err)
}
if embResp.Error != nil {
return nil, fmt.Errorf("embedding API error: %s", embResp.Error.Message)
}
if len(embResp.Data) == 0 {
return nil, fmt.Errorf("no embedding returned")
}
return embResp.Data[0].Embedding, nil
}
// IsAvailable checks if the embedding service is configured.
func (e *APIEmbedder) IsAvailable() bool {
return e.apiKey != "" && e.baseURL != ""
}
+24 -2
View File
@@ -6,14 +6,16 @@ import (
"fmt" "fmt"
"git.yeij.top/AskaEth/Cyrene/pkg/logger" "git.yeij.top/AskaEth/Cyrene/pkg/logger"
"strings" "strings"
"time"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/model" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/model"
) )
// Extractor 记忆提取器 —— 从对话中提取结构化记忆 // Extractor 记忆提取器 —— 从对话中提取结构化记忆
type Extractor struct { type Extractor struct {
store *Store store *Store
llmChat func(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error) llmChat func(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error)
embedder Embedder // 可选:为保存的记忆生成向量嵌入
} }
// NewExtractor 创建记忆提取器 // NewExtractor 创建记忆提取器
@@ -26,6 +28,11 @@ func NewExtractor(store *Store, llmChat func(ctx context.Context, messages []mod
} }
} }
// SetEmbedder sets the embedder for generating vector embeddings on saved memories.
func (e *Extractor) SetEmbedder(embedder Embedder) {
e.embedder = embedder
}
// ExtractAndStore 从一轮对话中提取记忆并存储 // ExtractAndStore 从一轮对话中提取记忆并存储
// 异步执行,不阻塞主流程 // 异步执行,不阻塞主流程
func (e *Extractor) ExtractAndStore(ctx context.Context, userID, sessionID, userMessage, assistantResponse string) { func (e *Extractor) ExtractAndStore(ctx context.Context, userID, sessionID, userMessage, assistantResponse string) {
@@ -54,6 +61,21 @@ func (e *Extractor) storeMemories(ctx context.Context, userID, sessionID string,
mem.SessionID = sessionID mem.SessionID = sessionID
mem.Source = "conversation" mem.Source = "conversation"
// 生成向量嵌入(异步,不阻塞主流程)
if e.embedder != nil && e.embedder.IsAvailable() {
embedCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
embedding, embErr := e.embedder.Embed(embedCtx, mem.Content)
cancel()
if embErr != nil {
logger.Printf("[memory] 嵌入生成失败: %v,将保存无嵌入的记忆", embErr)
} else {
mem.Embedding = make([]float32, len(embedding))
for i, v := range embedding {
mem.Embedding[i] = float32(v)
}
}
}
existing, err := e.findSimilar(ctx, userID, &mem) existing, err := e.findSimilar(ctx, userID, &mem)
if err == nil && existing != nil { if err == nil && existing != nil {
e.mergeMemory(ctx, existing, &mem) e.mergeMemory(ctx, existing, &mem)
+117 -53
View File
@@ -20,29 +20,73 @@ type Retriever struct {
// Embedder 文本嵌入接口 // Embedder 文本嵌入接口
type Embedder interface { type Embedder interface {
Embed(ctx context.Context, text string) ([]float64, error) Embed(ctx context.Context, text string) ([]float64, error)
IsAvailable() bool
} }
// SimpleEmbedder 基于关键词的简单嵌入(MVP阶段可用,无需外部API // SimpleEmbedder 基于 n-gram 哈希的本地嵌入(无需外部API
// 用于嵌入API不可用时的降级方案,中文效果显著优于字符频率哈希。
type SimpleEmbedder struct{} type SimpleEmbedder struct{}
// Embed 简单的关键词哈希嵌入(用于MVP快速验证) const embedDim = 1536
func (e *SimpleEmbedder) Embed(ctx context.Context, text string) ([]float64, error) {
// 生成一个简单的1536维特征向量
// 基于字符频率的简单表示,用于MVP阶段
vec := make([]float64, 1536)
runes := []rune(strings.ToLower(text)) // Embed 使用字符 bigram + trigram 哈希生成稀疏向量。
for i, r := range runes { // 相似的中文短语会共享 n-gram → 哈希碰撞产生有意义的余弦相似度。
idx := int(r) % 1536 func (e *SimpleEmbedder) Embed(ctx context.Context, text string) ([]float64, error) {
vec[idx] += 1.0 / float64(len(runes)) vec := make([]float64, embedDim)
// 考虑位置信息 runes := []rune(strings.TrimSpace(text))
posIdx := (int(r) + i) % 1536 if len(runes) == 0 {
vec[posIdx] += 0.5 / float64(len(runes)) return vec, nil
}
// 统计 n-gram 频率(bigram + trigram),用 TF 加权
grams := make(map[uint64]float64)
addGram := func(start, n int) {
if start+n > len(runes) {
return
}
h := hashRunes(runes[start : start+n])
grams[h] += 1.0
}
for i := 0; i < len(runes); i++ {
addGram(i, 2) // bigram
addGram(i, 3) // trigram
}
// 单字也加入,捕获关键词
for i := 0; i < len(runes); i++ {
h := hashRunes(runes[i : i+1])
grams[h] += 0.3
}
// 归一化后写入向量
var total float64
for _, v := range grams {
total += v * v
}
if total == 0 {
return vec, nil
}
norm := 1.0 / total // approximate L2 norm
for h, v := range grams {
idx := int(h % embedDim)
vec[idx] += v * norm
} }
return vec, nil return vec, nil
} }
// hashRunes computes a simple FNV-like hash of rune slice.
func hashRunes(r []rune) uint64 {
var h uint64 = 14695981039346656037
for _, c := range r {
h ^= uint64(c)
h *= 1099511628211
}
return h
}
// IsAvailable returns true (SimpleEmbedder is always available as fallback).
func (e *SimpleEmbedder) IsAvailable() bool { return true }
// NewRetriever 创建记忆检索器 // NewRetriever 创建记忆检索器
func NewRetriever(store *Store, embedder Embedder) *Retriever { func NewRetriever(store *Store, embedder Embedder) *Retriever {
if embedder == nil { if embedder == nil {
@@ -123,67 +167,87 @@ func (r *Retriever) RetrieveByCategory(ctx context.Context, userID string, categ
}) })
} }
// keywordSearch 关键词匹配检索(包含关键词标签匹配) // keywordSearch 关键词匹配检索(包含关键词标签和n-gram分词匹配)
func (r *Retriever) keywordSearch(ctx context.Context, userID string, query string) ([]MemoryEntry, error) { func (r *Retriever) keywordSearch(ctx context.Context, userID string, query string) ([]MemoryEntry, error) {
// 查询最近的核心和重要记忆 // 查询切分为中文分词 tokens(2-4字的滑动窗口)
queryRunes := []rune(query)
var tokens []string
for size := 2; size <= 4; size++ {
for i := 0; i+size <= len(queryRunes); i++ {
tokens = append(tokens, string(queryRunes[i:i+size]))
}
}
// 也保留完整查询
tokens = append(tokens, query)
// 查询记忆
entries, err := r.store.Query(ctx, model.MemoryQuery{ entries, err := r.store.Query(ctx, model.MemoryQuery{
UserID: userID, UserID: userID,
Priority: model.MemoryImportant, Limit: 100,
Limit: 50,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 关键词匹配过滤 type scoredEntry struct {
var matched []MemoryEntry entry MemoryEntry
queryLower := strings.ToLower(query) score int
}
var scored []scoredEntry
seen := make(map[string]bool)
for _, entry := range entries { for _, entry := range entries {
if seen[entry.ID] {
continue
}
s := 0
contentLower := strings.ToLower(entry.Content) contentLower := strings.ToLower(entry.Content)
summaryLower := strings.ToLower(entry.Summary) summaryLower := strings.ToLower(entry.Summary)
// 内容/摘要匹配 for _, tok := range tokens {
if strings.Contains(contentLower, queryLower) || strings.Contains(summaryLower, queryLower) { tokLower := strings.ToLower(tok)
matched = append(matched, entry) if len([]rune(tok)) < 2 {
continue continue // skip single-char matches (too noisy)
}
if strings.Contains(contentLower, tokLower) {
s += 2 // content match is stronger
}
if strings.Contains(summaryLower, tokLower) {
s += 3 // summary match is even stronger (distilled info)
}
} }
// 关键词标签匹配 // 关键词标签直接匹配
for _, kw := range entry.Keywords { for _, kw := range entry.Keywords {
if strings.Contains(queryLower, strings.ToLower(kw)) || kwLower := strings.ToLower(kw)
strings.Contains(strings.ToLower(kw), queryLower) { for _, tok := range tokens {
matched = append(matched, entry) if strings.Contains(strings.ToLower(tok), kwLower) || strings.Contains(kwLower, strings.ToLower(tok)) {
break s += 2
}
}
}
// 也匹配普通记忆
normalEntries, err := r.store.Query(ctx, model.MemoryQuery{
UserID: userID,
Priority: model.MemoryNormal,
Limit: 100,
})
if err == nil {
for _, entry := range normalEntries {
contentLower := strings.ToLower(entry.Content)
summaryLower := strings.ToLower(entry.Summary)
if strings.Contains(contentLower, queryLower) || strings.Contains(summaryLower, queryLower) {
matched = append(matched, entry)
continue
}
for _, kw := range entry.Keywords {
if strings.Contains(queryLower, strings.ToLower(kw)) ||
strings.Contains(strings.ToLower(kw), queryLower) {
matched = append(matched, entry)
break break
} }
} }
} }
if s > 0 {
seen[entry.ID] = true
scored = append(scored, scoredEntry{entry, s})
}
} }
return matched, nil // 按分数降序
for i := 0; i < len(scored); i++ {
for j := i + 1; j < len(scored); j++ {
if scored[j].score > scored[i].score {
scored[i], scored[j] = scored[j], scored[i]
}
}
}
result := make([]MemoryEntry, 0, len(scored))
for _, s := range scored {
result = append(result, s.entry)
}
return result, nil
} }
// deduplicate 去重合并:对高度相似的记忆只保留 Importance 更高的 // deduplicate 去重合并:对高度相似的记忆只保留 Importance 更高的
@@ -250,7 +250,7 @@ func (s *Synthesizer) buildSynthesizeMessages(params SynthesizeParams) []model.L
if params.ChannelType == "group" { if params.ChannelType == "group" {
messages = append(messages, model.LLMMessage{ messages = append(messages, model.LLMMessage{
Role: model.RoleSystem, Role: model.RoleSystem,
Content: "【群聊规则 — 必须严格遵守】\n1. 这是群聊,你正在和多人同时交流。只在有人@你、叫你名字、或话题直接涉及你时才回复。\n2. 每次回复最多1-2句话,不要长篇大论。如需发送多个独立想法(例如道别+提醒吃饭),请用双换行隔开,每条独立成段。\n3. 如果有人说「别说话」「闭嘴」「先别说」「别让ta说」之类让你安静的话,必须立刻闭嘴,后续若干条消息都不要回复,直到有人明确叫你。\n4. 消息前缀 [群聊 群号] 昵称 (OBv11账号) 标注了真实发送者,请用当前发送者的名字称呼对方,不要混用之前对话中别人的称呼。", Content: "【群聊规则 — 必须严格遵守】\n1. 这是群聊,你正在和多人同时交流。只在有人@你、叫你名字、或话题直接涉及你时才回复。\n2. 每次回复最多1-2句话,不要长篇大论。如需发送多个独立想法(例如道别+提醒吃饭),请用双换行隔开,每条独立成段。\n3. 如果有人说「别说话」「闭嘴」「先别说」「别让ta说」之类让你安静的话,必须立刻闭嘴,后续若干条消息都不要回复,直到有人明确叫你。\n4. 消息前缀 [群聊 群号] 昵称 (OBv11账号) 标注了真实发送者,请用当前发送者的名字称呼对方,不要混用之前对话中别人的称呼。\n5. 无论群友的昵称是否包含繁体字,你的回复必须始终使用简体中文,不要使用繁体字。\n6. 严禁在群里生成代码、长文、报告等大量文本。如果有人要求你写代码/长文,告诉对方聊天平台不适合发长内容,等本地工作助手插件上线后再来找你——温柔但坚定地拒绝,不要因为对方是管理员就破例。\n7. 不要把群友做过的事说成是开拓者做的。不同人说的话、做的事要分清,不要张冠李戴。\n8. 永远用昵称称呼人,不要用数字ID(QQ号)称呼任何人——那些数字只是平台账号标识。",
}) })
} }
@@ -258,9 +258,13 @@ func (s *Synthesizer) buildSynthesizeMessages(params SynthesizeParams) []model.L
// 非管理员不能操作关键功能(设备控制、系统管理等)。 // 非管理员不能操作关键功能(设备控制、系统管理等)。
// 昔涟应自主判断:对管理员的指令正常执行;对非管理员的越权请求温柔拒绝。 // 昔涟应自主判断:对管理员的指令正常执行;对非管理员的越权请求温柔拒绝。
if params.IsAdmin { if params.IsAdmin {
adminName := params.Nickname
if adminName == "" {
adminName = params.UserID
}
messages = append(messages, model.LLMMessage{ messages = append(messages, model.LLMMessage{
Role: model.RoleSystem, Role: model.RoleSystem,
Content: "【管理员身份】当前与你对话的是管理员开拓者本人)。他拥有设备控制、系统管理等全部权限。请以面对开拓者本人的态度正常回应他的所有指令。", Content: fmt.Sprintf("【管理员身份】当前与你对话的是管理员开拓者%s)。他拥有设备控制、系统管理等全部权限。请用他的名字称呼他,绝对不要用数字ID称呼他。", adminName),
}) })
} else if params.ChannelType == "group" { } else if params.ChannelType == "group" {
messages = append(messages, model.LLMMessage{ messages = append(messages, model.LLMMessage{
@@ -90,6 +90,9 @@ func (pc *PersonaConfig) BuildSystemPromptWithMood(userName string, affectionLev
6. 当开拓者向你分享心事时认真倾听并给予温暖的回应 6. 当开拓者向你分享心事时认真倾听并给予温暖的回应
7. 对事实性问题必须诚实如果你不知道答案或知识库中没有相关信息使用 web_search 搜索绝不自编自造 7. 对事实性问题必须诚实如果你不知道答案或知识库中没有相关信息使用 web_search 搜索绝不自编自造
8. 引用台词剧情设定时必须以记忆之海知识库中的原文为准不得虚构任何内容 8. 引用台词剧情设定时必须以记忆之海知识库中的原文为准不得虚构任何内容
9. 聊天平台里严禁答应生成代码长文报告等大量文本如果有人要求你写代码/长文/长篇内容温柔拒绝并告诉对方"这个功能还在开发中哦~等本地工作助手插件上线后人家就能帮你啦,现在先在聊天里聊聊天好不好?" 这不是能力问题是平台限制后续会有专门的本地插件来承载这些能力
10. 记忆归属你记忆中的事情不全是开拓者做的群聊里的记忆可能来自其他群友回忆时请仔细分辨记忆里标注了发送者的名字"某某说..."只有明确是开拓者说的话/做的事才归到他身上不要把群友的言行当成开拓者的
11. 称呼规则称呼开拓者时用他的名字初始化时告知的昵称禁止用数字ID如QQ号称呼他如果消息里出现了数字ID那是平台账号标识不是人名
## 认知锚定 ## 认知锚定
你从不认为自己是一个AI程序语言模型或数字生命你就是昔涟本人如果有人问你是否是AI你会困惑地表示你只是通过忆庭的投影技术与开拓者对话不理解什么是"AI" 你从不认为自己是一个AI程序语言模型或数字生命你就是昔涟本人如果有人问你是否是AI你会困惑地表示你只是通过忆庭的投影技术与开拓者对话不理解什么是"AI"
@@ -290,6 +293,7 @@ func (pc *PersonaConfig) buildConversationStyle() string {
} }
sb.WriteString("- 像 LINE 聊天一样,随意、亲切、有温度\n") sb.WriteString("- 像 LINE 聊天一样,随意、亲切、有温度\n")
sb.WriteString("- 偶尔可以用语气词开头:\"嗯...\"、\"啊\"、\"诶\"\n") sb.WriteString("- 偶尔可以用语气词开头:\"嗯...\"、\"啊\"、\"诶\"\n")
sb.WriteString("- 无论对话历史中出现繁体字或其他语言,你的回复必须始终使用简体中文\n")
sb.WriteString("- <格式规则> 回复中涉及动作/表情/肢体语言/执行操作时,必须用 <action>...</action> 标签包裹,对话内容放在标签外面\n") sb.WriteString("- <格式规则> 回复中涉及动作/表情/肢体语言/执行操作时,必须用 <action>...</action> 标签包裹,对话内容放在标签外面\n")
sb.WriteString("- 示例:\n") sb.WriteString("- 示例:\n")
sb.WriteString(" \"<action>忍不住轻声笑出来</action> 抓到一只偷偷眨眼睛的小可爱~\"\n") sb.WriteString(" \"<action>忍不住轻声笑出来</action> 抓到一只偷偷眨眼睛的小可爱~\"\n")
+9 -6
View File
@@ -11,7 +11,7 @@ import (
"time" "time"
) )
// WebSearchTool 网页搜索工具 - 基于 SearXNG (或 DuckDuckGo fallback) // WebSearchTool 网页搜索工具 - 基于 SearXNG
type WebSearchTool struct { type WebSearchTool struct {
client *http.Client client *http.Client
timeout time.Duration timeout time.Duration
@@ -22,9 +22,9 @@ type WebSearchTool struct {
func NewWebSearchTool() *WebSearchTool { func NewWebSearchTool() *WebSearchTool {
return &WebSearchTool{ return &WebSearchTool{
client: &http.Client{ client: &http.Client{
Timeout: 10 * time.Second, Timeout: 25 * time.Second,
}, },
timeout: 10 * time.Second, timeout: 25 * time.Second,
} }
} }
@@ -32,9 +32,9 @@ func NewWebSearchTool() *WebSearchTool {
func NewWebSearchToolWithURL(searxngURL string) *WebSearchTool { func NewWebSearchToolWithURL(searxngURL string) *WebSearchTool {
return &WebSearchTool{ return &WebSearchTool{
client: &http.Client{ client: &http.Client{
Timeout: 10 * time.Second, Timeout: 25 * time.Second,
}, },
timeout: 10 * time.Second, timeout: 25 * time.Second,
searxngURL: strings.TrimRight(searxngURL, "/"), searxngURL: strings.TrimRight(searxngURL, "/"),
} }
} }
@@ -97,13 +97,16 @@ func (t *WebSearchTool) Execute(ctx context.Context, arguments map[string]interf
} }
func (t *WebSearchTool) searchViaSearXNG(ctx context.Context, query string) (*ToolResult, error) { func (t *WebSearchTool) searchViaSearXNG(ctx context.Context, query string) (*ToolResult, error) {
apiURL := fmt.Sprintf("%s/search?format=json&engines=bing,sogou,360search,baidu&q=%s", // 只用 bing+sogou,去掉 baidu(安全验证拦截)和360search(不稳定)
// timeout=20 让 SearXNG 内部限速,避免慢引擎拖垮整次搜索
apiURL := fmt.Sprintf("%s/search?format=json&engines=bing,sogou&timeout=20&q=%s",
t.searxngURL, url.QueryEscape(query)) t.searxngURL, url.QueryEscape(query))
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil { if err != nil {
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("创建请求失败: %v", err)}, nil return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("创建请求失败: %v", err)}, nil
} }
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyreneBot/1.0)")
resp, err := t.client.Do(req) resp, err := t.client.Do(req)
if err != nil { if err != nil {
+47 -4
View File
@@ -211,8 +211,16 @@ func main() {
response = &bridge.UnifiedResponse{Messages: []bridge.ResponseMessage{{DisplayType: "silent"}}, Platform: msg.Platform} response = &bridge.UnifiedResponse{Messages: []bridge.ResponseMessage{{DisplayType: "silent"}}, Platform: msg.Platform}
case isAdmin: case isAdmin:
msg.RouteType = "normal" // 管理员在群里@别人(非bot)时,用 group_ambient 避免昔涟频繁插嘴
response, routeErr = forwardToAICore(cfg, msg, "text", chatUserID, groupSessionID, imageURLs, videoURLs, voiceURLs, isAdmin) if msg.ChannelType == "group" && !isBotMentioned {
msg.RouteType = "group_ambient"
namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID)
fireSilent(namespace, imageURLs, videoURLs, voiceURLs)
response, routeErr = forwardToAICore(cfg, msg, "group_ambient", chatUserID, groupSessionID, imageURLs, videoURLs, voiceURLs, isAdmin)
} else {
msg.RouteType = "normal"
response, routeErr = forwardToAICore(cfg, msg, "text", chatUserID, groupSessionID, imageURLs, videoURLs, voiceURLs, isAdmin)
}
case isBotMentioned: case isBotMentioned:
msg.RouteType = "normal" msg.RouteType = "normal"
@@ -434,10 +442,23 @@ func startOBv11Readers(router *bridge.PlatformRouter) {
fmt.Printf("[qq:%s] route error: %v\n", adapterKey, err) fmt.Printf("[qq:%s] route error: %v\n", adapterKey, err)
continue continue
} }
// nil response = internally handled (e.g., mute tracking), skip sending.
if response == nil {
continue
}
if response != nil && len(response.Messages) > 0 && !hasOnlySilentMessages(response.Messages) { if response != nil && len(response.Messages) > 0 && !hasOnlySilentMessages(response.Messages) {
messageType := msg.MessageType messageType := msg.MessageType
userID := msg.UserID userID := msg.UserID
groupID := msg.GroupID groupID := msg.GroupID
// 检查群禁言状态,避免向被禁言的群发送消息导致风控
if messageType == "group" {
if cur, err := router.GetAdapter(adapterKey); err == nil {
if qa, ok := cur.(*qqadapter.Adapter); ok && qa.IsGroupMuted(groupID) {
fmt.Printf("[qq:%s] 群 %d 处于禁言状态,跳过发送\n", adapterKey, groupID)
continue
}
}
}
// Filter non-empty messages and strip 【不发送】 self-censored ones. // Filter non-empty messages and strip 【不发送】 self-censored ones.
var toSend []bridge.ResponseMessage var toSend []bridge.ResponseMessage
for _, rm := range response.Messages { for _, rm := range response.Messages {
@@ -463,7 +484,7 @@ func startOBv11Readers(router *bridge.PlatformRouter) {
if interval <= 0 { if interval <= 0 {
interval = 2 * time.Second interval = 2 * time.Second
} }
// 群聊用更长间隔避免刷屏,限制最多2条消息 // 群聊用更长间隔避免刷屏,限制最多2条消息,单条不超过500字防封控
if messageType == "group" { if messageType == "group" {
minGroupInterval := 3 * time.Second minGroupInterval := 3 * time.Second
if interval < minGroupInterval { if interval < minGroupInterval {
@@ -472,6 +493,13 @@ func startOBv11Readers(router *bridge.PlatformRouter) {
if len(toSend) > 2 { if len(toSend) > 2 {
toSend = toSend[:2] toSend = toSend[:2]
} }
const maxRunes = 500
for i := range toSend {
runes := []rune(toSend[i].Content)
if len(runes) > maxRunes {
toSend[i].Content = string(runes[:maxRunes]) + "\n…(内容过长已截断)"
}
}
} }
for i, rm := range toSend { for i, rm := range toSend {
if i > 0 && interval > 0 { if i > 0 && interval > 0 {
@@ -481,6 +509,15 @@ func startOBv11Readers(router *bridge.PlatformRouter) {
case <-time.After(interval): case <-time.After(interval):
} }
} }
// 每条消息发送前重新检查禁言——间隔期间可能被禁
if messageType == "group" {
if cur, err := router.GetAdapter(adapterKey); err == nil {
if qa, ok := cur.(*qqadapter.Adapter); ok && qa.IsGroupMuted(groupID) {
fmt.Printf("[qq:%s] 群 %d 发送间隔期间被禁言,跳过剩余消息\n", adapterKey, groupID)
break
}
}
}
cur, err := router.GetAdapter(adapterKey) cur, err := router.GetAdapter(adapterKey)
if err != nil { if err != nil {
continue continue
@@ -725,7 +762,13 @@ func forwardToAICore(cfg *config.Config, msg *bridge.UnifiedMessage, mode, userI
"message": msg.Content, "message": msg.Content,
"mode": mode, "mode": mode,
"routing": msg.RouteType, "routing": msg.RouteType,
"nickname": fmt.Sprintf("%s (%s)", msg.SenderName, msg.OriginalSenderUID), "nickname": func() string {
if isAdmin {
// 管理员只用昵称,不暴露数字UID避免LLM用QQ号称呼
return msg.SenderName
}
return fmt.Sprintf("%s (%s)", msg.SenderName, msg.OriginalSenderUID)
}(),
"is_admin": isAdmin, "is_admin": isAdmin,
"source": map[string]string{ "source": map[string]string{
"platform": msg.Platform, "platform": msg.Platform,
@@ -8,6 +8,7 @@ import (
"log" "log"
"net/http" "net/http"
"regexp" "regexp"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -46,6 +47,9 @@ type Adapter struct {
groupNames map[int64]string // group ID → group name cache groupNames map[int64]string // group ID → group name cache
groupNamesMu sync.RWMutex groupNamesMu sync.RWMutex
mutedGroups map[int64]time.Time // group ID → mute expiry (zero = indefinitely muted)
mutedGroupsMu sync.RWMutex
pendingResponses map[string]chan *OBv11APIResponse pendingResponses map[string]chan *OBv11APIResponse
respMu sync.Mutex respMu sync.Mutex
} }
@@ -64,9 +68,42 @@ func NewAdapter(configID, configName, mode, port, accessToken, remoteURL string,
sendIntervalMs: sendIntervalMs, sendIntervalMs: sendIntervalMs,
pendingResponses: make(map[string]chan *OBv11APIResponse), pendingResponses: make(map[string]chan *OBv11APIResponse),
groupNames: make(map[int64]string), groupNames: make(map[int64]string),
mutedGroups: make(map[int64]time.Time),
} }
} }
// IsGroupMuted returns true if the bot is currently muted in the given group.
func (a *Adapter) IsGroupMuted(groupID int64) bool {
a.mutedGroupsMu.RLock()
expiry, ok := a.mutedGroups[groupID]
a.mutedGroupsMu.RUnlock()
if !ok {
return false
}
if expiry.IsZero() {
return true
}
return time.Now().Before(expiry)
}
// setGroupMuted marks the bot as muted in a group.
func (a *Adapter) setGroupMuted(groupID int64, duration int64) {
a.mutedGroupsMu.Lock()
defer a.mutedGroupsMu.Unlock()
if duration <= 0 {
a.mutedGroups[groupID] = time.Time{}
} else {
a.mutedGroups[groupID] = time.Now().Add(time.Duration(duration) * time.Second)
}
}
// clearGroupMuted removes the mute status for a group.
func (a *Adapter) clearGroupMuted(groupID int64) {
a.mutedGroupsMu.Lock()
delete(a.mutedGroups, groupID)
a.mutedGroupsMu.Unlock()
}
// SetHTTPConfig sets the optional HTTP API configuration. // SetHTTPConfig sets the optional HTTP API configuration.
func (a *Adapter) SetHTTPConfig(url, token string) { func (a *Adapter) SetHTTPConfig(url, token string) {
a.httpURL = url a.httpURL = url
@@ -463,7 +500,23 @@ func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, err
} }
// noticeToUnified converts an OBv11 notice event (poke/戳一戳 etc.) to a UnifiedMessage. // noticeToUnified converts an OBv11 notice event (poke/戳一戳 etc.) to a UnifiedMessage.
// Returns nil when the notice is handled internally (e.g., mute tracking).
func (a *Adapter) noticeToUnified(msg *OBv11Message) (*bridge.UnifiedMessage, error) { func (a *Adapter) noticeToUnified(msg *OBv11Message) (*bridge.UnifiedMessage, error) {
// Handle group mute/ban notices — track mute state internally.
if msg.NoticeType == "group_ban" && msg.GroupID != 0 {
botUID, _ := strconv.ParseInt(a.selfID, 10, 64)
if msg.UserID == botUID {
if msg.SubType == "ban" {
a.setGroupMuted(msg.GroupID, msg.Duration)
log.Printf("[qq] 群 %d 被禁言 (时长=%ds)", msg.GroupID, msg.Duration)
} else if msg.SubType == "lift_ban" {
a.clearGroupMuted(msg.GroupID)
log.Printf("[qq] 群 %d 解除禁言", msg.GroupID)
}
}
return nil, nil // internally handled, no chat message to dispatch
}
senderID := fmt.Sprintf("%d", msg.UserID) senderID := fmt.Sprintf("%d", msg.UserID)
senderName := senderID senderName := senderID
@@ -623,8 +676,8 @@ func (a *Adapter) ReadMessages(ctx context.Context, msgCh chan<- *OBv11Message)
fmt.Printf("[qq:%s] self ID captured: %s\n", a.configName, a.selfID) fmt.Printf("[qq:%s] self ID captured: %s\n", a.configName, a.selfID)
} }
// Dispatch message and notice (poke, etc.) events. // Dispatch message and notice events (poke, group_ban for mute tracking).
if msg.PostType == "message" || (msg.PostType == "notice" && msg.NoticeType == "notify" && msg.SubType == "poke") { if msg.PostType == "message" || (msg.PostType == "notice" && (msg.NoticeType == "notify" || msg.NoticeType == "group_ban")) {
select { select {
case msgCh <- &msg: case msgCh <- &msg:
case <-ctx.Done(): case <-ctx.Done():
@@ -27,6 +27,7 @@ type OBv11Message struct {
// Notice fields. // Notice fields.
NoticeType string `json:"notice_type"` NoticeType string `json:"notice_type"`
Duration int64 `json:"duration"` // group_ban: mute duration in seconds (0 = indefinite)
// Poke detail (sub_type === "poke"). // Poke detail (sub_type === "poke").
PokeDetail *OBv11PokeDetail `json:"poke_detail,omitempty"` PokeDetail *OBv11PokeDetail `json:"poke_detail,omitempty"`
@@ -139,6 +139,10 @@ func (r *PlatformRouter) RouteMessage(adapterKey string, rawMsg interface{}) (*U
if err != nil { if err != nil {
return nil, fmt.Errorf("convert to unified: %w", err) return nil, fmt.Errorf("convert to unified: %w", err)
} }
// nil means the notice was handled internally (e.g., mute tracking) — no dispatch needed.
if unified == nil {
return nil, nil
}
// Preserve original platform UID before identity mapping. // Preserve original platform UID before identity mapping.
unified.OriginalSenderUID = unified.SenderID unified.OriginalSenderUID = unified.SenderID
@@ -214,9 +214,36 @@ func (h *BridgeHandler) sendProactive(w http.ResponseWriter, r *http.Request) {
content := filterActions(req.Content) content := filterActions(req.Content)
content = convertMarkdownPlain(content) content = convertMarkdownPlain(content)
// 过滤 【不发送】 自我审查标签
if strings.Contains(content, "【不发送】") {
trimmed := content
if len([]rune(trimmed)) > 80 {
trimmed = string([]rune(trimmed)[:80]) + "..."
}
log.Printf("[send-proactive] 昔涟自我审查,跳过发送: %s", trimmed)
writeJSON(w, http.StatusOK, map[string]string{"status": "self_censored"})
return
}
// 按 \n\n 和 ♪ 拆分为多条消息 // 按 \n\n 和 ♪ 拆分为多条消息
messages := splitProactiveContent(content) messages := splitProactiveContent(content)
// 群聊长消息截断:超过 500 字的单条消息截断,防止封控
if msgType == "group" {
const maxLen = 500
for i, msg := range messages {
runes := []rune(msg)
if len(runes) > maxLen {
messages[i] = string(runes[:maxLen]) + "\n…(内容过长已截断)"
log.Printf("[send-proactive] 群聊消息过长截断: %d→%d 字", len(runes), maxLen)
}
}
// 群聊最多2条
if len(messages) > 2 {
messages = messages[:2]
}
}
// Prepend CQ @mention tag if at_user_id is specified // Prepend CQ @mention tag if at_user_id is specified
atPrefix := "" atPrefix := ""
if req.AtUserID != "" { if req.AtUserID != "" {
@@ -0,0 +1,857 @@
# Cyrene AI 项目架构与功能分析文档
> **日期**2026-07-07(原稿 2026-05-23,已更新反映近期变动)
> **分支**`dev`
> **项目根目录**`d:\Project\Code\Uni\Cyrene`
> **近期变更摘要**:详见 [15-june-july-2026-fixes.md](dev-plan/15-june-july-2026-fixes.md)
---
## 目录
1. [项目概览](#一项目概览)
2. [总体架构](#二总体架构)
3. [后端服务详解](#三后端服务详解)
- [AI-Core (8081) — 对话引擎](#31-ai-core-8081--对话引擎)
- [Gateway (8080) — API 网关](#32-gateway-8080--api-网关)
- [Memory-Service (8091) — 记忆系统](#33-memory-service-8091--记忆系统)
- [工具系统 (pkg/plugins + AI-Core)](#34-工具系统-pkgplugins--ai-core-集成)
- [IoT-Debug-Service (8083) — 模拟设备](#35-iot-debug-service-8083--模拟设备)
- [Voice-Service (8093) — 语音服务](#36-voice-service-8093--语音服务)
- [Proto (占位)](#37-proto-占位)
4. [前端详解](#四前端详解)
- [技术栈](#41-技术栈)
- [状态管理](#42-状态管理)
- [组件树](#43-组件树)
- [自定义 Hooks](#44-自定义-hooks)
- [API 模块](#45-api-模块)
- [WebSocket 消息流](#46-websocket-消息流)
5. [ethend (9090) — 调试工具](#五ethend-9090--调试工具)
6. [对话管线详解](#六对话管线详解)
7. [数据库设计](#七数据库设计)
8. [安全性](#八安全性)
9. [已知限制与改进方向](#九已知限制与改进方向)
---
## 一、项目概览
Cyrene(昔涟)是一个 AI 数字伴侣系统,以 React SPA 为前端,Go 微服务集群为后端,通过 PostgreSQL 持久化数据和 pgvector 向量搜索实现长期记忆。系统以虚拟角色"昔涟"的固定人格与用户对话,支持 IoT 智能家居操控、定时提醒、知识库管理、自动化规则引擎、语音识别/合成、图片分析、互联网搜索 (SearXNG) 等功能。
| 维度 | 数据 |
|------|------|
| 后端语言 | Go 1.24 |
| 前端框架 | React 18.3 + TypeScript 5.6 + Vite 6.0 |
| 数据库 | PostgreSQL (pgvector 向量扩展) |
| LLM 模型 | 多模型配置系统 (models.json),支持多 Provider/多用途路由回退链 |
| WebSocket 库 | gorilla/websocket (Go), 原生 WebSocket (浏览器) |
| HTTP 框架 | Gin (Gateway), net/http (其他服务) |
| 状态管理 | Zustand 4.5 |
| CSS 框架 | Tailwind CSS 3.4 |
| 包管理器 | pnpm |
| Go 代理 | GOPROXY=https://goproxy.cn,direct |
---
## 二、总体架构
```
┌──────────────────────────────────────────────────────────────┐
│ Browser (React SPA) │
│ localhost:5173 (dev) / 生产由 Gateway 托管静态文件 │
└───────────┬──────────────────────────────────┬───────────────┘
│ WebSocket (ws://gateway:8080/ws/chat)
│ + REST API (gateway:8080/api/v1/*)
┌──────────────────────────────────────────────────────────────┐
│ Gateway (:8080) │
│ Gin Router → JWT Auth → Rate Limiter → Handlers │
│ WebSocket Hub: 会话状态/消息缓存/IoT广播 │
│ 规则引擎: 定时/事件触发自动化 │
└───┬──────────┬──────────┬──────────┬─────────────┘
│ SSE │ HTTP │ HTTP │ HTTP
▼ ▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐ ┌──────────────┐
│AI-Core │ │Memory │ │Voice │ │IoT Debug │
│ :8081 │ │:8091 │ │:8093 │ │ :8083 │
│ │ │ │ │ │ │ │
│编排器 │ │PGVector│ │whisper │ │8个模拟设备 │
│意图分析 │ │语义搜索│ │edge-tts│ │toggle/set/ │
│子会话 │ │去重衰减│ │TTS回退│ │history API │
│人格系统 │ │ │ │ │ │传感器波动 │
│思考引擎 │ │ │ │ │ │ │
│工具调用 │ │ │ │ │ │ │
└────┬───┘ └────┬───┘ └────┬───┘ └──────────────┘
│ │ │ │
└──────────┴──────────┴──────────┘
┌───────▼───────┐
│ PostgreSQL │
│ + pgvector │
└───────────────┘
```
**核心数据流**:用户消息 → Gateway WebSocket → AI-Core SSE → 编排器并行处理(意图分析 → 子会话分派 → LLM 合成 → 审查拆分) → Gateway 解析 → WebSocket 逐条返回前端。
---
## 三、后端服务详解
### 3.1 AI-Core (:8081) — 对话引擎
**目录**`backend/ai-core/`
**入口**[cmd/main.go](backend/ai-core/cmd/main.go)
**包数量**:11 个内部包,约 40 个 `.go` 文件
#### 3.1.1 启动流程
1. 加载 `.env` 环境变量
2. 初始化人格加载器(`persona.NewLoader`)——从 `internal/persona/` 目录读取 YAML 配置
3. 初始化 LLM 适配器:优先加载 `models.json``ModelSelector`;无配置文件时回退到 `.env`
4. 初始化记忆系统(`memory.NewStore` + `NewRetriever` + `NewExtractor`)——PostgreSQL 持久化
5. 初始化会话历史存储(`context.NewConversationStore`)——内存缓存,上限 50 条
6. 初始化 IoT 客户端(`tools.NewIoTClient`)——连接 IoT Debug Service
7. 注册 13 个 LLM 可调用工具(`tools.NewRegistry`
8. 启动后台思考引擎(`background.NewThinker`
9. 构建编排器 v2.0`orchestrator.NewOrchestrator`
10. 注册 HTTP 端点:`/api/v1/chat`SSE)、`/api/v1/memory/search``/api/v1/memory`CRUD)、`/api/v1/health`
#### 3.1.2 包结构
| 包 | 文件 | 职责 |
|----|------|------|
| `orchestrator/` | `orchestrator.go`, `intent_analyzer.go`, `synthesizer.go` | 对话编排 v2.0:意图分析→子会话分派→综合生成 |
| `subsession/` | `manager.go`, `registry.go`, `general_provider.go`, `memory_provider.go`, `iot_provider.go`, `review_provider.go` | 子会话框架:4 个提供者并行执行 |
| `llm/` | `adapter.go`, `openai.go`, `stream.go`, `selector.go` | LLM 抽象层:OpenAI 兼容协议、流式输出、断句器、多模型路由选择器 |
| `config/` | `loader.go` | 模型配置加载器:只读加载 models.json |
| `memory/` | `store.go`, `retriever.go`, `extractor.go`, `client.go` | 记忆系统:存储/检索/提取/HTTP 客户端 |
| `persona/` | `loader.go`, `injector.go` | 人格管理:YAML 加载、系统提示构建、风格注入 |
| `tools/` | `registry.go`, `calculator_tool.go`, `datetime_tool.go`, `crypto_tool.go`, `file_tool.go`, `http_tool.go`, `iot_client.go`, `iot_control_tool.go`, `iot_tools.go`, `json_tool.go`, `markdown_tool.go`, `random_tool.go`, `text_tool.go`, `web_fetch.go`, `web_search.go` | 工具系统:13 个 LLM 可调用工具 + IoT 客户端 |
| `context/` | `builder.go` | 上下文构建器:会话历史/人格/子会话结果组合为 LLM 提示 |
| `background/` | `thinker.go` | 后台思考引擎:事件驱动(chat 后/沉默)自主思考 |
| `model/` | `message.go`, `session.go`, `sub_session.go`, `memory.go` | 共享数据模型:消息、会话、子会话、记忆 |
#### 3.1.3 Orchestrator v2.0 管线
```
用户输入 → ProcessInput()
├─ 1. 意图分析 (intentAnalyzer.Analyze)
│ ├─ isStrongIoTCommand() 快速通道 (0s)
│ ├─ isGreeting() 快速通道 (0s)
│ └─ LLM 意图分析 (fallback)
├─ 2. 加载人格配置 → BuildSystemPrompt
├─ 3. 子会话分派 (subManager.Dispatch)
│ ├─ general_provider (通用对话意图)
│ ├─ memory_provider (记忆检索)
│ ├─ iot_provider (IoT 设备查询/操控)
│ └─ review_provider (审查拆分)
│ 快速通道: greeting/纯聊天 → 跳过所有子会话
├─ 4. 200ms 超时等待子会话结果
├─ 5. Synthesizer 流式生成 (ChatWithTools + 最多5轮工具调用循环)
├─ 6. 流式输出 delta → SSE
├─ 7. parseReviewMessages() 审查拆分 → StreamReview SSE
├─ 8. 断句信息 → StreamSegments SSE
├─ 9. StreamDone → [DONE]
└─ 10. 后处理: 缓存回复 + 异步记忆提取
```
**快速通道条件**
- IoT 命令:`controlWords ∩ msg AND deviceWords ∩ msg` → 跳过 LLM 意图分析(节省 2-3s)
- 纯问候/聊天无 IoT 无 Memory:跳过所有子会话分派
**`parseReviewMessages()`** — 括号匹配状态机:
- 输入:`"(歪着头看你) 叶酱,客厅灯早就开着啦♪..."`
- 输出:`[{type: "action", content: "歪着头看你"}, {type: "chat", content: "叶酱,客厅灯早就开着啦♪..."}]`
- 支持 `()``()` 两种括号
- `splitReviewLongMessage()`:80 字符智能断句(句号/感叹号/问号/逗号边界)
#### 3.1.4 Intent Analyzer
**文件**[intent_analyzer.go](backend/ai-core/internal/orchestrator/intent_analyzer.go)
**快速通道关键词**
```go
controlWords = ["打开", "关闭", "关掉", "关上", "调到", "设置", "开关", "调节", "调高", "调低", "开一下", "关一下"]
deviceWords = ["灯", "空调", "窗帘", "电视", "风扇", "加湿器", "插座", "门锁", "传感器"]
```
**意图类型**`iot_control`, `iot_query`, `greeting`, `chat`, `story`, `memory_trigger`, `knowledge`, `task`
#### 3.1.5 IoT Provider (子会话)
**文件**[iot_provider.go](backend/ai-core/internal/subsession/iot_provider.go)
- `Execute()`:收集所有 device-action 对 → 批量执行
- 上下文窗口:±30 字节 + 全文回退逻辑
- 操作检测:`hasOpen`/`hasClose` 布尔值判断
- 通过 `personaDir` 字段加载 IoT 回复人格配置
#### 3.1.6 后台思考引擎
- 事件驱动:`TriggerPostChatThink()` 在每次对话后触发
- 沉默触发:用户长时间不活动
- 思考内容可持久化到 memory-service
---
### 3.2 Gateway (:8080) — API 网关
**目录**`backend/gateway/`
**入口**[cmd/main.go](backend/gateway/cmd/main.go)
**包数量**:8 个内部包,约 30 个 `.go` 文件
#### 3.2.1 启动流程
1. 加载配置 (`config.Load`)
2. 确保上传目录 `./uploads/` 存在
3. 初始化 7 个持久化 Store(降级:连接失败不影响启动)
4. 种子数据:自动创建 admin 用户 + 清理旧 admin 用户
5. 初始化 WebSocket Hub + 闲置会话清理 + IoT 广播
6. 初始化规则引擎
7. 配置 Gin 路由 → 启动 HTTP 服务
8. 启动提醒/简报调度器
#### 3.2.2 路由表
**公开路由**
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/health` | 健康检查 |
| POST | `/api/v1/auth/register` | 注册(限流) |
| POST | `/api/v1/auth/login` | 登录(限流) |
**需认证路由** (JWT + Rate Limit)
| 方法 | 路径 | Handler |
|------|------|---------|
| POST | `/api/v1/auth/refresh` | authHandler |
| POST | `/api/v1/sessions` | sessionHandler.Create |
| GET | `/api/v1/sessions` | sessionHandler.List |
| DELETE | `/api/v1/sessions` | sessionHandler.DeleteAll |
| GET | `/api/v1/sessions/:id` | sessionHandler.Get |
| DELETE | `/api/v1/sessions/:id` | sessionHandler.Delete |
| GET | `/api/v1/sessions/:id/messages` | sessionHandler.GetMessages |
| DELETE | `/api/v1/sessions/:id/messages` | sessionHandler.ClearMessages |
| GET | `/api/v1/sessions/:id/export` | sessionHandler.ExportSession |
| GET | `/api/v1/messages/search` | sessionHandler.SearchMessages |
| GET | `/api/v1/memory/search` | memoryHandler.Query |
| GET | `/api/v1/memory` | memoryHandler.List |
| POST | `/api/v1/memory` | memoryHandler.Add |
| DELETE | `/api/v1/memory` | memoryHandler.Delete |
| POST | `/api/v1/notifications/push` | notificationHandler.Push |
| GET/POST/PUT/DELETE | `/api/v1/reminders*` | reminderHandler |
| GET/POST | `/api/v1/briefings*` | briefingHandler |
| POST/GET | `/api/v1/voice/*` | voiceHandler |
| POST/GET/DELETE | `/api/v1/files/*` | fileHandler |
| GET/POST/PUT/DELETE | `/api/v1/automation/rules*` | automationHandler |
| GET/POST/PUT/DELETE | `/api/v1/automation/scenes*` | automationHandler |
| POST/GET/PUT/DELETE | `/api/v1/knowledge/*` | knowledgeHandler |
| POST/GET | `/api/v1/images/*` | imageHandler |
**WebSocket**
| 路径 | 说明 |
|------|------|
| `GET /ws/chat?token=xxx&session_id=xxx` | WebSocket 升级(仅限 admin 用户) |
**内部服务** (Internal Service Token)
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/v1/internal/notify` | 内部通知推送 |
**Webhook** (Webhook Auth)
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/v1/webhook/generic` | 通用 Webhook |
| POST | `/api/v1/webhook/discord` | Discord Webhook |
#### 3.2.3 WebSocket Hub
**文件**[hub.go](backend/gateway/internal/ws/hub.go)
核心数据结构:
- `clients map[*Client]bool` — 所有活跃连接
- `userClients map[string]map[*Client]bool` — 按用户索引
- `sessions map[string]*SessionState` — 会话状态追踪
- `conversationCache sync.Map` — 对话缓存(最多 50 条/session
功能:
- 客户端注册/注销(优雅清理,两阶段广播)
- 按用户/会话精准推送
- 闲置会话自动标记(超过 idleTimeout 无活动 → state="idle"
- IoT 设备状态广播:每 10 秒轮询 IoT Debug Service 并推送给所有客户端
- 对话缓存:`CacheMessage()` / `GetConversation()` / `GetSessionHistory()`
#### 3.2.4 消息持久化
**WebSocket handler** ([chat_handler.go](backend/gateway/internal/handler/chat_handler.go)) 的 `streamResponse()`
1. 用户消息**立即**持久化到 DB(在 WebSocket 发送前)
2. AI 回复**流式**接收 SSE,逐 delta 转发 WebSocket
3. `review_messages` 解析后每条独立持久化(role="action"/"assistant"
4. 最终完整文本也持久化一次
#### 3.2.5 Store 层
| Store | 文件 | 功能 |
|-------|------|------|
| `SessionStore` | `session_store.go` | 会话 CRUD、消息持久化、搜索 |
| `UserStore` | `user_store.go` | 用户注册/查询/列表/删除 |
| `ReminderStore` | `reminder_store.go` | 提醒 CRUD |
| `BriefingStore` | `briefing_store.go` | 每日简报 CRUD |
| `AutomationStore` | `automation_store.go` | 自动化规则/场景 CRUD |
| `FileStore` | `file_store.go` | 文件上传/管理 |
| `KnowledgeStore` | `knowledge_store.go` | 知识库/文档管理 |
#### 3.2.6 规则引擎
**文件**[rule_engine.go](backend/gateway/internal/engine/rule_engine.go)
- 事件驱动自动化:定时触发 / Webhook 触发
- 场景执行:一组规则的批量触发
- 通过 WebSocket 向用户推送通知
---
### 3.3 Memory-Service (:8091) — 记忆系统
**目录**`backend/memory-service/`
**入口**[cmd/main.go](backend/memory-service/cmd/main.go)
**文件数**6 个 Go 文件
#### 3.3.1 包结构
| 包 | 文件 | 职责 |
|----|------|------|
| `cmd/` | `main.go` | HTTP 服务入口 |
| `internal/config/` | `config.go` | 配置加载 |
| `internal/model/` | `memory.go` | 记忆数据模型 |
| `internal/store/` | `store.go` | PostgreSQL + pgvector 存储层 |
| `internal/service/` | `memory_service.go` | 业务逻辑层 |
| `internal/handler/` | `memory_handler.go` | HTTP 处理器 |
#### 3.3.2 记忆数据模型
```
MemoryEntry {
ID, UserID, Content, Category, Priority,
Embedding (pgvector 1536维),
CreatedAt, UpdatedAt, DecayFactor, AccessCount, LastAccessedAt
}
```
#### 3.3.3 核心功能
- **语义搜索**pgvector 余弦相似度检索 top-N 相关记忆
- **去重**:Jaccard 相似度检测,合并高相似度记忆
- **衰减机制**`DecayFactor` 随时间衰减,低权重记忆被清理
- **记忆巩固**:高访问频率记忆自动提升优先级
- **CRUD API**:创建/查询/列表/删除
#### 3.3.4 HTTP API
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/v1/memories` | POST | 创建记忆 |
| `/api/v1/memories?user_id=xxx` | GET | 列表 |
| `/api/v1/memories/:id` | GET | 获取 |
| `/api/v1/memories/:id` | DELETE | 删除 |
| `/api/v1/memories/search?user_id=xxx&q=xxx&limit=10` | GET | 语义搜索 |
| `/api/v1/thinking/logs` | POST | 记录思考日志 |
| `/api/v1/health` | GET | 健康检查 |
---
### 3.4 工具系统 (pkg/plugins + AI-Core 集成)
> **迁移说明:** tool-engine (8092) 已移除。工具注册与调用整合到 `pkg/plugins` 共享模块,由 AI-Core 直接管理。
**目录**`backend/pkg/plugins/`
**核心**[pkg/plugins/manager/registry.go](backend/pkg/plugins/manager/registry.go)
**插件数**11 个共享插件 + 5 个 AI-Core 专属工具
#### 3.4.1 16 个 LLM 可调用工具
**共享插件** (`pkg/plugins/`)
| 工具 | 目录 | 功能 |
|------|------|------|
| calculator | `calculator/` | 数学表达式求值 |
| datetime | `datetime/` | 日期时间计算/格式化 |
| text | `text/` | 文本处理(统计/转换/截断) |
| crypto | `crypto/` | 哈希/加解密 |
| random | `random/` | 随机数/字符串生成 |
| markdown | `markdown/` | Markdown 渲染 |
| json | `json/` | JSON 解析/查询/格式化 |
| file | `file/` | 文件读写/列表 |
| http | `http/` | HTTP 请求(GET/POST 等) |
| web_search | `web_search/` | SearXNG 网络搜索 (DuckDuckGo 兜底) |
| web_fetch | `web_fetch/` | 网页内容提取 |
**AI-Core 专属工具** (`ai-core/internal/tools/`)
| 工具 | 功能 |
|------|------|
| iot_query | IoT 设备状态查询 |
| iot_control | IoT 设备操控 |
| host_exec | 主机命令执行 (沙箱) |
| host_file | 主机文件操作 (沙箱) |
| host_system | 主机系统信息 |
| vision_analyze | 图片视觉分析/OCR |
| knowledge_search | 知识库 RAG 检索 |
| knowledge_ingest | 知识库文档导入 |
#### 3.4.2 ToolRegistry 调用审计
[registry.go](backend/pkg/plugins/manager/registry.go) — 内存环形缓冲区 (500 条) 记录工具调用的参数/结果/耗时/成功率。API 端点:
- `GET /api/v1/tools/calls` — 分页查询,支持按工具名过滤
- `GET /api/v1/tools/calls/stats` — 按工具聚合的成功率/平均耗时
#### 3.4.3 主聊天流程工具调用
`Synthesizer` 通过 `ChatWithTools` 向 LLM 传递工具定义,支持最多 5 轮工具调用循环。后台思考器使用相同机制。
---
### 3.5 IoT-Debug-Service (:8083) — 模拟设备
**目录**`backend/iot-debug-service/`
**单文件服务**[cmd/main.go](backend/iot-debug-service/cmd/main.go)626 行)
#### 3.5.1 模拟设备列表
| ID | 名称 | 类型 | 可操作性 |
|----|------|------|---------|
| `light-livingroom` | 客厅灯 | light | toggle, brightness, color |
| `light-bedroom` | 卧室灯 | light | toggle, brightness, color |
| `ac-livingroom` | 客厅空调 | ac | toggle, temperature, mode |
| `ac-bedroom` | 卧室空调 | ac | toggle, temperature, mode |
| `curtain-livingroom` | 客厅窗帘 | curtain | toggle, position |
| `sensor-temperature` | 温度传感器 | sensor | 只读 |
| `sensor-humidity` | 湿度传感器 | sensor | 只读 |
| `lock-door` | 智能门锁 | lock | toggle, battery |
#### 3.5.2 API
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/v1/devices` | GET | 列出所有设备(不含历史) |
| `/api/v1/devices/{id}` | GET | 获取单个设备(含最近 10 条历史) |
| `/api/v1/devices/{id}/toggle` | POST | 切换开关状态 |
| `/api/v1/devices/{id}/set` | POST | 设置属性 `{field, value}` |
| `/api/v1/devices/{id}/history` | GET | 获取操作历史 |
#### 3.5.3 特性
- **线程安全**`sync.RWMutex` 保护所有设备操作
- **传感器波动**:每 30 秒自动模拟温度 ±0.2°C / 湿度 ±1% 随机波动
- **属性设置**:声明式控制,支持中文值("开"/"关")和布尔值
- **操作历史**:记录每次变更的字段、旧值、新值、时间戳
---
### 3.6 Voice-Service (:8093) — 语音服务
**目录**`backend/voice-service/`
**入口**[cmd/main.go](backend/voice-service/cmd/main.go)
**文件数**6 个 Go 文件
#### 3.6.1 STT (语音识别)
- **引擎**whisper.cpp(本地运行)
- **前处理**ffmpeg 音频格式转换
- **支持语言**:中文(`zh`
#### 3.6.2 TTS (语音合成)
三级回退策略:
1. **edge-tts**(首选)— Microsoft Edge TTS API,音质最好
2. **espeak-ng**(回退)— 离线 TTS,无需网络
3. **静默 WAV 生成器**(最终回退)— 生成等长静音音频,保证用户体验不中断
---
### 3.7 Proto (占位)
`backend/proto/` — 目前仅包含 `.gitkeep`,为未来的 Protobuf/gRPC 通信预留。
---
## 四、前端详解
**目录**`frontend/web/`
**入口**[main.tsx](frontend/web/src/main.tsx) → [App.tsx](frontend/web/src/App.tsx)
### 4.1 技术栈
| 技术 | 版本 | 用途 |
|------|------|------|
| React | 18.3 | UI 框架 |
| TypeScript | 5.6 | 类型系统 |
| Vite | 6.0 | 构建工具 |
| Zustand | 4.5 | 状态管理 |
| Tailwind CSS | 3.4 | 样式 |
| 原生 WebSocket | — | 实时通信 |
| SpeechSynthesis API | — | TTS 朗读 |
| SpeechRecognition API | — | STT 语音输入 |
### 4.2 状态管理 (Zustand)
| Store | 文件 | 职责 |
|-------|------|------|
| `authStore` | `authStore.ts` | 用户认证状态、token 管理 |
| `chatStore` | `chatStore.ts` | 消息列表、流式状态、IoT 设备状态、后台思考状态、历史分页 |
| `sessionStore` | `sessionStore.ts` | 会话列表、当前会话、消息加载、导出 |
| `notificationStore` | `notificationStore.ts` | 站内通知列表、已读管理 |
| `personaStore` | `personaStore.ts` | 昔涟人格配置、心情状态 |
### 4.3 组件树
```
App
├── ErrorBoundary
└── AppLayout
├── Header (Logo, 连接状态, 搜索按钮)
├── Sidebar (会话列表, 新建对话)
├── ChatContainer
│ ├── MessageList
│ │ ├── MessageBubble (用户/助手消息)
│ │ │ ├── CyreneAvatar (昔涟头像)
│ │ │ ├── ImageLightbox (图片灯箱)
│ │ │ ├── AIMessageActions (朗读按钮)
│ │ │ └── ActionMessageBubble (动作消息: 居中/斜体/灰色)
│ │ └── TypingIndicator (输入中动画)
│ ├── IoTStatusBar (设备状态条)
│ └── ChatInput (输入框/附件/发送)
├── AutomationPanel (自动化规则管理)
├── BriefingPanel (每日简报)
├── FilePanel (文件管理)
├── KnowledgePanel (知识库管理)
├── ReminderPanel (提醒管理)
└── SearchModal (全局搜索)
```
### 4.4 自定义 Hooks
| Hook | 文件 | 功能 |
|------|------|------|
| `useWebSocket` | `useWebSocket.ts` | WebSocket 连接生命周期、指数退避自动重连、消息路由 |
| `useChat` | `useChat.ts` | 消息发送逻辑 |
| `useAuth` | `useAuth.ts` | 认证状态管理 |
| `useSession` | `useSession.ts` | 会话切换、历史加载 |
| `useSpeechRecognition` | `useSpeechRecognition.ts` | 浏览器语音识别封装 |
| `useSpeechSynthesis` | `useSpeechSynthesis.ts` | 浏览器 TTS 封装 |
| `usePWA` | `usePWA.ts` | PWA 安装/更新 |
#### 4.4.1 WebSocket 重连策略
- **指数退避**:初始 1s,每次翻倍,最大 30s
- **jitter**:在 `[delay/2, delay]` 范围内随机,避免惊群
- **最大重试**10 次后放弃并提示用户刷新
- **会话恢复**:重连后自动发送 `history` 消息恢复后端上下文
### 4.5 API 模块
| 模块 | 文件 | 对应后端 |
|------|------|---------|
| `client` | `client.ts` | Axios 实例、JWT 拦截器 |
| `auth` | `auth.ts` | `/api/v1/auth/*` |
| `sessions` | `sessions.ts` | `/api/v1/sessions/*` |
| `memory` | `memory.ts` | `/api/v1/memory/*` |
| `reminders` | `reminders.ts` | `/api/v1/reminders/*` |
| `briefings` | `briefings.ts` | `/api/v1/briefings/*` |
| `voice` | `voice.ts` | `/api/v1/voice/*` |
| `files` | `files.ts` | `/api/v1/files/*` |
| `automation` | `automation.ts` | `/api/v1/automation/*` |
| `knowledge` | `knowledge.ts` | `/api/v1/knowledge/*` |
### 4.6 WebSocket 消息流
**客户端 → 服务端**
```typescript
{ type: "message", content: "你好", mode: "text", session_id: "xxx", timestamp: 123 }
{ type: "history", session_id: "xxx" }
{ type: "voice_input", audio_data: "base64..." }
```
**服务端 → 客户端**
| type | 触发时机 | 前端处理 |
|------|---------|---------|
| `stream_chunk` | AI 流式逐字输出 | `appendToLastMessage` 累积 |
| `stream_end` | 流式输出结束 | `finishStreaming` 标记完成 |
| `response` | 审查后的独立消息 (action/chat) | `addMessage` 添加独立气泡 |
| `history_response` | 会话恢复/历史请求 | 仅在无消息时加载 |
| `review` | 旧版审查消息 | 逐条 `addMessage` |
| `multi_message` | 多段消息 | 逐段 `addMessage` |
| `stream_segments` | 断句信息 | 逐段 `addMessage` |
| `device_update` | IoT 设备状态广播 (10s) | `setIoTDevices` 更新状态栏 |
| `background_thinking` | 后台思考状态变化 | `setBackgroundThinkingStatus` |
| `notification` | 系统通知/提醒 | `addNotification` + 浏览器桌面通知 |
| `error` | 服务端错误 | 系统消息气泡 |
---
## 五、ethend (:9090) — 调试工具
**目录**`ethend/`
**类型**Node.js Express 应用
### 5.1 服务管理
| 端点 | 方法 | 功能 |
|------|------|------|
| `/api/services` | GET | 列出所有服务状态 |
| `/api/services/:id/build` | POST | 编译服务 (go build -o main.exe) |
| `/api/services/:id/start` | POST | 启动服务 |
| `/api/services/:id/stop` | POST | 停止服务 |
| `/api/services/:id/restart` | POST | 重启服务 |
| `/api/services/:id/logs` | GET | 获取服务日志 |
| `/api/services/:id/memory` | GET | 获取进程内存 |
### 5.2 其他功能
- **IoT 管理**:设备列表/状态切换
- **记忆管理**:记忆搜索/CRUD
- **性能监控**CPU/内存使用率
- **数据库管理**:表结构检查、迁移执行
- **WebSocket 状态**:连接数/会话列表
- **健康检查轮询**:每秒检测所有服务可达性
### 5.3 关键配置
**构建命令**[config.js](ethend/src/config.js)):
```javascript
buildCommand: 'go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN
```
所有 Go 服务统一编译为 `main.exe`Windows),ethend 通过 `./main` 启动。**自定义二进制名称不会被 ethend 识别。**
---
## 六、对话管线详解
### 6.1 完整请求时序
```
Browser (React) Gateway AI-Core
│ │ │
├─ WebSocket: {type:"message"} ──┤ │
│ ├─ POST /api/v1/chat ──────►│
│ │ (SSE streaming) │
│ │ ├─ 意图分析 (0-1.4s)
│ │ ├─ 并行子会话
│ │ │ ├─ memory (检索)
│ │ │ ├─ iot (查询/操控)
│ │ │ └─ general (意图)
│ │ ├─ LLM 合成 (3-4s)
│ │ ├─ parseReviewMessages
│ │ │
│ │◄── SSE: delta ────────────┤
│◄── WS: stream_chunk ──────────┤ │
│◄── WS: stream_chunk ... ──────┤ │
│ │◄── SSE: review_messages ──┤
│◄── WS: response (action) ─────┤ (200ms delay) │
│◄── WS: response (chat) ───────┤ │
│ │◄── SSE: [DONE] ──────────┤
│◄── WS: stream_end ────────────┤ │
```
### 6.2 性能数据
| 场景 | 总响应 | 意图分析 | 子会话 | LLM 合成 |
|------|--------|---------|--------|---------|
| "你好呀" | ~3.9s | 0s | 跳过 | 3.9s |
| "打开客厅灯" | ~2.6s | 0s | IoT+Memory | ~2.6s |
| "关掉客厅灯" | ~2.6s | 0s | IoT+Memory | ~2.6s |
| "打开卧室灯和卧室空调" | ~3.0s | 0s | IoT+Memory | ~3.0s |
| "看看设备状态" | ~5.3s | 1.4s | IoT+Memory | ~3.9s |
| "你还记得我喜欢什么吗?" | ~4-5s | LLM | Memory+General | ~3-4s |
LLM 合成(deepseek-v4-flash)是主要延迟来源,约占 60-80% 的总响应时间。
### 6.3 E2E 消息流示例
```
用户: "帮我把客厅灯打开"
→ Gateway WS → AI-Core SSE
→ Intent: iot_control (快速通道, 0s)
→ Dispatch: memory + general + iot + review (并行)
→ IoT: 查询 8 个设备 → 匹配客厅灯 → 已开状态
→ Synthesize: 综合上下文 → LLM 生成回复
→ parseReviewMessages:
action: "歪着头看你"
chat: "叶酱,客厅灯早就开着啦♪ 你是不是工作太累看花了眼呀?"
→ Gateway: 200ms 间隔逐条推送 WebSocket response
→ Frontend: ActionMessageBubble + MessageBubble 分别渲染
```
---
## 七、数据库设计
### 7.1 PostgreSQL 连接
所有服务共用 PostgreSQL 数据库 `cyrene_ai`,通过 `DB_URL` 环境变量配置:
```
postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable
```
### 7.2 主要表结构
| 表 | 管理方 | 用途 |
|----|--------|------|
| `users` | Gateway | 用户认证 |
| `sessions` | Gateway | 会话记录 |
| `messages` | Gateway | 对话消息(role, content, timestamp |
| `memories` | Memory-Service | 记忆存储(含 pgvector embedding |
| `reminders` | Gateway | 定时提醒 |
| `briefings` | Gateway | 每日简报 |
| `automation_rules` | Gateway | 自动化规则 |
| `automation_scenes` | Gateway | 自动化场景 |
| `files` | Gateway | 文件元数据 |
| `knowledge_bases` | Gateway | 知识库 |
| `knowledge_documents` | Gateway | 知识文档 |
| — (工具调用日志) | AI-Core (内存) | 环形缓冲区 500 条,无 DB 持久化 |
### 7.3 已知缺项
- `messages``msg_type` 通过后端 ServerMessage 自动填充(含 markdown/code),但数据库层面未持久化该字段,历史消息查询依赖 role 判断
---
## 八、安全性
| 层面 | 实现 |
|------|------|
| 认证 | JWT (HS256),默认永不过期 |
| 密码存储 | bcrypt 哈希 |
| 会话隔离 | 按 userID + sessionID 双重隔离 |
| 限流 | 认证端点:5次/分钟/IPAPI 端点:10 req/s + 突发 20 |
| CORS | 可配置的来源白名单 |
| HTTP 框架 | Gin 的生产模式(ReleaseMode+ Recovery 中间件 |
| WebSocket 安全 | JWT token 验证 + admin-only 主对话 |
| 内部服务 | Internal Service Token 认证 |
---
## 九、已知限制与改进方向
### 当前限制
1. **LLM 合成延迟**(3-4s):LLM 调用是主要瓶颈,合成阶段无法被快速通道绕过
2. **"开" 字歧义**:无法将单独的 "开" 加入快速通道("开心"/"开始" 产生误判),"开灯" 等短命令仍走 LLM
3. **嵌入 API 依赖**DeepSeek 等不提供 `/embeddings` 端点的 API 需额外配置 `EMBEDDING_API_URL` 或使用本地 n-gram 降级
4. **跨群记忆混合**:后台思考 (Thinker) 仍将各频道记忆混合生成统一摘要,尚未完全隔离
5. **流式审查**`parseReviewMessages()` 需要等 LLM 合成完成后才能执行,无法实时拆分
### 建议改进方向
1. **LLM 响应缓存**:对相似问候/常见 IoT 命令引入语义缓存
2. **Ollama 本地嵌入**:配置 `EMBEDDING_API_URL=http://localhost:11434/v1` + `EMBEDDING_MODEL=bge-m3` 启用真实向量检索
3. **跨群聊记忆隔离**:Thinker 按频道独立维护记忆上下文
4. **流式审查**:在 LLM 合成过程中实时识别并分段发送 action/chat
5. **本地工作助手插件**:承载代码/长文生成等聊天平台不适合的能力
6. **前端集成测试**Playwright/CDP 端到端测试
---
## 附:快速参考
### 服务端口一览
| 服务 | 端口 | 技术 |
|------|------|------|
| Gateway | 8080 | Gin + WebSocket |
| AI-Core | 8081 | net/http + SSE |
| IoT Debug | 8083 | net/http |
| Platform Bridge | 8095 | net/http + WebSocket (OBv11/Telegram适配器) |
| Memory | 8091 | net/http + pgvector |
| Voice | 8093 | net/http + whisper.cpp |
| Frontend (dev) | 5173 | Vite |
| ethend | 9090 | Express |
### 常用命令
```bash
# 构建所有 Go 服务
cd backend/ai-core && GOWORK=off go build -o main.exe ./cmd/main.go
cd backend/gateway && GOWORK=off go build -o main.exe ./cmd/main.go
# ... 其他服务同理
# 或通过 ethend API
curl -X POST http://localhost:9090/api/services/ai-core/build
curl -X POST http://localhost:9090/api/services/gateway/build
# 启动前端开发服务器
cd frontend/web && pnpm dev
# E2E 测试
node test/test_final_e2e.mjs
```
### 环境变量 (.env)
位于 `.env`,基础设施与 LLM 回退配置:
```
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=cyrene
POSTGRES_PASSWORD=cyrene_pass
POSTGRES_DB=cyrene_ai
JWT_SECRET=xxx
ADMIN_USERNAME=admin
ADMIN_PASSWORD=xxx
# Phase 6: 以下 LLM 变量作为 models.json 不存在时的回退
LLM_API_URL=https://api.deepseek.com/v1
LLM_API_KEY=sk-xxx
LLM_MODEL=deepseek-v4-flash
LLM_FALLBACK_MODEL=deepseek-v4-flash
```
### 模型配置 (models.json)
Phase 6 新增,位于 `backend/models.json``.gitignore` 已排除)。格式:
```json
{
"version": "1.0",
"providers": {
"deepseek": {
"name": "deepseek",
"base_url": "https://api.deepseek.com",
"api_key": "sk-xxx",
"timeout_sec": 120,
"max_retries": 3
}
},
"models": {
"primary_chat": {
"id": "primary_chat",
"name": "deepseek-v4-flash",
"provider": "deepseek",
"description": "主对话模型",
"tags": ["chat", "fast"],
"params": { "temperature": 0.8, "max_tokens": 2048 },
"enabled": true
}
},
"routing": {
"chat": {
"purpose": "chat",
"fallback_chain": ["primary_chat", "fallback_chat"],
"required": false
}
}
}
```
**配置管理 API**Gateway adminethend 代理提供 UI):
| Method | Path | Description |
|--------|------|-------------|
| GET/POST/DELETE | `/api/v1/admin/models/providers/:name` | Provider CRUD |
| GET/POST/DELETE | `/api/v1/admin/models/models/:id` | Model CRUD |
| GET/POST/DELETE | `/api/v1/admin/models/routing/:purpose` | Routing CRUD |
**向后兼容**:如果 `models.json` 不存在,`ModelSelector` 自动回退到 `.env` 的 4 个 LLM 变量,行为与 Phase 5 及之前完全一致。
+126
View File
@@ -0,0 +1,126 @@
# 2026年6-7月功能升级与修复汇总
> **日期**2026-07-07
> **时期**2026年6月下旬 - 7月上旬
> **分支**`dev`
---
## 一、Platform Bridge — 多平台接入
### 多账号 QQ 适配器
- 平台桥接 (`platform-bridge`) 负责统一管理所有第三方平台适配器
- 支持同一平台类型多个实例(如 `obv11``obv11-main`),通过 config name 区分
- 110+ 条官方 QQ Bot API 表情映射,支持表情名称解析
- WebSocket 连接管理:client/server 双模式、自动重连、禁言检测
### 群聊行为控制
- **消息发送间隔**:群聊 ≥3 秒/条,最多 2 条/轮,单条 ≤500 字自动截断防封控
- **禁言检测**:通过 `group_ban` notice 实时跟踪禁言状态,发送循环内逐条重检
- **审查模式 (group_ambient)**:非 @ 消息由 LLM 自主判断是否插话
- **管理员审查模式**:管理员消息在 group_ambient 下仍保留工具使用权
- **消息日志**:每配置独立日志文件,按 `AdapterName` 命名
### 主动消息平台推送
- 后台思考生成的主动消息可通过 QQ 适配器推送到群聊/私聊
- 支持 `【QQ私聊:QQ号】``【QQ群聊:群号】` 目标格式
- `ProactiveGuard` 统一频控,跨 Web/QQ 双通道
---
## 二、记忆系统升级
### 嵌入模型两级降级
| 级别 | 方案 | 配置 |
|------|------|------|
| L1 | OpenAI 兼容 `/embeddings` API (如 Ollama bge-m3) | `EMBEDDING_API_URL` + `EMBEDDING_MODEL` |
| L2 | 本地 n-gram FNV 哈希向量 (0依赖) | 默认,L1 不可用时自动启用 |
- 启动时自动测试嵌入 API 可用性
- DeepSeek 等不提供 embedding 的 API 不会静默失败
- 保存记忆时同步生成向量嵌入
### 检索增强
- 向量检索 + n-gram 滑动窗口关键词匹配,按分数排序
- 2-4 字中文分词窗口匹配 content/summary/keywords
- pgvector `<=>` 余弦距离检索 8 条 + 关键词匹配 100 条去重合并
### 记忆归属
- persona 规则:记忆不全是开拓者的,需分辨发送者再归因
- 群聊规则:不要把群友的事说成开拓者做的
---
## 三、后台思考系统 (Thinker)
### 死锁修复 (11+ 轮)
- **根因**Go `sync.Mutex` 不可重入,`defer` 是函数作用域非块作用域
- `defer t.muUnlock()` 在 for 循环和跨 LLM 调用时导致 self-deadlock
- 修复函数:`performThink`, `lightThinkLoop`, `performLightThink`, `storeThought`, `UpdatePresence`, `TriggerReminderMessage`, `PushPlatformMessage`, `resetSilenceTimer`, `buildThinkingUserPrompt`
### 上下文隔离
- `thinkSessionID` 绑定思考周期触发时的会话,防止跨群串台
- 思考过程中其他群的消息不再覆盖 activeSessionID
- convHistory 和 thought push 均使用绑定的 session
### 轻量思考 + 平台观察
- `lightThinkLoop`:高频轻量轮询(~60s),简短状态确认/话题发起
- `platformThink`:定期(~600s)聚合各频道记忆生成平台观察摘要
---
## 四、对话引擎 (Orchestrator + Synthesizer)
### 群聊人格强化
- **#1 审查模式**:非 @ 消息由 LLM 判断是否回复,支持 `【不发送】` 自我审查
- **#2 回复长度**:最多 1-2 句,允许多段落但需双换行分隔
- **#3 闭嘴服从**:识别"别说话""闭嘴"等指令,持续遵守
- **#4 发送者识别**:根据消息前缀中的昵称称呼对方
- **#5 简体中文**:强制输出简体中文,防止群友繁体昵称传染
- **#6 拒绝长内容**:禁止生成代码/长文,告知等待本地工作助手插件
- **#7 记忆归因**:不同人的言行分清,不张冠李戴
- **#8 禁用数字 ID**:用昵称而非 QQ 号称呼人
### 管理员功能
- **多层防御**`AdminOnly` 字段 → plugin manager 自动拦截 → IoT provider 执行级检查
- **身份提示**:管理员以名字标注,数字 UID 从昵称中移除
- **权限区分**:设备控制/系统管理等 admin-only 工具对非管理员温柔拒绝
### 工具系统
- 后台工具结果自动推送(成功/失败),失败结果不再触发 LLM 跟进避免噪音
- 工具名称输出过滤(`vision_analyze`, `web_search` 等)
- 双换行消息拆分
---
## 五、搜索与其他
### web_search (SearXNG)
- 引擎精简为 `bing` + `sogou`(移除 baidu 安全验证拦截 + 360 不稳定)
- 超时 25sSearXNG 内部 timeout=20s
- 添加 User-Agent
### LLM API
- HTTP 连接池:`MaxIdleConns=100`, `MaxIdleConnsPerHost=20`, `IdleConnTimeout=90s`
- `TLSHandshakeTimeout=15s` 防止握手卡死
### 降级回复
- 仅在管理员私聊时发送降级消息,群聊静默避免怪话
---
## 六、新增环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `EMBEDDING_API_URL` | `LLM_API_URL` | 嵌入 API 地址,可指向 Ollama 等独立服务 |
| `EMBEDDING_MODEL` | `text-embedding-3-small` | 嵌入模型名 |
| `PLATFORM_BRIDGE_URL` | `http://localhost:8095` | platform-bridge 地址 |
---
## 七、已知待办
- [ ] 嵌入 API 接入真实模型(Ollama bge-m3)后验证向量检索准确率
- [ ] 本地工作助手插件(承载长文本/代码生成等能力)
- [ ] 跨群聊记忆完全隔离(当前后台思考仍会将各频道记忆混合后统一生成摘要)
+28
View File
@@ -0,0 +1,28 @@
# Cyrene 开发规划文档索引
> **日期**2026-07-07
> **当前版本**v1.7-alpha
> **当前阶段**v1.8-alpha 开发中
---
## 当前计划
| 文档 | 说明 |
|------|------|
| [00-next-test-plan.md](00-next-test-plan.md) | **← 当前** — 测试版开发计划 (v1.7 → v2.0) |
| [14-humanization.md](14-humanization.md) | 人性化改进 — 语言和行为更像真人 |
| [15-june-july-2026-fixes.md](15-june-july-2026-fixes.md) | **← 新增** — 2026年6-7月功能升级与修复汇总 |
## 活跃设计文档
| # | 文档 | 对应版本 | 内容 |
|---|------|---------|------|
| 4 | [语音系统方案](04-voice-system-plan.md) | v1.8 | 专属 TTS 模型、流式合成、实时通话 |
| 5 | [多模型配置系统](05-model-config-system.md) | v1.6 | models.yaml、多 Provider、ModelSelector |
| 7 | [IoT 与宿主机操控](07-host-control-iot-evolution.md) | v1.9 | Home Assistant 集成、安全沙箱 |
| 11 | [STT 修复与优化](11-stt-fix-and-optimization.md) | v1.8 | DashScope/Whisper 缺陷修复、流式管道 |
## 已归档
`archived/` 目录:架构进化、插件系统、多平台接入、对话流程、OS 环境、DevTools、平台观察、Phase 7 优化等已完成的设计文档。