feat(channels): IM 入站图片接线(纯图片不再被丢 / data URI 进模型视图 / 诚实提示 / 来源白名单)

This commit is contained in:
AskaEth committed 2026-10-05 11:22:26 +08:00
1 parent 0cbc9ff886
commit b73a1276c6
18 files changed
+1224 -121

No files matched your search

+25 -42
View File
@@ -140,9 +140,10 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
b.normalizeInboundVoice(ctx, &msg)
text := strings.TrimSpace(msg.Content)
// 无文本且无**任何可用媒体** ⇒ 没有可处理的内容。带附件的「纯文件消息」与带语音的
// 「纯语音消息」必须继续往下走:附件/语音就是它的内容(design/24 §4.4 的
// 「不许静默丢」;语音那一路的归一已保证它要么有文本、要么有诚实提示)。
// 无文本且无**任何可用媒体** ⇒ 没有可处理的内容。带附件的「纯文件消息」、带语音的
// 「纯语音消息」与带图片的「纯图片消息」(微信/QQ 发图常常没有配文)必须继续往下走:
// 附件/语音/图片就是它的内容(design/24 §4.4 的「不许静默丢」;语音那一路的归一已
// 保证它要么有文本、要么有诚实提示,图片那一路的失败也留一行中文诚实提示)。
if text == "" && !hasUsableMedia(msg) {
b.log.Debug("inbound message has no chat text, skipped",
"platform", msg.Platform, "channel", msg.ChannelID)
@@ -230,9 +231,10 @@ func inboundTargetFor(sess session.Session, platform string) inboundTarget {
// runInboundTurn 为被放行的消息驱动 agent 轮次:解析
// 路由目标、把消息携带的文件类附件**落库并挂成文档部件**
// (design/24 §3;切片 M1)、构建模型可见的消息列表、在轮次之前持久化入站
// 用户消息**连同它们的 `document` 引用列**(因此即使渠道回复失败它也已落地,
// 客户端也有附件芯片可点),并在轮次之后把抽取结论补回同一列,运行
// (design/24 §3;切片 M1)、把图片媒体内联成 data: URI 部件挂上既有视觉链路
// (切片 IM-IMG;design/24 §4.2 末)、构建模型可见的消息列表、在轮次之前持久化入站
// 用户消息**连同它们的 `document` 引用列与 `image` 列**(因此即使渠道回复失败它也已落地,
// 客户端也有附件芯片与图片可看),并在轮次之后把抽取结论补回同一列,运行
// 循环并把结果交给 deliverReply。
func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
text string, actor adapter.Actor, sess session.Session) {
@@ -242,6 +244,9 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
// 最小打扰拦下的消息绝不在盘上留下文件。成功者挂成文档部件,装配期
// resolveDocuments 照既有链路抽取/注入;读不到的走诚实提示。
attachments := b.ingestInboundDocuments(ctx, msg)
// 图片内联与文档摄取**并列**、互不影响:文档走存储与抽取,图片走 data: URI 部件
// (同一条用户消息上两条都能挂)。它没有 ctx —— 这里没有任何 I/O(字节由适配器取回)。
images := b.ingestInboundImages(msg)
// 第三方边界只在受限(群聊/陌生人)上下文中生效:
// 记忆只读(MemWritable=false),并携带发送者名,
@@ -257,23 +262,25 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
runEnv.PersonaID = b.resolvePersona(ctx, sess, target, adapterName(a))
senderName := senderDisplayName(msg, actor)
loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg)
attachInboundAttachments(loopMsgs, attachments)
attachInboundContent(loopMsgs, attachments, images)
// 附件引用必须同批落进用户消息的 `document` 列(design/24 §2.4):只把部件挂在
// 内存里的模型可见消息上,客户端读到的那条消息就是一片空白 —— 附件芯片只认库
// 里的那一列。这里写的是引用与身份,结论要等回合跑完(下一段)。
// 附件引用与图片必须同批落进用户消息各自的列(design/24 §2.4 / design/16 §4.4):
// 只把部件挂在内存里的模型可见消息上,客户端读到的那条消息就是一片空白 —— 附件芯片
// 只认 `document` 列,图片只认 `image` 列(**图片绝不进 `document` 列**)。这里写的是
// 引用与身份,文档结论要等回合跑完(下一段)。
document := b.inboundDocumentColumn(msg, attachments)
image := inboundImageColumn(images)
// 主人私聊消息落在 Web 主会话并广播给 Web 客户端;
// 受限消息落在其隔离会话(适配器日志页),
// 以 `channel_persisted` 事件广播(绝不进入
// 主/`web` 会话或 Web 聊天)。
// 无论有没有文本都要落库:附件就是纯文件消息的内容(design/24 §4.4)。
// 无论有没有文本都要落库:附件/图片就是纯媒体消息的内容(design/24 §4.4)。
var userMsg history.Message
if target.private {
userMsg = b.persistToMain(ctx, history.RoleUser, text, "", document, target.platform, deviceIDFor(msg), adapterName(a))
userMsg = b.persistToMain(ctx, history.RoleUser, text, "", document, image, target.platform, deviceIDFor(msg), adapterName(a))
} else {
userMsg = b.persistIsolated(ctx, history.RoleUser, text, document, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a))
userMsg = b.persistIsolated(ctx, history.RoleUser, text, document, image, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a))
}
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), target.sessionID, loopMsgs)
@@ -315,9 +322,9 @@ func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a
// 广播;受限 → 隔离日志 + channel_persisted 广播),随后
// 送回渠道。
if target.private {
b.persistToMain(ctx, history.RoleAssistant, reply, mood, "", target.platform, deviceIDFor(msg), adapterName(a))
b.persistToMain(ctx, history.RoleAssistant, reply, mood, "", "", target.platform, deviceIDFor(msg), adapterName(a))
} else {
b.persistIsolated(ctx, history.RoleAssistant, reply, "", "", sess, msg.Platform, "", adapterName(a))
b.persistIsolated(ctx, history.RoleAssistant, reply, "", "", "", sess, msg.Platform, "", adapterName(a))
}
if err := b.sendReply(ctx, msg, a, reply); err != nil {
if errors.Is(err, adapter.ErrUnsupported) {
@@ -346,30 +353,6 @@ func deviceIDFor(msg adapter.UnifiedMessage) string {
return msg.Platform + ":" + msg.ChannelID
}
// attachInboundAttachments 把入站附件摄取的结果挂到消息列表里的**用户回合**上
// (design/24 §3/§4.4;切片 M1):文档部件与「读不到」的诚实提示行都只进
// `RoleUser`,且挂在那条已构建好的用户消息上 —— 来源系统行、第三方边界、
// SenderName 等既有装配因此逐字不变(本切片不重排消息,只追加部件)。
//
// 一个回合只有一条用户消息(buildLoopMessages 的既有形状),因此找到第一条即
// 挂载并返回;没有附件产物时是空操作。文本部件为空且既有文本为空时**不塞空文本
// 部件**(provider 会拒):附件部件本身就是这条消息的内容。
func attachInboundAttachments(msgs []model.Message, attach inboundAttachments) {
if len(attach.parts) == 0 && len(attach.notices) == 0 {
return
}
for i := range msgs {
if msgs[i].Role != model.RoleUser {
continue
}
msgs[i].Content = append(msgs[i].Content, attach.parts...)
if notice := attach.noticeText(); notice != "" {
msgs[i].Content = append(msgs[i].Content, model.Content{Type: model.ContentTypeText, Text: notice})
}
return
}
}
// buildLoopMessages 为一次入站轮次构建模型可见的消息列表。它
// 用 "[来源: <platform> · <conversation>]" 前缀装饰用户消息,使
// 模型能看到消息来自哪个平台、哪个会话(design/16
@@ -382,9 +365,9 @@ func attachInboundAttachments(msgs []model.Message, attach inboundAttachments) {
// 保持不动(构建带前缀的副本),因此持久化内容与
// 来源平台投递的内容一致。
//
// 入站附件摄取的产物(已落库的文档部件与「读不到」的诚实提示行)由
// attachInboundAttachments 挂到**同一条 RoleUser 消息**上 —— 用户内容只进用户
// 角色消息是项目红线。
// 入站内容摄取的产物(已落库的文档部件、内联成 data: URI 的图片部件,以及两条路上
// 「读不到」的诚实提示行)由 attachInboundContent(inbound_content.go)挂到**同一条
// RoleUser 消息**上 —— 用户内容只进用户角色消息是项目红线。
func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message {
msg := userTurnMessage(text)
thirdParty := env.Restricted() && !actor.IsOwner() && senderName != ""