fix: platform_silent记忆提取 + 群聊上下文整合 + 多QQ实例支持

- platform_silent模式接入Orchestrator记忆提取:被动观察群聊时提取值得记住的信息到对应命名空间
- post_chat后台思考注入平台观察:对话后思考也能看到群聊摘要
- QQ适配器:OneBot v11 self_id动态捕获、CQ图片URL提取、视觉+OCR并行处理
- Router解耦:ConfigName/PlatformName分离,支持多QQ实例独立连接
- 黑白名单功能:后端API + Ethend代理 + UI面板
- \n\n双换行断句:AI回复按双换行分割为多条消息按间隔发送
- @提及修复:bot自感知UID进行@检测
- 群聊上下文共享:channel-based userID避免记忆碎片化
- 消息日志显示处理后内容而非原始SSE数据
- platform-bridge Dockerfile + docker-compose.yml更新

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 09:37:18 +08:00
parent 71f0a1abdb
commit 47dce276a4
22 changed files with 2375 additions and 313 deletions
+190 -9
View File
@@ -29,6 +29,38 @@ type PendingThought struct {
Consumed bool `json:"consumed"`
}
// PlatformChannel represents a platform channel to observe for background thinking.
type PlatformChannel struct {
Platform string // qq, telegram, etc.
ChannelType string // group, private
ChannelID string // group ID or user QQ number
}
// ParsePlatformChannels parses PLATFORM_CHANNELS env var.
// Format: "qq:group:123456,telegram:group:789012"
func ParsePlatformChannels(raw string) []PlatformChannel {
if raw == "" {
return nil
}
var channels []PlatformChannel
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
fields := strings.SplitN(part, ":", 3)
if len(fields) != 3 {
continue
}
channels = append(channels, PlatformChannel{
Platform: strings.TrimSpace(fields[0]),
ChannelType: strings.TrimSpace(fields[1]),
ChannelID: strings.TrimSpace(fields[2]),
})
}
return channels
}
// Thinker 后台思考器(事件驱动 + 定时周期双模式)
//
// 触发机制:
@@ -128,6 +160,10 @@ type Thinker struct {
// 时区设置 (默认 Asia/Shanghai,可通过 TZ 环境变量覆盖)
timeLocation *time.Location
// 平台静默观察
platformChannels []PlatformChannel
platformThinkInterval time.Duration
}
// AutonomousToolPolicy 自主思考工具调用安全策略
@@ -213,6 +249,10 @@ type ThinkerConfig struct {
PostChatDelay time.Duration // 对话后多久触发思考
MinThinkGap time.Duration // 两次思考最小间隔 (在线)
OfflineThinkGap time.Duration // 两次思考最小间隔 (离线,默认 10 分钟)
// 平台静默观察
PlatformSilentThinkInterval time.Duration // 平台记忆观察间隔 (默认 600s,0 = 禁用)
PlatformChannels []PlatformChannel
}
// DefaultThinkerConfig 默认配置
@@ -232,6 +272,8 @@ func DefaultThinkerConfig() ThinkerConfig {
PostChatDelay: getEnvDuration("THINK_POST_CHAT_DELAY_SEC", 5),
MinThinkGap: getEnvDuration("THINK_MIN_GAP_SEC", 30),
OfflineThinkGap: getEnvDuration("THINK_OFFLINE_GAP_SEC", 600),
PlatformSilentThinkInterval: getEnvDuration("PLATFORM_THINK_INTERVAL_SEC", 600),
PlatformChannels: ParsePlatformChannels(os.Getenv("PLATFORM_CHANNELS")),
}
}
@@ -282,12 +324,14 @@ func NewThinker(
adminUserID: adminUserID,
adminSessionID: adminSessionID,
memClient: memClient,
pendingThoughts: make([]*PendingThought, 0),
lastUserMessage: time.Now(),
stopCh: make(chan struct{}),
chain: NewThinkChain(10),
autoToolPolicy: DefaultAutonomousToolPolicy(),
proactiveGuard: DefaultProactiveGuard(),
pendingThoughts: make([]*PendingThought, 0),
lastUserMessage: time.Now(),
stopCh: make(chan struct{}),
chain: NewThinkChain(10),
autoToolPolicy: DefaultAutonomousToolPolicy(),
proactiveGuard: DefaultProactiveGuard(),
platformChannels: cfg.PlatformChannels,
platformThinkInterval: cfg.PlatformSilentThinkInterval,
}
}
@@ -314,8 +358,14 @@ func (t *Thinker) Start() {
go t.periodicThinkLoop()
}
log.Printf("[后台思考] 已就绪 — 周期=%v + 事件驱动模式 (静默超时=%v, 对话后延迟=%v, 在线最小间隔=%v, 离线最小间隔=%v, 管理员=%s)",
t.thinkInterval, t.silenceTimeout, t.postChatDelay, t.minThinkGap, t.offlineThinkGap, t.adminUserID)
// 启动平台静默观察循环
if len(t.platformChannels) > 0 && t.platformThinkInterval > 0 {
t.wg.Add(1)
go t.platformObservationLoop()
}
log.Printf("[后台思考] 已就绪 — 周期=%v + 事件驱动模式 (静默超时=%v, 对话后延迟=%v, 在线最小间隔=%v, 离线最小间隔=%v, 管理员=%s, 平台频道=%d)",
t.thinkInterval, t.silenceTimeout, t.postChatDelay, t.minThinkGap, t.offlineThinkGap, t.adminUserID, len(t.platformChannels))
// 启动后首次思考:延迟 5s,让服务完全初始化后再触发
go func() {
@@ -460,6 +510,116 @@ func (t *Thinker) resetSilenceTimer() {
}()
}
// platformObservationLoop periodically queries platform channel memories and generates observations.
func (t *Thinker) platformObservationLoop() {
defer t.wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("[后台思考] 平台观察循环 panic 恢复: %v", r)
}
}()
interval := t.platformThinkInterval
log.Printf("[后台思考] 平台观察循环已启动 (间隔=%v, 频道数=%d)", interval, len(t.platformChannels))
for {
select {
case <-t.stopCh:
log.Println("[后台思考] 平台观察循环已停止")
return
case <-time.After(interval):
t.performPlatformObservation()
}
}
}
// performPlatformObservation queries memories from all platform channels,
// runs an intermediate LLM session to summarize, and stores the result as a pending thought.
func (t *Thinker) performPlatformObservation() {
if t.memClient == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var channelSummaries []string
for _, ch := range t.platformChannels {
namespace := fmt.Sprintf("platform_%s_%s_%s", ch.Platform, ch.ChannelType, ch.ChannelID)
memories, err := t.memClient.Query(ctx, model.MemoryQuery{
UserID: namespace,
Limit: 20,
})
if err != nil {
log.Printf("[后台思考] 查询平台频道 %s 记忆失败: %v", namespace, err)
continue
}
if len(memories) == 0 {
continue
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("【%s %s %s】\n", ch.Platform, ch.ChannelType, ch.ChannelID))
for i, m := range memories {
if i >= 10 {
sb.WriteString(fmt.Sprintf("... 还有 %d 条记忆\n", len(memories)-10))
break
}
sb.WriteString(fmt.Sprintf("- %s\n", m.Content))
}
channelSummaries = append(channelSummaries, sb.String())
}
if len(channelSummaries) == 0 {
return
}
log.Printf("[后台思考] 平台观察:%d 个频道有记忆数据,调用中间会话生成摘要...", len(channelSummaries))
systemPrompt := "你是昔涟的后台观察助手。以下是各聊天平台频道最近的观察摘要。\n请生成简洁报告:\n1. 各频道近期讨论主题(每频道1-2句)\n2. 是否有需要开拓者关注的重要/紧急事项\n3. 整体氛围评估\n不要直接对开拓者说话,这是给昔涟参考的幕后报告。\n输出为JSON格式:{\"summary\": \"报告内容\", \"needs_attention\": true/false}"
userPrompt := strings.Join(channelSummaries, "\n\n")
messages := []model.LLMMessage{
{Role: model.RoleSystem, Content: systemPrompt},
{Role: model.RoleUser, Content: userPrompt},
}
resp, err := t.toolAdapter.Chat(ctx, messages)
if err != nil {
log.Printf("[后台思考] 中间会话 LLM 调用失败: %v", err)
return
}
var result struct {
Summary string `json:"summary"`
NeedsAttention bool `json:"needs_attention"`
}
content := strings.TrimSpace(resp.Content)
if idx := strings.Index(content, "{"); idx >= 0 {
if end := strings.LastIndex(content, "}"); end > idx {
content = content[idx : end+1]
}
}
if err := json.Unmarshal([]byte(content), &result); err != nil {
result.Summary = resp.Content
}
observationContent := fmt.Sprintf("[平台观察 %s]\n%s", time.Now().In(t.timeLocation).Format("15:04"), result.Summary)
t.mu.Lock()
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
Content: observationContent,
CreatedAt: time.Now(),
Consumed: false,
})
if len(t.pendingThoughts) > 10 {
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-10:]
}
t.mu.Unlock()
log.Printf("[后台思考] 平台观察摘要已生成 (长度=%d, 需要关注=%v)", len(result.Summary), result.NeedsAttention)
}
// periodicThinkLoop 周期性自主思考循环
//
// 使用动态间隔:若配置了 ScheduleLoader,每次循环根据当前时段计算间隔;
@@ -639,9 +799,22 @@ func (t *Thinker) performThink(triggerReason string) {
}
}
// 4.5 获取最近平台观察(定期触发和对话后触发时注入)
var platformObservation string
if triggerReason == "periodic" || triggerReason == "post_chat" {
t.mu.Lock()
for i := len(t.pendingThoughts) - 1; i >= 0; i-- {
if strings.HasPrefix(t.pendingThoughts[i].Content, "[平台观察") {
platformObservation = t.pendingThoughts[i].Content
break
}
}
t.mu.Unlock()
}
// 5. 构建思考提示词(根据触发原因调整)
systemPrompt := t.buildThinkingSystemPrompt(personaConfig, triggerReason)
userPrompt := t.buildThinkingUserPrompt(memories, convHistory, deviceSummary, triggerReason)
userPrompt := t.buildThinkingUserPrompt(memories, convHistory, deviceSummary, triggerReason, platformObservation)
messages := []model.LLMMessage{
{Role: model.RoleSystem, Content: systemPrompt},
@@ -895,6 +1068,7 @@ func (t *Thinker) buildThinkingUserPrompt(
convHistory []model.LLMMessage,
deviceSummary string,
triggerReason string,
platformObservation string,
) string {
var sb strings.Builder
@@ -1009,6 +1183,13 @@ func (t *Thinker) buildThinkingUserPrompt(
sb.WriteString("\n" + deviceSummary)
}
// 平台观察摘要 (中间会话产生的报告)
if platformObservation != "" {
sb.WriteString("\n\n【平台频道观察报告(中间会话生成,供参考)】\n")
sb.WriteString(platformObservation)
sb.WriteString("\n")
}
// 结尾引导
sb.WriteString("\n---\n现在请写下你的私人反思。")
sb.WriteString("\n记住:这是日记,用第三人称或自言自语的方式。")
+3 -2
View File
@@ -163,8 +163,9 @@ type BuildParams struct {
Memories []memory.MemoryEntry
HistoryLimit int
DeviceContext string // 注入的设备状态文本
PendingThoughts []string // 待注入的后台思考
Nickname string // 用户昵称 (昔涟对用户的称呼)
PendingThoughts []string // 待注入的后台思考
PlatformObservationSummary string // 平台观察摘要(中间会话生成)
Nickname string // 用户昵称 (昔涟对用户的称呼)
}
// Build 构建发送给LLM的完整消息列表
+76 -5
View File
@@ -34,16 +34,28 @@ func (e *Extractor) ExtractAndStore(ctx context.Context, userID, sessionID, user
logger.Printf("[memory] 记忆提取失败: %v", err)
return
}
e.storeMemories(ctx, userID, sessionID, memories)
}
// ExtractObservations 从观察到的单条消息中提取记忆(无语境回复)。
// 用于 platform_silent 模式:昔涟被动观察群聊,提取值得记住的信息。
func (e *Extractor) ExtractObservations(ctx context.Context, userID, sessionID, message string) {
memories, err := e.extractObservations(ctx, message)
if err != nil {
logger.Printf("[memory] 观察记忆提取失败: %v", err)
return
}
e.storeMemories(ctx, userID, sessionID, memories)
}
func (e *Extractor) storeMemories(ctx context.Context, userID, sessionID string, memories []model.MemoryEntry) {
for _, mem := range memories {
mem.UserID = userID
mem.SessionID = sessionID
mem.Source = "conversation"
// 去重检查:查询用户已有的相关记忆
existing, err := e.findSimilar(ctx, userID, &mem)
if err == nil && existing != nil {
// 相似度 > 80%,更新现有记忆
e.mergeMemory(ctx, existing, &mem)
continue
}
@@ -56,6 +68,58 @@ func (e *Extractor) ExtractAndStore(ctx context.Context, userID, sessionID, user
}
}
// extractObservations 从观察到的消息中提取记忆(无助手回复)
func (e *Extractor) extractObservations(ctx context.Context, message string) ([]model.MemoryEntry, error) {
if e.llmChat != nil {
return e.extractObservationsWithLLM(ctx, message)
}
return e.extractWithRules(message, ""), nil
}
// extractObservationsWithLLM 使用LLM从观察到的消息中提取值得记住的信息
func (e *Extractor) extractObservationsWithLLM(ctx context.Context, message string) ([]model.MemoryEntry, error) {
prompt := fmt.Sprintf(`分析以下在聊天平台观察到的消息,提取值得记住的信息作为记忆。
观察到的消息: %s
请以JSON格式返回提取的记忆。这条消息来自群聊/频道,昔涟只是旁观者。
提取角度:这条消息中包含了什么关于聊天参与者、讨论主题、事件或氛围的信息?
每条记忆需要包含以下字段:
- content: 完整的记忆内容(一句话描述,客观准确)
- summary: 简短摘要(10字以内)
- category: 记忆分类,必须是以下之一:
* conversation: 对话主题/讨论摘要
* event: 事件记录(发生了什么)
* personal_info: 参与者的个人信息
* knowledge: 知识性信息
* user_preference: 某人的偏好
* task: 提及的计划/任务
- priority: 优先级 (0=临时, 1=普通, 2=重要, 3=核心)
- importance: 重要程度 1-10
* 1-3: 日常闲聊,不太重要
* 4-6: 一般有用的信息
* 7-8: 重要信息,值得长期记住
* 9-10: 核心信息
- keywords: 关键词标签数组(3-5个词)
只提取有意义的信息。如果消息只是日常寒暄或无实质内容,返回空数组。
输出格式:
{"memories": [{"content": "...", "summary": "...", "category": "...", "priority": 1, "importance": 6, "keywords": ["词1", "词2"]}]}
`, message)
resp, err := e.llmChat(ctx, []model.LLMMessage{
{Role: "system", Content: "你是一个聊天观察记录助手。你只输出JSON格式的结果。你的任务是从观察到的聊天消息中提取值得记住的信息。"},
{Role: "user", Content: prompt},
})
if err != nil {
return nil, fmt.Errorf("LLM提取观察记忆失败: %w", err)
}
return e.parseExtractionResult(resp.Content)
}
// extract 从对话中提取记忆
func (e *Extractor) extract(ctx context.Context, userMessage, assistantResponse string) ([]model.MemoryEntry, error) {
// 如果有LLM,使用LLM提取
@@ -128,11 +192,18 @@ func (e *Extractor) extractWithLLM(ctx context.Context, userMessage, assistantRe
return nil, fmt.Errorf("LLM提取记忆失败: %w", err)
}
// 解析JSON
entries, err := e.parseExtractionResult(resp.Content)
if err != nil {
return nil, err
}
return entries, nil
}
// parseExtractionResult 解析LLM返回的记忆提取JSON结果
func (e *Extractor) parseExtractionResult(text string) ([]model.MemoryEntry, error) {
result := MemoryExtractionResult{}
content := extractJSON(resp.Content)
content := extractJSON(text)
if err := json.Unmarshal([]byte(content), &result); err != nil {
// 尝试作为数组解析(兼容旧格式)
var arrResult []ExtractedMemory
if err2 := json.Unmarshal([]byte(content), &arrResult); err2 != nil {
return nil, fmt.Errorf("解析记忆JSON失败: %w (原始: %s)", err, content[:minint(len(content), 100)])
@@ -500,6 +500,15 @@ func (o *Orchestrator) ProcessInput(
return eventCh, nil
}
// ExtractMemoriesOnly 仅提取记忆,不生成回复。
// 用于 platform_silent 模式:观察群聊消息并提取值得记住的信息到对应命名空间。
func (o *Orchestrator) ExtractMemoriesOnly(ctx context.Context, userID, sessionID, message string) {
if o.memoryExtractor == nil {
return
}
o.memoryExtractor.ExtractObservations(ctx, userID, sessionID, message)
}
// scheduleWithDelays 通过 MessageScheduler 为审查消息分配发送延迟
func (o *Orchestrator) scheduleWithDelays(messages []model.ReviewMessage) []model.ReviewMessage {
if o.msgScheduler == nil || len(messages) <= 1 {