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:
@ -10,6 +10,7 @@ import (
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func (a *Agent) eventLoop() {
|
||||
@ -125,30 +126,18 @@ func (a *Agent) handleSelfInput(msg selfInputMsg) {
|
||||
if msg.channel == "" {
|
||||
msg.channel = channelConsolidation // 兼容空值:默认走整理路径
|
||||
}
|
||||
a.processTextInput(&agentIO.InputEvent{
|
||||
a.processInput(&agentIO.InputEvent{
|
||||
Source: "system",
|
||||
Type: "text",
|
||||
Payload: map[string]interface{}{"content": msg.text},
|
||||
OutputChannel: msg.channel,
|
||||
}, msg.text)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Agent) handleInput(evt *agentIO.InputEvent) {
|
||||
switch evt.Type {
|
||||
case "text":
|
||||
input, _ := evt.Payload["content"].(string)
|
||||
if input == "" {
|
||||
return
|
||||
}
|
||||
// 去重:webui/GUI 断线重连会重放未确认消息,短窗口内同来源同内容丢弃,避免轰炸
|
||||
if a.isDuplicateInput(evt.Source, input) {
|
||||
log.Printf("[agent] dropped duplicate input from %s: %s", evt.Source, truncateStr(input, 60))
|
||||
return
|
||||
}
|
||||
a.processTextInput(evt, input)
|
||||
|
||||
case "image", "audio":
|
||||
a.processMediaInput(evt)
|
||||
case "text", "image", "audio":
|
||||
a.processInput(evt)
|
||||
|
||||
case "event":
|
||||
log.Printf("[agent] event from %s: %v", evt.Source, evt.Payload)
|
||||
@ -162,87 +151,97 @@ func (a *Agent) handleInput(evt *agentIO.InputEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
|
||||
start := time.Now()
|
||||
a.pendingMedia = evt.Payload
|
||||
defer func() { a.pendingMedia = nil }()
|
||||
// inputPayload 是一次输入在「模态」这个维度上的全部内容。
|
||||
//
|
||||
// 拆出这个结构,是为了让 processInput 只有一条主干:模态不再决定走哪个函数,
|
||||
// 只决定这里的字段填不填。此前 text 与 image/audio 各有一个 process 函数,
|
||||
// 媒体那条缺了去重、no_memory、通道 Cleaner、中断语义、EventRawInput 五项——
|
||||
// 不是因为媒体不需要,而是复制粘贴之后文本那条继续演进、媒体那条没跟上。
|
||||
type inputPayload struct {
|
||||
// text 是进 LLM 与记忆的文本。纯媒体输入时它是 mediaToBlocks 给的 alt 文案。
|
||||
text string
|
||||
// blocks 非空表示本轮带多模态内容,随当前轮的 message 一起发给模型。
|
||||
blocks []agentAPI.ContentBlock
|
||||
// mediaType 供插件在 stage 里判断本轮媒体的模态。
|
||||
mediaType string
|
||||
// captureTool 是媒体落进 CAS 时记录的来源标签。
|
||||
captureTool string
|
||||
}
|
||||
|
||||
a.currentOutputChannel = evt.OutputChannel
|
||||
if a.currentOutputChannel == "" {
|
||||
a.currentOutputChannel = evt.Source
|
||||
// resolveInput 把 InputEvent 归一成 inputPayload。
|
||||
//
|
||||
// 三种来源在这里合流:
|
||||
// 1. evt.Type 是 image/audio —— 用户直接发的媒体,payload 里是 data/url;
|
||||
// 2. evt.Type 是 text 且 payload 带 media_blocks —— 插件经 IOInjector 的
|
||||
// InjectInputMedia / InjectInputMediaSync / InjectInterruptMedia 注入的
|
||||
// 媒体,块已经是成品;
|
||||
// 3. 纯文本。
|
||||
//
|
||||
// 第 2 种此前无处可去:注入方把块放进 payload,而文本路径不看这个键,
|
||||
// 于是插件注入的媒体到 payload 就断了,且不报错。
|
||||
func (a *Agent) resolveInput(evt *agentIO.InputEvent) (inputPayload, bool) {
|
||||
switch evt.Type {
|
||||
case "image", "audio":
|
||||
blocks, alt := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source)
|
||||
return inputPayload{
|
||||
text: alt,
|
||||
blocks: blocks,
|
||||
mediaType: evt.Type,
|
||||
captureTool: "input_" + evt.Type,
|
||||
}, true
|
||||
}
|
||||
|
||||
blocks, fallback := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source)
|
||||
|
||||
// 用户直接发来的媒体:先落进 CAS。
|
||||
// 不存的后果是 ContextEvent.Input 只剩一句 alt 文本
|
||||
//("[从 qq 收到了 image]"),base64 随 message 数组发给模型后就丢了。
|
||||
a.stageMediaDigests(a.captureBlockMedia(blocks, "input_"+evt.Type)...)
|
||||
|
||||
stageCtx := a.stageCtxFromInput(fallback, evt.Source, "")
|
||||
stageCtx.Extra = map[string]interface{}{
|
||||
"media_blocks": blocks,
|
||||
"media_type": evt.Type,
|
||||
"input_source": evt.Source,
|
||||
"output_channel": evt.OutputChannel,
|
||||
text, _ := evt.Payload["content"].(string)
|
||||
blocks, mediaType := injectedBlocks(evt.Payload)
|
||||
// 文本与媒体都空才算无效输入:只带图不带字是合法的(插件注入常这样)。
|
||||
if text == "" && len(blocks) == 0 {
|
||||
return inputPayload{}, false
|
||||
}
|
||||
a.injectSourceContext(stageCtx, evt)
|
||||
return inputPayload{
|
||||
text: text,
|
||||
blocks: blocks,
|
||||
mediaType: mediaType,
|
||||
captureTool: "inject_" + evt.Source,
|
||||
}, true
|
||||
}
|
||||
|
||||
if a.runStage(sdk.StageOnInput, stageCtx) {
|
||||
a.emitResponse(evt, *stageCtx.Response)
|
||||
return
|
||||
// injectedBlocks 取出 payload 里插件注入的多模态块。
|
||||
//
|
||||
// 两种静态类型都要认:内核内部注入直接给 []agentAPI.ContentBlock,
|
||||
// 而经公共 SDK 的 IOInjector 过来的是 []pubsdk.ContentBlock。两者字段完全一致,
|
||||
// 但 Go 不会自动转换,只认一种的后果是另一种被静默丢弃。
|
||||
func injectedBlocks(payload map[string]interface{}) ([]agentAPI.ContentBlock, string) {
|
||||
var blocks []agentAPI.ContentBlock
|
||||
switch v := payload["media_blocks"].(type) {
|
||||
case []agentAPI.ContentBlock:
|
||||
blocks = v
|
||||
case []pubsdk.ContentBlock:
|
||||
blocks = make([]agentAPI.ContentBlock, 0, len(v))
|
||||
for _, b := range v {
|
||||
nb := agentAPI.ContentBlock{Type: b.Type, Text: b.Text}
|
||||
if b.ImageURL != nil {
|
||||
nb.ImageURL = &agentAPI.ImageURL{URL: b.ImageURL.URL, Detail: b.ImageURL.Detail}
|
||||
}
|
||||
if b.AudioURL != nil {
|
||||
nb.AudioURL = &agentAPI.AudioURL{URL: b.AudioURL.URL}
|
||||
}
|
||||
blocks = append(blocks, nb)
|
||||
}
|
||||
}
|
||||
|
||||
a.publishEvent(events.EventRawInput, map[string]interface{}{
|
||||
"content": evt.Payload,
|
||||
"source": evt.Source,
|
||||
})
|
||||
|
||||
archived := a.context.Prune(fallback, a.maxContextSize-1, a.docStore)
|
||||
if archived > 0 {
|
||||
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
|
||||
if len(blocks) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: start,
|
||||
Source: evt.Source,
|
||||
Input: fallback,
|
||||
})
|
||||
|
||||
response, toolsUsed, toolResults, err := a.process(fallback, stageCtx)
|
||||
if err != nil {
|
||||
log.Printf("[agent] process media error: %v", err)
|
||||
resp := fmt.Sprintf("处理错误: %v", err)
|
||||
a.emitResponse(evt, resp)
|
||||
a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: fallback, Response: resp})
|
||||
return
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed)
|
||||
|
||||
// 本轮捕获的媒体(用户发的 + 工具注入的)挂到这条事件上。
|
||||
// 媒体描述并进 Input:描述文本才是持久语义记忆,blob 只是缓存。
|
||||
digests := a.drainMediaDigests()
|
||||
mediaEvt := ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "agent",
|
||||
Input: fallback,
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
ToolResults: toolResults,
|
||||
}
|
||||
a.bindEventMedia(&mediaEvt, digests)
|
||||
if s := a.mediaSummaryForEvent(mediaEvt.Media); s != "" {
|
||||
mediaEvt.Input = mediaEvt.Input + "\n" + s
|
||||
}
|
||||
a.context.Append(mediaEvt)
|
||||
|
||||
a.emitResponse(evt, response)
|
||||
|
||||
if !stageCtx.NoMemory {
|
||||
a.emitMemoryCandidate(evt.Source, fallback, response, toolResults, toolsUsed)
|
||||
// 模态由块自身判定,注入方不必额外声明。图优先:一次注入里图片是主体。
|
||||
mediaType := ""
|
||||
for _, b := range blocks {
|
||||
if b.ImageURL != nil {
|
||||
return blocks, "image"
|
||||
}
|
||||
if b.AudioURL != nil {
|
||||
mediaType = "audio"
|
||||
}
|
||||
}
|
||||
return blocks, mediaType
|
||||
}
|
||||
|
||||
func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string, source string) ([]agentAPI.ContentBlock, string) {
|
||||
@ -298,19 +297,51 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string,
|
||||
return blocks, alt
|
||||
}
|
||||
|
||||
func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
// processInput 是全部模态输入的唯一主干。
|
||||
//
|
||||
// 文本、用户上传的图/音频、插件注入的多模态块走同一条路径,因此去重、
|
||||
// no_memory、通道 Cleaner、中断语义、EventRawInput、媒体入 CAS、媒体记忆绑定
|
||||
// 对所有模态一致——不会再出现「文本路径加了功能、媒体路径没跟上」。
|
||||
func (a *Agent) processInput(evt *agentIO.InputEvent) {
|
||||
start := time.Now()
|
||||
|
||||
in, ok := a.resolveInput(evt)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// 去重按文本做:webui/GUI 断线重连会重放未确认消息。
|
||||
// 带媒体时跳过——媒体输入的 alt 文案("[从 qq 收到了 image]")对不同图片
|
||||
// 是同一句,拿它去重会把连发的两张图误判成重复。
|
||||
if len(in.blocks) == 0 && a.isDuplicateInput(evt.Source, in.text) {
|
||||
log.Printf("[agent] dropped duplicate input from %s: %s", evt.Source, truncateStr(in.text, 60))
|
||||
return
|
||||
}
|
||||
|
||||
a.currentOutputChannel = evt.OutputChannel
|
||||
if a.currentOutputChannel == "" {
|
||||
a.currentOutputChannel = evt.Source
|
||||
}
|
||||
|
||||
if evt.OutputChannel == "_consolidation_" {
|
||||
a.processConsolidation(evt, input)
|
||||
a.processConsolidation(evt, in.text)
|
||||
return
|
||||
}
|
||||
|
||||
// pendingMedia 让 describe_image / transcribe_audio / ocr_image 拿到本轮媒体的
|
||||
// 原始 data/url,也是这三个工具是否出现在工具表里的开关。仅对用户直接上传成立
|
||||
//(payload 里才有 data/url);插件注入的是成品 block,取不到原始数据。
|
||||
if evt.Type == "image" || evt.Type == "audio" {
|
||||
a.pendingMedia = evt.Payload
|
||||
defer func() { a.pendingMedia = nil }()
|
||||
}
|
||||
|
||||
// 媒体先落进 CAS。不存的后果是 ContextEvent.Input 只剩一句 alt 文本,
|
||||
// base64 随 message 数组发给模型后就丢了。
|
||||
if len(in.blocks) > 0 {
|
||||
a.stageMediaDigests(a.captureBlockMedia(in.blocks, in.captureTool)...)
|
||||
}
|
||||
|
||||
noMemory := false
|
||||
if v, ok := evt.Payload["no_memory"].(bool); ok {
|
||||
noMemory = v
|
||||
@ -331,9 +362,13 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
noMemory = true
|
||||
}
|
||||
|
||||
stageCtx := a.stageCtxFromInput(input, evt.Source, "")
|
||||
stageCtx := a.stageCtxFromInput(in.text, evt.Source, "")
|
||||
stageCtx.Extra["input_source"] = evt.Source
|
||||
stageCtx.Extra["output_channel"] = evt.OutputChannel
|
||||
if len(in.blocks) > 0 {
|
||||
stageCtx.Extra["media_blocks"] = in.blocks
|
||||
stageCtx.Extra["media_type"] = in.mediaType
|
||||
}
|
||||
if noMemory {
|
||||
stageCtx.NoMemory = true
|
||||
}
|
||||
@ -344,7 +379,7 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
return
|
||||
}
|
||||
|
||||
input = stageCtx.RawMessage
|
||||
input := stageCtx.RawMessage
|
||||
|
||||
// 计算层用的清洗文本(不改原文):通道 Cleaner 提取语义内容后用于向量化/提关键词
|
||||
cleanInput := input
|
||||
@ -354,10 +389,16 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
}
|
||||
}
|
||||
|
||||
a.publishEvent(events.EventRawInput, map[string]interface{}{
|
||||
"content": input,
|
||||
"source": evt.Source,
|
||||
})
|
||||
// upload_* 字段一并转发:webui 的 EventRawInput 订阅方靠它们还原附件卡片。
|
||||
// 媒体路径此前把整个 payload 塞进 content(一个 map),订阅方按 string 断言
|
||||
// 直接失败 → 用户发的图从不出现在聊天记录里。
|
||||
rawPayload := map[string]interface{}{"content": input, "source": evt.Source}
|
||||
for _, k := range []string{"upload_url", "upload_type", "upload_size", "upload_name"} {
|
||||
if v, ok := evt.Payload[k]; ok {
|
||||
rawPayload[k] = v
|
||||
}
|
||||
}
|
||||
a.publishEvent(events.EventRawInput, rawPayload)
|
||||
|
||||
archived := a.context.Prune(cleanInput, a.maxContextSize-1, a.docStore)
|
||||
if archived > 0 {
|
||||
@ -374,7 +415,7 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
|
||||
response, toolsUsed, toolResults, err := a.process(input, stageCtx)
|
||||
if err != nil {
|
||||
log.Printf("[agent] process error: %v", err)
|
||||
log.Printf("[agent] process %s error: %v", evt.Type, err)
|
||||
resp := fmt.Sprintf("处理错误: %v", err)
|
||||
a.emitResponse(evt, resp)
|
||||
a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: input, Response: resp})
|
||||
@ -382,11 +423,12 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
log.Printf("[agent] input from %s → response (%dms, tools=%v)", evt.Source, elapsed.Milliseconds(), toolsUsed)
|
||||
log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed)
|
||||
|
||||
// 纯文本输入也可能产生媒体:模型调 multimodal_see_picture / see_video 等工具时,
|
||||
// 插件经 SetToolBlocks 注入的块已在 process() 里被捕获。
|
||||
textEvt := ContextEvent{
|
||||
// 本轮捕获的媒体一起挂到这条事件上:用户上传的、插件注入的,以及模型调
|
||||
// multimodal_see_picture / see_video 时经 SetToolBlocks 注入的(后者在
|
||||
// process() 里被捕获,纯文本输入也会有)。
|
||||
turnEvt := ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "agent",
|
||||
Input: cleanInput,
|
||||
@ -394,11 +436,11 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
ToolsUsed: toolsUsed,
|
||||
ToolResults: toolResults,
|
||||
}
|
||||
a.bindEventMedia(&textEvt, a.drainMediaDigests())
|
||||
if s := a.mediaSummaryForEvent(textEvt.Media); s != "" {
|
||||
textEvt.Input = textEvt.Input + "\n" + s
|
||||
a.bindEventMedia(&turnEvt, a.drainMediaDigests())
|
||||
if s := a.mediaSummaryForEvent(turnEvt.Media); s != "" {
|
||||
turnEvt.Input = turnEvt.Input + "\n" + s
|
||||
}
|
||||
a.context.Append(textEvt)
|
||||
a.context.Append(turnEvt)
|
||||
|
||||
a.emitResponse(evt, response)
|
||||
|
||||
|
||||
@ -255,6 +255,130 @@ func (a *Agent) bindSentenceMedia(sentenceIDs map[string]int64) int {
|
||||
return bound
|
||||
}
|
||||
|
||||
// sentenceWithMediaMarkers 保证句子文本里带上这些 digest 的媒体标记。
|
||||
//
|
||||
// 存在的理由:媒体的绑定链是 SentenceText → sentences 表 → sentence_id →
|
||||
// media_refs。模型只知道 digest(从 memory_recall 的「关联媒体」或对话里的
|
||||
// 媒体标记读到),不该要求它自己按内核格式拼标记——格式写错的后果是引用
|
||||
// 静默挂不上,模型也无从察觉。
|
||||
//
|
||||
// 已出现过的 digest 不重复追加:模型可能既写了标记又填了 media_digests。
|
||||
func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) string {
|
||||
if a.mediaStore == nil || len(digests) == 0 {
|
||||
return sentence
|
||||
}
|
||||
present := make(map[string]bool)
|
||||
for _, d := range extractMediaDigests(sentence) {
|
||||
present[d] = true
|
||||
}
|
||||
|
||||
var add []string
|
||||
for _, d := range digests {
|
||||
if d == "" || present[shortDigest(d)] {
|
||||
continue
|
||||
}
|
||||
// 模型给的多半是短 digest(它在上下文里看到的就是短的),补全成完整
|
||||
// digest 才能进 media_refs 主键。补不上就跳过:内容可能已被 GC 清掉。
|
||||
full, err := a.mediaStore.ResolvePrefix(d)
|
||||
if err != nil {
|
||||
log.Printf("[media] 模型提交的 digest %s 无法解析: %v", d, err)
|
||||
continue
|
||||
}
|
||||
if line := a.mediaMarkerLine(full); line != "" {
|
||||
add = append(add, line)
|
||||
present[shortDigest(full)] = true
|
||||
}
|
||||
}
|
||||
if len(add) == 0 {
|
||||
return sentence
|
||||
}
|
||||
if sentence == "" {
|
||||
return strings.Join(add, "\n")
|
||||
}
|
||||
return sentence + "\n" + strings.Join(add, "\n")
|
||||
}
|
||||
|
||||
// docMediaContext 为一篇文档产出媒体说明,供 doc_query 拼进工具返回值。
|
||||
//
|
||||
// 优先读 media_refs(权威:谁挂上去的就是谁),为空时退回解析正文标记——
|
||||
// 历史文档与经旧版路径写入的文档只有标记、没有引用。
|
||||
func (a *Agent) docMediaContext(docID, content string) string {
|
||||
if a.mediaStore == nil {
|
||||
return ""
|
||||
}
|
||||
digests, err := a.mediaStore.Refs(media.OwnerDocument, docID)
|
||||
if err != nil {
|
||||
log.Printf("[media] 读取文档 %s 的媒体引用失败: %v", docID, err)
|
||||
}
|
||||
if len(digests) == 0 {
|
||||
for _, short := range extractMediaDigests(content) {
|
||||
full, err := a.mediaStore.ResolvePrefix(short)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
digests = append(digests, full)
|
||||
}
|
||||
}
|
||||
var lines []string
|
||||
for _, d := range digests {
|
||||
if line := a.mediaMarkerLine(d); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, ";")
|
||||
}
|
||||
|
||||
// resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。
|
||||
//
|
||||
// 补不上就丢弃那一条并记日志:模型可能凭印象编了个 digest,也可能内容已被
|
||||
// 容量 GC 淘汰。挂一条对不上的引用比不挂更糟——digest 进了 media_refs 主键,
|
||||
// 错了则 DropOwner 永远匹配不到它,那是一条永久泄漏的引用。
|
||||
func (a *Agent) resolveMediaDigests(digests []string) []string {
|
||||
if a.mediaStore == nil || len(digests) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool, len(digests))
|
||||
var out []string
|
||||
for _, d := range digests {
|
||||
full, err := a.mediaStore.ResolvePrefix(d)
|
||||
if err != nil {
|
||||
log.Printf("[media] 模型给的 digest %s 无法解析: %v", d, err)
|
||||
continue
|
||||
}
|
||||
if seen[full] {
|
||||
continue
|
||||
}
|
||||
seen[full] = true
|
||||
out = append(out, full)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bindDocMedia 把一组完整 digest 挂到文档 owner 上,返回成功条数。
|
||||
//
|
||||
// 与 releaseDocMedia 成对:文档归档进 L3 时释放,文档写入时绑定。
|
||||
// 只绑不放会让磁盘只增不减,只放不绑会让 GC 误删仍被引用的内容。
|
||||
func (a *Agent) bindDocMedia(docID string, digests []string) int {
|
||||
if a.mediaStore == nil || docID == "" || len(digests) == 0 {
|
||||
return 0
|
||||
}
|
||||
bound := 0
|
||||
for _, d := range digests {
|
||||
if err := a.mediaStore.AddRef(d, media.OwnerDocument, docID); err != nil {
|
||||
log.Printf("[media] 文档引用绑定失败 (%s → doc %s): %v", shortDigest(d), docID, err)
|
||||
continue
|
||||
}
|
||||
bound++
|
||||
}
|
||||
if bound > 0 {
|
||||
log.Printf("[media] 文档 %s 绑定 %d 个媒体引用", docID, bound)
|
||||
}
|
||||
return bound
|
||||
}
|
||||
|
||||
// commitTriplesWithMedia 提交三元组并绑定句子里的媒体引用。
|
||||
//
|
||||
// 包一层是为了让所有「三元组入库」的调用点用同一条路径拿到媒体绑定,
|
||||
@ -357,19 +481,9 @@ func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
|
||||
}
|
||||
var parts []string
|
||||
for _, d := range digests {
|
||||
it, err := a.mediaStore.Stat(d)
|
||||
if err != nil || it == nil {
|
||||
continue
|
||||
if line := a.mediaMarkerLine(d); line != "" {
|
||||
parts = append(parts, line)
|
||||
}
|
||||
label := string(it.Kind)
|
||||
if it.MIME != "" {
|
||||
label = it.MIME
|
||||
}
|
||||
desc := it.Description
|
||||
if desc == "" {
|
||||
desc = "(未描述)"
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("[%s %s] %s", label, shortDigest(d), desc))
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
lines = append(lines, fmt.Sprintf("句子 #%d 关联媒体:%s", sid, strings.Join(parts, ";")))
|
||||
|
||||
587
internal/agent/core/inputunify_test.go
Normal file
587
internal/agent/core/inputunify_test.go
Normal file
@ -0,0 +1,587 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// 统一输入主干(processInput / resolveInput / injectedBlocks)与
|
||||
// 模型可调用工具的媒体接线测试。
|
||||
//
|
||||
// 这一层此前的结构性缺陷:text 与 image/audio 各有一个 process 函数,
|
||||
// 媒体那条缺了去重、no_memory、通道 Cleaner、中断语义、EventRawInput 五项。
|
||||
// 归一成一条主干后,这些行为对所有模态一致——下面的断言就是这个不变量。
|
||||
|
||||
func newInputTestAgent(t *testing.T) (*Agent, *media.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("media.New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ms.Close() })
|
||||
|
||||
return &Agent{mediaStore: ms}, ms
|
||||
}
|
||||
|
||||
// ---------- injectedBlocks ----------
|
||||
|
||||
// 内核内部注入直接给 []agentAPI.ContentBlock;经公共 SDK 的 IOInjector 过来的是
|
||||
// []pubsdk.ContentBlock。两者字段一致但 Go 不会自动转换,只认一种的后果是
|
||||
// 另一种被静默丢弃——插件注入的图到 payload 就断了,且不报错。
|
||||
func TestInjectedBlocks_AcceptsBothStaticTypes(t *testing.T) {
|
||||
t.Run("内核类型", func(t *testing.T) {
|
||||
blocks, kind := injectedBlocks(map[string]interface{}{
|
||||
"media_blocks": []agentAPI.ContentBlock{
|
||||
{Type: "text", Text: "看图"},
|
||||
{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: "data:image/png;base64,AAA"}},
|
||||
},
|
||||
})
|
||||
if len(blocks) != 2 {
|
||||
t.Fatalf("blocks = %d,期望 2", len(blocks))
|
||||
}
|
||||
if kind != "image" {
|
||||
t.Errorf("mediaType = %q,期望 image", kind)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("公共SDK类型", func(t *testing.T) {
|
||||
blocks, kind := injectedBlocks(map[string]interface{}{
|
||||
"media_blocks": []pubsdk.ContentBlock{
|
||||
{Type: "text", Text: "听音频"},
|
||||
{Type: "audio_url", AudioURL: &pubsdk.AudioURL{URL: "data:audio/wav;base64,BBB"}},
|
||||
},
|
||||
})
|
||||
if len(blocks) != 2 {
|
||||
t.Fatalf("blocks = %d,期望 2(公共 SDK 类型被静默丢弃)", len(blocks))
|
||||
}
|
||||
if kind != "audio" {
|
||||
t.Errorf("mediaType = %q,期望 audio", kind)
|
||||
}
|
||||
// 转换必须保留 URL,否则块到了模型手上是空的
|
||||
if blocks[1].AudioURL == nil || blocks[1].AudioURL.URL != "data:audio/wav;base64,BBB" {
|
||||
t.Errorf("AudioURL 转换丢失: %+v", blocks[1].AudioURL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("图优先于音频", func(t *testing.T) {
|
||||
_, kind := injectedBlocks(map[string]interface{}{
|
||||
"media_blocks": []agentAPI.ContentBlock{
|
||||
{Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: "a"}},
|
||||
{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: "b"}},
|
||||
},
|
||||
})
|
||||
if kind != "image" {
|
||||
t.Errorf("mediaType = %q,期望 image", kind)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无媒体块", func(t *testing.T) {
|
||||
blocks, kind := injectedBlocks(map[string]interface{}{"content": "纯文本"})
|
||||
if blocks != nil || kind != "" {
|
||||
t.Errorf("无 media_blocks 时应返回 (nil,\"\"),实际 (%v,%q)", blocks, kind)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImageURL 的 Detail 透传", func(t *testing.T) {
|
||||
blocks, _ := injectedBlocks(map[string]interface{}{
|
||||
"media_blocks": []pubsdk.ContentBlock{
|
||||
{Type: "image_url", ImageURL: &pubsdk.ImageURL{URL: "u", Detail: "high"}},
|
||||
},
|
||||
})
|
||||
if len(blocks) != 1 || blocks[0].ImageURL.Detail != "high" {
|
||||
t.Errorf("Detail 未透传: %+v", blocks)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- resolveInput ----------
|
||||
|
||||
func TestResolveInput_UnifiesAllModalities(t *testing.T) {
|
||||
a, _ := newInputTestAgent(t)
|
||||
|
||||
t.Run("用户上传图片", func(t *testing.T) {
|
||||
in, ok := a.resolveInput(&agentIO.InputEvent{
|
||||
Source: "qq",
|
||||
Type: "image",
|
||||
Payload: map[string]interface{}{"data": "AAAA", "mime": "image/png"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("图片输入被判为无效")
|
||||
}
|
||||
if in.mediaType != "image" || in.captureTool != "input_image" {
|
||||
t.Errorf("mediaType=%q captureTool=%q", in.mediaType, in.captureTool)
|
||||
}
|
||||
if in.text == "" {
|
||||
t.Error("纯媒体输入应有 alt 文案作为文本落点")
|
||||
}
|
||||
if len(in.blocks) == 0 {
|
||||
t.Error("图片应转成内容块")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("插件注入的媒体", func(t *testing.T) {
|
||||
in, ok := a.resolveInput(&agentIO.InputEvent{
|
||||
Source: "myplugin",
|
||||
Type: "text",
|
||||
Payload: map[string]interface{}{
|
||||
"content": "帮我看看这张图",
|
||||
"media_blocks": []pubsdk.ContentBlock{
|
||||
{Type: "image_url", ImageURL: &pubsdk.ImageURL{URL: "data:image/png;base64,AAA"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("带媒体的文本输入被判为无效")
|
||||
}
|
||||
if in.text != "帮我看看这张图" {
|
||||
t.Errorf("text = %q", in.text)
|
||||
}
|
||||
if len(in.blocks) != 1 || in.mediaType != "image" {
|
||||
t.Errorf("blocks=%d mediaType=%q —— 插件注入的媒体到 payload 就断了", len(in.blocks), in.mediaType)
|
||||
}
|
||||
if in.captureTool != "inject_myplugin" {
|
||||
t.Errorf("captureTool = %q,期望带来源便于溯源", in.captureTool)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("只带图不带字也合法", func(t *testing.T) {
|
||||
_, ok := a.resolveInput(&agentIO.InputEvent{
|
||||
Source: "myplugin",
|
||||
Type: "text",
|
||||
Payload: map[string]interface{}{
|
||||
"media_blocks": []agentAPI.ContentBlock{
|
||||
{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: "u"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if !ok {
|
||||
t.Error("只带媒体不带文本应视为有效输入(插件注入常这样)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("文本与媒体都空才无效", func(t *testing.T) {
|
||||
if _, ok := a.resolveInput(&agentIO.InputEvent{
|
||||
Source: "cli",
|
||||
Type: "text",
|
||||
Payload: map[string]interface{}{"content": ""},
|
||||
}); ok {
|
||||
t.Error("空输入应被拒")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("纯文本", func(t *testing.T) {
|
||||
in, ok := a.resolveInput(&agentIO.InputEvent{
|
||||
Source: "cli",
|
||||
Type: "text",
|
||||
Payload: map[string]interface{}{"content": "你好"},
|
||||
})
|
||||
if !ok || in.text != "你好" || len(in.blocks) != 0 || in.mediaType != "" {
|
||||
t.Errorf("纯文本路径异常: ok=%v in=%+v", ok, in)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 模型工具侧:sentenceWithMediaMarkers ----------
|
||||
|
||||
// 模型只知道 digest(从对话或 memory_recall 的「关联媒体」读到),
|
||||
// 不该要求它自己按内核格式拼标记——格式写错的后果是引用静默挂不上。
|
||||
func TestSentenceWithMediaMarkers(t *testing.T) {
|
||||
a, ms := newInputTestAgent(t)
|
||||
digest, err := ms.Put([]byte("marker-bytes"), media.Item{
|
||||
MIME: "image/png", Description: "一张紫蓝红三色带图",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
t.Run("短digest补全并生成标记", func(t *testing.T) {
|
||||
got := a.sentenceWithMediaMarkers("用户发来一张图。", []string{digest[:12]})
|
||||
if !strings.Contains(got, "三色带图") {
|
||||
t.Errorf("描述未并入句子: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, digest[:12]) {
|
||||
t.Errorf("digest 未并入句子(反查会失效): %q", got)
|
||||
}
|
||||
// 反解必须成功,否则 bindSentenceMedia 挂不上引用
|
||||
if got := extractMediaDigests(got); len(got) != 1 {
|
||||
t.Errorf("生成的标记无法被 extractMediaDigests 反解: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("模型已写标记时不重复追加", func(t *testing.T) {
|
||||
sentence := "看这个 [image/png " + digest[:12] + "] 三色带图"
|
||||
got := a.sentenceWithMediaMarkers(sentence, []string{digest[:12]})
|
||||
if n := strings.Count(got, digest[:12]); n != 1 {
|
||||
t.Errorf("digest 出现 %d 次,期望 1 次: %q", n, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空句子时标记本身充当句子", func(t *testing.T) {
|
||||
got := a.sentenceWithMediaMarkers("", []string{digest})
|
||||
if got == "" {
|
||||
t.Error("媒体必须有句子落点,否则 media_refs 无从挂起")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无法解析的digest被跳过", func(t *testing.T) {
|
||||
got := a.sentenceWithMediaMarkers("原句。", []string{"ffffffffffff"})
|
||||
if got != "原句。" {
|
||||
t.Errorf("不存在的 digest 不该造出标记: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无媒体存储时原样返回", func(t *testing.T) {
|
||||
bare := &Agent{}
|
||||
if got := bare.sentenceWithMediaMarkers("原句。", []string{digest}); got != "原句。" {
|
||||
t.Errorf("无媒体存储时应原样返回: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- resolveMediaDigests ----------
|
||||
|
||||
func TestResolveMediaDigests(t *testing.T) {
|
||||
a, ms := newInputTestAgent(t)
|
||||
d1, _ := ms.Put([]byte("one"), media.Item{MIME: "image/png"})
|
||||
d2, _ := ms.Put([]byte("two"), media.Item{MIME: "image/png"})
|
||||
|
||||
got := a.resolveMediaDigests([]string{d1[:10], d2, d1, "ffffffffffff"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got = %v,期望 2 条(去重 + 丢弃无法解析的)", got)
|
||||
}
|
||||
for _, d := range got {
|
||||
if len(d) != 64 {
|
||||
t.Errorf("应返回完整 digest,实际 %q", d)
|
||||
}
|
||||
}
|
||||
|
||||
if a.resolveMediaDigests(nil) != nil {
|
||||
t.Error("空输入应返回 nil")
|
||||
}
|
||||
bare := &Agent{}
|
||||
if bare.resolveMediaDigests([]string{d1}) != nil {
|
||||
t.Error("无媒体存储时应返回 nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- bindDocMedia ----------
|
||||
|
||||
func TestBindDocMedia(t *testing.T) {
|
||||
a, ms := newInputTestAgent(t)
|
||||
d1, _ := ms.Put([]byte("doc-one"), media.Item{MIME: "image/png"})
|
||||
d2, _ := ms.Put([]byte("doc-two"), media.Item{MIME: "image/png"})
|
||||
|
||||
if n := a.bindDocMedia("doc_x", []string{d1, d2}); n != 2 {
|
||||
t.Fatalf("绑定 %d 条,期望 2", n)
|
||||
}
|
||||
refs, err := ms.Refs(media.OwnerDocument, "doc_x")
|
||||
if err != nil {
|
||||
t.Fatalf("Refs: %v", err)
|
||||
}
|
||||
if len(refs) != 2 {
|
||||
t.Errorf("引用 = %v,期望 2 条", refs)
|
||||
}
|
||||
|
||||
if n := a.bindDocMedia("", []string{d1}); n != 0 {
|
||||
t.Error("空 docID 不该绑定")
|
||||
}
|
||||
bare := &Agent{}
|
||||
if n := bare.bindDocMedia("doc_y", []string{d1}); n != 0 {
|
||||
t.Error("无媒体存储时不该绑定")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- docMediaContext ----------
|
||||
|
||||
func TestDocMediaContext(t *testing.T) {
|
||||
a, ms := newInputTestAgent(t)
|
||||
digest, _ := ms.Put([]byte("ctx-bytes"), media.Item{
|
||||
MIME: "image/png", Description: "文档里的配图",
|
||||
})
|
||||
|
||||
t.Run("优先用media_refs", func(t *testing.T) {
|
||||
if err := ms.AddRef(digest, media.OwnerDocument, "doc_refs"); err != nil {
|
||||
t.Fatalf("AddRef: %v", err)
|
||||
}
|
||||
got := a.docMediaContext("doc_refs", "正文里没有任何标记")
|
||||
if !strings.Contains(got, "文档里的配图") {
|
||||
t.Errorf("未从 media_refs 取到媒体说明: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无引用时回退解析正文标记", func(t *testing.T) {
|
||||
content := "旧正文 [image/png " + digest[:12] + "] 文档里的配图"
|
||||
got := a.docMediaContext("doc_legacy", content)
|
||||
if !strings.Contains(got, "文档里的配图") {
|
||||
t.Errorf("历史文档只有标记时应回退解析: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("既无引用也无标记", func(t *testing.T) {
|
||||
if got := a.docMediaContext("doc_empty", "普通正文"); got != "" {
|
||||
t.Errorf("应返回空串,实际 %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无媒体存储", func(t *testing.T) {
|
||||
bare := &Agent{}
|
||||
if got := bare.docMediaContext("doc_x", "任意"); got != "" {
|
||||
t.Errorf("无媒体存储时应返回空串,实际 %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- mediaMarkerLine ----------
|
||||
|
||||
// 标记格式的唯一生成处。此前 mediaSummaryForEvent 与 mediaContextForSentences
|
||||
// 各拼一份,改动截断长度或分隔符时只改一处,另一处写出的标记就再也解析不回来。
|
||||
func TestMediaMarkerLine(t *testing.T) {
|
||||
a, ms := newInputTestAgent(t)
|
||||
|
||||
described, _ := ms.Put([]byte("with-desc"), media.Item{
|
||||
MIME: "image/png", Description: "已描述的图",
|
||||
})
|
||||
if got := a.mediaMarkerLine(described); !strings.Contains(got, "已描述的图") {
|
||||
t.Errorf("有描述时应带描述: %q", got)
|
||||
}
|
||||
|
||||
// 「已入库但还没描述」与「压根没有媒体」必须可区分
|
||||
bare, _ := ms.Put([]byte("no-desc"), media.Item{MIME: "image/png"})
|
||||
got := a.mediaMarkerLine(bare)
|
||||
if !strings.Contains(got, "(未描述)") {
|
||||
t.Errorf("无描述时应有占位符: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, shortDigest(bare)) {
|
||||
t.Errorf("必须带短 digest 供反查: %q", got)
|
||||
}
|
||||
|
||||
// 查不到返回空串:媒体可能已被容量 GC 淘汰,此时不该造出指向虚无的标记
|
||||
if got := a.mediaMarkerLine("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); got != "" {
|
||||
t.Errorf("查不到的 digest 应返回空串,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 模型工具端到端:memory_commit / doc_commit / doc_query ----------
|
||||
|
||||
func newToolTestAgent(t *testing.T) (*Agent, *media.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewGraphDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { g.Close() })
|
||||
|
||||
ds := document.NewStore(filepath.Join(dir, "documents"))
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatalf("doc store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ds.Stop() })
|
||||
|
||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("media.New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ms.Close() })
|
||||
|
||||
emb := memory.NewStaticEmbedder("")
|
||||
a := &Agent{
|
||||
id: "tester",
|
||||
memory: g,
|
||||
docStore: ds,
|
||||
mediaStore: ms,
|
||||
context: NewRelevanceContext("", emb),
|
||||
}
|
||||
return a, ms
|
||||
}
|
||||
|
||||
// memory_commit 带 media_digests:三元组入库后必须能从句子反查回那份字节。
|
||||
func TestToolMemoryCommit_BindsMedia(t *testing.T) {
|
||||
a, ms := newToolTestAgent(t)
|
||||
digest, _ := ms.Put([]byte("commit-bytes"), media.Item{
|
||||
MIME: "image/png", Description: "提交时关联的图",
|
||||
})
|
||||
|
||||
out := a.executeMemoryTool(agentAPI.ToolCall{
|
||||
Name: "memory_commit",
|
||||
Arguments: map[string]interface{}{
|
||||
"triples": []interface{}{
|
||||
map[string]interface{}{
|
||||
"subject": "配色方案",
|
||||
"relation": "参考",
|
||||
"object": "三色带图",
|
||||
"media_digests": []interface{}{digest[:12]},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if !strings.Contains(out, "关联") {
|
||||
t.Errorf("返回值应告知模型媒体已关联: %q", out)
|
||||
}
|
||||
|
||||
res, err := a.memory.Recall([]string{"配色方案"}, nil, 2, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Recall: %v", err)
|
||||
}
|
||||
if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 {
|
||||
t.Fatal("没有句子落点 —— 媒体引用无从挂起")
|
||||
}
|
||||
refs, _ := ms.Refs(media.OwnerGraphSentence, strconv.FormatInt(res.Relations[0].SentenceID, 10))
|
||||
if len(refs) != 1 || refs[0] != digest {
|
||||
t.Errorf("句子引用 = %v,期望 [%s]", refs, digest)
|
||||
}
|
||||
}
|
||||
|
||||
// 不带 media_digests 时行为与本特性上线前一致(不多写句子、不报错)。
|
||||
func TestToolMemoryCommit_WithoutMedia(t *testing.T) {
|
||||
a, _ := newToolTestAgent(t)
|
||||
out := a.executeMemoryTool(agentAPI.ToolCall{
|
||||
Name: "memory_commit",
|
||||
Arguments: map[string]interface{}{
|
||||
"triples": []interface{}{
|
||||
map[string]interface{}{"subject": "甲方", "relation": "签署", "object": "合同"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if strings.Contains(out, "失败") {
|
||||
t.Errorf("普通提交不该失败: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "关联") {
|
||||
t.Errorf("无媒体时不该提媒体: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// sentence_text 必须透传:丢了它,图谱就回不到原文。
|
||||
func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) {
|
||||
a, _ := newToolTestAgent(t)
|
||||
a.executeMemoryTool(agentAPI.ToolCall{
|
||||
Name: "memory_commit",
|
||||
Arguments: map[string]interface{}{
|
||||
"triples": []interface{}{
|
||||
map[string]interface{}{
|
||||
"subject": "李四",
|
||||
"relation": "住在",
|
||||
"object": "杭州",
|
||||
"sentence_text": "李四搬到杭州已经三年了。",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
res, _ := a.memory.Recall([]string{"李四"}, nil, 2, "")
|
||||
if len(res.Relations) == 0 {
|
||||
t.Fatal("召回为空")
|
||||
}
|
||||
if res.Relations[0].SentenceText != "李四搬到杭州已经三年了。" {
|
||||
t.Errorf("SentenceText = %q", res.Relations[0].SentenceText)
|
||||
}
|
||||
}
|
||||
|
||||
// doc_commit 带 media_digests:标记进正文(否则检索不到)+ 引用挂文档 owner(否则 GC 会清)。
|
||||
func TestToolDocCommit_BindsMedia(t *testing.T) {
|
||||
a, ms := newToolTestAgent(t)
|
||||
digest, _ := ms.Put([]byte("doc-commit-bytes"), media.Item{
|
||||
MIME: "image/png", Description: "笔记里的插图",
|
||||
})
|
||||
|
||||
out := a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "doc_commit",
|
||||
Arguments: map[string]interface{}{
|
||||
"content": "这是一篇带图的笔记正文。",
|
||||
"summary": "带图笔记",
|
||||
"media_digests": []interface{}{digest[:12]},
|
||||
},
|
||||
})
|
||||
if !strings.Contains(out, "关联") {
|
||||
t.Errorf("返回值应告知模型媒体已关联: %q", out)
|
||||
}
|
||||
|
||||
docs := a.docStore.RecentDocs(5)
|
||||
if len(docs) == 0 {
|
||||
t.Fatal("文档未写入")
|
||||
}
|
||||
d := docs[0]
|
||||
if !strings.Contains(d.Content, "笔记里的插图") {
|
||||
t.Errorf("标记未进正文(向量索引看不到这份媒体): %q", d.Content)
|
||||
}
|
||||
refs, _ := ms.Refs(media.OwnerDocument, d.ID)
|
||||
if len(refs) != 1 || refs[0] != digest {
|
||||
t.Errorf("文档引用 = %v,期望 [%s]", refs, digest)
|
||||
}
|
||||
}
|
||||
|
||||
// doc_query 必须把媒体说明附在返回值里,否则模型检索到带图文档也不知道有图。
|
||||
func TestToolDocQuery_ShowsMedia(t *testing.T) {
|
||||
a, ms := newToolTestAgent(t)
|
||||
digest, _ := ms.Put([]byte("query-bytes"), media.Item{
|
||||
MIME: "image/png", Description: "检索命中的配图",
|
||||
})
|
||||
|
||||
a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "doc_commit",
|
||||
Arguments: map[string]interface{}{
|
||||
"content": "紫蓝红三色带配色说明正文",
|
||||
"summary": "紫蓝红三色带",
|
||||
"media_digests": []interface{}{digest},
|
||||
},
|
||||
})
|
||||
|
||||
a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "doc_query",
|
||||
Arguments: map[string]interface{}{"query": "紫蓝红三色带 配色说明", "top_k": float64(3)},
|
||||
})
|
||||
|
||||
// 正文进的是 cold_storage 事件(工具返回值只给引用编号),媒体说明也在那里。
|
||||
var found bool
|
||||
for _, e := range a.context.Recent(10) {
|
||||
if strings.Contains(e.Response, "检索命中的配图") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("doc_query 未把媒体说明带进上下文 —— 模型不知道这篇文档带过图")
|
||||
}
|
||||
}
|
||||
|
||||
// 无媒体存储时三个工具的行为与本特性上线前完全一致。
|
||||
func TestTools_NilMediaStoreDegrades(t *testing.T) {
|
||||
a, _ := newToolTestAgent(t)
|
||||
a.mediaStore = nil
|
||||
|
||||
out := a.executeMemoryTool(agentAPI.ToolCall{
|
||||
Name: "memory_commit",
|
||||
Arguments: map[string]interface{}{
|
||||
"triples": []interface{}{
|
||||
map[string]interface{}{
|
||||
"subject": "无存储", "relation": "仍可", "object": "提交",
|
||||
"media_digests": []interface{}{"aabbccddeeff"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if strings.Contains(out, "失败") {
|
||||
t.Errorf("无媒体存储时提交不该失败: %q", out)
|
||||
}
|
||||
|
||||
out = a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "doc_commit",
|
||||
Arguments: map[string]interface{}{
|
||||
"content": "无媒体存储的文档",
|
||||
"media_digests": []interface{}{"aabbccddeeff"},
|
||||
},
|
||||
})
|
||||
if strings.Contains(out, "失败") {
|
||||
t.Errorf("无媒体存储时文档写入不该失败: %q", out)
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,7 @@ import (
|
||||
//
|
||||
// 为何需要这一层:媒体进入对话有两条路,两条都只把**文字**留给记忆——
|
||||
//
|
||||
// 1. 用户直接发图 → processMediaInput → mediaToBlocks
|
||||
// 1. 用户直接发图 → processInput/resolveInput → mediaToBlocks
|
||||
// ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"),
|
||||
// base64 随 message 数组发给模型后就丢了。
|
||||
// 2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg
|
||||
@ -123,18 +123,8 @@ func (a *Agent) mediaSummaryForEvent(digests []string) string {
|
||||
}
|
||||
var lines []string
|
||||
for _, d := range digests {
|
||||
it, err := a.mediaStore.Stat(d)
|
||||
if err != nil || it == nil {
|
||||
continue
|
||||
}
|
||||
label := string(it.Kind)
|
||||
if it.MIME != "" {
|
||||
label = it.MIME
|
||||
}
|
||||
if it.Description != "" {
|
||||
lines = append(lines, fmt.Sprintf("[%s %s] %s", label, shortDigest(d), it.Description))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("[%s %s] (未描述)", label, shortDigest(d)))
|
||||
if line := a.mediaMarkerLine(d); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
@ -143,6 +133,34 @@ func (a *Agent) mediaSummaryForEvent(digests []string) string {
|
||||
return "媒体内容:\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// mediaMarkerLine 为一份媒体生成一行标记文本 `[<mime> <短digest>] <描述>`。
|
||||
//
|
||||
// 这是媒体标记格式的唯一生成处。此前 mediaSummaryForEvent 与
|
||||
// mediaContextForSentences 各拼一份,改动截断长度或分隔符时只改一处,
|
||||
// 另一处写出的标记就再也解析不回来——而解析失败是静默的(引用挂不上)。
|
||||
//
|
||||
// 查不到返回空串:媒体可能已被容量 GC 淘汰,此时不该造出一条指向虚无的标记。
|
||||
func (a *Agent) mediaMarkerLine(digest string) string {
|
||||
if a.mediaStore == nil {
|
||||
return ""
|
||||
}
|
||||
it, err := a.mediaStore.Stat(digest)
|
||||
if err != nil || it == nil {
|
||||
return ""
|
||||
}
|
||||
label := string(it.Kind)
|
||||
if it.MIME != "" {
|
||||
label = it.MIME
|
||||
}
|
||||
desc := it.Description
|
||||
if desc == "" {
|
||||
// 「已入库但还没描述」与「压根没有媒体」必须可区分:描述由后台循环
|
||||
// 异步补齐,占位符保证补齐前这份媒体也不会从文本里消失。
|
||||
desc = "(未描述)"
|
||||
}
|
||||
return fmt.Sprintf("[%s %s] %s", label, shortDigest(digest), desc)
|
||||
}
|
||||
|
||||
// newEventID 生成 ContextEvent 的稳定标识。
|
||||
//
|
||||
// 沿用 document.Store 的 doc_<unixnano> 手法(同一份代码库里保持一致,
|
||||
|
||||
@ -185,9 +185,16 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
for _, td := range triplesData {
|
||||
if m, ok := td.(map[string]interface{}); ok {
|
||||
t := memory.Triple{
|
||||
Subject: getString(m, "subject"),
|
||||
Relation: getString(m, "relation"),
|
||||
Object: getString(m, "object"),
|
||||
Subject: getString(m, "subject"),
|
||||
Relation: getString(m, "relation"),
|
||||
Object: getString(m, "object"),
|
||||
SentenceText: getString(m, "sentence_text"),
|
||||
}
|
||||
// 模型显式关联的媒体:标记由内核补进句子文本,模型不必知道格式。
|
||||
// 没有 sentence_text 时 sentenceWithMediaMarkers 会用标记本身
|
||||
// 充当句子——媒体必须有句子落点,否则 media_refs 无从挂起。
|
||||
if digests := getStringSlice(m, "media_digests"); len(digests) > 0 {
|
||||
t.SentenceText = a.sentenceWithMediaMarkers(t.SentenceText, digests)
|
||||
}
|
||||
if t.Subject != "" && t.Relation != "" && t.Object != "" {
|
||||
triples = append(triples, t)
|
||||
@ -199,10 +206,13 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
}
|
||||
// remember 工具是用户/模型显式写入,不涉及归档删除,
|
||||
// 因此不需要 mediaBound——没有旧引用要释放。
|
||||
ec, rc, _, err := a.commitTriplesWithMedia(triples, string(a.id), 0)
|
||||
ec, rc, mb, err := a.commitTriplesWithMedia(triples, string(a.id), 0)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("记忆写入失败: %v", err)
|
||||
}
|
||||
if mb > 0 {
|
||||
return fmt.Sprintf("已写入 %d 个实体和 %d 条关系,关联 %d 份媒体", ec, rc, mb)
|
||||
}
|
||||
return fmt.Sprintf("已写入 %d 个实体和 %d 条关系", ec, rc)
|
||||
|
||||
case "memory_introspect":
|
||||
@ -521,6 +531,11 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
if len(content) > 2000 {
|
||||
content = content[:2000] + "..."
|
||||
}
|
||||
// 媒体说明单独一行进冷存事件:正文可能被上面的 2000 字截断,
|
||||
// 而媒体标记往往在文档末尾——截掉之后模型就不知道这篇文档带过图。
|
||||
if mc := a.docMediaContext(d.ID, d.Content); mc != "" {
|
||||
content = content + "\n关联媒体: " + mc
|
||||
}
|
||||
a.context.InsertByTimestamp(ContextEvent{
|
||||
Timestamp: d.CreatedAt,
|
||||
Source: "cold_storage",
|
||||
@ -556,9 +571,21 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
Tags: tags,
|
||||
Source: "manual",
|
||||
}
|
||||
|
||||
// 模型显式关联的媒体:标记补进正文后再写入。顺序关键——向量索引用
|
||||
// Summary+Content 计算,标记进不去正文就检索不到这份媒体。
|
||||
mediaDigests := a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests"))
|
||||
doc.Content = a.sentenceWithMediaMarkers(doc.Content, mediaDigests)
|
||||
|
||||
if err := a.docStore.Insert(doc); err != nil {
|
||||
return fmt.Sprintf("文档写入失败: %v", err)
|
||||
}
|
||||
// 引用必须在拿到 doc.ID 之后挂:owner_id 就是文档 id。
|
||||
// 不挂的后果是这些媒体在文档里可见却无主,下一轮 GC 会把它们清掉。
|
||||
bound := a.bindDocMedia(doc.ID, mediaDigests)
|
||||
if bound > 0 {
|
||||
return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s, 关联 %d 份媒体)", doc.ID, summary, bound)
|
||||
}
|
||||
return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s)", doc.ID, summary)
|
||||
|
||||
default:
|
||||
|
||||
@ -374,6 +374,11 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
"description": "标签列表",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"media_digests": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "可选:这篇文档关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
"required": []string{"content"},
|
||||
},
|
||||
|
||||
@ -29,6 +29,24 @@ func getFloat(m map[string]interface{}, key string) float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// getStringSlice 从工具参数里取字符串数组。
|
||||
//
|
||||
// 需要单独一个 helper 而不是直接断言 []string:LLM 的参数经 JSON 解码后是
|
||||
// []interface{},直接断言 []string 恒失败——静默拿到 nil,参数像没传一样。
|
||||
func getStringSlice(m map[string]interface{}, key string) []string {
|
||||
raw, ok := m[key].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, v := range raw {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func truncateStr(s string, max int) string {
|
||||
if utf8.RuneCountInString(s) <= max {
|
||||
return s
|
||||
|
||||
@ -174,7 +174,9 @@ func (idx *Indexer) BuildToolPrompt() string {
|
||||
### memory_commit
|
||||
将三元组写入图记忆。
|
||||
参数:
|
||||
- triples: [{"subject": "实体名", "relation": "关系类型", "object": "目标实体"}]
|
||||
- triples: [{"subject": "实体名", "relation": "关系类型", "object": "目标实体",
|
||||
"sentence_text": "原始句子(可选)", "media_digests": ["图片digest(可选)"]}]
|
||||
填了 media_digests,日后从这条记忆就能取回当时那张图/那段音频。
|
||||
|
||||
### memory_introspect
|
||||
查看记忆统计信息。
|
||||
@ -261,6 +263,15 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
|
||||
"subject": map[string]interface{}{"type": "string"},
|
||||
"relation": map[string]interface{}{"type": "string"},
|
||||
"object": map[string]interface{}{"type": "string"},
|
||||
"sentence_text": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "可选:这条三元组的原始句子。填了才能日后从图谱回到原文。",
|
||||
},
|
||||
"media_digests": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "可选:这条记忆关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后从这条记忆能取回原图/音频。",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
"required": []string{"subject", "relation", "object"},
|
||||
},
|
||||
|
||||
@ -10,6 +10,14 @@ var (
|
||||
// 1.0.0:外部插件从 C ABI 动态库迁到子进程 + 共享内存。
|
||||
// 这是首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件必须
|
||||
// 用新版 plugindev 重编),故跃到主版本号。
|
||||
// 1.1.0:记忆系统支持二进制多媒体节点——CAS 媒体存储 + L0/L2/L3 贯通。
|
||||
// 1.1.1:多模态贯通**插件边界**。内核实现公开 SDK 1.1.0 新增的媒体接口
|
||||
// (doc.insertWithMedia、io.injectMedia / injectMediaSync /
|
||||
// injectInterruptMedia),并把 text/image/audio 三条输入路径归一成
|
||||
// 一条 processInput 主干。
|
||||
//
|
||||
// ❗main 上此值始终是**下一个未发布中版本**,不随 patch 发布变动
|
||||
//(见 docs/git-branching.md §2.1);已发布的版本号看对应的 release/vX.Y.x 与 tag。
|
||||
Version = "1.0.0"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
@ -22,7 +30,11 @@ var (
|
||||
KernelName = "HomeAgent"
|
||||
|
||||
// SDKCompatibleVersion 是此内核可兼容的最高 SDK 版本(semver)。
|
||||
SDKCompatibleVersion = "1.0.0"
|
||||
//
|
||||
// 1.1.0:本内核实现了 SDK 1.1.0 的全部新增方法。
|
||||
// 用 SDK 1.0.0 编的存量插件照旧可用——新增方法由**插件调用、内核实现**,
|
||||
// 不调就不受影响,无需重编。
|
||||
SDKCompatibleVersion = "1.1.0"
|
||||
)
|
||||
|
||||
// FullVersion 返回完整的版本字符串。
|
||||
|
||||
@ -101,6 +101,11 @@ var methodCapability = map[string]Capability{
|
||||
MethodIOInjectInterrupt: CapIO,
|
||||
MethodIOInjectTextNoMem: CapIO,
|
||||
MethodIOInjectSync: CapIO,
|
||||
// 带媒体的注入与纯文本注入同一权限组:能不能发起一轮对话是 IO 能力,
|
||||
// 带不带图不改变这个判断。
|
||||
MethodIOInjectMedia: CapIO,
|
||||
MethodIOInjectMediaSync: CapIO,
|
||||
MethodIOInjectInterruptMedia: CapIO,
|
||||
|
||||
// ---- 图记忆 ----
|
||||
MethodMemoryRecall: CapMemory,
|
||||
@ -114,6 +119,8 @@ var methodCapability = map[string]Capability{
|
||||
MethodDocInsert: CapDocMemory,
|
||||
MethodDocRemove: CapDocMemory,
|
||||
MethodDocStats: CapDocMemory,
|
||||
// 带媒体写入与普通写入同权限:都是往文档记忆里写东西。
|
||||
MethodDocInsertMedia: CapDocMemory,
|
||||
|
||||
// ---- 知识库 ----
|
||||
MethodKnowledgeSearch: CapKnowledge,
|
||||
|
||||
@ -34,10 +34,12 @@ func TestCapability_AllMethodsClassified(t *testing.T) {
|
||||
MethodAPIRegister, MethodInputRegister,
|
||||
MethodIOInjectText, MethodIOInjectInterrupt, MethodIOInjectTextNoMem,
|
||||
MethodIOInjectSync, MethodIOSetToolBlocks,
|
||||
MethodIOInjectMedia, MethodIOInjectMediaSync, MethodIOInjectInterruptMedia,
|
||||
MethodLifecycleAutoRestart,
|
||||
MethodMemoryRecall, MethodMemoryCommit, MethodMemoryIntrospect,
|
||||
MethodMemoryMerge, MethodMemoryPurge,
|
||||
MethodDocQuery, MethodDocInsert, MethodDocRemove, MethodDocStats,
|
||||
MethodDocInsertMedia,
|
||||
MethodKnowledgeSearch, MethodKnowledgeAdd, MethodKnowledgeList,
|
||||
MethodTextMemoryAppend,
|
||||
MethodSettingsGet, MethodSettingsSet, MethodSettingsRegisterDef,
|
||||
|
||||
@ -91,6 +91,10 @@ type CoreSDK interface {
|
||||
InjectInterruptText(source, channel, text string)
|
||||
InjectTextNoMemory(source, channel, text string)
|
||||
InjectInputSync(source, channel, text string) string
|
||||
// 带媒体的注入:子进程插件也能主动发起一轮带图/音频的对话。
|
||||
InjectInputMedia(source, channel, text string, blocks []pubsdk.ContentBlock)
|
||||
InjectInputMediaSync(source, channel, text string, blocks []pubsdk.ContentBlock) string
|
||||
InjectInterruptMedia(source, channel, text string, blocks []pubsdk.ContentBlock)
|
||||
|
||||
SetAutoRestart(enabled bool)
|
||||
}
|
||||
@ -163,6 +167,30 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
||||
}
|
||||
return map[string]interface{}{"reply": h.sdk.InjectInputSync(p.Source, p.Channel, p.Text)}, nil
|
||||
|
||||
case MethodIOInjectMedia:
|
||||
var p injectMediaParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInputMedia(p.Source, p.Channel, p.Text, p.Blocks)
|
||||
return nil, nil
|
||||
|
||||
case MethodIOInjectMediaSync:
|
||||
var p injectMediaParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply := h.sdk.InjectInputMediaSync(p.Source, p.Channel, p.Text, p.Blocks)
|
||||
return map[string]interface{}{"reply": reply}, nil
|
||||
|
||||
case MethodIOInjectInterruptMedia:
|
||||
var p injectMediaParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInterruptMedia(p.Source, p.Channel, p.Text, p.Blocks)
|
||||
return nil, nil
|
||||
|
||||
// ---- 生命周期(原 case 8)----
|
||||
case MethodLifecycleAutoRestart:
|
||||
var p struct {
|
||||
@ -293,6 +321,28 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
||||
}
|
||||
return nil, dm.Insert(p.Doc)
|
||||
|
||||
case MethodDocInsertMedia:
|
||||
dm := h.sdk.DocMemory()
|
||||
if dm == nil {
|
||||
return nil, errUnavailable("doc memory")
|
||||
}
|
||||
var p struct {
|
||||
Doc *pubsdk.Doc `json:"doc"`
|
||||
Attachments []pubsdk.MediaAttachment `json:"attachments"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Doc == nil {
|
||||
return nil, fmt.Errorf("doc.insertWithMedia: 缺少 doc 字段")
|
||||
}
|
||||
if err := dm.InsertWithMedia(p.Doc, p.Attachments); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 回传内核补过的字段:ID 新建时才生成,Content 含内核补的媒体标记,
|
||||
// MediaDigests 是附件落盘后的完整 digest——插件靠它们后续引用同一份媒体。
|
||||
return map[string]interface{}{"doc": p.Doc}, nil
|
||||
|
||||
case MethodDocRemove:
|
||||
dm := h.sdk.DocMemory()
|
||||
if dm == nil {
|
||||
@ -505,6 +555,17 @@ type injectParams struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// injectMediaParams 是带媒体注入的参数。
|
||||
//
|
||||
// blocks 走 JSON(而非共享段二进制通道):data URL 已经是 base64 文本,
|
||||
// 再套一层二进制传输不会更小,而 JSON 让这条路径与其他 method 一致。
|
||||
type injectMediaParams struct {
|
||||
Source string `json:"source"`
|
||||
Channel string `json:"channel"`
|
||||
Text string `json:"text"`
|
||||
Blocks []pubsdk.ContentBlock `json:"blocks"`
|
||||
}
|
||||
|
||||
func unmarshal(params json.RawMessage, out interface{}) error {
|
||||
if len(params) == 0 {
|
||||
return nil
|
||||
|
||||
@ -34,21 +34,26 @@ func newFakeCore() *fakeCoreSDK {
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeCoreSDK) PluginName() string { return "fake" }
|
||||
func (f *fakeCoreSDK) Settings() pubsdk.SettingsAPI { return nil }
|
||||
func (f *fakeCoreSDK) Memory() pubsdk.MemoryAPI { return nil }
|
||||
func (f *fakeCoreSDK) TextMemory() pubsdk.TextMemoryAPI { return nil }
|
||||
func (f *fakeCoreSDK) DocMemory() pubsdk.DocMemoryAPI { return nil }
|
||||
func (f *fakeCoreSDK) Knowledge() pubsdk.KnowledgeAPI { return nil }
|
||||
func (f *fakeCoreSDK) LLM() pubsdk.LLMAPI { return nil }
|
||||
func (f *fakeCoreSDK) Social() pubsdk.SocialAPI { return nil }
|
||||
func (f *fakeCoreSDK) PluginMgr() pubsdk.PluginMgrAPI { return nil }
|
||||
func (f *fakeCoreSDK) RegisterPluginAPI(name string) error { return nil }
|
||||
func (f *fakeCoreSDK) InjectText(s, c, t string) {}
|
||||
func (f *fakeCoreSDK) InjectInterruptText(s, c, t string) {}
|
||||
func (f *fakeCoreSDK) InjectTextNoMemory(s, c, t string) {}
|
||||
func (f *fakeCoreSDK) InjectInputSync(s, c, t string) string { return "" }
|
||||
func (f *fakeCoreSDK) SetAutoRestart(enabled bool) { f.autoStart = enabled }
|
||||
func (f *fakeCoreSDK) PluginName() string { return "fake" }
|
||||
func (f *fakeCoreSDK) Settings() pubsdk.SettingsAPI { return nil }
|
||||
func (f *fakeCoreSDK) Memory() pubsdk.MemoryAPI { return nil }
|
||||
func (f *fakeCoreSDK) TextMemory() pubsdk.TextMemoryAPI { return nil }
|
||||
func (f *fakeCoreSDK) DocMemory() pubsdk.DocMemoryAPI { return nil }
|
||||
func (f *fakeCoreSDK) Knowledge() pubsdk.KnowledgeAPI { return nil }
|
||||
func (f *fakeCoreSDK) LLM() pubsdk.LLMAPI { return nil }
|
||||
func (f *fakeCoreSDK) Social() pubsdk.SocialAPI { return nil }
|
||||
func (f *fakeCoreSDK) PluginMgr() pubsdk.PluginMgrAPI { return nil }
|
||||
func (f *fakeCoreSDK) RegisterPluginAPI(name string) error { return nil }
|
||||
func (f *fakeCoreSDK) InjectText(s, c, t string) {}
|
||||
func (f *fakeCoreSDK) InjectInterruptText(s, c, t string) {}
|
||||
func (f *fakeCoreSDK) InjectTextNoMemory(s, c, t string) {}
|
||||
func (f *fakeCoreSDK) InjectInputSync(s, c, t string) string { return "" }
|
||||
func (f *fakeCoreSDK) InjectInputMedia(s, c, t string, b []pubsdk.ContentBlock) {}
|
||||
func (f *fakeCoreSDK) InjectInputMediaSync(s, c, t string, b []pubsdk.ContentBlock) string {
|
||||
return ""
|
||||
}
|
||||
func (f *fakeCoreSDK) InjectInterruptMedia(s, c, t string, b []pubsdk.ContentBlock) {}
|
||||
func (f *fakeCoreSDK) SetAutoRestart(enabled bool) { f.autoStart = enabled }
|
||||
|
||||
func (f *fakeCoreSDK) RegisterTool(name string, def pubsdk.ToolDef, h pubsdk.ToolHandler) error {
|
||||
f.mu.Lock()
|
||||
|
||||
@ -74,6 +74,13 @@ const (
|
||||
MethodIOInjectInterrupt = "io.injectInterrupt" // 6 CORE_INJECT_INTERRUPT_TEXT
|
||||
MethodIOInjectTextNoMem = "io.injectTextNoMem" // 7 CORE_INJECT_TEXT_NO_MEMORY
|
||||
MethodIOInjectSync = "io.injectInputSync" // 47 CORE_INJECT_INPUT_SYNC
|
||||
// 带媒体的注入:blocks 随参数 JSON 一并过来,内核侧转成
|
||||
// payload["media_blocks"],由 resolveInput 归一进统一输入主干。
|
||||
// 与 SetToolBlocks 的区别:这三个是「主动发起一轮带图的对话」,
|
||||
// 后者是「工具返回值里带图」,只能在工具调用内部用。
|
||||
MethodIOInjectMedia = "io.injectMedia"
|
||||
MethodIOInjectMediaSync = "io.injectMediaSync"
|
||||
MethodIOInjectInterruptMedia = "io.injectInterruptMedia"
|
||||
// MethodIOSetToolBlocks 多模态注入——今日 C ABI 侧是空实现(§1.4),
|
||||
// 子进程下二进制落 arena、描述符回传,首次真正可用。
|
||||
MethodIOSetToolBlocks = "io.setToolBlocks"
|
||||
@ -93,6 +100,9 @@ const (
|
||||
MethodDocInsert = "doc.insert" // 32
|
||||
MethodDocRemove = "doc.remove" // 33
|
||||
MethodDocStats = "doc.stats" // 34
|
||||
// MethodDocInsertMedia 写入文档并关联媒体(附件带 data 则落盘去重,
|
||||
// 只带 digest 则引用已有内容)。
|
||||
MethodDocInsertMedia = "doc.insertWithMedia"
|
||||
|
||||
// 知识库(原 case 15/35/36)
|
||||
MethodKnowledgeSearch = "knowledge.search" // 15
|
||||
|
||||
@ -142,6 +142,23 @@ func (c procCore) InjectInputSync(source, channel, text string) string {
|
||||
return reply
|
||||
}
|
||||
|
||||
// ---- 带媒体的 IO 注入 ----
|
||||
//
|
||||
// 三个方法都直接转调 internal/sdk 的同名方法:那一层已经是三参数 + blocks
|
||||
// 的公开形态,不像 InjectInputSync 需要收窄。
|
||||
|
||||
func (c procCore) InjectInputMedia(source, channel, text string, blocks []pubsdk.ContentBlock) {
|
||||
c.sdk.InjectInputMedia(source, channel, text, blocks)
|
||||
}
|
||||
|
||||
func (c procCore) InjectInputMediaSync(source, channel, text string, blocks []pubsdk.ContentBlock) string {
|
||||
return c.sdk.InjectInputMediaSync(source, channel, text, blocks)
|
||||
}
|
||||
|
||||
func (c procCore) InjectInterruptMedia(source, channel, text string, blocks []pubsdk.ContentBlock) {
|
||||
c.sdk.InjectInterruptMedia(source, channel, text, blocks)
|
||||
}
|
||||
|
||||
// ---- 生命周期 ----
|
||||
|
||||
func (c procCore) SetAutoRestart(enabled bool) { c.sdk.SetAutoRestart(enabled) }
|
||||
|
||||
@ -22,6 +22,7 @@ import (
|
||||
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin/proc"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
@ -88,13 +89,17 @@ type Registry struct {
|
||||
memDB *memory.GraphDB
|
||||
textMem *text.Memory
|
||||
docStore *doc.Store
|
||||
ks *knowledge.Store
|
||||
mgr *agentAPI.ProviderManager
|
||||
cfgReg *internalConfig.ConfigRegistry
|
||||
plgDir string
|
||||
dataDir string // 守护进程数据目录(注入给插件 SettingsAPI.DataDir)
|
||||
lua *luaVM.VM
|
||||
baseKey string
|
||||
// mediaStore 让插件写入的记忆也能带媒体。
|
||||
// 为 nil 时(配置关闭或初始化失败)插件侧记忆包装退化为纯文本行为,
|
||||
// 与本特性上线前完全一致。
|
||||
mediaStore *media.Store
|
||||
ks *knowledge.Store
|
||||
mgr *agentAPI.ProviderManager
|
||||
cfgReg *internalConfig.ConfigRegistry
|
||||
plgDir string
|
||||
dataDir string // 守护进程数据目录(注入给插件 SettingsAPI.DataDir)
|
||||
lua *luaVM.VM
|
||||
baseKey string
|
||||
|
||||
regTool sdk.ToolRegistrar
|
||||
regStage sdk.StageRegistrar
|
||||
@ -184,6 +189,7 @@ func (r *Registry) SetEventBus(evBus *events.Bus) { r.
|
||||
func (r *Registry) SetMemory(memDB *memory.GraphDB) { r.memDB = memDB }
|
||||
func (r *Registry) SetTextMemory(tm *text.Memory) { r.textMem = tm }
|
||||
func (r *Registry) SetDocStore(ds *doc.Store) { r.docStore = ds }
|
||||
func (r *Registry) SetMediaStore(ms *media.Store) { r.mediaStore = ms }
|
||||
func (r *Registry) SetKnowledge(ks *knowledge.Store) { r.ks = ks }
|
||||
func (r *Registry) SetProviderManager(mgr *agentAPI.ProviderManager) { r.mgr = mgr }
|
||||
func (r *Registry) SetConfigRegistry(cfgReg *internalConfig.ConfigRegistry) { r.cfgReg = cfgReg }
|
||||
@ -311,11 +317,13 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
|
||||
}
|
||||
|
||||
return sdk.New(name, sdk.SDKConfig{
|
||||
IOManager: r.iom,
|
||||
EventBus: r.evBus,
|
||||
Memory: sdk.NewGraphMemory(r.memDB),
|
||||
TextMemory: sdk.NewTextMemory(r.textMem),
|
||||
DocMemory: sdk.NewDocMemory(r.docStore),
|
||||
IOManager: r.iom,
|
||||
EventBus: r.evBus,
|
||||
// 带 media 的包装:插件提交的三元组/文档/文本事件里的媒体会落进 CAS
|
||||
// 并挂上引用。传入插件名仅用于日志溯源(哪个插件写的媒体)。
|
||||
Memory: sdk.NewGraphMemoryWithMedia(name, r.memDB, r.mediaStore),
|
||||
TextMemory: sdk.NewTextMemoryWithMedia(name, r.textMem, r.mediaStore),
|
||||
DocMemory: sdk.NewDocMemoryWithMedia(name, r.docStore, r.mediaStore),
|
||||
Knowledge: sdk.NewKnowledge(r.ks),
|
||||
LLM: sdk.NewLLM(r.mgr, r.cfgReg, r.lua, r.baseKey),
|
||||
Settings: sett,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -24,5 +24,9 @@ type TextMemoryAPI interface {
|
||||
|
||||
type TextEvent = pubsdk.TextEvent
|
||||
|
||||
// MediaAttachment 是记忆附件(媒体)在插件边界上的表示。
|
||||
// 与公共 SDK 同一类型,内置插件与外部插件用同一套字段。
|
||||
type MediaAttachment = pubsdk.MediaAttachment
|
||||
|
||||
type DocMemoryAPI = pubsdk.DocMemoryAPI
|
||||
type Doc = pubsdk.Doc
|
||||
|
||||
@ -1,110 +1,587 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
)
|
||||
|
||||
type graphMemory struct{ db *memory.GraphDB }
|
||||
// 插件侧记忆接口的实现(SDK 桥接层)。
|
||||
//
|
||||
// 这一层原先的缺陷是**静默裁字段**:插件把 Triple / Doc 交进来,包装层只挑
|
||||
// 自己认识的几个字段转成内部结构,其余丢弃且不报错。两侧都中招:
|
||||
// - 图记忆:丢 Confidence/SubjectType/ObjectType/SentenceText,又走 Commit
|
||||
// 而非 CommitWithMedia,于是 sentences 表没有落点,媒体引用无从挂起;
|
||||
// - 知识库:Query 只回 ID/Title/Content,Insert 只写这三个,读写两个方向
|
||||
// 都把媒体元数据裁掉;Remove 不解引用,媒体永久算「被引用」,GC 收不掉。
|
||||
//
|
||||
// 现在的规则:内部结构有的字段一律透传;媒体一律走标记格式并挂到对应 owner。
|
||||
// 媒体存储为 nil 时整条链路静默降级为纯文本行为(媒体是记忆增强,不是必需品)。
|
||||
|
||||
// ---------- 媒体标记(本层内部) ----------
|
||||
//
|
||||
// 标记是媒体在**纯文本记忆**里的表示形式:
|
||||
//
|
||||
// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
|
||||
// └ label └ 短 digest └ 描述
|
||||
//
|
||||
// 之所以必须借文本承载:Doc.Content、sentences.text、文本记忆的 Input 全是
|
||||
// 字符串,没有字段能挂结构化数据。描述文本是持久的语义记忆(检索靠它),
|
||||
// digest 是回到字节的钥匙(反查靠它)。
|
||||
//
|
||||
// 格式与内核侧 graphmedia.go 的 mediaSummaryForEvent 一致——两边必须能互读
|
||||
// 对方写下的标记,否则插件写入的媒体在内核归档时挂不上引用,且不报错。
|
||||
|
||||
const sdkShortDigestLen = 12
|
||||
|
||||
// sdkMarkerPattern 拆解一条标记,捕获组依次为 label、短 digest、该行剩余描述。
|
||||
// digest 放宽到 8-64 位以容忍完整 digest 手写的情况;描述取到行尾而非贪婪到底,
|
||||
// 因为一条记忆可能挂多份媒体、各占一行。
|
||||
var sdkMarkerPattern = regexp.MustCompile(`\[([^\[\]\s]+)\s+([0-9a-f]{8,64})\]([^\n]*)`)
|
||||
|
||||
func sdkShortDigest(d string) string {
|
||||
if len(d) > sdkShortDigestLen {
|
||||
return d[:sdkShortDigestLen]
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// sdkMarkerFor 为一份已入库的媒体生成标记行。查不到就返回空串——
|
||||
// 媒体可能已被 GC 清掉,此时不该凭空造出一条指向虚无的标记。
|
||||
func sdkMarkerFor(ms *media.Store, digest string) string {
|
||||
it, err := ms.Stat(digest)
|
||||
if err != nil || it == nil {
|
||||
return ""
|
||||
}
|
||||
label := string(it.Kind)
|
||||
if it.MIME != "" {
|
||||
label = it.MIME
|
||||
}
|
||||
if it.Description == "" {
|
||||
// 「已入库但还没描述」与「压根没有媒体」必须可区分:
|
||||
// 描述由后台循环异步补齐,占位符保证补齐前这份媒体也不会从文本里消失。
|
||||
return fmt.Sprintf("[%s %s] (未描述)", label, sdkShortDigest(digest))
|
||||
}
|
||||
return fmt.Sprintf("[%s %s] %s", label, sdkShortDigest(digest), it.Description)
|
||||
}
|
||||
|
||||
// sdkDigestsIn 返回文本里出现过的短 digest 集合,用于避免重复追加标记。
|
||||
func sdkDigestsIn(s string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(s, -1) {
|
||||
out[m[2]] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sdkBindText 把文本里引用的媒体挂到 owner 上,返回新挂上的条数。
|
||||
//
|
||||
// 短 digest 补全失败(内容已 GC、或前缀有歧义)就跳过那一条:挂一条对不上的
|
||||
// 引用比不挂更糟——owner_kind/owner_id/digest 三者进了主键,digest 错了则
|
||||
// DropOwner 永远匹配不到它,那是一条永久泄漏的引用。
|
||||
//
|
||||
// done 记录本次已处理过的 digest。AddRef 幂等,重复挂不会多出一条引用,
|
||||
// 但会让计数虚高——文档路径先按附件挂一遍、再扫正文标记挂一遍,
|
||||
// 同一份媒体会被数两次,日志里「绑定 2 个」而实际只有 1 条引用。
|
||||
func sdkBindText(ms *media.Store, text, ownerKind, ownerID string, done map[string]bool) int {
|
||||
if ms == nil || text == "" || ownerID == "" {
|
||||
return 0
|
||||
}
|
||||
bound := 0
|
||||
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(text, -1) {
|
||||
full, err := ms.ResolvePrefix(m[2])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if done != nil && done[full] {
|
||||
continue
|
||||
}
|
||||
if err := ms.AddRef(full, ownerKind, ownerID); err != nil {
|
||||
continue
|
||||
}
|
||||
if done != nil {
|
||||
done[full] = true
|
||||
}
|
||||
bound++
|
||||
}
|
||||
return bound
|
||||
}
|
||||
|
||||
// sdkPutAttachment 把一份附件解析成完整 digest。
|
||||
//
|
||||
// 两种入口:带 Data 的是新内容(落进 CAS,相同字节自动去重);
|
||||
// 只给 Digest 的是引用已有内容(补全前缀即可)。两者都不给则无效。
|
||||
func sdkPutAttachment(ms *media.Store, a MediaAttachment, tool string) (string, error) {
|
||||
if len(a.Data) > 0 {
|
||||
mime := a.MIME
|
||||
if mime == "" {
|
||||
mime = "application/octet-stream"
|
||||
}
|
||||
return ms.Put(a.Data, media.Item{
|
||||
MIME: mime,
|
||||
Tool: tool,
|
||||
OriginPath: a.Name,
|
||||
Description: a.Description,
|
||||
})
|
||||
}
|
||||
if a.Digest == "" {
|
||||
return "", fmt.Errorf("附件既无 data 也无 digest")
|
||||
}
|
||||
full, err := ms.ResolvePrefix(a.Digest)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("digest %s: %w", a.Digest, err)
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// sdkAttachmentsFromText 从文本标记反解出附件元数据(不含字节),
|
||||
// 让插件不必自己写正则去认标记。
|
||||
func sdkAttachmentsFromText(ms *media.Store, s string) []MediaAttachment {
|
||||
if ms == nil || s == "" {
|
||||
return nil
|
||||
}
|
||||
var out []MediaAttachment
|
||||
seen := map[string]bool{}
|
||||
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(s, -1) {
|
||||
full, err := ms.ResolvePrefix(m[2])
|
||||
if err != nil || seen[full] {
|
||||
continue
|
||||
}
|
||||
seen[full] = true
|
||||
att := MediaAttachment{Digest: full, MIME: m[1], Description: strings.TrimSpace(m[3])}
|
||||
if it, err := ms.Stat(full); err == nil && it != nil {
|
||||
att.MIME = it.MIME
|
||||
if it.Description != "" {
|
||||
att.Description = it.Description
|
||||
}
|
||||
}
|
||||
out = append(out, att)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- 图记忆 ----------
|
||||
|
||||
type graphMemory struct {
|
||||
db *memory.GraphDB
|
||||
ms *media.Store
|
||||
plugin string
|
||||
}
|
||||
|
||||
func NewGraphMemory(db *memory.GraphDB) MemoryAPI { return &graphMemory{db: db} }
|
||||
|
||||
// NewGraphMemoryWithMedia 创建带媒体能力的图记忆包装。plugin 仅用于日志溯源。
|
||||
func NewGraphMemoryWithMedia(plugin string, db *memory.GraphDB, ms *media.Store) MemoryAPI {
|
||||
return &graphMemory{db: db, ms: ms, plugin: plugin}
|
||||
}
|
||||
|
||||
func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, error) {
|
||||
if m.db == nil { return nil, nil, nil }
|
||||
if m.db == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
result, err := m.db.Recall(query, nil, depth, "")
|
||||
if err != nil { return nil, nil, err }
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
entities := make([]Entity, len(result.Entities))
|
||||
for i, e := range result.Entities {
|
||||
entities[i] = Entity{Name: e.Name, Type: e.Type, MentionCount: e.MentionCount}
|
||||
}
|
||||
// Confidence 此前被丢弃:插件拿不到置信度就无法判断一条关系可不可信,
|
||||
// 只能把所有召回结果等同对待。
|
||||
relations := make([]Relation, len(result.Relations))
|
||||
for i, r := range result.Relations {
|
||||
relations[i] = Relation{SourceName: r.SourceName, TargetName: r.TargetName, RelationType: r.RelationType}
|
||||
relations[i] = Relation{
|
||||
SourceName: r.SourceName,
|
||||
TargetName: r.TargetName,
|
||||
RelationType: r.RelationType,
|
||||
Confidence: r.Confidence,
|
||||
}
|
||||
}
|
||||
return entities, relations, nil
|
||||
}
|
||||
|
||||
// Commit 把插件的三元组写入图库,并把三元组引用的媒体挂到句子上。
|
||||
//
|
||||
// 媒体的绑定链是 SentenceText → sentences 表 → sentence_id → media_refs。
|
||||
// 旧实现丢掉 SentenceText 又走 Commit(不回 sentenceIDs),这条链一步都走不通:
|
||||
// 插件即便按格式写好标记,媒体也永远挂不上。
|
||||
func (m *graphMemory) Commit(triples []Triple) error {
|
||||
if m.db == nil { return nil }
|
||||
ts := make([]memory.Triple, len(triples))
|
||||
for i, t := range triples {
|
||||
ts[i] = memory.Triple{Subject: t.Subject, Relation: t.Relation, Object: t.Object}
|
||||
if m.db == nil {
|
||||
return nil
|
||||
}
|
||||
ts := make([]memory.Triple, 0, len(triples))
|
||||
for _, t := range triples {
|
||||
mt := memory.Triple{
|
||||
Subject: t.Subject,
|
||||
Relation: t.Relation,
|
||||
Object: t.Object,
|
||||
Confidence: t.Confidence,
|
||||
SubjectType: t.SubjectType,
|
||||
ObjectType: t.ObjectType,
|
||||
SentenceText: t.SentenceText,
|
||||
}
|
||||
if len(t.MediaDigests) > 0 {
|
||||
mt.SentenceText = m.sentenceWithMedia(mt.SentenceText, t.MediaDigests)
|
||||
}
|
||||
ts = append(ts, mt)
|
||||
}
|
||||
|
||||
sentenceIDs, _, _, err := m.db.CommitWithMedia(ts, "plugin", 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.bindSentences(sentenceIDs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// sentenceWithMedia 保证句子文本里带有这些 digest 的媒体标记。
|
||||
//
|
||||
// 让插件填 MediaDigests 就够,不必知道标记格式——否则格式写错的后果是
|
||||
// 引用静默挂不上。已出现过的 digest 不重复追加:插件可能既手写了标记又填了
|
||||
// MediaDigests,重复标记会让同一份媒体产生两条一样的句子引用。
|
||||
func (m *graphMemory) sentenceWithMedia(sentence string, digests []string) string {
|
||||
present := sdkDigestsIn(sentence)
|
||||
var add []string
|
||||
for _, d := range digests {
|
||||
if d == "" || present[sdkShortDigest(d)] {
|
||||
continue
|
||||
}
|
||||
if m.ms == nil {
|
||||
// 没有媒体存储时也把 digest 留在文本里:拿不到描述,
|
||||
// 但将来存储可用时这条记忆仍能反查回字节。
|
||||
add = append(add, fmt.Sprintf("[media %s] (未描述)", sdkShortDigest(d)))
|
||||
present[sdkShortDigest(d)] = true
|
||||
continue
|
||||
}
|
||||
full, err := m.ms.ResolvePrefix(d)
|
||||
if err != nil {
|
||||
log.Printf("[sdk media] 插件 %s 提交的 digest %s 无法解析: %v", m.plugin, d, err)
|
||||
continue
|
||||
}
|
||||
if line := sdkMarkerFor(m.ms, full); line != "" {
|
||||
add = append(add, line)
|
||||
present[sdkShortDigest(full)] = true
|
||||
}
|
||||
}
|
||||
if len(add) == 0 {
|
||||
return sentence
|
||||
}
|
||||
if sentence == "" {
|
||||
return strings.Join(add, "\n")
|
||||
}
|
||||
return sentence + "\n" + strings.Join(add, "\n")
|
||||
}
|
||||
|
||||
// bindSentences 把每条句子里引用的媒体挂到该句子的 graph_sentence owner 上。
|
||||
func (m *graphMemory) bindSentences(sentenceIDs map[string]int64) {
|
||||
if m.ms == nil || len(sentenceIDs) == 0 {
|
||||
return
|
||||
}
|
||||
bound := 0
|
||||
for text, sid := range sentenceIDs {
|
||||
if sid == 0 {
|
||||
continue
|
||||
}
|
||||
// 每条句子一个独立的 done 集:同一份媒体挂在不同句子上是两条
|
||||
// 合法引用(owner_id 不同),不该被跨句子去重。
|
||||
bound += sdkBindText(m.ms, text, media.OwnerGraphSentence,
|
||||
strconv.FormatInt(sid, 10), map[string]bool{})
|
||||
}
|
||||
if bound > 0 {
|
||||
log.Printf("[sdk media] 插件 %s 的三元组绑定 %d 个媒体引用", m.plugin, bound)
|
||||
}
|
||||
_, _, err := m.db.Commit(ts, "plugin", 0)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *graphMemory) Introspect() (map[string]interface{}, error) {
|
||||
if m.db == nil { return map[string]interface{}{}, nil }
|
||||
if m.db == nil {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
return m.db.Introspect()
|
||||
}
|
||||
|
||||
func (m *graphMemory) MergeEntities(source, target string) (int, error) {
|
||||
if m.db == nil { return 0, nil }
|
||||
if m.db == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return m.db.MergeEntities(source, target)
|
||||
}
|
||||
|
||||
func (m *graphMemory) Purge(criteria map[string]string, mode string) (int, error) {
|
||||
if m.db == nil { return 0, nil }
|
||||
if m.db == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return m.db.Purge(criteria, mode)
|
||||
}
|
||||
|
||||
func (m *graphMemory) GraphData() (map[string]interface{}, error) {
|
||||
if m.db == nil { return map[string]interface{}{}, nil }
|
||||
if m.db == nil {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
return m.db.GraphData()
|
||||
}
|
||||
|
||||
type textMemoryImpl struct{ tm *text.Memory }
|
||||
// ---------- 文本记忆 ----------
|
||||
|
||||
type textMemoryImpl struct {
|
||||
tm *text.Memory
|
||||
ms *media.Store
|
||||
plugin string
|
||||
}
|
||||
|
||||
func NewTextMemory(tm *text.Memory) TextMemoryAPI { return &textMemoryImpl{tm: tm} }
|
||||
|
||||
// NewTextMemoryWithMedia 创建带媒体能力的文本记忆包装。
|
||||
func NewTextMemoryWithMedia(plugin string, tm *text.Memory, ms *media.Store) TextMemoryAPI {
|
||||
return &textMemoryImpl{tm: tm, ms: ms, plugin: plugin}
|
||||
}
|
||||
|
||||
// Append 追加一条文本事件;带附件时把媒体标记并进正文。
|
||||
//
|
||||
// 文本记忆是追加写 JSONL,没有稳定 owner_id 可挂 media_refs,所以媒体在这一层
|
||||
// 只能以标记形式存在。这不是妥协——描述文本才是持久的语义记忆,blob 只是缓存。
|
||||
func (m *textMemoryImpl) Append(evt TextEvent) error {
|
||||
if m.tm == nil { return nil }
|
||||
if m.tm == nil {
|
||||
return nil
|
||||
}
|
||||
content := evt.Content
|
||||
if len(evt.Attachments) > 0 && m.ms != nil {
|
||||
var lines []string
|
||||
for _, a := range evt.Attachments {
|
||||
d, err := sdkPutAttachment(m.ms, a, "plugin_text:"+m.plugin)
|
||||
if err != nil {
|
||||
log.Printf("[sdk media] 插件 %s 文本附件入库失败: %v", m.plugin, err)
|
||||
continue
|
||||
}
|
||||
if line := sdkMarkerFor(m.ms, d); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
if content == "" {
|
||||
content = strings.Join(lines, "\n")
|
||||
} else {
|
||||
content += "\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
return m.tm.Append(text.Event{
|
||||
Timestamp: evt.Timestamp, Source: evt.Role, Input: evt.Content, AgentID: evt.Channel,
|
||||
Timestamp: evt.Timestamp, Source: evt.Role, Input: content, AgentID: evt.Channel,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *textMemoryImpl) RecentEvents(n int) ([]TextEvent, error) {
|
||||
if m.tm == nil { return nil, nil }
|
||||
if m.tm == nil {
|
||||
return nil, nil
|
||||
}
|
||||
got, err := m.tm.RecentEvents(n)
|
||||
if err != nil { return nil, err }
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]TextEvent, len(got))
|
||||
for i, e := range got {
|
||||
out[i] = TextEvent{Role: e.Source, Content: e.Input, Timestamp: e.Timestamp, Channel: e.AgentID}
|
||||
out[i] = TextEvent{
|
||||
Role: e.Source, Content: e.Input, Timestamp: e.Timestamp, Channel: e.AgentID,
|
||||
Attachments: sdkAttachmentsFromText(m.ms, e.Input),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *textMemoryImpl) Stats() map[string]interface{} {
|
||||
if m.tm == nil { return map[string]interface{}{} }
|
||||
if m.tm == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return m.tm.Stats()
|
||||
}
|
||||
|
||||
type docMemoryImpl struct{ ds *doc.Store }
|
||||
// ---------- 文档记忆(知识库) ----------
|
||||
|
||||
type docMemoryImpl struct {
|
||||
ds *doc.Store
|
||||
ms *media.Store
|
||||
plugin string
|
||||
}
|
||||
|
||||
func NewDocMemory(ds *doc.Store) DocMemoryAPI { return &docMemoryImpl{ds: ds} }
|
||||
|
||||
// NewDocMemoryWithMedia 创建带媒体能力的文档记忆包装。
|
||||
func NewDocMemoryWithMedia(plugin string, ds *doc.Store, ms *media.Store) DocMemoryAPI {
|
||||
return &docMemoryImpl{ds: ds, ms: ms, plugin: plugin}
|
||||
}
|
||||
|
||||
// Query 检索文档,并补齐媒体元数据。
|
||||
//
|
||||
// 旧实现只回 ID/Title/Content,插件即便拿到一篇带媒体的文档也看不出这里有
|
||||
// 几份媒体、分别是什么。现在同时给出完整 digest 列表与 mime+描述,
|
||||
// 但**不回字节**:一次检索可能命中几十份媒体,全塞回去会把跨进程消息撑爆,
|
||||
// 需要字节时按 digest 单取。
|
||||
func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
|
||||
if m.ds == nil { return nil }
|
||||
if m.ds == nil {
|
||||
return nil
|
||||
}
|
||||
got := m.ds.Query(text, topK)
|
||||
out := make([]*Doc, len(got))
|
||||
for i, d := range got {
|
||||
out[i] = &Doc{ID: d.ID, Title: d.Summary, Content: d.Content}
|
||||
m.fillMedia(out[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *docMemoryImpl) Insert(d *Doc) error {
|
||||
if m.ds == nil { return nil }
|
||||
return m.ds.Insert(&doc.Doc{ID: d.ID, Summary: d.Title, Content: d.Content})
|
||||
// fillMedia 填充文档的媒体字段。
|
||||
//
|
||||
// 优先用 media_refs(权威:谁挂上去的就是谁),为空时退回解析正文标记——
|
||||
// 历史文档与经旧版插件写入的文档只有标记、没有引用。
|
||||
func (m *docMemoryImpl) fillMedia(out *Doc) {
|
||||
if m.ms == nil {
|
||||
return
|
||||
}
|
||||
digests, err := m.ms.Refs(media.OwnerDocument, out.ID)
|
||||
if err != nil {
|
||||
log.Printf("[sdk media] 读取文档 %s 的媒体引用失败: %v", out.ID, err)
|
||||
}
|
||||
if len(digests) == 0 {
|
||||
out.Attachments = sdkAttachmentsFromText(m.ms, out.Content)
|
||||
for _, a := range out.Attachments {
|
||||
out.MediaDigests = append(out.MediaDigests, a.Digest)
|
||||
}
|
||||
return
|
||||
}
|
||||
out.MediaDigests = digests
|
||||
for _, d := range digests {
|
||||
it, err := m.ms.Stat(d)
|
||||
if err != nil || it == nil {
|
||||
continue
|
||||
}
|
||||
out.Attachments = append(out.Attachments, MediaAttachment{
|
||||
Digest: it.Digest, MIME: it.MIME, Description: it.Description,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (m *docMemoryImpl) Remove(id string) { if m.ds != nil { m.ds.Remove(id) } }
|
||||
// Insert 写入文档。正文里已有的媒体标记会被挂成文档级引用,
|
||||
// 避免插件写进来的媒体在下一次 GC 时被当作无主内容清掉。
|
||||
func (m *docMemoryImpl) Insert(d *Doc) error { return m.InsertWithMedia(d, nil) }
|
||||
|
||||
// InsertWithMedia 写入文档并关联媒体。
|
||||
//
|
||||
// 标记由内核补进 Content——插件不必知道标记格式,也就不会因为格式写错导致
|
||||
// 引用挂不上。补标记必须在 ds.Insert 之前完成:向量索引用 Summary+Content
|
||||
// 计算,标记进不去正文就检索不到这份媒体。
|
||||
func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) error {
|
||||
if m.ds == nil || d == nil {
|
||||
return nil
|
||||
}
|
||||
target := &doc.Doc{ID: d.ID, Summary: d.Title, Content: d.Content}
|
||||
if target.Source == "" {
|
||||
target.Source = "plugin:" + m.plugin
|
||||
}
|
||||
|
||||
digests := m.storeAttachments(attachments, &target.Content)
|
||||
|
||||
if err := m.ds.Insert(target); err != nil {
|
||||
return err
|
||||
}
|
||||
// 回填给调用方:ID 是新建时内核生成的,Content 含内核补的标记。
|
||||
d.ID = target.ID
|
||||
d.Content = target.Content
|
||||
|
||||
m.bindDocMedia(target, digests)
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeAttachments 把附件落库并把标记追加进 content,返回全部完整 digest。
|
||||
func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment, content *string) []string {
|
||||
if m.ms == nil || len(atts) == 0 {
|
||||
return nil
|
||||
}
|
||||
present := sdkDigestsIn(*content)
|
||||
var digests, lines []string
|
||||
for _, a := range atts {
|
||||
full, err := sdkPutAttachment(m.ms, a, "plugin_doc:"+m.plugin)
|
||||
if err != nil {
|
||||
// 媒体存不进去不该让文档写入失败——它是记忆增强,不是文档必需品
|
||||
log.Printf("[sdk media] 插件 %s 文档附件入库失败: %v", m.plugin, err)
|
||||
continue
|
||||
}
|
||||
digests = append(digests, full)
|
||||
if present[sdkShortDigest(full)] {
|
||||
continue // 插件自己写了标记,不重复追加
|
||||
}
|
||||
present[sdkShortDigest(full)] = true
|
||||
if line := sdkMarkerFor(m.ms, full); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
if *content == "" {
|
||||
*content = strings.Join(lines, "\n")
|
||||
} else {
|
||||
*content += "\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
}
|
||||
return digests
|
||||
}
|
||||
|
||||
// bindDocMedia 把附件与正文标记引用的媒体一起挂到文档 owner 上。
|
||||
func (m *docMemoryImpl) bindDocMedia(target *doc.Doc, digests []string) {
|
||||
if m.ms == nil || target.ID == "" {
|
||||
return
|
||||
}
|
||||
bound := 0
|
||||
done := make(map[string]bool, len(digests))
|
||||
for _, full := range digests {
|
||||
if done[full] {
|
||||
continue
|
||||
}
|
||||
if err := m.ms.AddRef(full, media.OwnerDocument, target.ID); err != nil {
|
||||
log.Printf("[sdk media] 文档引用绑定失败 (%s → doc %s): %v",
|
||||
sdkShortDigest(full), target.ID, err)
|
||||
continue
|
||||
}
|
||||
done[full] = true
|
||||
bound++
|
||||
}
|
||||
// 插件手写在正文里的标记同样要挂上,否则那些媒体在文档里可见却无主。
|
||||
// 共用 done:附件刚挂过的那些是同一份媒体(内核自己把标记补进了正文)。
|
||||
bound += sdkBindText(m.ms, target.Content, media.OwnerDocument, target.ID, done)
|
||||
if bound > 0 {
|
||||
log.Printf("[sdk media] 插件 %s 写入文档 %s,绑定 %d 个媒体引用",
|
||||
m.plugin, target.ID, bound)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove 删除文档,同时释放它持有的媒体引用。
|
||||
//
|
||||
// 旧实现只删文档不解引用,于是那些媒体永久处于「被引用」状态:GC 不回收,
|
||||
// 磁盘只增不减。内核的归档路径(distill 的 releaseDocMedia)做了这一步,
|
||||
// 插件路径漏了同一步。
|
||||
func (m *docMemoryImpl) Remove(id string) {
|
||||
if m.ds == nil {
|
||||
return
|
||||
}
|
||||
if m.ms != nil && id != "" {
|
||||
if n, err := m.ms.DropOwner(media.OwnerDocument, id); err != nil {
|
||||
log.Printf("[sdk media] 释放文档 %s 的媒体引用失败: %v", id, err)
|
||||
} else if n > 0 {
|
||||
log.Printf("[sdk media] 文档 %s 删除,释放 %d 个媒体引用", id, n)
|
||||
}
|
||||
}
|
||||
m.ds.Remove(id)
|
||||
}
|
||||
|
||||
func (m *docMemoryImpl) Stats() map[string]interface{} {
|
||||
if m.ds == nil { return map[string]interface{}{} }
|
||||
if m.ds == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return m.ds.Stats()
|
||||
}
|
||||
|
||||
|
||||
434
internal/sdk/memory_impl_test.go
Normal file
434
internal/sdk/memory_impl_test.go
Normal file
@ -0,0 +1,434 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
)
|
||||
|
||||
// 插件边界的媒体透传测试。
|
||||
//
|
||||
// 断言的都是不变量,而不是"函数被调过":
|
||||
// 1. 插件填的字段一个都不许丢(旧实现静默裁掉 Confidence/类型/SentenceText);
|
||||
// 2. 插件不必知道媒体标记格式,内核负责补;
|
||||
// 3. 媒体引用挂到正确的 owner 上,删除时释放;
|
||||
// 4. mediaStore 为 nil 时整条链路退化成纯文本,不 panic 不报错。
|
||||
|
||||
func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory, *media.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewGraphDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { g.Close() })
|
||||
|
||||
ds := doc.NewStore(filepath.Join(dir, "documents"))
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatalf("doc store start: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ds.Stop() })
|
||||
|
||||
tm := text.New(filepath.Join(dir, "text"))
|
||||
if err := tm.Start(); err != nil {
|
||||
t.Fatalf("text memory start: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { tm.Stop() })
|
||||
|
||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("media.New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ms.Close() })
|
||||
|
||||
return g, ds, tm, ms
|
||||
}
|
||||
|
||||
// putDescribed 存一份带描述的媒体,返回完整 digest。
|
||||
func putDescribed(t *testing.T, ms *media.Store, payload, desc string) string {
|
||||
t.Helper()
|
||||
d, err := ms.Put([]byte(payload), media.Item{MIME: "image/png", Description: desc})
|
||||
if err != nil {
|
||||
t.Fatalf("media.Put: %v", err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ---------- 图记忆 ----------
|
||||
|
||||
// 旧实现只搬 Subject/Relation/Object,其余字段静默丢弃:
|
||||
// 插件标注的类型全部落成默认 Concept,置信度全成 1.0,SentenceText 直接消失。
|
||||
func TestGraphCommit_CarriesAllFields(t *testing.T) {
|
||||
g, _, _, ms := newTestStores(t)
|
||||
m := NewGraphMemoryWithMedia("tester", g, ms)
|
||||
|
||||
err := m.Commit([]Triple{{
|
||||
Subject: "张三",
|
||||
Relation: "养",
|
||||
Object: "橘猫",
|
||||
Confidence: 0.75,
|
||||
SubjectType: "Person",
|
||||
ObjectType: "Animal",
|
||||
SentenceText: "张三养了一只橘猫。",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("Commit: %v", err)
|
||||
}
|
||||
|
||||
res, err := g.Recall([]string{"张三"}, nil, 2, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Recall: %v", err)
|
||||
}
|
||||
if len(res.Relations) == 0 {
|
||||
t.Fatal("召回不到刚提交的关系")
|
||||
}
|
||||
r := res.Relations[0]
|
||||
if r.Confidence != 0.75 {
|
||||
t.Errorf("Confidence = %v,期望 0.75(插件标注的置信度被丢弃)", r.Confidence)
|
||||
}
|
||||
if r.SentenceText != "张三养了一只橘猫。" {
|
||||
t.Errorf("SentenceText = %q,期望原句(丢了它媒体就没有落点)", r.SentenceText)
|
||||
}
|
||||
|
||||
var subjType, objType string
|
||||
for _, e := range res.Entities {
|
||||
switch e.Name {
|
||||
case "张三":
|
||||
subjType = e.Type
|
||||
case "橘猫":
|
||||
objType = e.Type
|
||||
}
|
||||
}
|
||||
if subjType != "Person" || objType != "Animal" {
|
||||
t.Errorf("实体类型 = (%q,%q),期望 (Person,Animal)", subjType, objType)
|
||||
}
|
||||
}
|
||||
|
||||
// 插件只给 digest,标记与句子由内核合成;引用必须挂到 graph_sentence owner 上。
|
||||
func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
|
||||
g, _, _, ms := newTestStores(t)
|
||||
digest := putDescribed(t, ms, "png-bytes", "一张紫蓝红三色带图")
|
||||
|
||||
m := NewGraphMemoryWithMedia("tester", g, ms)
|
||||
if err := m.Commit([]Triple{{
|
||||
Subject: "配色图",
|
||||
Relation: "包含",
|
||||
Object: "三色带",
|
||||
MediaDigests: []string{digest[:12]}, // 插件手里通常只有短 digest
|
||||
}}); err != nil {
|
||||
t.Fatalf("Commit: %v", err)
|
||||
}
|
||||
|
||||
res, err := g.Recall([]string{"配色图"}, nil, 2, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Recall: %v", err)
|
||||
}
|
||||
if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 {
|
||||
t.Fatal("没有句子落点 —— 媒体引用无从挂起")
|
||||
}
|
||||
sid := res.Relations[0].SentenceID
|
||||
|
||||
// 描述必须进句子:描述文本才是持久语义记忆,检索靠它。
|
||||
if !strings.Contains(res.Relations[0].SentenceText, "三色带图") {
|
||||
t.Errorf("句子里没有媒体描述: %q", res.Relations[0].SentenceText)
|
||||
}
|
||||
|
||||
refs, err := ms.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10))
|
||||
if err != nil {
|
||||
t.Fatalf("Refs: %v", err)
|
||||
}
|
||||
if len(refs) != 1 || refs[0] != digest {
|
||||
t.Errorf("句子 #%d 的媒体引用 = %v,期望 [%s]", sid, refs, digest)
|
||||
}
|
||||
}
|
||||
|
||||
// 插件自己按格式写了标记又同时填了 MediaDigests,不能产生两条重复引用/两份标记。
|
||||
func TestGraphCommit_NoDuplicateMarker(t *testing.T) {
|
||||
g, _, _, ms := newTestStores(t)
|
||||
digest := putDescribed(t, ms, "dup-bytes", "重复标记测试图")
|
||||
short := digest[:12]
|
||||
|
||||
m := NewGraphMemoryWithMedia("tester", g, ms)
|
||||
if err := m.Commit([]Triple{{
|
||||
Subject: "重复图",
|
||||
Relation: "标记",
|
||||
Object: "一次",
|
||||
SentenceText: "看这个 [image/png " + short + "] 重复标记测试图",
|
||||
MediaDigests: []string{short},
|
||||
}}); err != nil {
|
||||
t.Fatalf("Commit: %v", err)
|
||||
}
|
||||
|
||||
res, _ := g.Recall([]string{"重复图"}, nil, 2, "")
|
||||
if len(res.Relations) == 0 {
|
||||
t.Fatal("召回不到关系")
|
||||
}
|
||||
if n := strings.Count(res.Relations[0].SentenceText, short); n != 1 {
|
||||
t.Errorf("句子里出现 %d 次 digest,期望 1 次: %q", n, res.Relations[0].SentenceText)
|
||||
}
|
||||
}
|
||||
|
||||
// mediaStore 为 nil 时仍要能提交(媒体是增强,不是必需品),digest 留在文本里备查。
|
||||
func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) {
|
||||
g, _, _, _ := newTestStores(t)
|
||||
m := NewGraphMemory(g)
|
||||
|
||||
if err := m.Commit([]Triple{{
|
||||
Subject: "无存储",
|
||||
Relation: "仍可",
|
||||
Object: "提交",
|
||||
MediaDigests: []string{"aabbccddeeff"},
|
||||
}}); err != nil {
|
||||
t.Fatalf("Commit 在无媒体存储时不该失败: %v", err)
|
||||
}
|
||||
|
||||
res, _ := g.Recall([]string{"无存储"}, nil, 2, "")
|
||||
if len(res.Relations) == 0 {
|
||||
t.Fatal("召回不到关系")
|
||||
}
|
||||
if !strings.Contains(res.Relations[0].SentenceText, "aabbccddeeff") {
|
||||
t.Errorf("digest 应留在句子里以备将来反查: %q", res.Relations[0].SentenceText)
|
||||
}
|
||||
}
|
||||
|
||||
// Recall 必须把置信度带回插件:拿不到它,插件只能把所有召回结果等同看待。
|
||||
func TestGraphRecall_CarriesConfidence(t *testing.T) {
|
||||
g, _, _, ms := newTestStores(t)
|
||||
m := NewGraphMemoryWithMedia("tester", g, ms)
|
||||
|
||||
// 实体名至少两个字符:validEntityName 会静默跳过单字实体,
|
||||
// 那样 Commit 返回 nil 但什么都没写,测试会退化成假阳性。
|
||||
if err := m.Commit([]Triple{{
|
||||
Subject: "甲方", Relation: "疑似", Object: "乙方", Confidence: 0.3,
|
||||
}}); err != nil {
|
||||
t.Fatalf("Commit: %v", err)
|
||||
}
|
||||
|
||||
_, rels, err := m.Recall([]string{"甲方"}, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("Recall: %v", err)
|
||||
}
|
||||
if len(rels) == 0 {
|
||||
t.Fatal("召回为空")
|
||||
}
|
||||
if rels[0].Confidence != 0.3 {
|
||||
t.Errorf("Confidence = %v,期望 0.3", rels[0].Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 文档记忆(知识库) ----------
|
||||
|
||||
// 附件带 Data → 落进 CAS、标记补进正文、引用挂到文档 owner。
|
||||
func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
|
||||
_, ds, _, ms := newTestStores(t)
|
||||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
||||
|
||||
d := &Doc{Title: "带图笔记", Content: "这是正文。"}
|
||||
err := dm.InsertWithMedia(d, []MediaAttachment{{
|
||||
MIME: "image/png",
|
||||
Data: []byte("attachment-bytes"),
|
||||
Name: "chart.png",
|
||||
Description: "一张柱状图",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertWithMedia: %v", err)
|
||||
}
|
||||
if d.ID == "" {
|
||||
t.Fatal("ID 未回填 —— 插件拿不到刚写入文档的 id")
|
||||
}
|
||||
|
||||
// 标记必须进正文:向量索引用 Summary+Content 计算,
|
||||
// 标记进不去正文就永远检索不到这份媒体。
|
||||
if !strings.Contains(d.Content, "柱状图") {
|
||||
t.Errorf("正文里没有媒体标记: %q", d.Content)
|
||||
}
|
||||
|
||||
refs, err := ms.Refs(media.OwnerDocument, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Refs: %v", err)
|
||||
}
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("文档媒体引用 = %v,期望 1 条", refs)
|
||||
}
|
||||
// 内容可读,说明真的落盘了而不只是记了个 digest。
|
||||
got, err := ms.Get(refs[0])
|
||||
if err != nil || string(got) != "attachment-bytes" {
|
||||
t.Errorf("媒体内容读回失败: %v / %q", err, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 只给 Digest 的附件是「引用已有内容」,不该报错也不该重复落盘。
|
||||
func TestDocInsertWithMedia_DigestOnlyReference(t *testing.T) {
|
||||
_, ds, _, ms := newTestStores(t)
|
||||
digest := putDescribed(t, ms, "existing", "已有的图")
|
||||
before := ms.Stats()["count"]
|
||||
|
||||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
||||
d := &Doc{Title: "引用已有", Content: "正文"}
|
||||
if err := dm.InsertWithMedia(d, []MediaAttachment{{Digest: digest[:10]}}); err != nil {
|
||||
t.Fatalf("InsertWithMedia: %v", err)
|
||||
}
|
||||
|
||||
if after := ms.Stats()["count"]; after != before {
|
||||
t.Errorf("媒体条数从 %v 变成 %v —— 引用已有内容不该新增", before, after)
|
||||
}
|
||||
refs, _ := ms.Refs(media.OwnerDocument, d.ID)
|
||||
if len(refs) != 1 || refs[0] != digest {
|
||||
t.Errorf("引用 = %v,期望 [%s]", refs, digest)
|
||||
}
|
||||
}
|
||||
|
||||
// Query 必须回媒体元数据但**不回字节**:一次检索可能命中几十份媒体,
|
||||
// 全塞回插件会把跨进程消息撑爆。
|
||||
func TestDocQuery_FillsMediaMetadataWithoutBytes(t *testing.T) {
|
||||
_, ds, _, ms := newTestStores(t)
|
||||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
||||
|
||||
d := &Doc{Title: "紫蓝红三色带", Content: "配色说明"}
|
||||
if err := dm.InsertWithMedia(d, []MediaAttachment{{
|
||||
MIME: "image/png", Data: []byte("query-bytes"), Description: "三色带图",
|
||||
}}); err != nil {
|
||||
t.Fatalf("InsertWithMedia: %v", err)
|
||||
}
|
||||
|
||||
got := dm.Query("紫蓝红三色带 配色说明", 3)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("检索不到刚写入的文档")
|
||||
}
|
||||
var hit *Doc
|
||||
for _, g := range got {
|
||||
if g.ID == d.ID {
|
||||
hit = g
|
||||
}
|
||||
}
|
||||
if hit == nil {
|
||||
t.Fatalf("检索结果里没有目标文档: %+v", got)
|
||||
}
|
||||
if len(hit.MediaDigests) != 1 {
|
||||
t.Errorf("MediaDigests = %v,期望 1 条", hit.MediaDigests)
|
||||
}
|
||||
if len(hit.Attachments) != 1 {
|
||||
t.Fatalf("Attachments = %v,期望 1 条", hit.Attachments)
|
||||
}
|
||||
att := hit.Attachments[0]
|
||||
if att.MIME != "image/png" || att.Description != "三色带图" {
|
||||
t.Errorf("附件元数据 = %+v,期望 mime=image/png desc=三色带图", att)
|
||||
}
|
||||
if len(att.Data) != 0 {
|
||||
t.Errorf("Attachments 不该带字节(%d 字节)—— 需要时按 digest 单取", len(att.Data))
|
||||
}
|
||||
}
|
||||
|
||||
// 历史文档只有标记、没有 media_refs(旧版插件写入的)。
|
||||
// 此时要能从正文标记反解出附件,否则那些文档的媒体对插件永远不可见。
|
||||
func TestDocQuery_FallsBackToMarkers(t *testing.T) {
|
||||
_, ds, _, ms := newTestStores(t)
|
||||
digest := putDescribed(t, ms, "legacy", "历史图片")
|
||||
|
||||
// 直接写底层 store,绕过 SDK 的绑定逻辑,模拟历史数据。
|
||||
if err := ds.Insert(&doc.Doc{
|
||||
Summary: "历史文档",
|
||||
Content: "旧正文 [image/png " + digest[:12] + "] 历史图片",
|
||||
}); err != nil {
|
||||
t.Fatalf("Insert: %v", err)
|
||||
}
|
||||
|
||||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
||||
got := dm.Query("历史文档 旧正文", 3)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("检索不到历史文档")
|
||||
}
|
||||
if len(got[0].MediaDigests) != 1 || got[0].MediaDigests[0] != digest {
|
||||
t.Errorf("MediaDigests = %v,期望从标记反解出 [%s]", got[0].MediaDigests, digest)
|
||||
}
|
||||
}
|
||||
|
||||
// 旧实现删文档不解引用 → 媒体永久"被引用",GC 收不掉,磁盘只增不减。
|
||||
func TestDocRemove_ReleasesMediaRefs(t *testing.T) {
|
||||
_, ds, _, ms := newTestStores(t)
|
||||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
||||
|
||||
d := &Doc{Title: "待删除", Content: "正文"}
|
||||
if err := dm.InsertWithMedia(d, []MediaAttachment{{
|
||||
MIME: "image/png", Data: []byte("to-be-freed"), Description: "会被释放的图",
|
||||
}}); err != nil {
|
||||
t.Fatalf("InsertWithMedia: %v", err)
|
||||
}
|
||||
if refs, _ := ms.Refs(media.OwnerDocument, d.ID); len(refs) != 1 {
|
||||
t.Fatalf("前置条件不成立,引用 = %v", refs)
|
||||
}
|
||||
|
||||
dm.Remove(d.ID)
|
||||
|
||||
if refs, _ := ms.Refs(media.OwnerDocument, d.ID); len(refs) != 0 {
|
||||
t.Errorf("删除文档后仍有 %v 条引用 —— GC 永远收不掉这份媒体", refs)
|
||||
}
|
||||
}
|
||||
|
||||
// mediaStore 为 nil 时 Insert/Query/Remove 必须与本特性上线前完全一致。
|
||||
func TestDocMemory_NilMediaStoreDegrades(t *testing.T) {
|
||||
_, ds, _, _ := newTestStores(t)
|
||||
dm := NewDocMemory(ds)
|
||||
|
||||
d := &Doc{Title: "无媒体存储", Content: "正文照常写入"}
|
||||
if err := dm.InsertWithMedia(d, []MediaAttachment{{
|
||||
MIME: "image/png", Data: []byte("ignored"),
|
||||
}}); err != nil {
|
||||
t.Fatalf("无媒体存储时写入不该失败: %v", err)
|
||||
}
|
||||
if d.ID == "" {
|
||||
t.Error("ID 仍应回填")
|
||||
}
|
||||
got := dm.Query("无媒体存储 正文照常写入", 3)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("检索不到文档")
|
||||
}
|
||||
if len(got[0].Attachments) != 0 {
|
||||
t.Errorf("无媒体存储时不该有附件: %+v", got[0].Attachments)
|
||||
}
|
||||
dm.Remove(d.ID) // 不该 panic
|
||||
}
|
||||
|
||||
// ---------- 文本记忆 ----------
|
||||
|
||||
// 文本记忆是追加写 JSONL,没有稳定 owner_id 可挂引用,
|
||||
// 媒体只能以标记形式留在正文里;读回时要能反解成结构化附件。
|
||||
func TestTextMemory_AttachmentRoundTrip(t *testing.T) {
|
||||
_, _, tm, ms := newTestStores(t)
|
||||
m := NewTextMemoryWithMedia("tester", tm, ms)
|
||||
|
||||
if err := m.Append(TextEvent{
|
||||
Role: "user",
|
||||
Content: "看这张图",
|
||||
Attachments: []MediaAttachment{{
|
||||
MIME: "image/png", Data: []byte("text-mem-bytes"), Description: "文本记忆里的图",
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Append: %v", err)
|
||||
}
|
||||
|
||||
got, err := m.RecentEvents(5)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentEvents: %v", err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatal("读不到刚追加的事件")
|
||||
}
|
||||
last := got[len(got)-1]
|
||||
if !strings.Contains(last.Content, "文本记忆里的图") {
|
||||
t.Errorf("正文里没有媒体标记: %q", last.Content)
|
||||
}
|
||||
if len(last.Attachments) != 1 {
|
||||
t.Fatalf("Attachments = %+v,期望 1 条(标记应能反解)", last.Attachments)
|
||||
}
|
||||
if last.Attachments[0].Description != "文本记忆里的图" {
|
||||
t.Errorf("附件描述 = %q", last.Attachments[0].Description)
|
||||
}
|
||||
}
|
||||
@ -195,6 +195,57 @@ func (a ioAdapter) InjectTextNoMemory(source, channel, text string) {
|
||||
}
|
||||
}
|
||||
|
||||
// InjectInputMedia 注入带媒体内容块的输入。
|
||||
//
|
||||
// blocks 放在 payload 的 media_blocks 里,由 eventloop 取出转进
|
||||
// stageCtx.Extra——与用户直接发图走的是同一条通道,因此自动获得
|
||||
// CAS 落盘与媒体记忆绑定。与 SetToolBlocks 的区别:后者只能在工具
|
||||
// 调用内部用,且媒体要等到下一条 tool message 才到模型手上。
|
||||
func (a ioAdapter) InjectInputMedia(source, channel, text string, blocks []pubsdk.ContentBlock) {
|
||||
if a.iom != nil {
|
||||
a.iom.InjectInputTo(source, channel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"media_blocks": blocks,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// InjectInputMediaSync 注入带媒体内容块的输入并同步等待回复。
|
||||
func (a ioAdapter) InjectInputMediaSync(source, channel, text string, blocks []pubsdk.ContentBlock) string {
|
||||
if a.iom == nil {
|
||||
return ""
|
||||
}
|
||||
out := a.iom.InjectInputSyncTo(source, channel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"media_blocks": blocks,
|
||||
})
|
||||
if out == nil {
|
||||
return ""
|
||||
}
|
||||
reply, _ := out.Payload["content"].(string)
|
||||
return reply
|
||||
}
|
||||
|
||||
// InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。
|
||||
func (a ioAdapter) InjectInterruptMedia(source, channel, text string, blocks []pubsdk.ContentBlock) {
|
||||
if a.iom != nil {
|
||||
a.iom.InjectInterrupt(source, channel, map[string]interface{}{
|
||||
"type": "text",
|
||||
"content": text,
|
||||
"media_blocks": blocks,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ContentBlock / ImageURL / AudioURL 是多模态内容块在插件边界上的类型。
|
||||
//
|
||||
// 别名到公共 SDK 而非另建一套:内置插件(webui/multimodal 等)与外部插件必须
|
||||
// 用同一套结构,否则 resolveInput 的类型分支要认第三种类型,而漏认的后果是
|
||||
// 媒体被静默丢弃。
|
||||
type ContentBlock = pubsdk.ContentBlock
|
||||
type ImageURL = pubsdk.ImageURL
|
||||
type AudioURL = pubsdk.AudioURL
|
||||
|
||||
// SDKConfig holds all dependencies for creating a PluginSDK.
|
||||
type SDKConfig struct {
|
||||
IOManager *agentIO.IOManager
|
||||
|
||||
Reference in New Issue
Block a user