mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
feat(sdk): 多模态贯通插件边界——公开接口、内核桥接与统一输入主干
记忆系统在 1.1.0 支持了二进制多媒体节点,但那条链路只对**内核自己**开放: 用户在 qq 发图能落进 CAS、能被记忆引用,而插件调 Commit / DocMemory().Insert 交进来的媒体一律无处安放。原因是三层都断着,且**每一层都不报错**。 ## 一、公开 SDK:补上媒体的表达能力(全部新增,无签名变更) - `Triple` += `SentenceText`、`MediaDigests` - `Doc` += `MediaDigests`、`Attachments`;新增 `MediaAttachment` - `TextEvent` += `Attachments` - `DocMemoryAPI` += `InsertWithMedia` - `IOInjector` += `InjectInputMedia` / `InjectInputMediaSync` / `InjectInterruptMedia` - `PluginSDK` 补上一直缺失的 `SetToolBlocks` 包装(接口里有、便捷方法里没有) `MediaAttachment` 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(CAS 按字节 去重),只给 `Digest` 是引用已有内容。读路径**只回元数据不回字节**——一次检索 可能命中几十份媒体,全塞回去会把跨进程消息撑爆。 媒体注入不能搭 `SetToolBlocks` 的车:那个方法只在工具处理函数内部可用,且媒体 要等下一条 tool message 才到模型手上。插件主动发起一轮带媒体的对话、以及中断 注入,需要自己的签名,且媒体在**本轮**就送到模型。 ## 二、内核桥接层:原先在静默裁字段 `internal/sdk/memory_impl.go` 此前只搬自己认识的几个字段,其余丢弃且返回 nil: - 图记忆丢 `Confidence`/`SubjectType`/`ObjectType`/`SentenceText`,又走 `Commit` 而非 `CommitWithMedia`(不回 sentenceIDs)→ 媒体绑定链 `SentenceText → sentences → sentence_id → media_refs` 一步都走不通,插件即便按格式写好标记也永远挂不上; - 知识库 `Query` 只回 ID/Title/Content,`Insert` 只写这三个;`Remove` 不解引用, 于是那些媒体永久处于「被引用」状态,GC 收不掉、磁盘只增不减 (内核的归档路径 `releaseDocMedia` 做了这一步,插件路径漏了同一步)。 规则改为:**内部结构有的字段一律透传**。标记格式处理作为包级私有辅助留在桥接 层自己手里,但必须与内核 `mediaSummaryForEvent` 字节兼容——两边要能互读对方 写下的标记。 标记插入必须在 `ds.Insert` **之前**(向量索引取 `Summary + " " + Content`, 之后补的标记检索不到),引用绑定必须在**之后**(owner_id 是 Insert 生成的 ID)。 ## 三、跨进程链路:不接线就是全体外部插件编译失败 `go test` 直接把这一层拍出来了——`procIO does not implement sdk.IOInjector`。 公开接口加方法后,生成模板不跟上,**每个外部插件都编不过**,是硬失败不是软降级。 六处接线:`protocol.go` 四个 method 常量、`capability.go` 能力归属、 `corehandler.go` 四个分派分支、`proc_core.go` 委托、`proc_main.go.tmpl` 模板侧 实现、以及三个测试替身。 ## 四、统一输入主干:把模态从「函数选择」降级为「字段」 `processTextInput` / `processMediaInput` 合并为 `processInput`。这个分叉是历史 产物而非设计:`processTextInput` 本来就处理媒体(`bindEventMedia` + `mediaSummaryForEvent`,与媒体路径尾部完全相同),`process()` 只看 `stageCtx.Extra["media_blocks"]`、根本不认识 `evt.Type`。模态是输入的**属性**, 不是输入的**种类**。 媒体路径由此获得它一直缺的六项:去重、`no_memory`、通道 `Cleaner`、中断语义、 `_consolidation_` 路由、正确的 `EventRawInput`。 最后一项是个真 bug:媒体路径发布 `"content": evt.Payload`(一个 map),而 `webui/handler.go` 断言 `.(string)` → 断言失败、`content == ""`、提前返回。 **用户发的图从来没出现在 WebUI 聊天记录里。** `media_blocks` 同时接受 `[]agentAPI.ContentBlock` 与 `[]pubsdk.ContentBlock`: 字段一致但 Go 不自动转换,只认一种的后果是另一种被静默丢弃。 ## 五、模型可调用的三个工具 `memory_commit` 的 `sentence_text` **从未暴露给模型**,而它是绑定链上的必经环节; 连同 `media_digests` 一起补进 JSON schema 与工具文档。`doc_commit` 加 `media_digests`。`doc_query` 把关联媒体单独一行附在结果末尾(正文按 2000 字截断, 标记通常就在尾部)。 标记由**内核**生成而非插件/模型拼装:要求调用方知道格式,等于让一个拼写错误 静默切断引用绑定,而全链路无人报错。 ## 六、WebUI 上传走真实媒体链路 图片/音频读回字节拼 data URL 注入 `media_blocks`(8MB 上限,超限退回按路径处理)。 此前只注入一句「文件已保存到 <路径>」,指望模型自己调 `files_read`——但那返回 文本,图片字节对模型永远不可见。附件类型识别扩展到 audio 并在缺 Content-Type 时按扩展名兜底(判错不只是卡片样式问题,图片被当普通文件就进不了视觉链路)。 ## 测试 - `internal/sdk/memory_impl_test.go`(12 例,此前该包**没有任何测试文件**) - `internal/agent/core/inputunify_test.go`(统一主干 + 双静态类型 + 三工具媒体) - `third_party/homeagent-sdk/sdk/stress_test.go`(13 例并发压测) 压测抓到两处**真**竞态(不是理论风险):`PluginSDK` 的 API 字段与 `autoRestart` 无锁,而写方(内核注入 API、插件 `SetAutoRestart`)与读方(插件后台 goroutine 注入、内核 registry 读 `AutoRestart`)天然跨 goroutine。加 `apiMu` 修掉;约定 只在持锁期间取字段值,取完即释放再调用——持锁调用会把 `InjectInputSync` 这类 阻塞到 agent 回复(可达数分钟)的方法与 `SetIOInjector` 串起来,让插件重载卡死。 测试还抓出两个自身缺陷:`bindDocMedia` 把同一份媒体数两次(`AddRef` 幂等所以表 是对的,但日志说「绑定 2 个」而实际 1 条——误导后续排查),以及用单字符实体名 时 `validEntityName` 静默跳过、`Commit` 返回 nil 却什么都没写。 存量插件不需要改一行也不需要重编:新增方法由插件调用、内核实现,不调就不受影响。 17 个 example 插件源码零改动通过类型检查。
This commit is contained in:
@ -32,7 +32,7 @@ func (tc *toolCapture) RegisterTool(name string, def sdk.ToolDef, handler sdk.To
|
||||
return nil
|
||||
}
|
||||
func (tc *toolCapture) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) {}
|
||||
func (tc *toolCapture) RegisterAPI(name string) error { return nil }
|
||||
func (tc *toolCapture) RegisterAPI(name string) error { return nil }
|
||||
|
||||
func setupPlugin() (*Plugin, *toolCapture, error) {
|
||||
p := New("agentcli")
|
||||
@ -422,6 +422,24 @@ func (c *injectCapture) SetToolBlocks(blocks []sdkpub.ContentBlock) {
|
||||
}
|
||||
func (c *injectCapture) InjectInputSync(source, channel, text string) string { return "" }
|
||||
|
||||
// 三个带媒体的注入方法同样记录文本:本测试只关心「注入了什么话」,
|
||||
// 媒体块的转发在 core 的 injectedBlocks 测试里覆盖。
|
||||
func (c *injectCapture) InjectInputMedia(source, channel, text string, blocks []sdkpub.ContentBlock) {
|
||||
c.mu.Lock()
|
||||
c.texts = append(c.texts, text)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *injectCapture) InjectInputMediaSync(source, channel, text string, blocks []sdkpub.ContentBlock) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *injectCapture) InjectInterruptMedia(source, channel, text string, blocks []sdkpub.ContentBlock) {
|
||||
c.mu.Lock()
|
||||
c.texts = append(c.texts, text)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *injectCapture) snapshot() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@ -3148,6 +3148,19 @@
|
||||
'" ' +
|
||||
'alt="image" loading="lazy" style="max-width:320px;max-height:240px;border-radius:10px;display:block;cursor:zoom-in" ' +
|
||||
'onerror="this.parentElement.innerHTML=\'<span class=\\"att-err\\">图片加载失败</span>\'"/></a>';
|
||||
} else if (att.type === "audio") {
|
||||
// 音频用原生播放器:与图片同理,附件能在聊天里直接消费才算可见。
|
||||
// preload="metadata" 只拉时长不拉全部字节,避免历史消息满屏时并发下载。
|
||||
attHtml =
|
||||
'<audio class="chat-attachment-audio" controls preload="metadata" src="' +
|
||||
escHtml(att.url) +
|
||||
'" style="max-width:320px;display:block"></audio>' +
|
||||
'<a href="' +
|
||||
escHtml(att.url) +
|
||||
'" download style="font-size:12px;color:var(--text-muted,#888);text-decoration:none">' +
|
||||
escHtml(att.name || "audio") +
|
||||
(att.size ? " (" + formatBytes(att.size) + ")" : "") +
|
||||
"</a>";
|
||||
} else {
|
||||
var sizeStr = att.size ? formatBytes(att.size) : "";
|
||||
attHtml =
|
||||
@ -3929,7 +3942,13 @@
|
||||
role: "user",
|
||||
content: message,
|
||||
attachment: {
|
||||
type: /^image\//.test(fileObj.type) ? "image" : "file",
|
||||
// 与服务端的 attType 判定保持一致(image/audio/file),
|
||||
// 否则乐观渲染的卡片会在 SSE 回流后变成另一种样式。
|
||||
type: /^image\//.test(fileObj.type)
|
||||
? "image"
|
||||
: /^audio\//.test(fileObj.type)
|
||||
? "audio"
|
||||
: "file",
|
||||
url: URL.createObjectURL(fileObj),
|
||||
name: fileObj.name,
|
||||
size: fileObj.size,
|
||||
@ -4578,7 +4597,11 @@
|
||||
if (p.kind === "channel_output") {
|
||||
// 附件输出(output_type=image/file):渲染为图片预览/下载卡片
|
||||
var att = null;
|
||||
if (p.output_type === "image" || p.output_type === "file") {
|
||||
if (
|
||||
p.output_type === "image" ||
|
||||
p.output_type === "audio" ||
|
||||
p.output_type === "file"
|
||||
) {
|
||||
att = {
|
||||
type: p.output_type,
|
||||
url: p.url || p.content,
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@ -1446,6 +1447,13 @@ func parseIntDefault(s string, def int) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// maxInlineMediaBytes 是上传媒体内联进 LLM 请求的字节上限。
|
||||
//
|
||||
// base64 会胀大 4/3,8MB 原图变成 ~11MB 文本;再加上网关的请求体上限与
|
||||
// 模型的图像 token 预算,超过这个量级多半会被上游 413 拒掉。
|
||||
// 超限时退回按路径处理(模型可用 describe_image 主动看)而不是报错。
|
||||
const maxInlineMediaBytes = 8 << 20
|
||||
|
||||
// handleChatFile 处理用户经 webui 上传文件并附带消息注入 agent。
|
||||
// 设计对齐 qq 插件收文件模式:文件落盘到固定目录(<data>/uploads),
|
||||
// 注入文本带「文件名 + 保存路径」,agent 用 files_read 等工具按路径消费。
|
||||
@ -1511,8 +1519,45 @@ func (h *Handler) handleChatFile(w http.ResponseWriter, r *http.Request) {
|
||||
dlURL := "/uploads/" + filepath.Base(savePath)
|
||||
attType := "file"
|
||||
ct := hdr.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(ct, "image/") {
|
||||
if ct == "" {
|
||||
// 部分客户端(curl -F、某些移动端)不带 Content-Type,退回按扩展名判定。
|
||||
// 判错的后果不只是卡片样式:图片被当普通文件就走不进视觉链路,模型看不到图。
|
||||
ct = contentTypeByExt(strings.ToLower(filepath.Ext(savePath)))
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(ct, "image/"):
|
||||
attType = "image"
|
||||
case strings.HasPrefix(ct, "audio/"):
|
||||
attType = "audio"
|
||||
}
|
||||
|
||||
// 图片/音频直接进多模态链路:读回字节拼 data URL,随本轮 message 发给模型。
|
||||
//
|
||||
// 此前只注入一句「文件已保存到 <路径>」,指望模型自己调 files_read——
|
||||
// 但 files_read 返回的是文本,图片的字节对模型永远不可见,除非它想到再调
|
||||
// describe_image。走 InjectInputMedia 后与用户在 qq 发图走同一条统一输入主干:
|
||||
// 自动落进 CAS、挂上媒体记忆引用,且模型「本轮」就看得到图。
|
||||
var mediaBlocks []sdk.ContentBlock
|
||||
if attType == "image" || attType == "audio" {
|
||||
if sz > maxInlineMediaBytes {
|
||||
log.Printf("[webui] %s %s 有 %s,超过 %s 内联上限,退回按路径处理",
|
||||
attType, base, formatBytesGo(sz), formatBytesGo(maxInlineMediaBytes))
|
||||
} else if raw, err := os.ReadFile(savePath); err != nil {
|
||||
log.Printf("[webui] 读回上传的%s失败,退回按路径处理: %v", attType, err)
|
||||
} else {
|
||||
dataURL := "data:" + ct + ";base64," + base64.StdEncoding.EncodeToString(raw)
|
||||
if attType == "image" {
|
||||
mediaBlocks = []sdk.ContentBlock{{
|
||||
Type: "image_url",
|
||||
ImageURL: &sdk.ImageURL{URL: dataURL, Detail: "auto"},
|
||||
}}
|
||||
} else {
|
||||
mediaBlocks = []sdk.ContentBlock{{
|
||||
Type: "audio_url",
|
||||
AudioURL: &sdk.AudioURL{URL: dataURL},
|
||||
}}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 注入 agent:文件元信息走 interrupt 通道(内核以 system 角色注入 LLM,
|
||||
@ -1524,8 +1569,17 @@ func (h *Handler) handleChatFile(w http.ResponseWriter, r *http.Request) {
|
||||
if deviceID != "" {
|
||||
source = "webui/" + deviceID
|
||||
}
|
||||
typeLabel := map[string]string{"image": "图片", "audio": "音频", "file": "文件"}[attType]
|
||||
if typeLabel == "" {
|
||||
typeLabel = "文件"
|
||||
}
|
||||
fileNote := fmt.Sprintf("[用户通过 webui 发送了%s: %s (%s)]\n文件已保存到: %s\n可用 files_read 等工具读取此路径处理。",
|
||||
map[string]string{"image": "图片", "file": "文件"}[attType], base, humanSize, savePath)
|
||||
typeLabel, base, humanSize, savePath)
|
||||
// 媒体已随本轮发给模型时不再叫它去读文件:那只会读到一堆二进制字节。
|
||||
if len(mediaBlocks) > 0 {
|
||||
fileNote = fmt.Sprintf("[用户通过 webui 发送了%s: %s (%s)]\n原文件保存在: %s",
|
||||
typeLabel, base, humanSize, savePath)
|
||||
}
|
||||
if message != "" {
|
||||
text := message
|
||||
go func() {
|
||||
@ -1538,14 +1592,15 @@ func (h *Handler) handleChatFile(w http.ResponseWriter, r *http.Request) {
|
||||
if clientMsgID != "" {
|
||||
payload2["client_msg_id"] = clientMsgID + "-note"
|
||||
}
|
||||
h.sdk.InjectInput(source, "webui", "text", func() map[string]interface{} {
|
||||
p := payload2
|
||||
p["upload_url"] = dlURL
|
||||
p["upload_type"] = attType
|
||||
p["upload_size"] = sz
|
||||
p["upload_name"] = base
|
||||
return p
|
||||
}())
|
||||
payload2["upload_url"] = dlURL
|
||||
payload2["upload_type"] = attType
|
||||
payload2["upload_size"] = sz
|
||||
payload2["upload_name"] = base
|
||||
// 媒体跟附言同一条注入:拆开会让模型先看到「帮我看看这张图」而图在下一轮才到。
|
||||
if len(mediaBlocks) > 0 {
|
||||
payload2["media_blocks"] = mediaBlocks
|
||||
}
|
||||
h.sdk.InjectInput(source, "webui", "text", payload2)
|
||||
}()
|
||||
time.Sleep(100 * time.Millisecond) // 保证附言先入队
|
||||
h.sdk.InjectInterrupt(source, "webui", "text", map[string]interface{}{"content": fileNote, "no_memory": true})
|
||||
@ -1563,6 +1618,9 @@ func (h *Handler) handleChatFile(w http.ResponseWriter, r *http.Request) {
|
||||
"upload_size": sz,
|
||||
"upload_name": base,
|
||||
}
|
||||
if len(mediaBlocks) > 0 {
|
||||
payload["media_blocks"] = mediaBlocks
|
||||
}
|
||||
if deviceID != "" {
|
||||
payload["device_id"] = deviceID
|
||||
payload["device_name"] = deviceName
|
||||
|
||||
Reference in New Issue
Block a user