feat: IM平台列 + deviceID标识 + 会话持久化完善
- agent-96: chat_messages 加 platform 列(迁移0004) + IM主人私聊落主会话web(带platform) + 前端按平台过滤契约 + message_persisted 带 platform(复用 BroadcastPersisted) - agent-97: chat_messages 加 device_id 列(迁移0005) + history.Message.DeviceID + Web发消息带deviceId(localStorage getClientId) + IM persistToMain 带 deviceID + 前端气泡展示 SourceLabel + 修主会话基础2个bug(fn scanMessage语法/DDL缺列) - design/16 §4.4(平台) + §4.5(deviceID) 已入档 - 实机: POST /chat 带 deviceId 读回 user/assistant 均带 deviceId+platform; 后端 build/vet/test 绿 + 前端 build 绿 - 红线: 身份靠 actor 元数据; 禁止 shell 读 .env(JWT冒烟由主会话跑)
This commit is contained in:
1 parent
1bf60c1ae4
commit
37e98ec584
26 files changed
+828
-116
No files matched your search
@@ -24,6 +24,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
@@ -72,21 +73,30 @@ type SessionStore interface {
|
||||
|
||||
// Options carries the bridge's injected dependencies. Loop and Sessions are
|
||||
// required; Owner may be nil (then no account is recognized as owner, the safe
|
||||
// default-deny posture from design/11 §0).
|
||||
// default-deny posture from design/11 §0). History and OnPersisted are optional
|
||||
// (design/16 §4.4): when a history store is wired, owner-private messages are
|
||||
// routed into the main web session and tagged with the channel platform, and every
|
||||
// persisted message is handed to OnPersisted so the gateway can broadcast a
|
||||
// `message_persisted` event; when both are absent the bridge runs exactly as
|
||||
// before (isolated sess-xxx, no chat_messages writes).
|
||||
type Options struct {
|
||||
Log *slog.Logger
|
||||
Loop Chatter
|
||||
Sessions SessionStore
|
||||
Owner *user.OwnerProfile
|
||||
Log *slog.Logger
|
||||
Loop Chatter
|
||||
Sessions SessionStore
|
||||
Owner *user.OwnerProfile
|
||||
History history.HistoryStore
|
||||
OnPersisted func(history.Message)
|
||||
}
|
||||
|
||||
// Bridge owns the channel adapter lifecycle and the inbound message pipeline. It
|
||||
// is safe for concurrent use.
|
||||
type Bridge struct {
|
||||
log *slog.Logger
|
||||
run Chatter
|
||||
sessions SessionStore
|
||||
owner *user.OwnerProfile
|
||||
log *slog.Logger
|
||||
run Chatter
|
||||
sessions SessionStore
|
||||
owner *user.OwnerProfile
|
||||
history history.HistoryStore
|
||||
onPersisted func(history.Message)
|
||||
|
||||
mu sync.RWMutex
|
||||
adapters map[string]adapter.PlatformAdapter
|
||||
@@ -114,14 +124,16 @@ func New(opts Options) (*Bridge, error) {
|
||||
log = logger.New(logger.Options{Out: nil})
|
||||
}
|
||||
return &Bridge{
|
||||
log: logger.WithSource(log, "services/channelbridge"),
|
||||
run: opts.Loop,
|
||||
sessions: opts.Sessions,
|
||||
owner: opts.Owner,
|
||||
adapters: make(map[string]adapter.PlatformAdapter),
|
||||
connected: make(map[string]bool),
|
||||
cancel: func() {},
|
||||
sem: make(chan struct{}, maxConcurrent),
|
||||
log: logger.WithSource(log, "services/channelbridge"),
|
||||
run: opts.Loop,
|
||||
sessions: opts.Sessions,
|
||||
owner: opts.Owner,
|
||||
history: opts.History,
|
||||
onPersisted: opts.OnPersisted,
|
||||
adapters: make(map[string]adapter.PlatformAdapter),
|
||||
connected: make(map[string]bool),
|
||||
cancel: func() {},
|
||||
sem: make(chan struct{}, maxConcurrent),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -353,28 +365,52 @@ func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
"session", sess.SessionID, "actor_role", sess.ActorRole,
|
||||
"env", sess.Env(), "mem_writable", sess.MemWritable)
|
||||
|
||||
// design/16 §4.4 + design/02 §3: an owner private chat is routed INTO the single
|
||||
// main web session (shared across platforms) and persisted to chat_messages with
|
||||
// the channel platform, so the Web client mirrors it; group chats and strangers
|
||||
// keep their isolated sess-xxx context and are NOT persisted to the main session.
|
||||
sessID := sess.SessionID
|
||||
isOwnerPrivate := !sess.IsGroup && sess.Owner
|
||||
platform := ""
|
||||
if isOwnerPrivate {
|
||||
sessID = history.MainSessionID
|
||||
platform = msg.Platform
|
||||
}
|
||||
|
||||
// Persist the inbound user message into the main web session before the turn, so
|
||||
// the Web client sees it even if the channel reply fails (design/16 §4.4).
|
||||
if isOwnerPrivate {
|
||||
b.persistToMain(ctx, history.RoleUser, text, platform, deviceIDFor(msg))
|
||||
}
|
||||
|
||||
// Thread the resolved isolation boundary into the loop's memory gates: the
|
||||
// owner's private conversation reads+writes memory, group chats and strangers
|
||||
// are read-only isolation (design/02 §3, design/11 §2).
|
||||
runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable}
|
||||
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), sess.SessionID, []model.Message{
|
||||
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), sessID, []model.Message{
|
||||
model.TextMessage(model.RoleUser, text),
|
||||
})
|
||||
if err != nil {
|
||||
b.log.Warn("agent run failed", "platform", msg.Platform,
|
||||
"session", sess.SessionID, "error", err)
|
||||
"session", sessID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
reply := strings.TrimSpace(res.Text)
|
||||
if reply == "" {
|
||||
b.log.Debug("no reply produced", "platform", msg.Platform, "session", sess.SessionID)
|
||||
b.log.Debug("no reply produced", "platform", msg.Platform, "session", sessID)
|
||||
return
|
||||
}
|
||||
// Persist the assistant reply into the main web session (design/16 §4.4) and
|
||||
// broadcast, then deliver it back to the channel — so the Web client mirrors the
|
||||
// IM reply too.
|
||||
if isOwnerPrivate {
|
||||
b.persistToMain(ctx, history.RoleAssistant, reply, platform, deviceIDFor(msg))
|
||||
}
|
||||
if err := b.sendReply(ctx, msg, reply); err != nil {
|
||||
if errors.Is(err, adapter.ErrUnsupported) {
|
||||
b.log.Debug("channel does not support outbound reply, skipped",
|
||||
"platform", msg.Platform, "session", sess.SessionID)
|
||||
"platform", msg.Platform, "session", sessID)
|
||||
return
|
||||
}
|
||||
b.log.Warn("send reply failed", "platform", msg.Platform, "error", err)
|
||||
@@ -422,3 +458,20 @@ func cfgStr(m map[string]any, key string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// deviceIDFor builds the client deviceID tag for an inbound IM message
|
||||
// (design/16 §4.5): <im平台名>:<本地配置唯一id>:<用户平台唯一uid>. UnifiedMessage has no
|
||||
// explicit deviceId field yet, so the local config id and the user platform uid are
|
||||
// taken from the normalized channel binding: ChannelID stands in for the local
|
||||
// adapter binding and SenderID is the owner's platform uid. When SenderID is absent
|
||||
// the tag degrades to <platform>:<channelID> so it stays non-empty and readable.
|
||||
//
|
||||
// Extension point: when an adapter/UnifiedMessage grows a concrete deviceId (or the
|
||||
// bridge gains a local config id), replace the ChannelID stand-in here and return
|
||||
// that value directly.
|
||||
func deviceIDFor(msg adapter.UnifiedMessage) string {
|
||||
if msg.SenderID != "" {
|
||||
return msg.Platform + ":" + msg.ChannelID + ":" + msg.SenderID
|
||||
}
|
||||
return msg.Platform + ":" + msg.ChannelID
|
||||
}
|
||||
Reference in new issue
Block a user