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:
@@ -734,6 +734,22 @@ func handleChat(
|
||||
req.Mode = "text"
|
||||
}
|
||||
|
||||
// 平台静默观察模式:只记录消息、提取记忆、触发后台思考,不生成回复。
|
||||
if req.Mode == "platform_silent" {
|
||||
if thinker != nil {
|
||||
thinker.RecordUserMessage(req.SessionID)
|
||||
}
|
||||
ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, req.Message)
|
||||
// 从观察到的群聊消息中提取记忆。
|
||||
orch.ExtractMemoriesOnly(r.Context(), req.UserID, req.SessionID, req.Message)
|
||||
if thinker != nil {
|
||||
thinker.TriggerPostChatThink()
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"silent_processed"}`))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// 0. 记录用户活动(重置闲置计时器)
|
||||
|
||||
@@ -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记住:这是日记,用第三人称或自言自语的方式。")
|
||||
|
||||
@@ -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的完整消息列表
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user