Files
Cyrene_v2/internal/services/channelbridge/proactive.go
T
Aska 06fd2f4aaf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(backthink): 主动推送也落主会话上下文——修复回复时上下文断片
bridge.SendProactive 之前只发送不落历史,导致她主动说的话不在主会话 chat_messages,
你回复时 LLM 看不到她的主动消息→上下文不连贯。现发送后 persistToMain 镜像到主会话
(assistant+platform+deviceID),你回复时上下文带上她的主动消息。build/test 全绿。
2026-09-07 20:18:03 +08:00

109 lines
4.0 KiB
Go

package channelbridge
import (
"context"
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// proactiveTarget is the owner's IM address (platform + account id) resolved for
// a proactive push. The account id doubles as the DM peer on every adapter.
type proactiveTarget struct {
platform string
accountID string
}
// SendProactive pushes msg to the owner's IM channel via the registered adapter
// for that platform. It is the backthink IMAdapter seam (design/12 §3 fallback).
// The target account is chosen by msg.Platform when it names one of the owner's
// whitelisted channels, otherwise the first whitelisted owner channel is used.
// When the owner profile has no whitelisted account (or none carries an account
// id) it returns a clear error so the caller records the reason rather than
// silently dropping the push.
func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error {
if err := ctx.Err(); err != nil {
return err
}
target, ok := b.proactiveTarget(msg)
if !ok {
return fmt.Errorf("channelbridge: no owner IM channel for proactive push")
}
ad := b.adapterForPlatform(target.platform)
if ad == nil {
return fmt.Errorf("channelbridge: no adapter for owner platform %q", target.platform)
}
out := msg
out.Platform = target.platform
out.ChannelType = adapter.ChannelDirect
// The owner's account id is the DM peer on every adapter: telegram/qq read
// ChannelID, kook/weixin read SenderID (with ChannelID as the fallback). Setting
// both keeps a direct message targetable regardless of which field the adapter
// prefers, without duplicating the message.
out.ChannelID = target.accountID
out.SenderID = target.accountID
if err := ad.SendProactive(ctx, out); err != nil {
return fmt.Errorf("channelbridge: send proactive via %q: %w", target.platform, err)
}
// Mirror the proactive message into the main conversation context (design/16
// §4.4 + §4.6.1). Without this the model would not see what it proactively said, so
// a follow-up owner reply would feel incoherent (the message "loses" its history).
// It is the same mirror path as owner-private IM replies, scoped to MainSessionID.
b.persistToMain(ctx, history.RoleAssistant, msg.Content, target.platform,
fmt.Sprintf("%s:%s:%s", target.platform, target.accountID, target.accountID),
target.platform)
return nil
}
// proactiveTarget resolves the owner account a proactive push should reach:
// msg.Platform, when it names one of the owner's whitelisted platforms, selects
// that account; otherwise the first whitelisted owner channel (with a non-empty
// account id) is used. A nil owner or an empty whitelist yields !ok.
func (b *Bridge) proactiveTarget(msg adapter.UnifiedMessage) (proactiveTarget, bool) {
if b.owner == nil {
return proactiveTarget{}, false
}
accounts := b.owner.Accounts
if len(accounts) == 0 {
return proactiveTarget{}, false
}
platform := strings.ToLower(strings.TrimSpace(msg.Platform))
if platform != "" {
for _, acct := range accounts {
if acct.AccountID != "" && strings.EqualFold(acct.Platform, platform) {
return proactiveTarget{acct.Platform, acct.AccountID}, true
}
}
}
for _, acct := range accounts {
if acct.AccountID != "" {
return proactiveTarget{acct.Platform, acct.AccountID}, true
}
}
return proactiveTarget{}, false
}
// adapterForPlatform returns a registered adapter whose base platform matches
// platform (multi-account names like "kook:3be..." resolve to their bare base),
// preferring a connected one so a dead account is only tried when it is the only
// option. It returns nil when none is registered.
func (b *Bridge) adapterForPlatform(platform string) adapter.PlatformAdapter {
b.mu.RLock()
defer b.mu.RUnlock()
var first adapter.PlatformAdapter
for name, a := range b.adapters {
if !strings.EqualFold(basePlatform(name), platform) {
continue
}
if a.IsConnected() {
return a
}
if first == nil {
first = a
}
}
return first
}