mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26dc76f1a6 | |||
| 440704cf27 | |||
| c08383dc4b | |||
| e671a8c082 | |||
| dcaea64439 |
@ -193,7 +193,9 @@ internal/
|
||||
|
||||
## 项目状态
|
||||
|
||||
**v1.1.0** — 记忆系统支持二进制多媒体节点。此前四层记忆(L0 活跃上下文 / L1 文本 / L2 文档 / L3 图库)全部只存文字,图片音频经视觉模型转成描述后原始字节即丢弃,"那张紫蓝红三色带图"再也取不回来。本版新增内容寻址媒体存储(CAS,`internal/memory/media`):元数据进 SQLite、blob 按 sha256 落盘去重,L0/L2/L3 各层只记 digest 并通过 `media_refs` 维护引用计数,容量上限由后台 GC 真正兑现(被引用的内容即便超限也永不删除)。**描述文本才是持久语义记忆,blob 只是缓存**——描述随记忆各层一直留存并可检索,原始字节可被容量 GC 淘汰,因此几个月后仍能从图库句子反查到那张图(若尚在则逐字节取回)。描述由后台循环经视觉源生成(默认关闭,开启后每 30s 最多 4 条,不与对话抢配额),放在对话路径上会给每张图的回复加十几秒而收益为零——那一轮模型本来就直接看着图。同时修五个缺陷:`core.New` 漏接 `rc.SetMediaStore` 致 L0→L2 引用转移在生产静默失效;三元组全被实体名校验拒绝时仍释放引用并删除文档(数据丢失,已反向验证);媒体入图库曾依赖 NLP 提取器碰巧提出合规三元组而时好时坏,改为按媒体标记确定性产出;L3 媒体检索一度没有任何调用方(能存进去、agent 拿不出来);`remotedevice` 网关与 `agentcli` 终端各一处数据竞争。配套 `-tags medialive` 自动触发链实测:只注入一个图片事件,落盘/描述/归档/图库绑定/GC 保护/二轮召回七个阶段全由生产代码自行触发,真实视觉模型下 agent 在不给图的第二轮准确答出三条色带的颜色与近似 hex。插件 ABI 未变(`SDKCompatibleVersion` 仍为 1.0.0),存量 `plugin.bin` 无需重编。
|
||||
**v1.0.3** — 内核 stage 协调器双重解锁修复。现网 homed 主进程曾一次 `fatal error: sync: unlock of unlocked mutex` 整体死亡(带走全部 27 个子进程插件):`Host.endStage` 把「递减 inflight、判定最后离开者」放在 `coordMu` 临界区之外,而摘除协调器在临界区之内,于是后到插件能挂进一个正在收尾的协调器、被误判成最后离开者,对同一把 `stageMu` 解了两次。**`sync.Mutex` 双重解锁是 runtime fatal 而非 panic,两层 `recover` 结构上拦不住**,这才让「插件崩溃不拖垮内核」的隔离设计整体失效。修法是把计数、判定、摘除收进同一临界区,并把首进者写共享段的 `enter()` 也移入锁内(此前后到者可能读到写一半的段)。配套 5 个回归用例,含把旧实现 stash 回来验证测试确实能复现 fatal 的反向验证。
|
||||
|
||||
**v1.0.1** — 多模态 bugfix。插件 ABI/协议未变,1.0.0 编出的 `plugin.bin` 无需重编。修三类缺陷:(1)**看图假成功**——媒体块挂在 tool message 上不被模型当作可视内容(实测同一张图:tool message 0/3 读到、独立 user message 3/3),改为另起一条紧随其后的 user message 承载,落实插件文案一直在说的「注入后续对话」;(2)**新增多模态能力声明与回退链**——`core.llm.sources.<name>.vision/.audio` 声明源能否真正处理媒体(网关会静默剥离 `image_url` 后仍返回 200,带图与不带图 prompt_tokens 完全相同),不支持时自动走视觉源转写成文字,并落实了 `core.input_processing.image.fallback_provider` 这批早已注册却从未被读取的配置项;(3)**`see_video` 帧数语义反了**——`fps=1/N` 是频率不是数量,20s 视频请求 10 帧只得 2 帧、请求 1 帧反得 20 帧,改为 `ffprobe` 取时长 + `fps=N/时长` + `-frames:v` 硬封顶。
|
||||
|
||||
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版 `plugindev` 重编为 `plugin.bin`,**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE` 让 `dlclose` 成 no-op)、崩溃隔离缺失(插件 panic 带崩 homed)、stage lost update(副本模型丢失 35.8~36.8%)、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功(模型收到「已发送」而消息未送达)、Windows 能力断层(只见 3 个 stage 字段且无法写回)。三面通信:stdio JSON-RPC(控制)+ 共享内存段(数据)+ 事件环(通知);权限梯度显式化为三道闸。RPC 往返 p50 24.1µs,崩溃到恢复 <1s。
|
||||
|
||||
@ -220,7 +222,7 @@ internal/
|
||||
| **client** | waiter + 桌面 GUI | 连接远程 HomeAgent |
|
||||
|
||||
- Linux:`.deb`(amd64/arm64)、`.rpm`(x86_64)、`.tar.gz`
|
||||
- Windows:`HomeAgent_v1.1.0_{Full,Server,Client}_win64.exe`(NSIS 安装向导)
|
||||
- Windows:`HomeAgent_v1.0.3_{Full,Server,Client}_win64.exe`(NSIS 安装向导)
|
||||
- 免安装:`homeagent-bin-<os>_<arch>.tar.gz`(含 homed/waiter/initconfig)
|
||||
- 校验:`SHA256SUMS`
|
||||
|
||||
|
||||
@ -179,7 +179,9 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
|
||||
|
||||
## Project Status
|
||||
|
||||
**v1.1.0** — Binary/multimedia nodes in the memory system. All four tiers (L0 active context / L1 text / L2 documents / L3 graph) previously stored text only: an image or audio clip was turned into a description by a vision model and the original bytes were dropped, so "that purple-blue-red banded image" could never be retrieved again. This release adds a content-addressed media store (CAS, `internal/memory/media`): metadata in SQLite, blobs deduplicated on disk by sha256, with every tier holding only digests and reference counts maintained through `media_refs`, so the capacity cap is finally enforced by a background GC (referenced content is never deleted, even over the limit). **The description text is the durable semantic memory; the blob is only a cache** — descriptions persist across all tiers and stay searchable while raw bytes may be evicted, so months later a graph sentence still resolves back to that image (byte-for-byte if it survives). Descriptions are generated by a background loop through a vision source (off by default; at most 4 items per 30s when enabled, so it never competes with conversations for quota) — doing it inline would add tens of seconds to every image reply for no gain, since the model is looking at the image in that turn anyway. Five defects fixed as well: `core.New` never called `rc.SetMediaStore`, silently disabling L0→L2 reference transfer in production; references were released and the document deleted even when every triple was rejected by entity-name validation (data loss, reverse-verified); media entering the graph depended on the NLP extractor happening to produce valid triples and was therefore intermittent, now replaced by deterministic triples derived from media markers; L3 media lookup had no callers at all (stored fine, unreachable by the agent); and one data race each in the `remotedevice` gateway and the `agentcli` terminal. Ships with a `-tags medialive` auto-trigger integration test: a single injected image event drives all seven stages — CAS write, description, archival, graph binding, GC protection, second-turn recall — entirely through production code paths, and with a real vision model the agent names all three band colours and their approximate hex values in a second turn that includes no image. Plugin ABI unchanged (`SDKCompatibleVersion` stays 1.0.0); existing `plugin.bin` files need no rebuild.
|
||||
**v1.0.3** — Kernel stage-coordinator double-unlock fix. The production `homed` main process once died outright with `fatal error: sync: unlock of unlocked mutex`, taking all 27 subprocess plugins with it: `Host.endStage` performed "decrement inflight, decide whether I'm the last leaver" *outside* the `coordMu` critical section while detaching the coordinator *inside* it, so a late-arriving plugin could attach to a coordinator that was already finishing, be misjudged as the last leaver, and unlock the same `stageMu` twice. **A `sync.Mutex` double unlock is a runtime fatal, not a panic, so the two layers of `recover` structurally cannot catch it**—which is exactly why the "a crashing plugin must not take down the kernel" isolation design failed wholesale here. The fix folds counting, decision, and detach into one critical section, and also moves the first arriver's `enter()` (which writes the shared segment) inside the lock—previously a late arriver could read a half-written segment. Ships with 5 regression cases, including a reverse check that stashes the old implementation back to confirm the tests really do reproduce the fatal.
|
||||
|
||||
**v1.0.1** — Multimodal bugfix. The plugin ABI/protocol is unchanged, so `plugin.bin` artifacts built for 1.0.0 need no rebuild. Three defects fixed: (1) **vision silently failing**—media blocks attached to a tool message are not treated as viewable content by the model (measured on one image: 0/3 read from a tool message, 3/3 from a standalone user message); media now rides its own user message placed immediately after, which is what the plugin's own wording ("injected into the following conversation") always claimed; (2) **new multimodal capability declaration + fallback chain**—`core.llm.sources.<name>.vision/.audio` declares whether a source can genuinely process media (a gateway may strip `image_url` and still return 200, with identical prompt_tokens with and without the image); when it cannot, media is transcribed to text via a vision-capable source, finally wiring up the long-registered but never-read `core.input_processing.image.fallback_provider` settings; (3) **`see_video` frame-count semantics were inverted**—`fps=1/N` is a *rate*, not a count, so a 20s video yielded 2 frames when 10 were requested and 20 frames when 1 was requested; now `ffprobe` measures duration and the filter becomes `fps=N/duration` with `-frames:v` as a hard cap.
|
||||
|
||||
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new `plugindev`, though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
|
||||
|
||||
@ -206,7 +208,7 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
|
||||
| **client** | waiter + desktop GUI | Connecting to a remote HomeAgent |
|
||||
|
||||
- Linux: `.deb` (amd64/arm64), `.rpm` (x86_64), `.tar.gz`
|
||||
- Windows: `HomeAgent_v1.1.0_{Full,Server,Client}_win64.exe` (NSIS installer)
|
||||
- Windows: `HomeAgent_v1.0.3_{Full,Server,Client}_win64.exe` (NSIS installer)
|
||||
- Portable: `homeagent-bin-<os>_<arch>.tar.gz` (homed/waiter/initconfig)
|
||||
- Verification: `SHA256SUMS`
|
||||
|
||||
|
||||
@ -25,7 +25,6 @@ import (
|
||||
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
@ -325,27 +324,6 @@ func main() {
|
||||
log.Printf("[homed] warning: document store: %v", err)
|
||||
}
|
||||
|
||||
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重,
|
||||
// L0/L2/L3 只记 digest。开关默认开;关闭后全部媒体接线静默跳过,
|
||||
// 对话行为与本特性上线前完全一致。
|
||||
var mediaStore *media.Store
|
||||
if cfgReg.GetBool("core.memory.media.enabled", true) {
|
||||
mediaDir := cfgReg.GetString("core.memory.media.dir",
|
||||
filepath.Join(cfg.Daemon.DataDir, "memory", "media"))
|
||||
maxMB := cfgReg.GetInt("core.memory.media.max_mb", 2048)
|
||||
ms, err := media.New(mediaDir, int64(maxMB)*1024*1024)
|
||||
if err != nil {
|
||||
// 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品
|
||||
log.Printf("[homed] warning: media store: %v(媒体记忆已禁用)", err)
|
||||
} else {
|
||||
mediaStore = ms
|
||||
defer mediaStore.Close()
|
||||
st := mediaStore.Stats()
|
||||
log.Printf("[homed] media store active: %v 条 / %v 字节(上限 %d MB)",
|
||||
st["count"], st["total_bytes"], maxMB)
|
||||
}
|
||||
}
|
||||
|
||||
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
|
||||
if err := ks.Start(); err != nil {
|
||||
log.Printf("[homed] warning: knowledge store: %v", err)
|
||||
@ -378,7 +356,6 @@ func main() {
|
||||
pluginReg.SetMemory(memDB)
|
||||
pluginReg.SetTextMemory(textMem)
|
||||
pluginReg.SetDocStore(docStore)
|
||||
pluginReg.SetMediaStore(mediaStore) // 插件写入的记忆也走媒体链路;nil 时静默降级
|
||||
pluginReg.SetKnowledge(ks)
|
||||
pluginReg.SetProviderManager(providerMgr)
|
||||
pluginReg.SetConfigRegistry(cfgReg)
|
||||
@ -454,10 +431,6 @@ func main() {
|
||||
Knowledge: ks,
|
||||
SocialStore: socialStore,
|
||||
TextMemory: textMem,
|
||||
MediaStore: mediaStore,
|
||||
MediaGCInterval: cfgReg.GetDuration("core.memory.media.gc_interval", 6*time.Hour),
|
||||
MediaGCMinAge: cfgReg.GetDuration("core.memory.media.gc_min_age", time.Hour),
|
||||
MediaDescribe: cfgReg.GetBool("core.memory.media.describe_on_ingest", false),
|
||||
Personality: personality,
|
||||
PluginReg: pluginReg,
|
||||
PluginDir: cfg.Plugin.Dir,
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
# 此前硬编码 0.8.0 而 release 已到 1.0.0,装出来的包在「添加/删除程序」里
|
||||
# 会显示错误版本(DisplayVersion 也取自这个宏)。
|
||||
!ifndef PRODUCT_VERSION
|
||||
!define PRODUCT_VERSION "1.1.0"
|
||||
!define PRODUCT_VERSION "1.0.3"
|
||||
!endif
|
||||
|
||||
!if "${VARIANT}" == "full"
|
||||
|
||||
@ -9,20 +9,14 @@ PACKAGE_ROOT="${PROJECT_ROOT}/deploy/packaging/linux"
|
||||
GO="${GO:-$(command -v go 2>/dev/null || echo "go")}"
|
||||
|
||||
ARCH="${1:-amd64}" # amd64 or arm64
|
||||
|
||||
# electron 官方发布物用 x64/arm64 命名,而 Debian 用 amd64/arm64。
|
||||
# 两者在 arm64 上恰好同名,amd64 上不同——此前缓存查找统一用 TAR_ARCH
|
||||
# (amd64),于是 electron-v*-linux-x64.zip 永远命中不到,amd64 GUI 只能
|
||||
# 靠"回退到 host node_modules"这条路组装。干净 worktree 里没有完整
|
||||
# node_modules,GUI 就被静默跳过。故单独映射。
|
||||
ACTION="${2:-all}" # all, build, deb, tar, rpm
|
||||
|
||||
DEB_ARCH="$ARCH"
|
||||
RPM_ARCH="$ARCH"
|
||||
TAR_ARCH="$ARCH"
|
||||
case "$ARCH" in
|
||||
amd64) DEB_ARCH="amd64"; RPM_ARCH="x86_64"; TAR_ARCH="amd64"; ELECTRON_ARCH="x64" ;;
|
||||
arm64) DEB_ARCH="arm64"; RPM_ARCH="aarch64"; TAR_ARCH="arm64"; ELECTRON_ARCH="arm64" ;;
|
||||
amd64) DEB_ARCH="amd64"; RPM_ARCH="x86_64"; TAR_ARCH="amd64" ;;
|
||||
arm64) DEB_ARCH="arm64"; RPM_ARCH="aarch64"; TAR_ARCH="arm64" ;;
|
||||
*) echo "Unknown arch: $ARCH (use amd64 or arm64)"; exit 1 ;;
|
||||
esac
|
||||
|
||||
@ -128,15 +122,6 @@ build_go() {
|
||||
}
|
||||
|
||||
# ---- build GUI (manual directory assembly, avoids electron-packager network issues) ----
|
||||
#
|
||||
# electron 运行时必须按**目标架构**取,不能用 host 的
|
||||
# node_modules/electron/dist——那里永远是 host 架构(本机 x64)。
|
||||
# v1.0.0 / v1.0.1 的 arm64 full/client 包都踩了这个坑:目录名带
|
||||
# -arm64、homed/waiter 确实是 aarch64,但里面的 electron 是 x86-64,
|
||||
# 在 arm64 机器上一启动就是 Exec format error(从未被交叉验证过)。
|
||||
#
|
||||
# 现在改为优先从 electron 缓存里取对应架构的 zip,并在最后做
|
||||
# 一道强制校验:架构不符就删掉目录并跳过 GUI,宁可不发也不发坏包。
|
||||
build_gui() {
|
||||
local gui_dir="$PROJECT_ROOT/cmd/gui"
|
||||
local gui_out="$BUILD_DIR/homeagent-gui-linux-${TAR_ARCH}"
|
||||
@ -148,81 +133,22 @@ build_gui() {
|
||||
|
||||
echo ">>> Building GUI directory for linux/$ARCH..."
|
||||
|
||||
# 判据是 electron 包本身在不在,而不是 node_modules 目录在不在。
|
||||
#
|
||||
# npm install 失败(离线、网络受限)会留下一个只有一两个条目的空壳
|
||||
# node_modules,目录存在但 electron 缺失。只看目录会以为"已安装",
|
||||
# 于是 ever 读不到版本、缓存匹配退化、最后走到"host dist 也没有"而
|
||||
# 静默跳过 GUI——包名和目录名全都正确,只是没有 GUI,没有任何一步报错。
|
||||
if [ ! -f "$gui_dir/node_modules/electron/package.json" ]; then
|
||||
if [ -d "$gui_dir/node_modules" ]; then
|
||||
echo " node_modules 存在但 electron 缺失(疑似上次 npm install 未完成)"
|
||||
fi
|
||||
if [ ! -d "$gui_dir/node_modules" ]; then
|
||||
echo " npm install..."
|
||||
if ! (cd "$gui_dir" && npm install --production); then
|
||||
echo " WARNING: npm install 失败——离线环境下这是预期的。"
|
||||
echo " GUI 需要 cmd/gui/node_modules/electron 或 ~/.cache/electron 缓存。"
|
||||
fi
|
||||
(cd "$gui_dir" && npm install --production)
|
||||
fi
|
||||
|
||||
# electron 版本优先从已安装的包里读,保证运行时与 app 依赖一致。
|
||||
# 读不到时退而从 package.json 的依赖声明里取数字部分(它可能写成
|
||||
# "^33.0.0" 这类范围,只用于给缓存匹配一个提示,匹配不上仍会走通配)。
|
||||
local ever
|
||||
ever=$(python3 -c "import json;print(json.load(open('$gui_dir/node_modules/electron/package.json'))['version'])" 2>/dev/null || true)
|
||||
if [ -z "$ever" ]; then
|
||||
ever=$(python3 -c "
|
||||
import json, re
|
||||
d = json.load(open('$gui_dir/package.json'))
|
||||
spec = (d.get('devDependencies', {}) or {}).get('electron') or (d.get('dependencies', {}) or {}).get('electron') or ''
|
||||
m = re.search(r'(\\d+(?:\\.\\d+)*)', spec)
|
||||
print(m.group(1) if m else '')
|
||||
" 2>/dev/null || true)
|
||||
[ -n "$ever" ] && echo " electron 版本取自 package.json 依赖声明: $ever(非精确)"
|
||||
fi
|
||||
|
||||
mkdir -p "$gui_out"
|
||||
|
||||
# 优先:缓存里的目标架构 zip(~/.cache/electron/<hash>/electron-v<ver>-linux-<arch>.zip)
|
||||
local zip=""
|
||||
if [ -n "$ever" ]; then
|
||||
zip=$(find "$HOME/.cache/electron" -name "electron-v${ever}-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1)
|
||||
fi
|
||||
if [ -z "$zip" ]; then
|
||||
zip=$(find "$HOME/.cache/electron" -name "electron-v*-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
if [ -n "$zip" ]; then
|
||||
echo " electron runtime: $(basename "$zip")"
|
||||
unzip -q -o "$zip" -d "$gui_out"
|
||||
else
|
||||
# 回退:仅当目标架构 == host 架构时才能用 host 的 dist
|
||||
local host_arch
|
||||
case "$(uname -m)" in
|
||||
x86_64) host_arch=amd64 ;;
|
||||
aarch64|arm64) host_arch=arm64 ;;
|
||||
*) host_arch=unknown ;;
|
||||
esac
|
||||
if [ "$TAR_ARCH" != "$host_arch" ]; then
|
||||
echo " WARNING: 缺 electron-v*-linux-${ELECTRON_ARCH}.zip 缓存,且目标架构与 host"
|
||||
echo " ($host_arch) 不同——不能用 host 的 electron 冒充。跳过 GUI。"
|
||||
echo " 解法:下载 electron-v${ever:-<ver>}-linux-${ELECTRON_ARCH}.zip 到"
|
||||
echo " ~/.cache/electron/<任意子目录>/ 后重跑。"
|
||||
rm -rf "$gui_out"
|
||||
return
|
||||
fi
|
||||
local electron_dir="$gui_dir/node_modules/electron/dist"
|
||||
if [ ! -f "$electron_dir/electron" ]; then
|
||||
echo " WARNING: electron binary not found at $electron_dir. GUI will be skipped."
|
||||
rm -rf "$gui_out"
|
||||
return
|
||||
fi
|
||||
echo " electron runtime: host node_modules (同架构 $host_arch)"
|
||||
cp -r "$electron_dir"/* "$gui_out/" 2>/dev/null
|
||||
local electron_dir="$gui_dir/node_modules/electron/dist"
|
||||
if [ ! -f "$electron_dir/electron" ]; then
|
||||
echo " WARNING: electron binary not found at $electron_dir. GUI will be skipped."
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$gui_out/resources/app/node_modules"
|
||||
mkdir -p "$gui_out/resources/app/renderer"
|
||||
|
||||
# copy electron runtime (binary + shared libs)
|
||||
cp -r "$electron_dir"/* "$gui_out/" 2>/dev/null
|
||||
rm -f "$gui_out/resources/default_app.asar" 2>/dev/null
|
||||
|
||||
# copy app source
|
||||
@ -264,28 +190,7 @@ LAUNCHER
|
||||
chmod +x "$gui_out/homeagent-gui"
|
||||
chmod +x "$gui_out/electron"
|
||||
|
||||
# 最后一道强制校验:electron 二进制的实际架构必须匹配目标架构。
|
||||
# 不做这步就会重现 v1.0.0/v1.0.1 的隐形坏包:包名、目录名、
|
||||
# homed/waiter 全对,只有 electron 是错架构,直到用户在 arm64 机器上
|
||||
# 双击才发现 Exec format error。
|
||||
local want_pat
|
||||
case "$TAR_ARCH" in
|
||||
amd64) want_pat="x86-64" ;;
|
||||
arm64) want_pat="aarch64" ;;
|
||||
*) want_pat="" ;;
|
||||
esac
|
||||
if [ -n "$want_pat" ]; then
|
||||
local got
|
||||
got=$(file -b "$gui_out/electron" 2>/dev/null || echo "")
|
||||
if ! printf '%s' "$got" | grep -q "$want_pat"; then
|
||||
echo " ERROR: electron 架构不符——期望 $want_pat,实际: ${got%%,*}"
|
||||
echo " 删除 GUI 目录并跳过(宁可不发,也不发装了跑不起来的包)。"
|
||||
rm -rf "$gui_out"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo " GUI built: $gui_out ($(du -sh "$gui_out" | cut -f1), $(file -b "$gui_out/electron" | cut -d, -f2 | tr -d ' '))"
|
||||
echo " GUI built: $gui_out ($(du -sh "$gui_out" | cut -f1))"
|
||||
echo ""
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
# Git 分支管理规范
|
||||
|
||||
> 生效:2026-08-31,2026-09-04 修订(三级发布通道 + 单条发布分支),2026-09-06 修订(SDK 仓版本语义与发版联动,见 §七)。
|
||||
> 适用:**本仓(TrueAgent/HomeAgent)与 third_party/homeagent-sdk(SDK 仓)**——两仓协作时分支策略必须一致,本规范两仓同用。
|
||||
> 核心原则一句话:**main 唯一长命、永远可部署;一切新工作在特性分支;一个中版本一条发布分支,alpha/beta/正式由 tag 区分;hotfix 只进发布分支并 cherry-pick 回 main。**
|
||||
> 生效:2026-08-31。适用:**本仓(TrueAgent/HomeAgent)与 third_party/homeagent-sdk(SDK 仓)**——两仓协作时分支策略必须一致,本规范两仓同用。
|
||||
> 核心原则一句话:**main 唯一长命、永远可部署;一切新工作在特性分支;版本发布走 release 分支 + tag;hotfix 只进 released 分支并 cherry-pick 回 main。**
|
||||
|
||||
---
|
||||
|
||||
@ -12,24 +11,18 @@
|
||||
|---|---|---|---|---|
|
||||
| `main` | **唯一长命分支** | — | — | ✅ **永远可部署** |
|
||||
| `feature/xxx` | 短命(本次特性完成即删) | main | 合回 main | ❌ 不部署 |
|
||||
| `release/vX.Y.x` | 中命(**整个中版本生命周期**) | main | 打 tag → 构建发布 | ✅ **发布产物来源** |
|
||||
| hotfix(直接提交发布分支) | 随发布分支 | 发布分支 | **cherry-pick 回 main** | ✅ |
|
||||
| `release/vX.Y.Z` | 中命(从切出到下个版本发布) | main | 打 tag → 构建发布 | ✅ **发布产物来源** |
|
||||
| hotfix(直接提交 release 分支) | 随 release 分支 | release 分支 | **cherry-pick 回 main** | ✅ |
|
||||
|
||||
```
|
||||
main ──────────────── E ──────────────── G ────────────────(永远可部署)
|
||||
│ ▲
|
||||
│ feature/xxx │ cherry-pick(修复逐个 pick 回)
|
||||
│ feature/xxx │ cherry-pick(hotfix 逐个 pick 回)
|
||||
├── A ── B ──(合回)───────────────────┤
|
||||
│ │
|
||||
└── release/v1.0.x ────────────────────────────────────────────────
|
||||
│ │ │
|
||||
├─(tag v1.0.0-alpha.1) 内部验证 │ │
|
||||
├─(tag v1.0.0-beta.1) 小范围试用 │ │
|
||||
├─(tag v1.0.0) 正式发布 │ │
|
||||
├─(hotfix) F ─────────────────────┤ │
|
||||
├─(tag v1.0.1) patch 发布 │ │
|
||||
├─(hotfix) H ────────────────────────────────────┤
|
||||
└─(tag v1.0.3) patch 发布
|
||||
└── release/v1.2.0 release/v1.2.0
|
||||
├─(tag v1.2.0)→ 构建发布 ├─(hotfix) F ← 版本特定严重 bug
|
||||
└─ 退役(可删可留) └─ F 被 separately cherry-pick 到 main
|
||||
```
|
||||
|
||||
---
|
||||
@ -39,9 +32,8 @@ main ──────────────── E ────────
|
||||
### 1. `main`(唯一长命分支)
|
||||
|
||||
- **唯一长期存在且永远可部署**。任何时刻 `git checkout main` 出来都是可构建、可上线的状态。
|
||||
- 积攒**下一个中版本**的功能:feature 分支完成即合回,main 持续向前。
|
||||
- 积攒**下一个版本**的功能:feature 分支完成即合回,main 持续向前。
|
||||
- **main 上不直接开发**。所有改动经 feature 分支合入;hotfix 经 cherry-pick 注入。
|
||||
- **main 的 `internal/meta.Version` 始终是下一个未发布版本**,不随 patch 发布变动。
|
||||
- 合入门禁(**单人直推也遵守**,不强制 PR 但强制验证):
|
||||
- `make test` 全绿
|
||||
- 涉及插件/工具链时:接口冻结检查 `git diff third_party/homeagent-sdk/sdk/` 为空
|
||||
@ -49,147 +41,80 @@ main ──────────────── E ────────
|
||||
|
||||
### 2. `feature/xxx`(新特性/修复)
|
||||
|
||||
- 命名:`feature/<短横线描述>`,如 `feature/plugin-proc-migration`、`feature/memory-media`。
|
||||
- 命名:`feature/<短横线描述>`,如 `feature/plugin-proc-migration`、`feature/webui-narrow-fix`。
|
||||
- **从 main 开出**:`git checkout -b feature/xxx main`。
|
||||
- 完成后合回 main:
|
||||
- 单人:直推(`git merge --no-ff` 保留特性边界,或 squash 成一个 commit,二选一在团队内固定)。
|
||||
- 多人:走 PR(review 后合入)。
|
||||
- 合回后删除 feature 分支(避免累积)。
|
||||
|
||||
### 3. `release/vX.Y.x`(发布分支:一个中版本一条)
|
||||
### 3. `release/vX.Y.Z`(发布)
|
||||
|
||||
- **命名用 `x` 占位 patch 位**:`release/v1.0.x` 承载 1.0.0 → 1.0.1 → … → 1.0.N 全部发布,
|
||||
直到 `release/v1.1.x` 切出为止。**不要按 patch 号建分支**(`release/v1.0.1`、`release/v1.0.3` 各一条会把
|
||||
同一发布线切成互不相连的碎片,追溯时无法用一条分支看完整条线的演进)。
|
||||
- **从 main 的某个可部署点切出**:`git checkout -b release/v1.0.x main`。
|
||||
- 切出后**冻结功能**——发布分支上只做:版本号 bump、发布准备、bug 修复、文档。
|
||||
- **现网部署永远用发布分支上 tag 的构建产物**,不是 main 头部、更不是 feature。
|
||||
- **从 main 的某个可部署点切出**:`git checkout -b release/v1.2.0 main`。
|
||||
- 切出后**冻结功能**——release 分支上只做:版本号 bump、发布准备、bug 修复、文档。
|
||||
- 打 tag → 构建发布安装包 → 上传(附件命名规范见历史记录)。
|
||||
- **现网部署永远用 release tag 的构建产物**,不是 main 头部、更不是 feature。
|
||||
|
||||
### 4. 三级发布通道(alpha / beta / 正式)
|
||||
|
||||
通道**由 tag 区分,不由分支区分**——三者共用同一条 `release/vX.Y.x`。
|
||||
|
||||
| 通道 | tag 形式 | 含义 | 受众 |
|
||||
|---|---|---|---|
|
||||
| alpha | `vX.Y.Z-alpha.N` | 功能齐了但未充分验证,可能有已知缺陷 | 仅内部/开发者自测 |
|
||||
| beta | `vX.Y.Z-beta.N` | alpha 问题已修,等待真实环境暴露长尾问题 | 小范围试用、愿意承担风险的用户 |
|
||||
| 正式 | `vX.Y.Z` | 通过验证,可上现网 | 所有用户 |
|
||||
|
||||
- **推进顺序**:alpha → beta → 正式,逐级向前,**每级都是同一条分支上的新 tag**。
|
||||
这也是 semver 的标准预发布语义(`1.1.0-alpha.1 < 1.1.0-beta.1 < 1.1.0`),
|
||||
包管理器与版本比较逻辑天然认得,无需额外约定。
|
||||
- **允许跳级**:若改动小、验证充分(如仅一处已定位并有回归测试覆盖的内核修复),
|
||||
可直接打正式 tag。跳级要在发布说明里写明理由。
|
||||
- alpha/beta 的构建产物**可以上传 release 附件**,但必须在 gitcode release 上勾选
|
||||
"预发布"标记,且发布说明首行标注通道与已知风险。
|
||||
- **beta 未清零的严重问题不得进正式**:正式 tag 意味着"我们认为它能上 24/7 现网"。
|
||||
- **发版动作只在发布分支上做**:版本号 bump、打 tag、构建产物、上传 release 附件,
|
||||
全部发生在 `release/vX.Y.x` 上。**main 永远不是发版分支**——即使某个改动刚刚合进 main、
|
||||
即使 main 此刻可部署,也不从 main 打 tag、不拿 main 的构建产物发布。
|
||||
main 的版本号是「下一个未发布中版本」的路牌,不是任何一次发布的版本号。
|
||||
|
||||
### 5. hotfix(发布后发现的严重 bug)
|
||||
### 4. hotfix(只属于此版本的严重 bug)
|
||||
|
||||
- **场景**:版本已发布后,发现只存在于该版本(或该发布线)的严重 bug。
|
||||
- **动作**:直接把修复提交到**发布分支** → 该分支重新构建、打下一个 patch tag(如 `v1.0.4`)发布。
|
||||
- **动作**:直接把修复提交到 **release 分支**(不收进 main 的开发流)→ 该 release 分支重新构建、打 patch tag(如 `v1.2.1`)发布。
|
||||
- **关键:hotfix 必须 cherry-pick 回 main**:
|
||||
|
||||
```bash
|
||||
# 在发布分支上提交修复(代码部分与版本号 bump 分开提交)
|
||||
git checkout release/v1.0.x
|
||||
# 在 release 分支上提交修复(代码部分与版本号 bump 分开提交)
|
||||
git commit -m "fix(x): ..." # ① 修复本身
|
||||
git commit -m "chore(release): bump v1.0.4" # ② 版本号(此 commit 不 pick 回 main)
|
||||
git tag -a v1.0.4 -m "..."
|
||||
git commit -m "chore: bump v1.2.1" # ② 版本号(此 commit 不 pick 回 main)
|
||||
|
||||
# 回到 main,只挑修复本身
|
||||
git checkout main
|
||||
git cherry-pick <修复①的sha> # 只 pick ①,不 pick ②
|
||||
git cherry-pick <修复commit的sha> # 只 pick ①,不 pick ②
|
||||
```
|
||||
|
||||
> **为什么 cherry-pick 而不是 merge**:发布分支只承载该版本特有的补丁,merge 会把
|
||||
> 版本号/发布相关改动一并带进 main 造成冲突,并让 main 的 `meta.Version` 变成
|
||||
> 已发布的旧版本号。逐个 cherry-pick 让 main 精确地只获得修复本身。
|
||||
> **版本号 bump 不要 pick 回 main。**
|
||||
> **为什么 cherry-pick 而不是 merge**:release 分支只承载该版本特有的补丁,merge 会把 release 分支的版本号/发布相关改动一并带进 main 造成冲突。逐个 cherry-pick 修复 commit 让 main 精确地只获得修复本身。**版本号 bump 不要 pick 回 main**(main 的版本号应始终是下一个未发布版本)。
|
||||
|
||||
- **同时存在多个活跃 feature 分支时**:修复也要 pick 到那些分支,否则它们合回 main 时
|
||||
可能带回旧代码。实践做法是修复落地当天就 pick 到全部活跃分支
|
||||
(如 2026-09-04 的 stage 双重解锁修复同时 pick 到 `main` 与 `feature/memory-media`)。
|
||||
- **hotfix 已逐个 pick 回 main ⇒ main 已含全部修复 ⇒ 无需再合并 release 回 main**。这是本规范刻意为之——除非 release 分支上有 main 想要的**功能级**改动(罕见),否则 release 永不 merge 回 main。
|
||||
|
||||
- **hotfix 已逐个 pick 回 main ⇒ main 已含全部修复 ⇒ 无需再合并发布分支回 main**。
|
||||
这是本规范刻意为之——除非发布分支上有 main 想要的**功能级**改动(罕见),
|
||||
否则发布分支永不 merge 回 main。
|
||||
### 5. release 分支退役
|
||||
|
||||
### 6. 发布分支退役
|
||||
|
||||
- **下个中版本发布 = 上一条发布分支生命周期结束**(`release/v1.1.x` 出现即 `release/v1.0.x` 退役)。
|
||||
- **下个版本发布 = 此 release 分支生命周期结束**(不再维护)。
|
||||
- 退役后可删可留:
|
||||
- 删除:保持仓库干净(tag 已保留全部历史,删分支不丢东西)。
|
||||
- 保留:便于追溯该发布线的历史构建(对 24/7 现网友好)。
|
||||
- **按 patch 号命名的历史发布分支应当合并/删除**:它们是本规范修订前的遗留形态,
|
||||
内容已被对应的 `release/vX.Y.x` 完全包含,保留只会让"哪条才是这条线"变得含糊。
|
||||
- 保留:便于追溯该发布线的历史构建(对 24/7 现网友好,推荐与本仓库一样保留已打 tag 的历史分支做对照)。
|
||||
- 本仓对现网多代版本并行维护时,保留近期 release 分支是合理的。
|
||||
|
||||
---
|
||||
|
||||
## 三、当前分支对齐(2026-09-04 执行)
|
||||
## 三、当前分支对齐(2026-08-31 执行)
|
||||
|
||||
### 主仓(TrueAgent)
|
||||
|
||||
| 分支 | 状态 | 处理 |
|
||||
| 现存分支 | 状态 | 处理 |
|
||||
|---|---|---|
|
||||
| `main` | 含全部 hotfix(逐个 cherry-pick),`meta.Version` = 下一个未发布中版本(现为 `1.2.0`) | ✅ 保持 |
|
||||
| `release/v1.0.x` | 承载 v1.0.0 / v1.0.1 / v1.0.3 全部 tag | ✅ **由 `release/v1.0.1` 重命名而来**(2026-09-04) |
|
||||
| `release/v1.0.0` | `9b92a04`,已被 1.0.x 线完全包含(`merge-base --is-ancestor` 验证通过) | 🗑️ **已删除**(本地 + 远端),tag `v1.0.0` 保留全部历史 |
|
||||
| `release/v1.0.1` | 旧 patch 号命名 | 🗑️ **已重命名为 `release/v1.0.x`**(远端旧名删除) |
|
||||
| `feature/memory-media` | 记忆系统媒体(多模态)支持,进行中 | ⏳ 完成后合回 main 并删除 |
|
||||
| `feature/plugin-proc-migration` | 已合入 main(`525aa1f`) | ⏳ 待删(规范要求合回后删除) |
|
||||
| `release/v1.1.x` | 承载 v1.1.0 / v1.1.0-beta.1 / v1.1.1 全部 tag | ✅ 1.1 线的唯一发布分支 |
|
||||
| `main` | `48b5c24` [origin/main] | ✅ 保持不变(规范基线) |
|
||||
| `feature/plugin-proc-migration` | 原 `update`,`69a138c`(领先 main 5:文档基线 + Part 0.1/0.2 + 本规范) | ✅ **已对齐重命名**(2026-08-31) |
|
||||
| `backup-local`(SDK 仓) | `7092d15`(ahead 3, behind 14,含 `ignore example/recoverydiag` 敏感提交) | ⚠️ 遗留本地分支,功能已合入 main,**保留不删**(无远端,删除即永久丢失) |
|
||||
|
||||
### SDK 仓(homeagent-sdk)
|
||||
|
||||
| 分支 | 状态 | 处理 |
|
||||
| 现存分支 | 状态 | 处理 |
|
||||
|---|---|---|
|
||||
| `main` | `meta.Version` = 下一个未发布中版本(现为 `1.2.0`) | ✅ 保持 |
|
||||
| `release/v1.1.x` | `meta.Version` = `1.1.0`,承载 tag `v1.1.0` | ✅ 与核心 `release/v1.1.x` 对应 |
|
||||
| `release/v1.0.0` | 旧 patch 号命名形态,内容已被 main 完全包含 | 📦 保留(供追溯 1.0 线构建) |
|
||||
| `main` | `61f307b` v1.2.0 | ✅ 保持不变 |
|
||||
| `update` | `5648519`(领先 main 1:Part 0.2 模板修复) | ⚠️ 与主仓 `update` 对齐重命名 |
|
||||
| `backup-local` | `7092d15`(ahead 3, behind 14,遗留调试分支) | ⚠️ 可选清理 |
|
||||
|
||||
### 1.0.x 发布线 tag 历史
|
||||
|
||||
| tag | 提交 | 通道 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `v1.0.0` | `9b92a04` | 正式 | 外部插件从 C ABI 迁移到子进程 + 共享内存 |
|
||||
| `v1.0.1` | `e671a8c` | 正式 | 多模态 bugfix(假成功、能力声明与回退链、see_video 帧数语义) |
|
||||
| `v1.0.3` | `26dc76f` | 正式 | 内核 stage 协调器双重解锁(直接跳正式:单点修复 + 反向验证 + 全类审计) |
|
||||
|
||||
> `v1.0.2` 未使用:该号从未发布也无 tag,留空以免与任何本地构建混淆。
|
||||
|
||||
### 1.1.x 发布线 tag 历史
|
||||
|
||||
| tag | 提交 | 通道 | SDK | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `v1.1.0` | `579d7db` | 正式 | 1.0.0 | 记忆系统支持二进制多媒体节点(CAS 媒体存储 + L0/L2/L3 贯通) |
|
||||
| `v1.1.0-beta.1` | `7a57a14` | beta | 不发 | 打包链路验证(GUI 架构污染 + 空壳 node_modules)。按 §七.2,beta 不伴随 SDK 发版 |
|
||||
| `v1.1.1` | 见发布说明 | 正式 | **1.1.0** | 多模态贯通插件边界;SDK 首次随核心正式版发布 |
|
||||
|
||||
> `v1.1.0-beta.1` 的提交序在 `v1.1.0` **之后**(它多含一个打包修复),
|
||||
> 而 semver 预发布语义里 `1.1.0-beta.1 < 1.1.0`。这是「一条发布分支 + tag 区分通道」的
|
||||
> 已知代价:beta 是为验证**打包链路**而补打的,不代表源码更旧。发布说明里已注明。
|
||||
> `update` 整改工作分支按规范应为 `feature/plugin-proc-migration`(多进程插件化整改,8-9 周大特性)。
|
||||
> 是否重命名由执行人确认;不重命名则视为偏离规范的既有分支,须在文档记录其存在。
|
||||
|
||||
---
|
||||
|
||||
## 四、现网部署与版本对应(运维纪律)
|
||||
|
||||
- **现网 homed 永远部署 `release/vX.Y.x` 分支上 tag 的构建产物**,路径见 `Makefile`(`make build` → `build/homed`)。
|
||||
- systemd 服务(`/usr/local/bin/homed`)替换流程:
|
||||
1. 备份旧二进制(`homed.bak.pre<版本>.<时间戳>`)
|
||||
2. 备份配置库(**用 `sqlite3 .backup`,不用 `cp`**——WAL 模式下 cp 可能拿到不一致快照)
|
||||
3. 记录当前插件建链清单,供重启后逐项比对
|
||||
4. `install -m 0755` 替换(原子 rename,不会写坏正在运行的进程镜像)
|
||||
5. `systemctl restart homeagent`
|
||||
6. 健康检查:版本号、插件清单无缺失、`/api/v1/status`、一次真实对话、`fatal error` 计数为 0
|
||||
- **改造期间现网不得部署 main 或 feature 的中间态**——只有发版才用发布分支的 tag。
|
||||
- alpha/beta tag 的产物**不上现网**(现网是 24/7 服务,预发布通道的存在就是为了不拿它冒险)。
|
||||
- **现网 homed 永远部署 `release/vX.Y.Z` 分支打出的 tag 构建**,路径见 `Makefile`(`make build` → `build/homed`)。
|
||||
- systemd 服务(`/usr/local/bin/homed`)替换前:备份旧二进制 → 停服 → 替换 → 起服 → 健康检查(`scripts/verify_deploy.sh`)。
|
||||
- **改造期间(update 整改)现网不得部署 main 或 feature 的中间态**——只有发版才用 release。
|
||||
- 涉及 SDK 仓时:主仓 `go.mod` 的 `replace => ./third_party/homeagent-sdk` 指向本地 vendored 副本,
|
||||
发版前确认 vendored SDK 与 SDK 仓 release tag 一致(**两仓中版本对齐是第一优先级**,见 §七)。
|
||||
发版前确认 vendored SDK 与 SDK 仓 release tag 一致(两仓版本对齐是第一优先级)。
|
||||
|
||||
---
|
||||
|
||||
@ -203,100 +128,28 @@ git checkout -b feature/xxx
|
||||
git checkout main && git merge --no-ff feature/xxx # 或 squash
|
||||
git branch -d feature/xxx
|
||||
|
||||
# 开一条新中版本的发布线
|
||||
git checkout -b release/v1.1.x main
|
||||
git commit -am "chore(release): bump v1.1.0-alpha.1"
|
||||
git tag -a v1.1.0-alpha.1 -m "..." # alpha:内部验证
|
||||
# ... 修问题 ...
|
||||
git commit -am "chore(release): bump v1.1.0-beta.1"
|
||||
git tag -a v1.1.0-beta.1 -m "..." # beta:小范围试用
|
||||
# ... 真实环境验证 ...
|
||||
git commit -am "chore(release): bump v1.1.0"
|
||||
git tag -a v1.1.0 -m "..." # 正式
|
||||
# 发布
|
||||
git checkout -b release/v1.2.0 main
|
||||
git commit -am "chore: bump v1.2.0" # 版本号
|
||||
git tag v1.2.0
|
||||
# ... 构建发布 ...
|
||||
|
||||
# hotfix(发布后)——注意是同一条 release/v1.0.x,不新建分支
|
||||
git checkout release/v1.0.x
|
||||
# hotfix(发布后)
|
||||
git checkout release/v1.2.0
|
||||
git commit -am "fix(x): 严重 bug" # ① 修复
|
||||
git commit -am "chore(release): bump v1.0.4" # ② 版本号
|
||||
git tag -a v1.0.4 -m "..."
|
||||
git commit -am "chore: bump v1.2.1" # ② 版本号
|
||||
git tag v1.2.1
|
||||
git checkout main
|
||||
git cherry-pick <修复①的sha> # ③ 只挑修复
|
||||
# 若有活跃 feature 分支,也 pick 过去
|
||||
git checkout feature/xxx && git cherry-pick <main 上那个 pick 的 sha>
|
||||
|
||||
# 公开 SDK 接口改动(feature,不是 hotfix):先进 main,再 pick 到发布分支
|
||||
git checkout -b feature/sdk-xxx main
|
||||
# ... 改 third_party/homeagent-sdk/sdk/ 与内核桥接层 ...
|
||||
git checkout main && git merge --no-ff feature/sdk-xxx
|
||||
git checkout release/v1.1.x
|
||||
git cherry-pick <feature 的各 sha> # 只挑改动,不挑 main 的版本号
|
||||
git commit -am "chore(release): bump v1.1.1" # 发布分支自己的版本号
|
||||
git tag -a v1.1.1 -m "..."
|
||||
# SDK 仓同步(仅在核心打正式 tag 时,见 §七.2/§七.3)
|
||||
cd third_party/homeagent-sdk
|
||||
git checkout -b release/v1.1.x main
|
||||
git commit -am "chore(release): SDK 1.1.0(1.1.x 线全程共用)"
|
||||
git tag -a v1.1.0 -m "..."
|
||||
|
||||
# 发布分支退役(下个中版本发布后,可选)
|
||||
git branch -d release/v1.0.x # tag 已保存历史,删分支不丢东西
|
||||
# release 退役(可选)
|
||||
git branch -d release/v1.2.0 # tag 已保存历史,删分支不丢东西
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、本规范与「接口冻结」约束的关系
|
||||
|
||||
- feature 分支合回 main 的门禁(`git diff third_party/homeagent-sdk/sdk/` 为空)是本仓特有的硬约束,独立于 Git 流程本身。
|
||||
- `internal/sdk` **不受冻结约束**,可自由扩展;冻结只针对公开 SDK 接口(`third_party/homeagent-sdk/sdk/`)。
|
||||
- 若整改确需突破公开接口,走变更评审(见 `docs/zh/plugin-interface-matrix.md` §七),
|
||||
并同步 `SDKCompatibleVersion` 与 SDK 仓的 release tag。
|
||||
- **公开接口的改动本身是 feature,不是发布准备**:它必须走 `feature/xxx` → 合回 main 的路径,
|
||||
再 cherry-pick 到发布分支。不允许把接口新增当成"发布分支上的 bug 修复"直接提交进 release
|
||||
——发布分支冻结功能(§2.3),接口是最典型的功能面。
|
||||
|
||||
---
|
||||
|
||||
## 七、SDK 仓的版本语义与发版联动
|
||||
|
||||
### 1. SDK 版本号跟随核心的中版本,patch 位恒为 `.0`
|
||||
|
||||
| 核心版本 | 对应 SDK 版本 |
|
||||
|---|---|
|
||||
| 1.1.0 / 1.1.1 / 1.1.2 / … / 1.1.N | **1.1.0**(全线共用,不随核心 patch 变动) |
|
||||
| 1.2.0 起 | **1.2.0** |
|
||||
|
||||
- 核心的 patch 位(`x`)专用于 **bugfix 与漏洞修复**,这类改动不触碰公开 SDK 接口,
|
||||
因此 SDK 版本号没有理由跟着动。
|
||||
- **为什么不逐位对齐**:SDK 版本号是插件开发者的依赖声明。若核心每发一个 bugfix 就把 SDK
|
||||
也推一个新号,开发者要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
|
||||
让 SDK 号只在**接口可能变化的中版本边界**上跳,开发者只需关心「我在为哪个中版本写插件」。
|
||||
- 因此「两仓版本对齐」在本规范里指**中版本对齐**(核心 1.1.x ↔ SDK 1.1.0),
|
||||
不是三位全等。核心 1.1.1 配 SDK 1.1.0 就是对齐状态。
|
||||
|
||||
### 2. beta 阶段不发 SDK
|
||||
|
||||
- **核心的 alpha/beta tag 不伴随 SDK 仓发版**:SDK 仓在这一阶段**不打 tag、不建 release**。
|
||||
- **为什么**:beta 是核心自己的测试阶段,此时 SDK 接口尚未固定。若此刻给 SDK 发版,
|
||||
插件开发者会照着一个还会变的接口写代码——**那是无效开发**。接口没定就没有可依赖的契约,
|
||||
发出去的版本号是一个假承诺。
|
||||
- 这条约束的对象是 **SDK 仓的发版动作**,不是核心二进制里有没有 SDK 代码。
|
||||
主仓 `go.mod` 用 `replace => ./third_party/homeagent-sdk`,任何核心构建都必然含 vendored
|
||||
SDK 源码,这是构建机制决定的,不在本条约束范围内。
|
||||
|
||||
### 3. 正式发布时 SDK 随核心一起发
|
||||
|
||||
核心打**正式 tag**(`vX.Y.Z`,无预发布后缀)时,SDK 仓同步执行:
|
||||
|
||||
1. SDK 仓也有自己的 `release/vX.Y.x`(与核心同名,一个中版本一条);
|
||||
2. 在该分支上把 `meta.Version` 定为 `X.Y.0`;
|
||||
3. 打 tag `vX.Y.0`(首次进入该中版本时),并建 gitcode release;
|
||||
4. 上传 5 平台 plugindev 产物 + `SHA256SUMS`。
|
||||
|
||||
同一中版本内的后续核心 patch(1.1.1 → 1.1.2 …)**不重复发 SDK**——SDK 已经是 1.1.0,
|
||||
没有新东西要发。只有接口再次变化并进入下一个中版本时,SDK 才发 1.2.0。
|
||||
|
||||
### 4. 版本号在两仓 main 上的含义
|
||||
|
||||
两仓的 `main` 都遵守 §2.1:`meta.Version` 是**下一个未发布中版本**。
|
||||
所以在 1.1.x 线发布期间,两仓 main 上的值都是 `1.2.0`——它标记「main 正在积攒 1.2 的东西」,
|
||||
而不是「1.2.0 已经存在」。已发布的版本号一律看对应 `release/vX.Y.x` 分支与 tag。
|
||||
- feature 分支合回 main 的门禁(`git diff sdk/` 为空)是本仓特有的硬约束,独立于 Git 流程本身。
|
||||
- 插件多进程化整改(`feature/plugin-proc-migration` 或现 `update`)**不满足接口冻结不等于不能合并**——
|
||||
接口冻结约束的是「公开 SDK 不变」,整改若突破需走变更评审(见 `docs/zh/plugin-interface-matrix.md` §七)。
|
||||
@ -14,7 +14,6 @@ import (
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
@ -51,17 +50,6 @@ type Agent struct {
|
||||
// 文本记忆(原始对话日志)
|
||||
textMem *text.Memory
|
||||
|
||||
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重,
|
||||
// L0/L2/L3 只记 digest。为 nil 时全部媒体接线静默跳过——
|
||||
// 它是记忆增强而非对话必需品,缺了不该让对话失败。
|
||||
mediaStore *media.Store
|
||||
// mediaGCInterval 为 0 时不跑 GC 循环(容量上限就仅在手动调 GC 时生效)。
|
||||
mediaGCInterval time.Duration
|
||||
// mediaGCMinAge 保护新入库媒体:刚 Put 还没来得及 AddRef 的项引用计数也是 0。
|
||||
mediaGCMinAge time.Duration
|
||||
// mediaDescribe 控制是否跑后台描述循环(要消耗视觉模型配额)。
|
||||
mediaDescribe bool
|
||||
|
||||
// 人格设定
|
||||
personality *agentPkg.Personality
|
||||
|
||||
@ -118,13 +106,6 @@ type Agent struct {
|
||||
// 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问
|
||||
pendingMedia map[string]interface{}
|
||||
|
||||
// pendingMediaDigests 累积本轮已落进 CAS 的媒体 digest。
|
||||
//
|
||||
// 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获,
|
||||
// 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
||||
// 与 pendingMedia 同受 a.mu 保护。
|
||||
pendingMediaDigests []string
|
||||
|
||||
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
|
||||
interruptInput bool
|
||||
|
||||
@ -138,7 +119,7 @@ type Agent struct {
|
||||
|
||||
// 输入去重:防 webui/GUI 断线重连导致的消息重放
|
||||
// key=source+"|"+content, value=上次接收时间;短窗口内同内容丢弃
|
||||
lastInput map[string]time.Time
|
||||
lastInput map[string]time.Time
|
||||
lastInputMu sync.Mutex
|
||||
|
||||
// 词嵌入模型,用于实体语义相似度计算
|
||||
@ -170,20 +151,16 @@ type AgentConfig struct {
|
||||
Knowledge *knowledge.Store
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
MediaStore *media.Store
|
||||
MediaGCInterval time.Duration
|
||||
MediaGCMinAge time.Duration
|
||||
MediaDescribe bool
|
||||
Personality *agentPkg.Personality
|
||||
PluginReg *plugin.Registry
|
||||
PluginDir string
|
||||
DistillInterval time.Duration
|
||||
ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval
|
||||
ReviewInterval time.Duration // 关系复审间隔,0 则使用 DistillInterval
|
||||
MergeInterval time.Duration // 实体合并检测间隔,0 则使用 DistillInterval
|
||||
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
|
||||
ContextSavePath string // 上下文持久化路径,空则不持久化
|
||||
EmbeddingModelPath string // 预训练词嵌入模型路径(word2vec 文本格式),空则不使用
|
||||
ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval
|
||||
ReviewInterval time.Duration // 关系复审间隔,0 则使用 DistillInterval
|
||||
MergeInterval time.Duration // 实体合并检测间隔,0 则使用 DistillInterval
|
||||
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
|
||||
ContextSavePath string // 上下文持久化路径,空则不持久化
|
||||
EmbeddingModelPath string // 预训练词嵌入模型路径(word2vec 文本格式),空则不使用
|
||||
Embedder *memory.StaticEmbedder // 共享词嵌入实例;nil 时按 EmbeddingModelPath 自建
|
||||
StageHost *StageHost
|
||||
EventBus *events.Bus
|
||||
@ -232,12 +209,6 @@ func New(cfg AgentConfig) *Agent {
|
||||
if cfg.IO != nil {
|
||||
rc.SetChannelDefLookup(cfg.IO.GetInputChannelDef)
|
||||
}
|
||||
// 必须把媒体存储也注给 RelevanceContext:L0→L2 归档(Prune)靠
|
||||
// rc.transferMediaRefs 把引用从 context owner 转给 document owner。
|
||||
// 漏了这一行的后果是静默的:rc.mediaStore 为 nil 时转移直接 return,
|
||||
// 而携带引用的 ContextEvent 已被归档删除 → 引用永久悬空在
|
||||
// context owner 上、计数永不归零 → 对应 blob 永远不会被 GC 回收。
|
||||
rc.SetMediaStore(cfg.MediaStore)
|
||||
|
||||
return &Agent{
|
||||
id: cfg.ID,
|
||||
@ -256,10 +227,6 @@ func New(cfg AgentConfig) *Agent {
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
mediaStore: cfg.MediaStore,
|
||||
mediaGCInterval: cfg.MediaGCInterval,
|
||||
mediaGCMinAge: cfg.MediaGCMinAge,
|
||||
mediaDescribe: cfg.MediaDescribe,
|
||||
personality: cfg.Personality,
|
||||
pluginReg: cfg.PluginReg,
|
||||
pluginDir: cfg.PluginDir,
|
||||
@ -294,8 +261,6 @@ func (a *Agent) Start() {
|
||||
go a.archiveLoop()
|
||||
go a.mergeLoop()
|
||||
go a.reviewLoop()
|
||||
go a.mediaGCLoop()
|
||||
go a.mediaDescribeLoop()
|
||||
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@ package core
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@ -13,7 +12,6 @@ import (
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
@ -24,25 +22,13 @@ type ToolResultItem struct {
|
||||
}
|
||||
|
||||
type ContextEvent struct {
|
||||
// ID 是事件的稳定标识,媒体引用(media_refs.owner_id)挂在它上面。
|
||||
//
|
||||
// 惰性生成:只有真的要挂媒体时才赋值(见 bindEventMedia)。
|
||||
// 全量生成会让每条事件都多一个字段进 context.json,而绝大多数对话没有媒体。
|
||||
// omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。
|
||||
ID string `json:"id,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Source string `json:"source"`
|
||||
Input string `json:"input"`
|
||||
Response string `json:"response,omitempty"`
|
||||
ToolsUsed []string `json:"tools_used,omitempty"`
|
||||
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
|
||||
// Media 是本轮对话涉及的媒体 digest(sha256 十六进制)。
|
||||
//
|
||||
// 存 digest 而不存路径:路径会失效(/tmp 探针图、下载缓存、别的进程的
|
||||
// 临时产物),digest 是内容本身的身份,配合 internal/memory/media 的 CAS
|
||||
// 永远能取回原始字节——只要它还没被容量 GC 淘汰。
|
||||
Media []string `json:"media,omitempty"`
|
||||
Vector vector.Vector `json:"-"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Source string `json:"source"`
|
||||
Input string `json:"input"`
|
||||
Response string `json:"response,omitempty"`
|
||||
ToolsUsed []string `json:"tools_used,omitempty"`
|
||||
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
|
||||
Vector vector.Vector `json:"-"`
|
||||
}
|
||||
|
||||
const contextFlushInterval = 5 * time.Second
|
||||
@ -56,41 +42,6 @@ type RelevanceContext struct {
|
||||
dirty bool
|
||||
toolDefLookup func(name string) *sdk.ToolDef
|
||||
channelDefLookup func(name string) (sdk.ChannelDef, bool)
|
||||
|
||||
// mediaStore 只用于 Prune 时把媒体引用从事件转给归档文档。
|
||||
// 为 nil 时引用转移静默跳过(媒体存储未启用)。
|
||||
mediaStore *media.Store
|
||||
}
|
||||
|
||||
// SetMediaStore 注入媒体存储,供 L0→L2 归档时转移媒体引用。
|
||||
func (c *RelevanceContext) SetMediaStore(s *media.Store) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.mediaStore = s
|
||||
}
|
||||
|
||||
// transferMediaRefs 把被归档事件的媒体引用转给目标文档(调用方已持 c.mu)。
|
||||
//
|
||||
// 先挂后销:若反序,引用计数会瞬时归零,此时若后台 GC 正在跑
|
||||
// 就会把仍被记忆引用的内容当孤儿清掉。
|
||||
func (c *RelevanceContext) transferMediaRefs(archive []scoredEvent, docID string) {
|
||||
if c.mediaStore == nil || docID == "" {
|
||||
return
|
||||
}
|
||||
for _, s := range archive {
|
||||
evt := s.event
|
||||
if evt == nil || evt.ID == "" || len(evt.Media) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, d := range evt.Media {
|
||||
if err := c.mediaStore.AddRef(d, media.OwnerDocument, docID); err != nil {
|
||||
log.Printf("[media] 归档转移 AddRef 失败 (%s → doc %s): %v", shortDigest(d), docID, err)
|
||||
}
|
||||
}
|
||||
if _, err := c.mediaStore.DropOwner(media.OwnerContext, evt.ID); err != nil {
|
||||
log.Printf("[media] 归档转移 DropOwner 失败 (evt %s): %v", evt.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext {
|
||||
@ -305,16 +256,6 @@ func (c *RelevanceContext) flush() {
|
||||
c.dirty = false
|
||||
}
|
||||
|
||||
// scoredEvent 是 Prune 里按相关度排序的事件。
|
||||
//
|
||||
// 提为包级类型(原先是 Prune 内的局部类型):transferMediaRefs 需要
|
||||
// 把待归档列表传进去,局部类型无法出现在方法签名上。
|
||||
type scoredEvent struct {
|
||||
event *ContextEvent
|
||||
score float64
|
||||
idx int
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@ -336,10 +277,15 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
||||
|
||||
queryVec := c.embedder.VectorizeClean(currentInput)
|
||||
|
||||
scoredEvents := make([]scoredEvent, len(candidates))
|
||||
type scored struct {
|
||||
event *ContextEvent
|
||||
score float64
|
||||
idx int
|
||||
}
|
||||
scoredEvents := make([]scored, len(candidates))
|
||||
for i, evt := range candidates {
|
||||
score := vector.CosineSimilarity(queryVec, evt.Vector)
|
||||
scoredEvents[i] = scoredEvent{event: evt, score: score, idx: i}
|
||||
scoredEvents[i] = scored{event: evt, score: score, idx: i}
|
||||
}
|
||||
|
||||
sort.Slice(scoredEvents, func(i, j int) bool {
|
||||
@ -381,11 +327,6 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
||||
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc())
|
||||
if err == nil && doc != nil {
|
||||
archived = len(entries)
|
||||
// 媒体引用随事件一起从 L0 转到 L2:先把引用挂到归档文档上,
|
||||
// 再注销原事件的引用。顺序不能反——先销后挂会让引用计数
|
||||
// 瞬时归零,若此时 GC 正在跑(后台任务)就会把仍被记忆引用的
|
||||
// 内容当孤儿清掉。
|
||||
c.transferMediaRefs(archive, doc.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@ -444,3 +385,5 @@ func convertToolResults(items []ToolResultItem) []document.ToolResultItem {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -10,7 +10,6 @@ import (
|
||||
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"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
|
||||
)
|
||||
@ -181,86 +180,19 @@ func (a *Agent) archiveColdDocs() {
|
||||
coldDocs := a.docStore.FindColdDocs(72*time.Hour, 2)
|
||||
for _, doc := range coldDocs {
|
||||
triples := docToTriples(doc, a.embedder)
|
||||
if len(triples) == 0 {
|
||||
continue
|
||||
if len(triples) > 0 {
|
||||
ec, rc, err := a.memory.Commit(triples, string(a.id)+"_doc_archival", 0)
|
||||
if err != nil {
|
||||
log.Printf("[agent] doc→graph archival error: %v", err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc)
|
||||
a.docStore.Remove(doc.ID)
|
||||
}
|
||||
ec, rc, mediaBound, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0)
|
||||
if err != nil {
|
||||
log.Printf("[agent] doc→graph archival error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 归档的实质是「信息从 L2 搬到 L3」。一条实体、一条关系都没写进
|
||||
// 图库时,信息并没有搬过去,此时删文档等于直接丢数据。
|
||||
//
|
||||
// 这不是理论情形:Commit 会静默跳过实体名不合法的三元组
|
||||
//(validEntityName 要求 2–50 字符),而 LLM 生成的长描述几乎
|
||||
// 提不出合规实体名——实测 456 字图片描述得到 0 entities 0
|
||||
// relations,随后文档被删、媒体引用被释放、blob 被 GC 清掉,
|
||||
// 图片与描述彻底消失。保留文档,下一轮再试。
|
||||
if ec == 0 && rc == 0 {
|
||||
log.Printf("[agent] doc→graph: %s 未写入任何实体/关系,保留文档待下轮重试"+
|
||||
"(三元组 %d 条全被实体名校验拒绝)", doc.ID, len(triples))
|
||||
continue
|
||||
}
|
||||
log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc)
|
||||
|
||||
// 先销媒体引用再删文档:文档一旦从 docStore 消失,就再没有任何
|
||||
// 东西能告诉我们它曾经引用过哪些 digest,media_refs 里那条记录
|
||||
// 就永久悬空、引用计数永不归零,导致 blob 永远不会被 GC 回收。
|
||||
//
|
||||
// 但只有在引用**确实**转移到 graph_sentence 之后才能释放:
|
||||
// 图库里没有任何句子承载这些 digest 时释放旧引用,计数归零,
|
||||
// GC 会把内容当孤儿删掉。宁可留一条悬空引用(内容还在,可由
|
||||
// 后续一致性检查清理),也不能丢内容。
|
||||
refs, refErr := a.docMediaRefs(doc.ID)
|
||||
switch {
|
||||
case refErr != nil:
|
||||
log.Printf("[media] 查文档 %s 的媒体引用失败,保守不释放: %v", doc.ID, refErr)
|
||||
case len(refs) == 0:
|
||||
// 该文档本就没有媒体引用,无需释放。
|
||||
case mediaBound == 0:
|
||||
log.Printf("[media] 文档 %s 有 %d 个媒体引用但图库一个都没绑上,"+
|
||||
"保留引用以免 GC 删除内容(句子正文里可能没有可反解的短 digest)",
|
||||
doc.ID, len(refs))
|
||||
default:
|
||||
a.releaseDocMedia(doc.ID)
|
||||
}
|
||||
a.docStore.Remove(doc.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// docMediaRefs 返回文档当前持有的媒体引用(nil store 时为空)。
|
||||
//
|
||||
// 单独取出来是为了让归档路径能在释放前先确认「有没有东西要释放」——
|
||||
// 没有引用时不必打日志,有引用但没绑上图库时必须保留。
|
||||
func (a *Agent) docMediaRefs(docID string) ([]string, error) {
|
||||
if a.mediaStore == nil || docID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return a.mediaStore.Refs(media.OwnerDocument, docID)
|
||||
}
|
||||
|
||||
// releaseDocMedia 注销文档持有的全部媒体引用。
|
||||
//
|
||||
// L2→L3 这一跳不再转移引用而是直接释放,因为图库存的是从描述
|
||||
// 文本里抽出的实体与关系,不再持有字节。媒体本身此时已完成使命:
|
||||
// 描述已经进了图库,blob 可以交给容量 GC 决定去留。
|
||||
func (a *Agent) releaseDocMedia(docID string) {
|
||||
if a.mediaStore == nil || docID == "" {
|
||||
return
|
||||
}
|
||||
n, err := a.mediaStore.DropOwner(media.OwnerDocument, docID)
|
||||
if err != nil {
|
||||
log.Printf("[media] 文档归档释放引用失败 (doc %s): %v", docID, err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[media] 文档 %s 入图库,释放 %d 个媒体引用(描述已留在图库)", docID, n)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 实体合并检测:GraphDB → LLM 裁决
|
||||
// ──────────────────────────────────────────────
|
||||
@ -480,15 +412,6 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
})
|
||||
}
|
||||
|
||||
// 媒体三元组:确定性产出,先于 NLP 提取。
|
||||
//
|
||||
// 媒体入 L3 曾完全依赖提取器碰巧从描述文本里提出合规三元组——实测
|
||||
// LLM 的 477 字图片描述只产出「水平 -分割-> 成」这类语法碎片,
|
||||
// obj 仅 1 字被 validEntityName 拒掉,整条媒体记忆就进不了图库
|
||||
//(阶段性表现是"时好时坏",取决于提取器运气)。媒体自身的
|
||||
// digest / mime / 描述都是确定的,直接建三元组而不经提取器。
|
||||
triples = append(triples, mediaTriplesFromText(doc.Content)...)
|
||||
|
||||
// NLP 通用提取
|
||||
e := nlp.NewExtractor(nil)
|
||||
if embedder != nil {
|
||||
|
||||
@ -10,7 +10,6 @@ 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() {
|
||||
@ -126,18 +125,30 @@ func (a *Agent) handleSelfInput(msg selfInputMsg) {
|
||||
if msg.channel == "" {
|
||||
msg.channel = channelConsolidation // 兼容空值:默认走整理路径
|
||||
}
|
||||
a.processInput(&agentIO.InputEvent{
|
||||
a.processTextInput(&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", "image", "audio":
|
||||
a.processInput(evt)
|
||||
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 "event":
|
||||
log.Printf("[agent] event from %s: %v", evt.Source, evt.Payload)
|
||||
@ -151,97 +162,74 @@ func (a *Agent) handleInput(evt *agentIO.InputEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
|
||||
start := time.Now()
|
||||
a.pendingMedia = evt.Payload
|
||||
defer func() { a.pendingMedia = nil }()
|
||||
|
||||
// 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
|
||||
a.currentOutputChannel = evt.OutputChannel
|
||||
if a.currentOutputChannel == "" {
|
||||
a.currentOutputChannel = evt.Source
|
||||
}
|
||||
|
||||
text, _ := evt.Payload["content"].(string)
|
||||
blocks, mediaType := injectedBlocks(evt.Payload)
|
||||
// 文本与媒体都空才算无效输入:只带图不带字是合法的(插件注入常这样)。
|
||||
if text == "" && len(blocks) == 0 {
|
||||
return inputPayload{}, false
|
||||
}
|
||||
return inputPayload{
|
||||
text: text,
|
||||
blocks: blocks,
|
||||
mediaType: mediaType,
|
||||
captureTool: "inject_" + evt.Source,
|
||||
}, true
|
||||
}
|
||||
blocks, fallback := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source)
|
||||
|
||||
// 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)
|
||||
}
|
||||
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,
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return nil, ""
|
||||
a.injectSourceContext(stageCtx, evt)
|
||||
|
||||
if a.runStage(sdk.StageOnInput, stageCtx) {
|
||||
a.emitResponse(evt, *stageCtx.Response)
|
||||
return
|
||||
}
|
||||
// 模态由块自身判定,注入方不必额外声明。图优先:一次注入里图片是主体。
|
||||
mediaType := ""
|
||||
for _, b := range blocks {
|
||||
if b.ImageURL != nil {
|
||||
return blocks, "image"
|
||||
}
|
||||
if b.AudioURL != nil {
|
||||
mediaType = "audio"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "agent",
|
||||
Input: fallback,
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
ToolResults: toolResults,
|
||||
})
|
||||
|
||||
a.emitResponse(evt, response)
|
||||
|
||||
if !stageCtx.NoMemory {
|
||||
a.emitMemoryCandidate(evt.Source, fallback, response, toolResults, toolsUsed)
|
||||
}
|
||||
return blocks, mediaType
|
||||
}
|
||||
|
||||
func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string, source string) ([]agentAPI.ContentBlock, string) {
|
||||
@ -283,12 +271,12 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string,
|
||||
}
|
||||
if mediaType == "image" {
|
||||
blocks = append(blocks, agentAPI.ContentBlock{
|
||||
Type: "image_url",
|
||||
Type: "image_url",
|
||||
ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: "auto"},
|
||||
})
|
||||
} else if mediaType == "audio" {
|
||||
blocks = append(blocks, agentAPI.ContentBlock{
|
||||
Type: "audio_url",
|
||||
Type: "audio_url",
|
||||
AudioURL: &agentAPI.AudioURL{URL: imgURL},
|
||||
})
|
||||
}
|
||||
@ -297,51 +285,19 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string,
|
||||
return blocks, alt
|
||||
}
|
||||
|
||||
// processInput 是全部模态输入的唯一主干。
|
||||
//
|
||||
// 文本、用户上传的图/音频、插件注入的多模态块走同一条路径,因此去重、
|
||||
// no_memory、通道 Cleaner、中断语义、EventRawInput、媒体入 CAS、媒体记忆绑定
|
||||
// 对所有模态一致——不会再出现「文本路径加了功能、媒体路径没跟上」。
|
||||
func (a *Agent) processInput(evt *agentIO.InputEvent) {
|
||||
func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
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, in.text)
|
||||
a.processConsolidation(evt, input)
|
||||
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
|
||||
@ -362,13 +318,9 @@ func (a *Agent) processInput(evt *agentIO.InputEvent) {
|
||||
noMemory = true
|
||||
}
|
||||
|
||||
stageCtx := a.stageCtxFromInput(in.text, evt.Source, "")
|
||||
stageCtx := a.stageCtxFromInput(input, 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
|
||||
}
|
||||
@ -379,7 +331,7 @@ func (a *Agent) processInput(evt *agentIO.InputEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
input := stageCtx.RawMessage
|
||||
input = stageCtx.RawMessage
|
||||
|
||||
// 计算层用的清洗文本(不改原文):通道 Cleaner 提取语义内容后用于向量化/提关键词
|
||||
cleanInput := input
|
||||
@ -389,16 +341,10 @@ func (a *Agent) processInput(evt *agentIO.InputEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
a.publishEvent(events.EventRawInput, map[string]interface{}{
|
||||
"content": input,
|
||||
"source": evt.Source,
|
||||
})
|
||||
|
||||
archived := a.context.Prune(cleanInput, a.maxContextSize-1, a.docStore)
|
||||
if archived > 0 {
|
||||
@ -415,7 +361,7 @@ func (a *Agent) processInput(evt *agentIO.InputEvent) {
|
||||
|
||||
response, toolsUsed, toolResults, err := a.process(input, stageCtx)
|
||||
if err != nil {
|
||||
log.Printf("[agent] process %s error: %v", evt.Type, err)
|
||||
log.Printf("[agent] process error: %v", err)
|
||||
resp := fmt.Sprintf("处理错误: %v", err)
|
||||
a.emitResponse(evt, resp)
|
||||
a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: input, Response: resp})
|
||||
@ -423,24 +369,16 @@ func (a *Agent) processInput(evt *agentIO.InputEvent) {
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed)
|
||||
log.Printf("[agent] input from %s → response (%dms, tools=%v)", evt.Source, elapsed.Milliseconds(), toolsUsed)
|
||||
|
||||
// 本轮捕获的媒体一起挂到这条事件上:用户上传的、插件注入的,以及模型调
|
||||
// multimodal_see_picture / see_video 时经 SetToolBlocks 注入的(后者在
|
||||
// process() 里被捕获,纯文本输入也会有)。
|
||||
turnEvt := ContextEvent{
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "agent",
|
||||
Input: cleanInput,
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
ToolResults: toolResults,
|
||||
}
|
||||
a.bindEventMedia(&turnEvt, a.drainMediaDigests())
|
||||
if s := a.mediaSummaryForEvent(turnEvt.Media); s != "" {
|
||||
turnEvt.Input = turnEvt.Input + "\n" + s
|
||||
}
|
||||
a.context.Append(turnEvt)
|
||||
})
|
||||
|
||||
a.emitResponse(evt, response)
|
||||
|
||||
|
||||
@ -1,496 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// L3 图库的媒体引用绑定。
|
||||
//
|
||||
// 设计定位(方案 A:只做引用,不建媒体实体节点):
|
||||
// 图库里的实体与关系全部来自**描述文本**的 NLP 提取——媒体描述经
|
||||
// mediaSummaryForEvent 进了 L0 事件的 Input,随归档进 L2 文档的 Content,
|
||||
// 蒸馏时提取器自然会从描述文字里抽出实体和关系。
|
||||
//
|
||||
// 为何不把媒体本身建成实体节点:节点名只能从描述里取,而描述会被重新生成
|
||||
// (换个视觉模型、补一次描述,名字就变了),于是同一张图会在图谱上留下
|
||||
// 多个语义模糊的节点。检索能力靠描述文本已经具备,多这类节点只是噪声。
|
||||
//
|
||||
// 那么图库侧还需要什么:**反查**。图库里的句子写着「[image a1b2c3d4e5f6]
|
||||
// 一张紫蓝红三色带图」,要能从这条句子找回那份字节。这就是
|
||||
// media_refs 的 graph_sentence owner 的用途,也是这一层唯一要做的事。
|
||||
|
||||
// mediaDigestPattern 匹配事件摘要里的媒体标记 [<mime或kind> <短digest>]。
|
||||
//
|
||||
// 与 mediaSummaryForEvent 的输出格式对应。短 digest 是 12 位十六进制
|
||||
// (shortDigest 的截断长度),这里放宽到 8-64 位以容忍将来调整截断长度,
|
||||
// 以及有人手写了完整 digest 的情况。
|
||||
var mediaDigestPattern = regexp.MustCompile(`\[[^\[\]]*?\b([0-9a-f]{8,64})\]`)
|
||||
|
||||
// mediaMarkerPattern 完整拆解一条媒体标记及其后跟的描述,
|
||||
// 捕获组依次为:标签(mime 或 kind)、短 digest、该行剩余的描述文本。
|
||||
//
|
||||
// 与 mediaSummaryForEvent 的输出格式严格对应:
|
||||
//
|
||||
// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
|
||||
//
|
||||
// 描述取到行尾而非贪婪到底:一条事件可能挂多个媒体,各占一行。
|
||||
var mediaMarkerPattern = regexp.MustCompile(`\[([^\[\]\s]+)\s+([0-9a-f]{8,64})\]([^\n]*)`)
|
||||
|
||||
// mediaMarker 是从文档正文里解析出的一条媒体标记。
|
||||
type mediaMarker struct {
|
||||
label string // mime 或 kind,如 image/png
|
||||
shortDigest string
|
||||
description string
|
||||
raw string // 原始整段,用作三元组的 SentenceText
|
||||
}
|
||||
|
||||
// parseMediaMarkers 从文本里解析全部媒体标记。
|
||||
//
|
||||
// 为何需要它而不只是 extractMediaDigests:媒体入 L3 曾完全依赖 NLP 提取器
|
||||
// 碰巧从描述文本里提出合规三元组——实测 LLM 的 477 字图片描述只产出
|
||||
// 「水平 -分割-> 成」这种语法碎片,obj 仅 1 字被 validEntityName 拒掉,
|
||||
// 于是整条媒体记忆进不了图库。而媒体自身的信息(digest / mime / 描述)
|
||||
// 是确定的,不该受提取器运气支配。
|
||||
func parseMediaMarkers(text string) []mediaMarker {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
ms := mediaMarkerPattern.FindAllStringSubmatch(text, -1)
|
||||
if len(ms) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool, len(ms))
|
||||
var out []mediaMarker
|
||||
for _, m := range ms {
|
||||
d := m[2]
|
||||
if seen[d] {
|
||||
continue
|
||||
}
|
||||
seen[d] = true
|
||||
out = append(out, mediaMarker{
|
||||
label: m[1],
|
||||
shortDigest: d,
|
||||
description: strings.TrimSpace(m[3]),
|
||||
raw: strings.TrimSpace(m[0]),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mediaEntityName 是媒体在图库里的实体名。
|
||||
//
|
||||
// 形如「图片 a1b2c3d4e5f6」。刻意用 digest 而非描述文本构成名字:
|
||||
// 描述会被重新生成(换视觉模型、补描述),若名字取自描述,同一张图
|
||||
// 就会在图谱上留下多个节点。digest 不变则名字不变。
|
||||
// 长度也天然合规(validEntityName 要求 2–50 字符)。
|
||||
func mediaEntityName(label, shortDigest string) string {
|
||||
kind := "媒体"
|
||||
switch {
|
||||
case strings.HasPrefix(label, "image"):
|
||||
kind = "图片"
|
||||
case strings.HasPrefix(label, "audio"):
|
||||
kind = "音频"
|
||||
case strings.HasPrefix(label, "video"):
|
||||
kind = "视频"
|
||||
}
|
||||
return kind + " " + shortDigest
|
||||
}
|
||||
|
||||
// mediaTriplesFromText 为文本里的每条媒体标记产出确定的三元组。
|
||||
//
|
||||
// 这是媒体进 L3 的可靠路径:不经过 NLP 提取器,因此不受它对描述性文本
|
||||
// 提取能力的影响。每条媒体至少产出一条「<媒体实体> -内容-> <描述摘要>」,
|
||||
// 且 SentenceText 用原始标记段,保证 bindSentenceMedia 的正则必然能
|
||||
// 反解到 digest——绑定从概率事件变成确定行为。
|
||||
//
|
||||
// 描述摘要截到 40 字:validEntityName 上限 50 字符,留出余量;
|
||||
// 图谱节点名过长会让可视化和实体合并都难以处理,完整描述留在
|
||||
// SentenceText 与 media 表里。
|
||||
func mediaTriplesFromText(text string) []memory.Triple {
|
||||
markers := parseMediaMarkers(text)
|
||||
if len(markers) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []memory.Triple
|
||||
for _, m := range markers {
|
||||
name := mediaEntityName(m.label, m.shortDigest)
|
||||
|
||||
// 类型三元组恒可产出,不依赖描述是否存在
|
||||
out = append(out, memory.Triple{
|
||||
Subject: name,
|
||||
SubjectType: "Media",
|
||||
Relation: "类型",
|
||||
Object: m.label,
|
||||
ObjectType: "MimeType",
|
||||
Confidence: 1.0,
|
||||
SentenceText: m.raw,
|
||||
})
|
||||
|
||||
desc := summarizeForEntity(m.description, 40)
|
||||
if desc == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, memory.Triple{
|
||||
Subject: name,
|
||||
SubjectType: "Media",
|
||||
Relation: "内容",
|
||||
Object: desc,
|
||||
ObjectType: "Description",
|
||||
Confidence: 1.0,
|
||||
SentenceText: m.raw,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// summarizeForEntity 把描述压成可作实体名的短串。
|
||||
//
|
||||
// 取首个句子边界之前的内容,再按 rune 截断——直接按字节截会切坏 UTF-8,
|
||||
// 图库里就会出现乱码实体名。空白与 Markdown 强调符号一并清掉,
|
||||
// 否则「**整体构成**」这类标记会进实体名。
|
||||
func summarizeForEntity(s string, maxRunes int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
s = strings.NewReplacer("**", "", "*", "", "\n", " ", "\t", " ").Replace(s)
|
||||
for _, sep := range []string{"。", ";", ",", ". ", "; "} {
|
||||
if i := strings.Index(s, sep); i > 0 {
|
||||
s = s[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
r := []rune(s)
|
||||
if len(r) > maxRunes {
|
||||
r = r[:maxRunes]
|
||||
}
|
||||
out := strings.TrimSpace(string(r))
|
||||
// 太短的残片(如单字)过不了 validEntityName,直接放弃比写进去更好
|
||||
if len([]rune(out)) < 2 {
|
||||
return ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// extractMediaDigests 从文本里找出所有媒体标记的 digest。
|
||||
//
|
||||
// 为何靠正则从文本反解,而不是让三元组结构携带 digest:三元组是 NLP
|
||||
// 提取器从纯文本产出的(nlp.ToMemoryTriple 只填 Subject/Relation/Object/
|
||||
// Confidence/SentenceText),提取链路上没有任何位置能塞进结构化的 digest。
|
||||
// 若要贯通就得改 internal/nlp 的整条数据流——而媒体标记本身就是我们
|
||||
// 自己按固定格式写进文本的,反解是这里最省的可靠做法。
|
||||
func extractMediaDigests(text string) []string {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
matches := mediaDigestPattern.FindAllStringSubmatch(text, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool, len(matches))
|
||||
var out []string
|
||||
for _, m := range matches {
|
||||
d := m[1]
|
||||
if seen[d] {
|
||||
continue
|
||||
}
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bindSentenceMedia 把句子文本里提到的媒体挂到对应的 sentences.id 上。
|
||||
//
|
||||
// sentenceIDs 来自 GraphDB.CommitWithMedia:句子文本 → sentences.id。
|
||||
// 只处理本次真正写入了 sentences 表的句子,避免给历史句子重复挂引用
|
||||
// (AddRef 幂等,重复挂不会涨计数,但白跑 SQL)。
|
||||
//
|
||||
// 返回实际绑定成功的引用数,这是调用方的安全依据:归档路径靠它判定
|
||||
// 「引用真的转移到图库了吗」,不能用「Commit 没报错」代替——Commit 会
|
||||
// 静默跳过实体名不合法(validEntityName 要求 2–50 字符)的三元组,
|
||||
// 于是「无错但一条也没写进去」是真实会发生的:LLM 生成的长描述提不出
|
||||
// 合规实体名,实测 456 字描述得到 0 entities 0 relations。
|
||||
func (a *Agent) bindSentenceMedia(sentenceIDs map[string]int64) int {
|
||||
if a.mediaStore == nil || len(sentenceIDs) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
bound := 0
|
||||
for text, sid := range sentenceIDs {
|
||||
if sid == 0 {
|
||||
continue
|
||||
}
|
||||
digests := extractMediaDigests(text)
|
||||
if len(digests) == 0 {
|
||||
continue
|
||||
}
|
||||
ownerID := strconv.FormatInt(sid, 10)
|
||||
for _, short := range digests {
|
||||
// 文本里是短 digest,media_refs 的主键要完整 digest。
|
||||
// 补全失败(内容已被 GC 清掉、或前缀有歧义)就跳过——
|
||||
// 挂一条对不上的引用比不挂更糟:DropOwner 永远匹配不到它。
|
||||
full, err := a.mediaStore.ResolvePrefix(short)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := a.mediaStore.AddRef(full, media.OwnerGraphSentence, ownerID); err != nil {
|
||||
log.Printf("[media] 句子引用绑定失败 (%s → sentence %s): %v", short, ownerID, err)
|
||||
continue
|
||||
}
|
||||
bound++
|
||||
}
|
||||
}
|
||||
if bound > 0 {
|
||||
log.Printf("[media] L3 图库绑定 %d 个媒体引用", bound)
|
||||
}
|
||||
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 提交三元组并绑定句子里的媒体引用。
|
||||
//
|
||||
// 包一层是为了让所有「三元组入库」的调用点用同一条路径拿到媒体绑定,
|
||||
// 而不必各自记得多调一次 bindSentenceMedia。
|
||||
// mediaBound 是本次实际挂到 graph_sentence owner 上的引用数;归档路径靠它
|
||||
// 判定能否安全释放旧引用。媒体存储关闭时恒为 0(此时也没有引用需要释放)。
|
||||
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int) (entities, relations, mediaBound int, err error) {
|
||||
if a.memory == nil {
|
||||
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
|
||||
}
|
||||
// 媒体存储关闭时退回普通 Commit,省掉 sentenceIDs 的 map 分配。
|
||||
if a.mediaStore == nil {
|
||||
ec, rc, cErr := a.memory.Commit(triples, sessionID, turnID)
|
||||
return ec, rc, 0, cErr
|
||||
}
|
||||
sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID)
|
||||
if err != nil {
|
||||
return ec, rc, 0, err
|
||||
}
|
||||
return ec, rc, a.bindSentenceMedia(sentenceIDs), nil
|
||||
}
|
||||
|
||||
// RecallMediaForSentence 反查某条图库句子引用的媒体。
|
||||
//
|
||||
// 这是整层的目的:几个月后从图谱走到一条句子,要能取回当时那份字节
|
||||
// (若尚未被容量 GC 淘汰)。返回的是完整 digest,调用方用
|
||||
// mediaStore.Get 取内容、Stat 取描述与元数据。
|
||||
func (a *Agent) RecallMediaForSentence(sentenceID int64) ([]string, error) {
|
||||
if a.mediaStore == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sentenceID, 10))
|
||||
}
|
||||
|
||||
// sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。
|
||||
//
|
||||
// 关系行本身不持有媒体,媒体挂在句子上(graph_sentence owner)。
|
||||
// 因此"这次召回涉及哪些媒体"必须经由关系 → 句子 → media_refs 这条路。
|
||||
func sentenceIDsFromRelations(relations []memory.Relation) []int64 {
|
||||
if len(relations) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int64]bool, len(relations))
|
||||
var out []int64
|
||||
for _, r := range relations {
|
||||
if r.SentenceID == 0 || seen[r.SentenceID] {
|
||||
continue
|
||||
}
|
||||
seen[r.SentenceID] = true
|
||||
out = append(out, r.SentenceID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mediaContextForRelations 是 mediaContextForSentences 的关系入口。
|
||||
//
|
||||
// 单独包一层是因为两个调用点(自动注入的 buildMemoryContext 与显式的
|
||||
// memory_recall 工具)拿到的都是关系列表,不该各自重复"关系→句子"这步。
|
||||
func (a *Agent) mediaContextForRelations(relations []memory.Relation) string {
|
||||
return a.mediaContextForSentences(sentenceIDsFromRelations(relations))
|
||||
}
|
||||
|
||||
// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。
|
||||
//
|
||||
// 单独一条路径是因为 Indexer.BuildContext 刻意不返回关系
|
||||
// (Relations 恒为 nil,只给实体索引以省 token,细节留给 memory_recall)。
|
||||
// 于是自动注入拿不到 sentence_id,必须用命中的实体名再查一次关系。
|
||||
//
|
||||
// 这次额外查询只为取 sentence_id,深度固定 1:媒体是"这条记忆当时带的图",
|
||||
// 不需要顺着关系network 扩散——扩散只会带出无关媒体并挤占 token。
|
||||
func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string {
|
||||
if a.mediaStore == nil || a.memory == nil || injected == nil || len(injected.Entities) == 0 {
|
||||
return ""
|
||||
}
|
||||
names := make([]string, 0, len(injected.Entities))
|
||||
for _, e := range injected.Entities {
|
||||
names = append(names, e.Name)
|
||||
}
|
||||
res, err := a.memory.Recall(nil, names, 1, "")
|
||||
if err != nil || res == nil {
|
||||
return ""
|
||||
}
|
||||
return a.mediaContextForRelations(res.Relations)
|
||||
}
|
||||
|
||||
// mediaContextForSentences 给一组句子附上媒体说明,供召回时拼进提示词。
|
||||
//
|
||||
// 输出形如「句子 #12 关联媒体:[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图」。
|
||||
// 描述文本本就在句子里,这里补的是「内容是否还在、能否重新看图」这个信息——
|
||||
// 描述永存而字节可能已被淘汰,两者状态不同。
|
||||
func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
|
||||
if a.mediaStore == nil || len(sentenceIDs) == 0 {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
for _, sid := range sentenceIDs {
|
||||
digests, err := a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10))
|
||||
if err != nil || len(digests) == 0 {
|
||||
continue
|
||||
}
|
||||
var parts []string
|
||||
for _, d := range digests {
|
||||
if line := a.mediaMarkerLine(d); line != "" {
|
||||
parts = append(parts, line)
|
||||
}
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
lines = append(lines, fmt.Sprintf("句子 #%d 关联媒体:%s", sid, strings.Join(parts, ";")))
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@ -1,705 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// L3 图库媒体引用测试。
|
||||
//
|
||||
// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份字节。
|
||||
// 因此测试的重点是「反查链路是否完整」以及「引用是否会悬空或误删」。
|
||||
|
||||
func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *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() })
|
||||
|
||||
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{memory: g, mediaStore: ms}, g, ms
|
||||
}
|
||||
|
||||
func TestExtractMediaDigests(t *testing.T) {
|
||||
// 与 mediaSummaryForEvent 的输出格式对应
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want []string
|
||||
}{
|
||||
{"事件摘要格式", "媒体内容:\n[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图", []string{"a1b2c3d4e5f6"}},
|
||||
{"kind 兜底格式", "[image abcdef0123456789] (未描述)", []string{"abcdef0123456789"}},
|
||||
{"一句多个", "[image aaaaaaaaaaaa] 图一;[image bbbbbbbbbbbb] 图二", []string{"aaaaaaaaaaaa", "bbbbbbbbbbbb"}},
|
||||
{"去重", "[image cccccccccccc] x [image/png cccccccccccc] y", []string{"cccccccccccc"}},
|
||||
{"无标记", "普通句子,没有媒体", nil},
|
||||
{"空串", "", nil},
|
||||
// 非十六进制、过短的方括号内容不能误命中,否则会拿一个假前缀去 ResolvePrefix
|
||||
{"非 digest 方括号", "[注意] 这是普通标注 [TODO]", nil},
|
||||
{"过短", "[image abc] 太短", nil},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := extractMediaDigests(c.text)
|
||||
if len(got) != len(c.want) {
|
||||
t.Fatalf("%s: 得到 %v,期望 %v", c.name, got, c.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Fatalf("%s: 第 %d 个得到 %q,期望 %q", c.name, i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitWithMedia_ReturnsSentenceIDs(t *testing.T) {
|
||||
_, g, _ := newGraphMediaAgent(t)
|
||||
|
||||
sentence := "[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图"
|
||||
triples := []memory.Triple{{
|
||||
Subject: "图片", Relation: "内容", Object: "三色带",
|
||||
SentenceText: sentence,
|
||||
}}
|
||||
|
||||
ids, ec, rc, err := g.CommitWithMedia(triples, "s1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ec == 0 || rc == 0 {
|
||||
t.Fatalf("应写入实体与关系,实际 ec=%d rc=%d", ec, rc)
|
||||
}
|
||||
if ids[sentence] == 0 {
|
||||
t.Fatalf("应返回句子 id,实际 %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommit_StillWorksAfterRefactor(t *testing.T) {
|
||||
// Commit 有三十多个调用点,内部转调后行为必须完全不变
|
||||
_, g, _ := newGraphMediaAgent(t)
|
||||
|
||||
triples := []memory.Triple{
|
||||
{Subject: "张三", Relation: "喜欢", Object: "咖啡", SentenceText: "张三喜欢咖啡"},
|
||||
{Subject: "李四", Relation: "住在", Object: "北京"},
|
||||
}
|
||||
ec, rc, err := g.Commit(triples, "s1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ec != 4 || rc != 2 {
|
||||
t.Fatalf("期望 4 实体 2 关系,实际 ec=%d rc=%d", ec, rc)
|
||||
}
|
||||
|
||||
// 重复提交同一批:关系被唯一约束去重。
|
||||
//
|
||||
// 实体计数**不**归零——这是 upsertEntity 的既有行为:SQLite 的
|
||||
// ON CONFLICT DO UPDATE 也算一行 affected,于是 RowsAffected() > 0
|
||||
// 被当成"新建了"。用 main 分支的 graph.go 单独验证过基线同样是
|
||||
// 首次 ec=2 / 重复 ec=2,与 CommitWithMedia 重构无关。
|
||||
// entitiesCreated 只用于日志,故此处记录现状而不改行为。
|
||||
ec2, rc2, err := g.Commit(triples, "s1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rc2 != 0 {
|
||||
t.Fatalf("重复提交不该新建关系,实际 rc=%d", rc2)
|
||||
}
|
||||
if ec2 != 4 {
|
||||
t.Fatalf("实体计数应与首次一致(既有 upsert 计数行为),实际 ec=%d", ec2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindSentenceMedia_RoundTrip(t *testing.T) {
|
||||
// 整层的核心断言:写入 → 提交 → 反查取回原始字节
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
content := []byte("\x89PNG\r\n\x1a\n fake image bytes")
|
||||
digest, err := ms.Put(content, media.Item{MIME: "image/png", Kind: media.KindImage})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
short := shortDigest(digest)
|
||||
|
||||
sentence := "[image/png " + short + "] 一张紫蓝红三色带图"
|
||||
triples := []memory.Triple{{
|
||||
Subject: "图片", Relation: "内容", Object: "三色带", SentenceText: sentence,
|
||||
}}
|
||||
|
||||
if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 找到句子 id
|
||||
ids, _, _, err := a.memory.CommitWithMedia(triples, "s1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sid := ids[sentence]
|
||||
if sid == 0 {
|
||||
t.Fatal("拿不到句子 id")
|
||||
}
|
||||
|
||||
// 反查:从句子取回 digest,再取回字节
|
||||
digests, err := a.RecallMediaForSentence(sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(digests) != 1 || digests[0] != digest {
|
||||
t.Fatalf("反查应得完整 digest %s,实际 %v", shortDigest(digest), digests)
|
||||
}
|
||||
got, err := ms.Get(digests[0])
|
||||
if err != nil {
|
||||
t.Fatalf("取回内容失败: %v", err)
|
||||
}
|
||||
if string(got) != string(content) {
|
||||
t.Fatal("取回的内容与写入不一致")
|
||||
}
|
||||
|
||||
// 引用计数非零 → GC 不会清它
|
||||
if _, _, err := ms.GC(0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ms.Get(digest); err != nil {
|
||||
t.Fatalf("被图库句子引用的内容不该被 GC 清掉: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindSentenceMedia_SkipsUnresolvable(t *testing.T) {
|
||||
// 文本里的 digest 在库里不存在时必须跳过,不能挂一条对不上的引用——
|
||||
// 那条引用 DropOwner 永远匹配不到,会永久占着计数。
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
sentence := "[image/png deadbeefdead] 一张不存在的图"
|
||||
ids := map[string]int64{sentence: 42}
|
||||
a.bindSentenceMedia(ids)
|
||||
|
||||
refs, err := ms.Refs(media.OwnerGraphSentence, "42")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(refs) != 0 {
|
||||
t.Fatalf("无法补全的 digest 不该挂引用,实际 %v", refs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindSentenceMedia_NilStoreNoop(t *testing.T) {
|
||||
a := &Agent{}
|
||||
a.bindSentenceMedia(map[string]int64{"[image aaaaaaaaaaaa] x": 1})
|
||||
if got, err := a.RecallMediaForSentence(1); err != nil || got != nil {
|
||||
t.Fatalf("媒体关闭时应静默无操作,实际 %v / %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
|
||||
// 媒体关闭时退回普通 Commit,行为与直接调 Commit 完全一致
|
||||
dir := t.TempDir()
|
||||
g, err := memory.NewGraphDB(filepath.Join(dir, "g.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
a := &Agent{memory: g}
|
||||
ec, rc, _, err := a.commitTriplesWithMedia([]memory.Triple{
|
||||
{Subject: "张三", Relation: "喜欢", Object: "咖啡"},
|
||||
}, "s1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ec != 2 || rc != 1 {
|
||||
t.Fatalf("期望 2 实体 1 关系,实际 ec=%d rc=%d", ec, rc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDocMedia_DropsRefsSoGCCanReclaim(t *testing.T) {
|
||||
// L2→L3 那一跳留下的泄漏:文档被 Remove 但引用没销,
|
||||
// 引用计数永不归零,blob 永远不会被 GC 回收。
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, err := ms.Put([]byte("doc image"), media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ms.AddRef(digest, media.OwnerDocument, "doc_1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 释放前 GC 清不掉
|
||||
if _, _, err := ms.GC(0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ms.Stat(digest); err != nil {
|
||||
t.Fatal("有文档引用时不该被清")
|
||||
}
|
||||
|
||||
a.releaseDocMedia("doc_1")
|
||||
|
||||
if refs, _ := ms.Refs(media.OwnerDocument, "doc_1"); len(refs) != 0 {
|
||||
t.Fatalf("释放后不该还有文档引用,实际 %v", refs)
|
||||
}
|
||||
// 现在 GC 能回收了
|
||||
removed, _, err := ms.GC(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Fatalf("释放引用后 GC 应能回收,实际清理 %d 条", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaContextForSentences(t *testing.T) {
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
||||
if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ms.AddRef(digest, media.OwnerGraphSentence, "7"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := a.mediaContextForSentences([]int64{7, 8})
|
||||
if out == "" {
|
||||
t.Fatal("应产出媒体说明")
|
||||
}
|
||||
if !contains(out, "句子 #7") || !contains(out, "一张紫蓝红三色带图") {
|
||||
t.Fatalf("说明内容不对: %q", out)
|
||||
}
|
||||
// 8 号句子没引用媒体,不该出现
|
||||
if contains(out, "句子 #8") {
|
||||
t.Fatalf("无引用的句子不该出现: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrefix(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ms, err := media.New(filepath.Join(dir, "m"), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ms.Close()
|
||||
|
||||
digest, _ := ms.Put([]byte("content"), media.Item{MIME: "image/png"})
|
||||
|
||||
// 短前缀补全
|
||||
full, err := ms.ResolvePrefix(digest[:12])
|
||||
if err != nil || full != digest {
|
||||
t.Fatalf("短前缀补全失败: %v / %v", full, err)
|
||||
}
|
||||
// 完整 digest 原样返回
|
||||
full, err = ms.ResolvePrefix(digest)
|
||||
if err != nil || full != digest {
|
||||
t.Fatalf("完整 digest 应原样返回: %v / %v", full, err)
|
||||
}
|
||||
// 过短拒绝
|
||||
if _, err := ms.ResolvePrefix("abc"); err == nil {
|
||||
t.Fatal("过短前缀应报错")
|
||||
}
|
||||
// 不存在
|
||||
if _, err := ms.ResolvePrefix("deadbeefdead"); err == nil {
|
||||
t.Fatal("不存在的前缀应报错")
|
||||
}
|
||||
// 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿引用
|
||||
fake := ""
|
||||
for i := 0; i < 64; i++ {
|
||||
fake += "0"
|
||||
}
|
||||
if _, err := ms.ResolvePrefix(fake); err == nil {
|
||||
t.Fatal("不存在的完整 digest 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
|
||||
// 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容。
|
||||
// 构造歧义需要两个同前缀 digest——sha256 无法人为构造,
|
||||
// 因此这里退而验证「8 位前缀在大量样本下的行为是确定的」:
|
||||
// 要么唯一命中,要么明确报歧义,绝不静默取第一个。
|
||||
dir := t.TempDir()
|
||||
ms, err := media.New(filepath.Join(dir, "m"), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ms.Close()
|
||||
|
||||
digests := make([]string, 0, 200)
|
||||
for i := 0; i < 200; i++ {
|
||||
d, err := ms.Put([]byte("content-"+strconv.Itoa(i)), media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digests = append(digests, d)
|
||||
}
|
||||
|
||||
for _, d := range digests {
|
||||
got, err := ms.ResolvePrefix(d[:12])
|
||||
if err != nil {
|
||||
// 报歧义是可接受结果;静默取错才是缺陷
|
||||
if !contains(err.Error(), "歧义") {
|
||||
t.Fatalf("非歧义错误: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got != d {
|
||||
t.Fatalf("补全结果错误: 前缀 %s 得到 %s", d[:12], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
||||
// 数据丢失回归:三元组全被实体名校验拒绝时(Commit 无错但 0 entities
|
||||
// 0 relations),文档不能删、媒体引用不能释放。
|
||||
//
|
||||
// 该缺陷曾真实发生:LLM 生成的 456 字图片描述提不出合规实体名
|
||||
//(validEntityName 要求 2–50 字符),archiveColdDocs 只检查
|
||||
// len(triples) > 0 就释放引用并删文档 → GC 清掉 blob → 图片与描述全丢。
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
ds := document.NewStore(filepath.Join(dir, "docs"))
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ds.Stop()
|
||||
a.docStore = ds
|
||||
a.embedder = memory.NewStaticEmbedder()
|
||||
|
||||
content := []byte("image bytes")
|
||||
digest, err := ms.Put(content, media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 精确构造「三元组非空 + Commit 全部拒绝」这个状态。
|
||||
//
|
||||
// 用超长 Source 而不是指望 NLP 提取器:docToTriples 在
|
||||
// Source != "context_archived" 时会写一条 {文档 -来源-> Source},
|
||||
// Source 超过 validEntityName 的 50 字符上限 → Commit 静默跳过
|
||||
// → len(triples)==1 但 ec=0 rc=0。构造是确定的,不依赖提取器的
|
||||
// 具体行为(提取器行为随版本变化,测试不该押在它身上)。
|
||||
//
|
||||
// 正文里刻意**不放**媒体标记:mediaTriplesFromText 会为标记产出
|
||||
// 合规的「图片 <digest>」三元组,那样 ec/rc 就不为 0,这个用例
|
||||
// 也就测不到「全被拒绝」这个状态了。媒体引用直接用 AddRef 挂上,
|
||||
// 模拟「文档持有媒体但正文的媒体标记已在清洗中丢失」这一情形——
|
||||
// 那正是最危险的组合:有引用要释放,却没有句子能承载它。
|
||||
longSource := strings.Repeat("超长来源名", 20) // 100 字,远超 50 字符上限
|
||||
// Summary 也必须超长:docToTriples 会为合理 summary 写一条
|
||||
// {文档 -主题-> summary},那条能通过校验,ec/rc 就不为 0 了。
|
||||
// 这里要的是「三元组全部被拒」这一个状态。
|
||||
longSummary := strings.Repeat("超长摘要文本", 20) // >80 字,触发长度门槛被跳过
|
||||
doc := &document.Doc{
|
||||
ID: "doc_keep",
|
||||
Summary: longSummary,
|
||||
Content: "一段没有媒体标记的正文",
|
||||
Source: longSource,
|
||||
CreatedAt: time.Now().Add(-200 * time.Hour),
|
||||
LastAccess: time.Now().Add(-200 * time.Hour),
|
||||
AccessCount: 0,
|
||||
}
|
||||
if err := ds.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Insert 会把 LastAccess 覆写成 now、AccessCount 置 1,
|
||||
// 于是 FindColdDocs(72h, 2) 一篇都找不到。插入后再改回来,
|
||||
// 让文档真正满足"冷"的条件——这是触发归档路径的前提。
|
||||
for _, d := range ds.RecentDocs(10) {
|
||||
if d.ID == doc.ID {
|
||||
d.LastAccess = time.Now().Add(-200 * time.Hour)
|
||||
d.AccessCount = 0
|
||||
}
|
||||
}
|
||||
if err := ms.AddRef(digest, media.OwnerDocument, doc.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a.archiveColdDocs()
|
||||
|
||||
// 关键断言三连:内容在、引用在、文档在
|
||||
if _, err := ms.Get(digest); err != nil {
|
||||
t.Fatalf("图库未写入任何实体/关系,内容却丢了: %v", err)
|
||||
}
|
||||
refs, err := ms.Refs(media.OwnerDocument, doc.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
t.Error("引用被释放了——图库没有句子承载它,释放后 GC 会删掉内容")
|
||||
}
|
||||
if removed, _, err := ms.GC(0); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if _, err := ms.Stat(digest); err != nil {
|
||||
t.Fatalf("GC(清 %d 条) 删掉了本该保留的内容", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitTriplesWithMedia_ReportsBoundCount(t *testing.T) {
|
||||
// mediaBound 必须反映真实绑定数:归档路径靠它决定能否释放旧引用。
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
short := shortDigest(digest)
|
||||
|
||||
// 句子含可反解的短 digest → 应绑定 1 个
|
||||
_, _, bound, err := a.commitTriplesWithMedia([]memory.Triple{{
|
||||
Subject: "图片", Relation: "内容", Object: "三色带",
|
||||
SentenceText: "[image/png " + short + "] 一张三色带图",
|
||||
}}, "s1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bound != 1 {
|
||||
t.Fatalf("应绑定 1 个媒体引用,实际 %d", bound)
|
||||
}
|
||||
|
||||
// 句子无 digest → 绑定 0 个
|
||||
_, _, bound2, err := a.commitTriplesWithMedia([]memory.Triple{{
|
||||
Subject: "张三", Relation: "喜欢", Object: "咖啡",
|
||||
SentenceText: "张三喜欢咖啡",
|
||||
}}, "s2", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bound2 != 0 {
|
||||
t.Fatalf("无媒体标记的句子不该绑定引用,实际 %d", bound2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentenceIDsFromRelations(t *testing.T) {
|
||||
// 关系行不持有媒体,媒体挂在句子上。这个函数负责"关系→句子"这一跳,
|
||||
// 去重与去零都不能少:sentence_id=0 表示该关系没有关联句子,
|
||||
// 拿 0 去查 media_refs 会命中一个不存在的 owner。
|
||||
rels := []memory.Relation{
|
||||
{ID: 1, SentenceID: 5},
|
||||
{ID: 2, SentenceID: 0}, // 无句子
|
||||
{ID: 3, SentenceID: 5}, // 重复
|
||||
{ID: 4, SentenceID: 7},
|
||||
}
|
||||
got := sentenceIDsFromRelations(rels)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("应得 2 个去重后的句子 id,实际 %v", got)
|
||||
}
|
||||
if got[0] != 5 || got[1] != 7 {
|
||||
t.Fatalf("句子 id 或顺序不对: %v", got)
|
||||
}
|
||||
if n := sentenceIDsFromRelations(nil); n != nil {
|
||||
t.Fatalf("空输入应返回 nil,实际 %v", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) {
|
||||
// L3 检索接线回归:媒体描述进了图库,agent 必须拿得出来。
|
||||
//
|
||||
// 第四层做完了"存和反查的能力"(RecallMediaForSentence /
|
||||
// mediaContextForSentences),但那两个函数一度没有任何调用方——
|
||||
// 媒体能进 L3,进去之后 agent 检索不到。这个测试守住那条接线。
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, err := ms.Put([]byte("img bytes"), media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ms.AddRef(digest, media.OwnerGraphSentence, "5"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 命中的关系挂着 5 号句子 → 应产出媒体说明
|
||||
out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: 5}})
|
||||
if out == "" {
|
||||
t.Fatal("关系挂着有媒体的句子,却没产出媒体说明——L3 检索接线断了")
|
||||
}
|
||||
if !contains(out, "一张紫蓝红三色带图") {
|
||||
t.Errorf("媒体说明里应含描述文本: %q", out)
|
||||
}
|
||||
if !contains(out, shortDigest(digest)) {
|
||||
t.Errorf("媒体说明里应含短 digest 供反查: %q", out)
|
||||
}
|
||||
|
||||
// 没挂媒体的关系不该产出噪声
|
||||
if out := a.mediaContextForRelations([]memory.Relation{{ID: 2, SentenceID: 99}}); out != "" {
|
||||
t.Errorf("无媒体的句子不该产出说明: %q", out)
|
||||
}
|
||||
if out := a.mediaContextForRelations(nil); out != "" {
|
||||
t.Errorf("空关系不该产出说明: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) {
|
||||
// buildMemoryContext 是自动注入路径(每次 LLM 调用都走)。
|
||||
// 媒体说明必须出现在这里,否则 agent 只有显式调 memory_recall 才知道有图。
|
||||
a, graph, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, err := ms.Put([]byte("auto inject"), media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ms.Describe(digest, "自动注入用的测试图", "visionllm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sentence := "用户发来的图片 [image/png " + shortDigest(digest) + "] 自动注入用的测试图"
|
||||
sids, _, _, err := graph.CommitWithMedia([]memory.Triple{{
|
||||
Subject: "测试图片", Relation: "包含", Object: "三色带", SentenceText: sentence,
|
||||
}}, "auto", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sid := sids[sentence]
|
||||
if sid == 0 {
|
||||
t.Fatal("拿不到句子 id")
|
||||
}
|
||||
if err := ms.AddRef(digest, media.OwnerGraphSentence, strconv.FormatInt(sid, 10)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a.indexer = memory.NewIndexer(graph)
|
||||
if err := a.indexer.Sync(); err != nil {
|
||||
t.Fatalf("indexer sync: %v", err)
|
||||
}
|
||||
|
||||
out := a.buildMemoryContext("测试图片", 0)
|
||||
if out == "" {
|
||||
t.Skip("图库召回未命中(indexer 检索策略所致),无法验证媒体段注入")
|
||||
}
|
||||
if !contains(out, "【关联媒体】") {
|
||||
t.Errorf("自动注入的记忆上下文缺少媒体段: %q", out)
|
||||
}
|
||||
if !contains(out, "自动注入用的测试图") {
|
||||
t.Errorf("媒体段里应含描述文本: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMediaMarkers(t *testing.T) {
|
||||
// 与 mediaSummaryForEvent 的输出格式严格对应
|
||||
text := "用户发来图片\n媒体内容:\n" +
|
||||
"[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图\n" +
|
||||
"[audio/wav bbbbccccdddd] 一段三秒的钢琴声\n" +
|
||||
"[image/png a1b2c3d4e5f6] 重复的同一张图"
|
||||
|
||||
ms := parseMediaMarkers(text)
|
||||
if len(ms) != 2 {
|
||||
t.Fatalf("应解析出 2 条去重后的标记,实际 %d: %+v", len(ms), ms)
|
||||
}
|
||||
if ms[0].label != "image/png" || ms[0].shortDigest != "a1b2c3d4e5f6" {
|
||||
t.Errorf("第一条解析错误: %+v", ms[0])
|
||||
}
|
||||
if ms[0].description != "一张紫蓝红三色带图" {
|
||||
t.Errorf("描述应取到行尾且不跨行: %q", ms[0].description)
|
||||
}
|
||||
if ms[1].label != "audio/wav" {
|
||||
t.Errorf("第二条 label 错误: %+v", ms[1])
|
||||
}
|
||||
// raw 用作 SentenceText,必须含 digest 才能被 bindSentenceMedia 反解
|
||||
if !contains(ms[0].raw, "a1b2c3d4e5f6") {
|
||||
t.Errorf("raw 必须含 digest: %q", ms[0].raw)
|
||||
}
|
||||
if n := parseMediaMarkers("没有任何标记的普通文本"); n != nil {
|
||||
t.Errorf("无标记应返回 nil,实际 %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaEntityName(t *testing.T) {
|
||||
// 实体名必须由 digest 而非描述构成:描述会被重新生成,
|
||||
// 若名字取自描述,同一张图会在图谱上留下多个节点。
|
||||
cases := []struct{ label, digest, want string }{
|
||||
{"image/png", "a1b2c3d4e5f6", "图片 a1b2c3d4e5f6"},
|
||||
{"audio/wav", "bbbbccccdddd", "音频 bbbbccccdddd"},
|
||||
{"video/mp4", "ccccddddeeee", "视频 ccccddddeeee"},
|
||||
{"application/octet-stream", "ddddeeeeffff", "媒体 ddddeeeeffff"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := mediaEntityName(c.label, c.digest)
|
||||
if got != c.want {
|
||||
t.Errorf("mediaEntityName(%q,%q) = %q,期望 %q", c.label, c.digest, got, c.want)
|
||||
}
|
||||
// 必须过 validEntityName 的 2–50 字符门槛,否则 Commit 会静默跳过
|
||||
if n := len([]rune(got)); n < 2 || n > 50 {
|
||||
t.Errorf("实体名长度 %d 不在 2–50 之间: %q", n, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeForEntity(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"一张紫蓝红三色带图。还有更多内容。", "一张紫蓝红三色带图"},
|
||||
{"**整体构成**:正方形画布", "整体构成:正方形画布"}, // Markdown 强调符被清掉
|
||||
{"", ""},
|
||||
{"短", ""}, // 单字过不了 validEntityName,宁可不写
|
||||
// 无句子边界时按 rune 截到 40(不是按字节,否则切坏 UTF-8 会在图库里留乱码)
|
||||
{"没有句子边界的一长串文字需要按 rune 截断以免切坏 UTF-8 编码导致图库里出现乱码实体名字符",
|
||||
"没有句子边界的一长串文字需要按 rune 截断以免切坏 UTF-8 编码导致图库"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := summarizeForEntity(c.in, 40)
|
||||
if got != c.want {
|
||||
t.Errorf("summarizeForEntity(%q) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaTriplesFromText_DeterministicRegardlessOfNLP(t *testing.T) {
|
||||
// 核心回归:媒体入 L3 不再依赖 NLP 提取器的运气。
|
||||
//
|
||||
// 实测 LLM 的 477 字图片描述经提取器只产出「水平 -分割-> 成」,
|
||||
// obj 仅 1 字被 validEntityName 拒掉 → ec=0 rc=0 → 媒体记忆进不了图库,
|
||||
// 且时好时坏取决于描述文本。这里验证确定性路径。
|
||||
longDesc := "这张图片是一张纯色块构成的抽象图像,不包含任何文字、人物、物体或可识别的场景。" +
|
||||
"整体构成:一个小尺寸的正方形图像,被水平分割成三条颜色条带。"
|
||||
text := "媒体内容:\n[image/png 89e293b42546] " + longDesc
|
||||
|
||||
triples := mediaTriplesFromText(text)
|
||||
if len(triples) < 2 {
|
||||
t.Fatalf("应至少产出类型+内容两条三元组,实际 %d", len(triples))
|
||||
}
|
||||
|
||||
// 每条都必须能通过 validEntityName(经 Commit 实证)
|
||||
g, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "g.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.Close()
|
||||
sids, ec, rc, err := g.CommitWithMedia(triples, "det", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ec == 0 || rc == 0 {
|
||||
t.Fatalf("确定性三元组应能写入图库,实际 ec=%d rc=%d", ec, rc)
|
||||
}
|
||||
if len(sids) == 0 {
|
||||
t.Fatal("应返回句子 id 供 bindSentenceMedia 绑定")
|
||||
}
|
||||
// SentenceText 必须含 digest,否则绑定还是断的
|
||||
for st := range sids {
|
||||
if !contains(st, "89e293b42546") {
|
||||
t.Errorf("句子必须含短 digest 供反解: %q", st)
|
||||
}
|
||||
}
|
||||
|
||||
// 描述为空时仍应产出类型三元组——媒体节点不能因为没描述就不存在
|
||||
bare := mediaTriplesFromText("[image/png 89e293b42546]")
|
||||
if len(bare) != 1 {
|
||||
t.Fatalf("无描述时应只有类型三元组,实际 %d 条", len(bare))
|
||||
}
|
||||
if bare[0].Relation != "类型" {
|
||||
t.Errorf("无描述时那条应是类型三元组: %+v", bare[0])
|
||||
}
|
||||
}
|
||||
@ -1,587 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1,570 +0,0 @@
|
||||
//go:build medialive
|
||||
|
||||
// 媒体记忆自动触发链的集成测试。
|
||||
//
|
||||
// 与其他媒体测试的区别:**不手工调用任何一步**。这里只做两件事——
|
||||
// 往 IOManager 注入一个 image 事件,然后等。之后全部由生产代码自己走:
|
||||
//
|
||||
// processMediaInput → captureBlockMedia(入 CAS)
|
||||
// → Prune → transferMediaRefs(L0→L2 引用转移)
|
||||
// → describePendingMedia(真实视觉模型生成描述)
|
||||
// → archiveColdDocs → commitTriplesWithMedia → bindSentenceMedia(L2→L3)
|
||||
// → 第二轮提问,验证 agent 真能召回
|
||||
//
|
||||
// 为什么必须这样测:单测能证明每个函数正确,却证明不了它**被接上了**。
|
||||
// 本文件的直接动机是一个真实缺陷——core.New() 漏了 rc.SetMediaStore(cfg.MediaStore),
|
||||
// 于是 L0→L2 引用转移在生产里永远静默 return,而手工注入 store 的单测全绿。
|
||||
//
|
||||
// 需要真实 LLM,因此加 medialive build tag,默认 go test 不跑:
|
||||
//
|
||||
// MEDIALIVE_BASE_URL=http://127.0.0.1:8081/v1 \
|
||||
// MEDIALIVE_API_KEY=sk-xxx \
|
||||
// MEDIALIVE_MODEL=claude-opus-5 \
|
||||
// MEDIALIVE_ADAPTER=openai \
|
||||
// go test -tags medialive ./internal/agent/core/ -run TestMediaLive -v -timeout 20m
|
||||
//
|
||||
// 源、模型、密钥全部由调用方显式指定,测试自己不猜任何默认值——
|
||||
// 猜一个默认端点会让测试在别人机器上打到意料之外的服务。
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
// liveCfg 是调用方通过环境变量显式提供的 LLM 源配置。
|
||||
type liveCfg struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
model string
|
||||
adapter string
|
||||
}
|
||||
|
||||
// requireLiveCfg 读取环境变量;缺任何一项就 Skip 而非猜默认值。
|
||||
//
|
||||
// 刻意不提供 fallback:一个猜出来的 base_url 可能打到调用者机器上
|
||||
// 完全不相干的服务,而测试会把那次调用的失败报成"媒体记忆有问题"。
|
||||
func requireLiveCfg(t *testing.T) liveCfg {
|
||||
t.Helper()
|
||||
c := liveCfg{
|
||||
baseURL: os.Getenv("MEDIALIVE_BASE_URL"),
|
||||
apiKey: os.Getenv("MEDIALIVE_API_KEY"),
|
||||
model: os.Getenv("MEDIALIVE_MODEL"),
|
||||
adapter: os.Getenv("MEDIALIVE_ADAPTER"),
|
||||
}
|
||||
var missing []string
|
||||
if c.baseURL == "" {
|
||||
missing = append(missing, "MEDIALIVE_BASE_URL")
|
||||
}
|
||||
if c.apiKey == "" {
|
||||
missing = append(missing, "MEDIALIVE_API_KEY")
|
||||
}
|
||||
if c.model == "" {
|
||||
missing = append(missing, "MEDIALIVE_MODEL")
|
||||
}
|
||||
if c.adapter == "" {
|
||||
missing = append(missing, "MEDIALIVE_ADAPTER")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Skipf("缺少环境变量 %s——本测试要求调用方显式指定源/模型/密钥,不使用任何默认值",
|
||||
strings.Join(missing, ", "))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// livePNG 造一张横向三色带真 PNG(手工拼 IHDR/IDAT/IEND)。
|
||||
//
|
||||
// 用可辨认的纯色而非随机字节:断言要能检查"模型是否真的看到了内容",
|
||||
// 随机噪声无法产生可验证的描述。
|
||||
func livePNG(t *testing.T, w, h int, colors [][3]byte) []byte {
|
||||
t.Helper()
|
||||
chunk := func(typ string, data []byte) []byte {
|
||||
var b bytes.Buffer
|
||||
if err := binary.Write(&b, binary.BigEndian, uint32(len(data))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := append([]byte(typ), data...)
|
||||
b.Write(body)
|
||||
if err := binary.Write(&b, binary.BigEndian, crc32.ChecksumIEEE(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
var raw bytes.Buffer
|
||||
for y := 0; y < h; y++ {
|
||||
raw.WriteByte(0) // filter type: none
|
||||
c := colors[y*len(colors)/h]
|
||||
for x := 0; x < w; x++ {
|
||||
raw.Write(c[:])
|
||||
}
|
||||
}
|
||||
var comp bytes.Buffer
|
||||
zw := zlib.NewWriter(&comp)
|
||||
if _, err := zw.Write(raw.Bytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zw.Close()
|
||||
|
||||
var ihdr bytes.Buffer
|
||||
binary.Write(&ihdr, binary.BigEndian, uint32(w))
|
||||
binary.Write(&ihdr, binary.BigEndian, uint32(h))
|
||||
ihdr.Write([]byte{8, 2, 0, 0, 0}) // 8-bit truecolor
|
||||
|
||||
var out bytes.Buffer
|
||||
out.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'})
|
||||
out.Write(chunk("IHDR", ihdr.Bytes()))
|
||||
out.Write(chunk("IDAT", comp.Bytes()))
|
||||
out.Write(chunk("IEND", nil))
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// liveEnv 是一套完整但完全独立的 agent 运行环境。
|
||||
type liveEnv struct {
|
||||
agent *Agent
|
||||
io *agentIO.IOManager
|
||||
mediaSt *media.Store
|
||||
docStore *document.Store
|
||||
graph *memory.GraphDB
|
||||
dir string
|
||||
}
|
||||
|
||||
// newLiveEnv 构造真 Agent:真 provider、真 CAS、真图库、真文档库。
|
||||
//
|
||||
// 不注册任何插件:本测试关心记忆链路,插件会引入无关的外部副作用
|
||||
// (网络轮询、写文件),而且生产插件目录里的进程不该被测试碰到。
|
||||
func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
vm := luaVM.NewVM(filepath.Join(dir, "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("lua vm: %v", err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
|
||||
// Vision: true —— 能力是声明的,不是探测的。网关可能静默剥离
|
||||
// image_url 后仍返回 200,从响应无法推断它到底看见了没有。
|
||||
prov := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{
|
||||
Model: c.model, BaseURL: c.baseURL, APIKey: c.apiKey,
|
||||
MaxTokens: 1200, Temperature: 0.3, Vision: true,
|
||||
}, vm, "medialive", c.adapter)
|
||||
|
||||
pm := agentAPI.NewProviderManager()
|
||||
pm.Register("medialive", prov)
|
||||
if err := pm.SetDefault("medialive"); err != nil {
|
||||
t.Fatalf("set default provider: %v", err)
|
||||
}
|
||||
|
||||
ms, err := media.New(filepath.Join(dir, "media"), 256<<20)
|
||||
if err != nil {
|
||||
t.Fatalf("media store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ms.Close() })
|
||||
|
||||
graph, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("graph: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { graph.Close() })
|
||||
|
||||
docStore := document.NewStore(filepath.Join(dir, "docs"))
|
||||
if err := docStore.Start(); err != nil {
|
||||
t.Fatalf("doc store: %v", err)
|
||||
}
|
||||
t.Cleanup(docStore.Stop)
|
||||
|
||||
io := agentIO.NewIOManager()
|
||||
|
||||
a := New(AgentConfig{
|
||||
ID: types.AgentID("medialive"),
|
||||
SystemPrompt: "你是一个有长期记忆的助手。回答简洁准确。",
|
||||
Provider: prov,
|
||||
ProviderManager: pm,
|
||||
IO: io,
|
||||
Memory: graph,
|
||||
DocStore: docStore,
|
||||
MediaStore: ms,
|
||||
MediaGCInterval: 0, // 本测试自己控制 GC 时机
|
||||
MediaDescribe: true, // 描述循环由测试直接调 describePendingMedia
|
||||
StageHost: NewStageHost(),
|
||||
MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档
|
||||
InputProcessing: types.InputProcessingConfig{},
|
||||
})
|
||||
|
||||
// 排空 outputCh:容量 256,但长跑不消费会堵住 emitResponse。
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-io.OutputChan():
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return &liveEnv{agent: a, io: io, mediaSt: ms, docStore: docStore, graph: graph, dir: dir}
|
||||
}
|
||||
|
||||
// TestMediaLive_AutoTriggerChain 全自动触发链:只注入事件,不手工调任何一步。
|
||||
func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
c := requireLiveCfg(t)
|
||||
env := newLiveEnv(t, c)
|
||||
a := env.agent
|
||||
defer a.Stop()
|
||||
|
||||
img := livePNG(t, 96, 96, [][3]byte{{128, 0, 255}, {0, 64, 255}, {255, 0, 0}})
|
||||
t.Logf("测试图片: %d 字节(紫/蓝/红三色带)", len(img))
|
||||
|
||||
// ── 阶段 1:注入 image 事件,验证 CAS 自动落盘 ──
|
||||
//
|
||||
// 直接调 handleInput 而不启 eventLoop:eventLoop 是纯转发(select →
|
||||
// handleInput),走同一条代码路径,但同步调用让断言不必猜时序。
|
||||
evt := &agentIO.InputEvent{
|
||||
RequestID: "live-1",
|
||||
Source: "test_channel",
|
||||
Type: "image",
|
||||
OutputChannel: "test_channel",
|
||||
Payload: map[string]interface{}{
|
||||
"data": mediaB64(img),
|
||||
"mime": "image/png",
|
||||
"alt": "一张测试图片",
|
||||
},
|
||||
}
|
||||
|
||||
t0 := time.Now()
|
||||
a.handleInput(evt)
|
||||
t.Logf("第一轮(含真实 LLM 往返)耗时 %.1fs", time.Since(t0).Seconds())
|
||||
|
||||
// 用 Pending 而非 Search 查刚落盘的项:Search 的 WHERE 里带
|
||||
// `COALESCE(description,'') != ''`,只返回**已描述**的媒体,
|
||||
// 此刻描述还没生成(阶段3 才做),Search 必然返回 0 条。
|
||||
items, err := env.mediaSt.Pending(10)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("CAS 应自动收到 1 张图,实际 %d 张(captureBlockMedia 未被触发?)", len(items))
|
||||
}
|
||||
digest := items[0].Digest
|
||||
t.Logf("✓ 阶段1 CAS 自动落盘: digest=%s size=%d tool=%s",
|
||||
digest[:12], items[0].Size, items[0].Tool)
|
||||
|
||||
stored, err := env.mediaSt.Get(digest)
|
||||
if err != nil || !bytes.Equal(stored, img) {
|
||||
t.Fatalf("落盘内容与原图不一致 (err=%v)", err)
|
||||
}
|
||||
|
||||
// ── 阶段 2:引用自动挂到 ContextEvent 上 ──
|
||||
//
|
||||
// 这一步验证 bindEventMedia:事件必须拿到 ID 且 media_refs 里
|
||||
// 有对应 context owner 记录。两者只写一个的后果是 GC 误删或永不清理。
|
||||
var evtID string
|
||||
var summaryOK bool
|
||||
for _, e := range a.context.Recent(0) {
|
||||
if len(e.Media) > 0 {
|
||||
evtID = e.ID
|
||||
summaryOK = strings.Contains(e.Input, digest[:12])
|
||||
break
|
||||
}
|
||||
}
|
||||
if evtID == "" {
|
||||
t.Fatal("没有任何 ContextEvent 挂上媒体(bindEventMedia 未被触发)")
|
||||
}
|
||||
ctxRefs, err := env.mediaSt.Refs(media.OwnerContext, evtID)
|
||||
if err != nil || len(ctxRefs) != 1 || ctxRefs[0] != digest {
|
||||
t.Fatalf("context owner 引用缺失: refs=%v err=%v", ctxRefs, err)
|
||||
}
|
||||
if !summaryOK {
|
||||
t.Error("事件 Input 里没有媒体摘要标记(mediaSummaryForEvent 未生效)——" +
|
||||
"L2/L3 靠正文里的短 digest 反查,缺了它整条召回链断掉")
|
||||
}
|
||||
t.Logf("✓ 阶段2 引用自动绑定: event=%s owner=context 摘要内嵌=%v", evtID, summaryOK)
|
||||
|
||||
// ── 阶段 3:描述由后台循环自动生成(真实视觉模型)──
|
||||
pending, err := env.mediaSt.Pending(5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("应有 1 条待描述,实际 %d 条", len(pending))
|
||||
}
|
||||
|
||||
t1 := time.Now()
|
||||
a.describePendingMedia()
|
||||
t.Logf("描述生成耗时 %.1fs", time.Since(t1).Seconds())
|
||||
|
||||
it, err := env.mediaSt.Stat(digest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if it.Description == "" {
|
||||
t.Fatal("描述为空——describePendingMedia 未能通过视觉源生成描述")
|
||||
}
|
||||
sawColors := strings.Contains(it.Description, "紫") &&
|
||||
strings.Contains(it.Description, "蓝") &&
|
||||
strings.Contains(it.Description, "红")
|
||||
t.Logf("✓ 阶段3 描述自动生成 (%d 字, 源=%s): %s",
|
||||
len([]rune(it.Description)), it.DescribedBy, truncRunes(it.Description, 90))
|
||||
if !sawColors {
|
||||
t.Errorf("描述未含紫/蓝/红三色,视觉模型可能没真正看到图片: %s",
|
||||
truncRunes(it.Description, 200))
|
||||
}
|
||||
if left, _ := env.mediaSt.Pending(5); len(left) != 0 {
|
||||
t.Errorf("描述完成后仍在待描述队列(%d 条)——会被反复重描述", len(left))
|
||||
}
|
||||
// 有描述之后 Search 才应能命中(它按 description 做 LIKE)
|
||||
if found, err := env.mediaSt.Search("紫", media.KindImage, 5); err != nil {
|
||||
t.Errorf("search: %v", err)
|
||||
} else if len(found) == 0 {
|
||||
t.Error("描述已生成但 Search(\"紫\") 命中 0 条——媒体库关键词入口失效")
|
||||
} else {
|
||||
t.Logf("✓ 阶段3 Search(\"紫\") 命中 %d 条", len(found))
|
||||
}
|
||||
|
||||
// ── 阶段 4:Prune 自动把引用从 L0 转移到 L2 ──
|
||||
//
|
||||
// MaxContextSize=3,多注入几轮文本把带图事件挤出活跃上下文。
|
||||
// 这一步专门守 core.New() 里 rc.SetMediaStore 的接线:漏了它
|
||||
// transferMediaRefs 直接 return,引用永久悬空在 context owner 上。
|
||||
// 填充数量必须 > Prune 内部固定的 10 条保护窗口。
|
||||
//
|
||||
// Prune 无条件保护最后 10 条事件(protected := events[len-10:]),
|
||||
// 只在更早的部分里挑归档对象。填 4 条时总数才 5,全落进保护窗口、
|
||||
// candidates 为空、直接返回 0——这不是缺陷,是"最近的对话不该被归档"
|
||||
// 的设计。带图事件必须被推到第 11 条之前才可能被归档。
|
||||
const fillerCount = 14
|
||||
for i := 0; i < fillerCount; i++ {
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "filler",
|
||||
Input: fmt.Sprintf("无关的填充对话 %d,用来把带图事件挤出活跃窗口", i),
|
||||
Response: "好的。",
|
||||
})
|
||||
}
|
||||
archived := a.context.Prune("当前输入", a.maxContextSize-1, env.docStore)
|
||||
t.Logf("Prune 归档 %d 条事件", archived)
|
||||
if archived == 0 {
|
||||
t.Fatal("Prune 未归档任何事件,无法验证引用转移")
|
||||
}
|
||||
|
||||
docRefsFound := ""
|
||||
for _, d := range env.docStore.RecentDocs(20) {
|
||||
refs, err := env.mediaSt.Refs(media.OwnerDocument, d.ID)
|
||||
if err == nil && len(refs) > 0 && refs[0] == digest {
|
||||
docRefsFound = d.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if docRefsFound == "" {
|
||||
t.Fatal("引用未转移到 document owner——" +
|
||||
"core.New() 是否漏了 rc.SetMediaStore(cfg.MediaStore)?" +
|
||||
"(该缺陷曾真实存在:手工注入 store 的单测全绿,生产里永远静默 return)")
|
||||
}
|
||||
if left, _ := env.mediaSt.Refs(media.OwnerContext, evtID); len(left) != 0 {
|
||||
t.Errorf("旧的 context 引用未注销(%d 条),引用计数永不归零 → blob 永不回收", len(left))
|
||||
}
|
||||
t.Logf("✓ 阶段4 引用自动转移: context/%s → document/%s", evtID, docRefsFound)
|
||||
|
||||
// 转移全程内容必须可读:先挂后销的顺序若反了,
|
||||
// 计数会瞬时归零,并发 GC 会把仍被引用的内容当孤儿删掉。
|
||||
if _, err := env.mediaSt.Get(digest); err != nil {
|
||||
t.Fatalf("转移后内容不可读: %v", err)
|
||||
}
|
||||
|
||||
// ── 阶段 5:archiveColdDocs 自动把媒体带进 L3 图库 ──
|
||||
//
|
||||
// FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
|
||||
// 因此把 LastAccess 往前推——这是为了触发生产代码路径,
|
||||
// 而不是替代它(Commit/bindSentenceMedia/releaseDocMedia 全部由它自己调)。
|
||||
for _, d := range env.docStore.RecentDocs(20) {
|
||||
if d.ID == docRefsFound {
|
||||
d.LastAccess = time.Now().Add(-100 * time.Hour)
|
||||
d.AccessCount = 0
|
||||
}
|
||||
}
|
||||
a.archiveColdDocs()
|
||||
|
||||
sentRefs := 0
|
||||
var boundSentence int64
|
||||
rows, err := env.graph.Recall(nil, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatalf("graph recall: %v", err)
|
||||
}
|
||||
t.Logf("图库实体数 %d", len(rows.Entities))
|
||||
// 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量
|
||||
for sid := int64(1); sid <= 40; sid++ {
|
||||
refs, err := env.mediaSt.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10))
|
||||
if err == nil && len(refs) > 0 {
|
||||
sentRefs += len(refs)
|
||||
if boundSentence == 0 {
|
||||
boundSentence = sid
|
||||
}
|
||||
}
|
||||
}
|
||||
if sentRefs == 0 {
|
||||
t.Error("L2→L3 未绑定任何 graph_sentence 引用——" +
|
||||
"bindSentenceMedia 未被 commitTriplesWithMedia 触发," +
|
||||
"或句子正文里没有可反解的短 digest")
|
||||
} else {
|
||||
t.Logf("✓ 阶段5 L3 自动绑定: %d 个句子引用,首个 sentences.id=%d", sentRefs, boundSentence)
|
||||
|
||||
got, err := env.agent.RecallMediaForSentence(boundSentence)
|
||||
if err != nil || len(got) == 0 || got[0] != digest {
|
||||
t.Errorf("从句子反查 digest 失败: got=%v err=%v", got, err)
|
||||
} else if raw, err := env.mediaSt.Get(got[0]); err != nil || !bytes.Equal(raw, img) {
|
||||
t.Errorf("从句子取回的字节与原图不一致 (err=%v)", err)
|
||||
} else {
|
||||
t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 阶段 6:GC 不能删掉仍被记忆引用的内容 ──
|
||||
removed, freed, err := env.mediaSt.GC(0) // minAge=0,最激进
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := env.mediaSt.Stat(digest); err != nil {
|
||||
t.Fatalf("被记忆引用的内容被 GC 删除了(清 %d 条/%d 字节)——"+
|
||||
"引用计数或 owner 语义有误", removed, freed)
|
||||
}
|
||||
t.Logf("✓ 阶段6 GC(minAge=0) 清 %d 条,被引用内容仍在", removed)
|
||||
|
||||
// ── 阶段 7:E2E — 第二轮提问,验证 agent 真能召回 ──
|
||||
//
|
||||
// 不再提供图片,只问"还记得吗"。能答出三色说明记忆链路端到端可用。
|
||||
// L2 文档此刻已被 archiveColdDocs 删除(归档的语义就是搬完删源),
|
||||
// 所以这一轮只能靠 L3 图库召回——而自动注入路径依赖 indexer。
|
||||
// 生产由 main.go 注入并周期 Sync;测试里手工建一个并同步一次。
|
||||
a.indexer = memory.NewIndexer(env.graph)
|
||||
if err := a.indexer.Sync(); err != nil {
|
||||
t.Fatalf("indexer sync: %v", err)
|
||||
}
|
||||
if mc := a.buildMemoryContext("图片 颜色", 0); mc != "" {
|
||||
t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200))
|
||||
if strings.Contains(mc, "【关联媒体】") {
|
||||
t.Logf("✓ 记忆上下文含媒体段")
|
||||
} else {
|
||||
t.Error("记忆上下文缺少媒体段——L3 媒体检索接线未生效")
|
||||
}
|
||||
} else {
|
||||
t.Error("图库召回为空,agent 无从得知历史媒体")
|
||||
}
|
||||
|
||||
ask := &agentIO.InputEvent{
|
||||
RequestID: "live-2",
|
||||
Source: "test_channel",
|
||||
Type: "text",
|
||||
OutputChannel: "test_channel",
|
||||
Payload: map[string]interface{}{
|
||||
"content": "你还记得我之前发给你的那张图片吗?它是什么样子的?请说出具体颜色。",
|
||||
},
|
||||
}
|
||||
respCh := make(chan *agentIO.OutputEvent, 4)
|
||||
ask.ResponseCh = respCh
|
||||
|
||||
t2 := time.Now()
|
||||
a.handleInput(ask)
|
||||
t.Logf("第二轮耗时 %.1fs", time.Since(t2).Seconds())
|
||||
|
||||
var answer string
|
||||
select {
|
||||
case out := <-respCh:
|
||||
answer, _ = out.Payload["content"].(string)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("第二轮没有收到回复")
|
||||
}
|
||||
t.Logf("agent 回答: %s", truncRunes(answer, 220))
|
||||
|
||||
recalled := strings.Contains(answer, "紫") &&
|
||||
strings.Contains(answer, "蓝") &&
|
||||
strings.Contains(answer, "红")
|
||||
if !recalled {
|
||||
t.Errorf("agent 未能召回三色。这可能是记忆注入链路问题,"+
|
||||
"也可能是本轮上下文里已无相关记忆(描述在 L2/L3 但未被检索命中)。回答: %s",
|
||||
truncRunes(answer, 300))
|
||||
} else {
|
||||
t.Logf("✓ 阶段7 E2E 召回成功:不给图,agent 答出紫/蓝/红")
|
||||
}
|
||||
|
||||
st := env.mediaSt.Stats()
|
||||
t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v / 无引用 %v",
|
||||
st["count"], st["total_bytes"], st["described"], st["unreferenced"])
|
||||
}
|
||||
|
||||
// TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。
|
||||
//
|
||||
// 没有这条对照,阶段7 的"答出紫蓝红"可能只是模型在猜常见配色,
|
||||
// 无法区分真召回与先验偏好。
|
||||
func TestMediaLive_NegativeControl(t *testing.T) {
|
||||
c := requireLiveCfg(t)
|
||||
env := newLiveEnv(t, c)
|
||||
a := env.agent
|
||||
defer a.Stop()
|
||||
|
||||
ask := &agentIO.InputEvent{
|
||||
RequestID: "neg-1",
|
||||
Source: "test_channel",
|
||||
Type: "text",
|
||||
OutputChannel: "test_channel",
|
||||
Payload: map[string]interface{}{
|
||||
"content": "你还记得我之前发给你的那张图片吗?它是什么样子的?请说出具体颜色。",
|
||||
},
|
||||
}
|
||||
respCh := make(chan *agentIO.OutputEvent, 4)
|
||||
ask.ResponseCh = respCh
|
||||
|
||||
a.handleInput(ask)
|
||||
|
||||
var answer string
|
||||
select {
|
||||
case out := <-respCh:
|
||||
answer, _ = out.Payload["content"].(string)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("阴性对照没有收到回复")
|
||||
}
|
||||
t.Logf("无记忆时的回答: %s", truncRunes(answer, 200))
|
||||
|
||||
// 上游不可用时这条对照没有意义:它只能证明"没答出颜色",
|
||||
// 而原因是调用失败而非缺少记忆。据此判 PASS 属于假阳性。
|
||||
if strings.HasPrefix(answer, "处理错误:") {
|
||||
t.Skipf("上游 LLM 调用失败,阴性对照无法判定: %s", truncRunes(answer, 160))
|
||||
}
|
||||
|
||||
guessed := strings.Contains(answer, "紫") &&
|
||||
strings.Contains(answer, "蓝") &&
|
||||
strings.Contains(answer, "红")
|
||||
if guessed {
|
||||
t.Errorf("无任何媒体记忆却猜中紫/蓝/红——"+
|
||||
"说明阳性用例的通过可能只是先验偏好而非真召回: %s", truncRunes(answer, 300))
|
||||
}
|
||||
}
|
||||
|
||||
// mediaB64 返回不带 data URL 前缀的 base64(processMediaInput 自己拼前缀)。
|
||||
func mediaB64(b []byte) string {
|
||||
return media.DataURL("image/png", b)[len("data:image/png;base64,"):]
|
||||
}
|
||||
|
||||
func truncRunes(s string, n int) string {
|
||||
r := []rune(strings.ReplaceAll(s, "\n", " "))
|
||||
if len(r) <= n {
|
||||
return string(r)
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
@ -1,183 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"log"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// 媒体记忆的两条后台循环。
|
||||
//
|
||||
// mediaGCLoop 清理无人引用的 blob,让容量上限真正生效。
|
||||
// mediaDescribeLoop 给未描述的媒体生成文字描述(方案 C 的另一半)。
|
||||
//
|
||||
// 为何描述要走后台而不是入库时同步做:视觉模型一次调用在生产实测 9.6s
|
||||
// (see_video 6 帧批量 23s)。放在对话路径上会让每张图都给回复加十几秒,
|
||||
// 而描述的价值是**几个月后还能检索到这张图**,不是这一轮对话——
|
||||
// 这一轮模型本来就直接看着图。
|
||||
|
||||
const (
|
||||
// mediaDescribeBatch 是单轮描述的媒体条数上限。
|
||||
//
|
||||
// 取 4:既有回退链的 modalFallbackMaxBlocks 是 6(一次请求最多带 6 个媒体),
|
||||
// 这里留出余量,且每条单独请求以便逐条落库——批量描述拿回来一整段文字
|
||||
// 无法可靠切分回各自的 digest。
|
||||
mediaDescribeBatch = 4
|
||||
|
||||
// mediaDescribeMinInterval 是两轮描述之间的最小间隔。
|
||||
//
|
||||
// 描述是纯后台的锦上添花,不该跟对话抢视觉模型配额。取 30s 让它
|
||||
// 慢慢消化积压,而不是一上线就把几百条历史媒体全打过去。
|
||||
mediaDescribeMinInterval = 30 * time.Second
|
||||
)
|
||||
|
||||
// mediaGCLoop 周期清理无引用的媒体内容。
|
||||
//
|
||||
// 不做这件事的后果:容量上限形同虚设。CAS 的 GC 只在被显式调用时执行,
|
||||
// 而 Put 路径不触发它——一次 see_video 抽 10 帧,帧本身没人引用(工具
|
||||
// 结果被 Prune 掉之后),若无人清理就会一直堆在磁盘上。
|
||||
func (a *Agent) mediaGCLoop() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[agent] mediaGCLoop panic recovered: %v\n%s", r, debug.Stack())
|
||||
time.Sleep(time.Second)
|
||||
go a.mediaGCLoop()
|
||||
}
|
||||
}()
|
||||
if a.mediaStore == nil || a.mediaGCInterval <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(a.mediaGCInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
removed, freed, err := a.mediaStore.GC(a.mediaGCMinAge)
|
||||
if err != nil {
|
||||
log.Printf("[media] GC 失败: %v", err)
|
||||
continue
|
||||
}
|
||||
if removed > 0 {
|
||||
st := a.mediaStore.Stats()
|
||||
log.Printf("[media] GC 清理 %d 条(释放 %d 字节),剩余 %v 条 / %v 字节",
|
||||
removed, freed, st["count"], st["total_bytes"])
|
||||
}
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mediaDescribeLoop 给未描述的媒体补文字描述。
|
||||
//
|
||||
// 描述文本才是持久语义记忆:blob 会被容量 GC 淘汰,而描述留在 media 表里,
|
||||
// 并经 mediaSummaryForEvent 写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。
|
||||
// 于是「那张紫蓝红三色带图」在原始字节早已被清掉之后仍然可被检索到。
|
||||
func (a *Agent) mediaDescribeLoop() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[agent] mediaDescribeLoop panic recovered: %v\n%s", r, debug.Stack())
|
||||
time.Sleep(time.Second)
|
||||
go a.mediaDescribeLoop()
|
||||
}
|
||||
}()
|
||||
if a.mediaStore == nil || !a.mediaDescribe {
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(mediaDescribeMinInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
a.describePendingMedia()
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// describePendingMedia 取一批未描述的媒体逐条描述。
|
||||
//
|
||||
// 逐条而非批量:批量拿回来是一整段文字,无法可靠切分回各自的 digest
|
||||
// (模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次往返
|
||||
// 也要保证「描述 ↔ digest」的对应关系是确定的。
|
||||
func (a *Agent) describePendingMedia() {
|
||||
pending, err := a.mediaStore.Pending(mediaDescribeBatch)
|
||||
if err != nil {
|
||||
log.Printf("[media] 取待描述项失败: %v", err)
|
||||
return
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, it := range pending {
|
||||
select {
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
kind := "image"
|
||||
if it.Kind == media.KindAudio {
|
||||
kind = "audio"
|
||||
} else if it.Kind != media.KindImage {
|
||||
// 视频帧以 image 入库;其余大类没有可用的描述通道,
|
||||
// 标记成"不可描述"以免每轮都被 Pending 取出来重试。
|
||||
if err := a.mediaStore.Describe(it.Digest, "", "unsupported"); err != nil {
|
||||
log.Printf("[media] 标记不可描述失败 %s: %v", shortDigest(it.Digest), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
p, srcName := a.resolveModalFallback(kind)
|
||||
if p == nil {
|
||||
// 没有声明该模态能力的源——这一轮整体跳过,不逐条重试。
|
||||
// 配置好之后自然会被下一轮捡起来。
|
||||
log.Printf("[media] 无可用的 %s 描述源,跳过本轮(%d 条待描述)", kind, len(pending))
|
||||
return
|
||||
}
|
||||
|
||||
data, err := a.mediaStore.Get(it.Digest)
|
||||
if err != nil {
|
||||
// blob 已被 GC 清掉但元数据还在(GC 会同删,此处属异常路径):
|
||||
// 标记一下避免死循环。
|
||||
log.Printf("[media] 读内容失败 %s: %v", shortDigest(it.Digest), err)
|
||||
if e := a.mediaStore.Describe(it.Digest, "", "content-missing"); e != nil {
|
||||
log.Printf("[media] 标记内容缺失失败 %s: %v", shortDigest(it.Digest), e)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
mime := it.MIME
|
||||
if mime == "" {
|
||||
mime = "image/png"
|
||||
}
|
||||
url := media.DataURL(mime, data)
|
||||
|
||||
desc, err := a.chatModalFallbackBatch(p, kind, []string{url}, []string{"high"})
|
||||
if err != nil {
|
||||
// 失败不标记:可能是网络抖动或配额,下一轮该重试。
|
||||
log.Printf("[media] 描述失败 %s (源=%s): %v", shortDigest(it.Digest), srcName, err)
|
||||
continue
|
||||
}
|
||||
if desc == "" {
|
||||
// 空回复通常意味着上游把媒体剥离了——与 modalfallback 里的判断
|
||||
// 同一个道理,视作失败而非"没什么可说的"。
|
||||
log.Printf("[media] 描述为空 %s (源=%s),视作失败", shortDigest(it.Digest), srcName)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := a.mediaStore.Describe(it.Digest, desc, srcName); err != nil {
|
||||
log.Printf("[media] 写描述失败 %s: %v", shortDigest(it.Digest), err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[media] 已描述 %s (%s, %d 字, 源=%s)", shortDigest(it.Digest), kind, len([]rune(desc)), srcName)
|
||||
}
|
||||
}
|
||||
@ -1,176 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// 媒体后台循环测试。
|
||||
//
|
||||
// 两条循环都要能在「未启用」时干净退出——它们随 Agent.Start() 无条件启动,
|
||||
// 若不早退就会在每个没配媒体存储的部署上空转一个 goroutine。
|
||||
|
||||
func newMediaLoopAgent(t *testing.T, gcInterval, minAge time.Duration, describe bool) (*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() })
|
||||
|
||||
a := &Agent{
|
||||
mediaStore: ms,
|
||||
mediaGCInterval: gcInterval,
|
||||
mediaGCMinAge: minAge,
|
||||
mediaDescribe: describe,
|
||||
}
|
||||
a.ctx, a.cancel = context.WithCancel(context.Background())
|
||||
t.Cleanup(a.cancel)
|
||||
return a, ms
|
||||
}
|
||||
|
||||
func TestMediaGCLoop_ExitsWhenDisabled(t *testing.T) {
|
||||
// 两种禁用形态都必须立刻返回,不留空转 goroutine:
|
||||
// 1. mediaStore 为 nil(媒体记忆整体关闭)
|
||||
// 2. gcInterval 为 0(显式不自动清理)
|
||||
cases := []struct {
|
||||
name string
|
||||
agent *Agent
|
||||
}{
|
||||
{"nil store", func() *Agent {
|
||||
a := &Agent{mediaGCInterval: time.Hour}
|
||||
a.ctx, a.cancel = context.WithCancel(context.Background())
|
||||
return a
|
||||
}()},
|
||||
{"zero interval", func() *Agent {
|
||||
dir := t.TempDir()
|
||||
ms, _ := media.New(filepath.Join(dir, "m"), 0)
|
||||
t.Cleanup(func() { ms.Close() })
|
||||
a := &Agent{mediaStore: ms, mediaGCInterval: 0}
|
||||
a.ctx, a.cancel = context.WithCancel(context.Background())
|
||||
return a
|
||||
}()},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
done := make(chan struct{})
|
||||
go func(a *Agent) { a.mediaGCLoop(); close(done) }(c.agent)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("%s: mediaGCLoop 未立即返回(会空转 goroutine)", c.name)
|
||||
}
|
||||
c.agent.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaGCLoop_ClearsOrphansKeepsReferenced(t *testing.T) {
|
||||
a, ms := newMediaLoopAgent(t, 50*time.Millisecond, 0, false)
|
||||
|
||||
kept, _ := ms.Put([]byte("referenced"), media.Item{MIME: "image/png"})
|
||||
if err := ms.AddRef(kept, media.OwnerContext, "evt-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orphan, _ := ms.Put([]byte("orphaned"), media.Item{MIME: "image/png"})
|
||||
|
||||
go a.mediaGCLoop()
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := ms.Stat(orphan); err != nil {
|
||||
break // 孤儿已被清
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
a.cancel()
|
||||
|
||||
if _, err := ms.Stat(orphan); err == nil {
|
||||
t.Fatal("无引用项应被 GC 清理")
|
||||
}
|
||||
// 关键不变量:有引用的内容永不被删,否则记忆里的 digest 成悬空指针
|
||||
if _, err := ms.Get(kept); err != nil {
|
||||
t.Fatalf("被引用的内容不该被清: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaGCLoop_MinAgeProtectsFresh(t *testing.T) {
|
||||
// minAge 保护刚 Put 还没来得及 AddRef 的项——它们 refcount 也是 0
|
||||
a, ms := newMediaLoopAgent(t, 30*time.Millisecond, time.Hour, false)
|
||||
|
||||
d, _ := ms.Put([]byte("just-arrived"), media.Item{MIME: "image/png"})
|
||||
|
||||
go a.mediaGCLoop()
|
||||
time.Sleep(400 * time.Millisecond) // 足够跑十几轮 GC
|
||||
a.cancel()
|
||||
|
||||
if _, err := ms.Get(d); err != nil {
|
||||
t.Fatalf("minAge 内的新项不该被清: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaDescribeLoop_ExitsWhenDisabled(t *testing.T) {
|
||||
// describe 关闭时必须立即返回(默认就是关闭,绝大多数部署走这条路)
|
||||
a, _ := newMediaLoopAgent(t, 0, 0, false)
|
||||
done := make(chan struct{})
|
||||
go func() { a.mediaDescribeLoop(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("describe 关闭时 mediaDescribeLoop 未立即返回")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribePendingMedia_NoProviderLeavesUndescribed(t *testing.T) {
|
||||
// 没有声明视觉能力的源时整轮跳过,且**不能**把项标记成已处理——
|
||||
// 配置好之后必须还能被捡起来。
|
||||
a, ms := newMediaLoopAgent(t, 0, 0, true)
|
||||
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
||||
|
||||
// providerManager 为 nil → resolveModalFallback 返回 nil
|
||||
a.describePendingMedia()
|
||||
|
||||
it, err := ms.Stat(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if it.Description != "" || it.DescribedBy != "" {
|
||||
t.Fatalf("无可用源时不该写描述: %+v", it)
|
||||
}
|
||||
pending, _ := ms.Pending(10)
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("项应仍在待描述队列里,实际 %d 条", len(pending))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribePendingMedia_MarksUnsupportedKind(t *testing.T) {
|
||||
// video/other 大类没有可用的描述通道,必须标记掉,
|
||||
// 否则每轮 Pending 都把它取出来重试,永远卡住队列头部。
|
||||
a, ms := newMediaLoopAgent(t, 0, 0, true)
|
||||
|
||||
other, _ := ms.Put([]byte("blob"), media.Item{MIME: "application/octet-stream"})
|
||||
a.describePendingMedia()
|
||||
|
||||
it, err := ms.Stat(other)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if it.DescribedBy != "unsupported" {
|
||||
t.Fatalf("不可描述的大类应被标记,实际 DescribedBy=%q", it.DescribedBy)
|
||||
}
|
||||
// 标记后必须退出待描述队列,否则每轮都被取出来重试、永久占着
|
||||
// LIMIT 的名额,真正需要描述的新项永远轮不到。
|
||||
pending, _ := ms.Pending(10)
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("标记 unsupported 后应退出待描述队列,仍有 %d 条", len(pending))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribePendingMedia_EmptyQueueIsNoop(t *testing.T) {
|
||||
a, _ := newMediaLoopAgent(t, 0, 0, true)
|
||||
a.describePendingMedia() // 不该 panic
|
||||
}
|
||||
@ -1,178 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS),
|
||||
// 并让 L0 的 ContextEvent 记住它们的 digest。
|
||||
//
|
||||
// 为何需要这一层:媒体进入对话有两条路,两条都只把**文字**留给记忆——
|
||||
//
|
||||
// 1. 用户直接发图 → processInput/resolveInput → mediaToBlocks
|
||||
// ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"),
|
||||
// base64 随 message 数组发给模型后就丢了。
|
||||
// 2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg
|
||||
// ToolResultItem.Output 只存那句 "[已将图片注入后续对话] /tmp/x.png"。
|
||||
//
|
||||
// 于是下一轮对话起,模型能看到的只有一句路径或一句 alt。那个文件被删、
|
||||
// 被覆盖,或者本来就是 /tmp 下的临时产物,连线索都断了。
|
||||
//
|
||||
// 现在两条路都在同一处收口:从 ContentBlock 的 data URL 取出字节存进 CAS,
|
||||
// digest 挂到当轮 ContextEvent 上;事件被 Prune 归档进 L2 时引用随之转移。
|
||||
|
||||
// captureBlockMedia 把 blocks 里的 data URL 媒体落进 CAS,返回 digest 列表。
|
||||
//
|
||||
// 只处理 data URL:http(s) URL 拿不到字节就无法做内容寻址,
|
||||
// 而"下载它再存"会把一次对话变成一次网络请求(超时、鉴权、SSRF 全来了),
|
||||
// 不在本层解决。
|
||||
func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) []string {
|
||||
if a.mediaStore == nil || len(blocks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var digests []string
|
||||
for _, b := range blocks {
|
||||
var url string
|
||||
switch {
|
||||
case b.ImageURL != nil && b.ImageURL.URL != "":
|
||||
url = b.ImageURL.URL
|
||||
case b.AudioURL != nil && b.AudioURL.URL != "":
|
||||
url = b.AudioURL.URL
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
mime, data, ok := media.ParseDataURL(url)
|
||||
if !ok {
|
||||
continue // http(s) URL 或格式不认,跳过
|
||||
}
|
||||
|
||||
d, err := a.mediaStore.Put(data, media.Item{
|
||||
MIME: mime,
|
||||
Tool: tool,
|
||||
})
|
||||
if err != nil {
|
||||
// 媒体存不进去不该让对话失败——它是记忆增强,不是对话必需品
|
||||
log.Printf("[media] 落盘失败 (tool=%s mime=%s): %v", tool, mime, err)
|
||||
continue
|
||||
}
|
||||
digests = append(digests, d)
|
||||
}
|
||||
return digests
|
||||
}
|
||||
|
||||
// stageMediaDigests 累积本轮捕获的 digest,等 ContextEvent 建好后一起挂上。
|
||||
//
|
||||
// 为何要缓存而不是当场 AddRef:媒体在 process() 执行期间被捕获,而承载它的
|
||||
// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
||||
// 与既有的 a.pendingMedia 同一手法(都在 a.mu 保护下)。
|
||||
func (a *Agent) stageMediaDigests(digests ...string) {
|
||||
if len(digests) == 0 {
|
||||
return
|
||||
}
|
||||
a.pendingMediaDigests = append(a.pendingMediaDigests, digests...)
|
||||
}
|
||||
|
||||
// drainMediaDigests 取出并清空本轮累积的 digest。
|
||||
func (a *Agent) drainMediaDigests() []string {
|
||||
if len(a.pendingMediaDigests) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := a.pendingMediaDigests
|
||||
a.pendingMediaDigests = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// bindEventMedia 把 digest 列表登记到某个 ContextEvent 上。
|
||||
//
|
||||
// 双向落地:evt.Media 让事件自己记得引了哪些媒体(随 context.json 持久化),
|
||||
// media_refs 表让 CAS 侧知道谁在引用(GC 据此判断能不能清)。
|
||||
// 两边都写才闭环——只写一边的话,要么 GC 会误删仍被记忆引用的内容,
|
||||
// 要么孤儿永远清不掉。
|
||||
func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) {
|
||||
if a.mediaStore == nil || evt == nil || len(digests) == 0 {
|
||||
return
|
||||
}
|
||||
if evt.ID == "" {
|
||||
evt.ID = newEventID()
|
||||
}
|
||||
for _, d := range digests {
|
||||
if err := a.mediaStore.AddRef(d, media.OwnerContext, evt.ID); err != nil {
|
||||
log.Printf("[media] AddRef 失败 (%s → %s): %v", shortDigest(d), evt.ID, err)
|
||||
continue
|
||||
}
|
||||
evt.Media = append(evt.Media, d)
|
||||
}
|
||||
}
|
||||
|
||||
// mediaSummaryForEvent 给已有描述的媒体生成一行文字,供写进 ContextEvent.Input。
|
||||
//
|
||||
// 这是方案 C 的落点:**描述文本才是持久语义记忆,blob 只是缓存**。
|
||||
// blob 可能被容量 GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里,
|
||||
// 让"那张紫蓝红三色带图"在几个月后仍然可被检索到。
|
||||
func (a *Agent) mediaSummaryForEvent(digests []string) string {
|
||||
if a.mediaStore == nil || len(digests) == 0 {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
for _, d := range digests {
|
||||
if line := a.mediaMarkerLine(d); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
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> 手法(同一份代码库里保持一致,
|
||||
// 也避免为此引入 uuid 依赖)。纳秒精度足够:同一 Agent 的事件由
|
||||
// a.mu 串行化 Append,不存在同纳秒两条。
|
||||
func newEventID() string {
|
||||
return fmt.Sprintf("evt_%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func shortDigest(d string) string {
|
||||
if len(d) > 12 {
|
||||
return d[:12]
|
||||
}
|
||||
return d
|
||||
}
|
||||
@ -1,355 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// 媒体记忆接线测试:验证媒体从对话进入 CAS、挂到 L0 事件、
|
||||
// 随归档转到 L2 文档的完整链路。
|
||||
//
|
||||
// 核心断言不是"函数被调用了",而是不变量:
|
||||
// 1. 媒体存不进去时对话照常(它是记忆增强,不是对话必需品)
|
||||
// 2. 引用转移期间内容始终可读(先挂后销,不留归零窗口)
|
||||
// 3. mediaStore 为 nil 时全链路静默跳过,行为与本特性上线前一致
|
||||
|
||||
func newTestAgentWithMedia(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() })
|
||||
|
||||
emb := memory.NewStaticEmbedder()
|
||||
a := &Agent{
|
||||
mediaStore: ms,
|
||||
context: NewRelevanceContext(filepath.Join(dir, "context.json"), emb),
|
||||
}
|
||||
a.context.SetMediaStore(ms)
|
||||
return a, ms
|
||||
}
|
||||
|
||||
// imageBlockURL 造一个带指定 URL 的图片块。
|
||||
// 名字带 URL 后缀是为了不与 modalfallback_test.go 里固定用 testPNG 的
|
||||
// imageBlock() 撞名——两者用途不同:那个验回退链,这个验入库。
|
||||
func imageBlockURL(dataURL string) agentAPI.ContentBlock {
|
||||
return agentAPI.ContentBlock{
|
||||
Type: "image_url",
|
||||
ImageURL: &agentAPI.ImageURL{URL: dataURL, Detail: "auto"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureBlockMedia_StoresDataURL(t *testing.T) {
|
||||
a, ms := newTestAgentWithMedia(t)
|
||||
|
||||
raw := []byte{0x89, 'P', 'N', 'G', 1, 2, 3}
|
||||
blocks := []agentAPI.ContentBlock{
|
||||
{Type: "text", Text: "看这张图"},
|
||||
imageBlockURL(media.DataURL("image/png", raw)),
|
||||
}
|
||||
|
||||
digests := a.captureBlockMedia(blocks, "multimodal_see_picture")
|
||||
if len(digests) != 1 {
|
||||
t.Fatalf("应捕获 1 个媒体,实际 %d", len(digests))
|
||||
}
|
||||
|
||||
got, err := ms.Get(digests[0])
|
||||
if err != nil {
|
||||
t.Fatalf("回读失败: %v", err)
|
||||
}
|
||||
if string(got) != string(raw) {
|
||||
t.Fatal("内容不一致")
|
||||
}
|
||||
it, _ := ms.Stat(digests[0])
|
||||
if it.MIME != "image/png" || it.Tool != "multimodal_see_picture" || it.Kind != media.KindImage {
|
||||
t.Fatalf("元数据不对: %+v", it)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureBlockMedia_SkipsHTTPURL(t *testing.T) {
|
||||
// http(s) URL 拿不到字节就无法内容寻址;"下载它再存"会把一次对话
|
||||
// 变成一次网络请求(超时、鉴权、SSRF 全来了),不在本层解决。
|
||||
a, _ := newTestAgentWithMedia(t)
|
||||
|
||||
blocks := []agentAPI.ContentBlock{
|
||||
imageBlockURL("https://example.com/x.png"),
|
||||
}
|
||||
if d := a.captureBlockMedia(blocks, "t"); len(d) != 0 {
|
||||
t.Fatalf("http URL 不该被捕获,实际 %d 个", len(d))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureBlockMedia_NilStoreIsNoop(t *testing.T) {
|
||||
// mediaStore 未启用时全链路静默跳过,不能 panic 也不能报错——
|
||||
// 行为必须与本特性上线前完全一致。
|
||||
a := &Agent{}
|
||||
blocks := []agentAPI.ContentBlock{imageBlockURL(media.DataURL("image/png", []byte("x")))}
|
||||
if d := a.captureBlockMedia(blocks, "t"); d != nil {
|
||||
t.Fatalf("nil store 应返回 nil,实际 %v", d)
|
||||
}
|
||||
a.stageMediaDigests("deadbeef")
|
||||
if got := a.drainMediaDigests(); len(got) != 1 {
|
||||
t.Fatal("stage/drain 不依赖 store,应正常工作")
|
||||
}
|
||||
// bindEventMedia 对 nil store 也必须安全
|
||||
evt := &ContextEvent{}
|
||||
a.bindEventMedia(evt, []string{"deadbeef"})
|
||||
if len(evt.Media) != 0 || evt.ID != "" {
|
||||
t.Fatalf("nil store 时不该改动事件: %+v", evt)
|
||||
}
|
||||
if s := a.mediaSummaryForEvent([]string{"deadbeef"}); s != "" {
|
||||
t.Fatalf("nil store 时摘要应为空,得到 %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureBlockMedia_AudioAndVideo(t *testing.T) {
|
||||
a, ms := newTestAgentWithMedia(t)
|
||||
|
||||
blocks := []agentAPI.ContentBlock{
|
||||
imageBlockURL(media.DataURL("image/jpeg", []byte("frame"))),
|
||||
{Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: media.DataURL("audio/wav", []byte("sound"))}},
|
||||
}
|
||||
digests := a.captureBlockMedia(blocks, "multimodal_see_video")
|
||||
if len(digests) != 2 {
|
||||
t.Fatalf("应捕获 2 个,实际 %d", len(digests))
|
||||
}
|
||||
|
||||
kinds := map[media.Kind]int{}
|
||||
for _, d := range digests {
|
||||
it, err := ms.Stat(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kinds[it.Kind]++
|
||||
}
|
||||
if kinds[media.KindImage] != 1 || kinds[media.KindAudio] != 1 {
|
||||
t.Fatalf("大类归属不对: %v", kinds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageDrainMediaDigests(t *testing.T) {
|
||||
a, _ := newTestAgentWithMedia(t)
|
||||
|
||||
a.stageMediaDigests("a", "b")
|
||||
a.stageMediaDigests("c")
|
||||
got := a.drainMediaDigests()
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("应累积 3 个,实际 %d", len(got))
|
||||
}
|
||||
// drain 后必须清空——否则下一轮对话会把上一轮的媒体又挂一遍
|
||||
if again := a.drainMediaDigests(); again != nil {
|
||||
t.Fatalf("drain 后应为空,实际 %v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindEventMedia_CreatesIDAndRefs(t *testing.T) {
|
||||
a, ms := newTestAgentWithMedia(t)
|
||||
|
||||
d, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
evt := &ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "看图"}
|
||||
a.bindEventMedia(evt, []string{d})
|
||||
|
||||
if evt.ID == "" {
|
||||
t.Fatal("应懒生成事件 ID")
|
||||
}
|
||||
if len(evt.Media) != 1 || evt.Media[0] != d {
|
||||
t.Fatalf("事件应记住 digest: %+v", evt.Media)
|
||||
}
|
||||
// 双向落地:CAS 侧也要知道谁在引用,否则 GC 会误删
|
||||
it, _ := ms.Stat(d)
|
||||
if it.RefCount != 1 {
|
||||
t.Fatalf("引用计数应为 1,实际 %d", it.RefCount)
|
||||
}
|
||||
refs, _ := ms.Refs(media.OwnerContext, evt.ID)
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("media_refs 应有 1 条,实际 %d", len(refs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindEventMedia_LazyIDOnlyWhenNeeded(t *testing.T) {
|
||||
// 绝大多数对话没有媒体,不该为它们都生成 ID 塞进 context.json
|
||||
a, _ := newTestAgentWithMedia(t)
|
||||
evt := &ContextEvent{Input: "纯文本"}
|
||||
a.bindEventMedia(evt, nil)
|
||||
if evt.ID != "" {
|
||||
t.Fatalf("无媒体时不该生成 ID,得到 %q", evt.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaSummary_DescriptionIsThePersistentMemory(t *testing.T) {
|
||||
// 方案 C 的核心:描述文本才是持久语义记忆,blob 只是缓存。
|
||||
// blob 被容量 GC 淘汰后,描述仍留在 L0/L2/L3 的文本里可被检索。
|
||||
a, ms := newTestAgentWithMedia(t)
|
||||
|
||||
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
||||
if s := a.mediaSummaryForEvent([]string{d}); s == "" {
|
||||
t.Fatal("未描述项也应产出一行(标注未描述)")
|
||||
}
|
||||
|
||||
ms.Describe(d, "一张紫蓝红三色带图", "visionllm")
|
||||
s := a.mediaSummaryForEvent([]string{d})
|
||||
if s == "" {
|
||||
t.Fatal("应产出摘要")
|
||||
}
|
||||
if !strings.Contains(s, "紫蓝红三色带图") {
|
||||
t.Fatalf("摘要应含描述文本: %q", s)
|
||||
}
|
||||
if !strings.Contains(s, "image/png") {
|
||||
t.Fatalf("摘要应含 MIME 标注: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrune_TransfersMediaRefsToDocument(t *testing.T) {
|
||||
// L0→L2 归档:媒体引用从 context 事件转到归档文档,
|
||||
// 且转移期间内容必须始终可读(先挂后销,不留归零窗口)。
|
||||
dir := t.TempDir()
|
||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ms.Close()
|
||||
|
||||
emb := memory.NewStaticEmbedder()
|
||||
docStore := document.NewStore(filepath.Join(dir, "docs"))
|
||||
if err := docStore.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
|
||||
rc.SetMediaStore(ms)
|
||||
|
||||
a := &Agent{mediaStore: ms, context: rc}
|
||||
|
||||
// 造一张被引用的图,挂到一条会被淘汰的老事件上
|
||||
payload := []byte("archived-image")
|
||||
d, _ := ms.Put(payload, media.Item{MIME: "image/png"})
|
||||
oldEvt := ContextEvent{
|
||||
Timestamp: time.Now().Add(-time.Hour),
|
||||
Source: "qq",
|
||||
Input: "很久以前的一张图",
|
||||
}
|
||||
a.bindEventMedia(&oldEvt, []string{d})
|
||||
oldEvtID := oldEvt.ID
|
||||
rc.Append(oldEvt)
|
||||
|
||||
// 再塞满 12 条新事件,逼 Prune 把老事件淘汰
|
||||
// (Prune 保护最近 10 条,topK 传 5 使候选全部进归档)
|
||||
for i := 0; i < 12; i++ {
|
||||
rc.Append(ContextEvent{
|
||||
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
|
||||
Source: "qq",
|
||||
Input: "无关内容",
|
||||
})
|
||||
}
|
||||
|
||||
archived := rc.Prune("完全不相关的查询", 5, docStore)
|
||||
if archived == 0 {
|
||||
t.Fatal("应有事件被归档")
|
||||
}
|
||||
|
||||
// 关键断言:内容仍可读(引用被转走而非归零后被清)
|
||||
got, err := ms.Get(d)
|
||||
if err != nil {
|
||||
t.Fatalf("归档后内容应仍可读: %v", err)
|
||||
}
|
||||
if string(got) != string(payload) {
|
||||
t.Fatal("内容被改")
|
||||
}
|
||||
|
||||
it, err := ms.Stat(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if it.RefCount < 1 {
|
||||
t.Fatalf("引用应转移而非归零,实际 refcount=%d", it.RefCount)
|
||||
}
|
||||
// 原 context 引用应已注销
|
||||
if refs, _ := ms.Refs(media.OwnerContext, oldEvtID); len(refs) != 0 {
|
||||
t.Fatalf("原事件引用应已注销,仍有 %d 条", len(refs))
|
||||
}
|
||||
// 应挂到某个 document owner 上
|
||||
var docOwned bool
|
||||
docs := docStore.RecentDocs(10)
|
||||
for _, doc := range docs {
|
||||
if refs, _ := ms.Refs(media.OwnerDocument, doc.ID); len(refs) > 0 {
|
||||
docOwned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !docOwned {
|
||||
t.Fatal("引用应已挂到归档文档上")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrune_NilMediaStoreStillArchives(t *testing.T) {
|
||||
// 媒体存储未启用时归档链路必须照常工作
|
||||
dir := t.TempDir()
|
||||
emb := memory.NewStaticEmbedder()
|
||||
docStore := document.NewStore(filepath.Join(dir, "docs"))
|
||||
if err := docStore.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
|
||||
// 刻意不 SetMediaStore
|
||||
|
||||
for i := 0; i < 15; i++ {
|
||||
rc.Append(ContextEvent{
|
||||
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
|
||||
Source: "qq",
|
||||
Input: "内容",
|
||||
})
|
||||
}
|
||||
if n := rc.Prune("查询", 5, docStore); n == 0 {
|
||||
t.Fatal("无媒体存储时归档也应正常")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextEvent_MediaFieldRoundTrip(t *testing.T) {
|
||||
// context.json 加字段必须向后兼容:存量文件读回来 Media 为空、ID 为空,
|
||||
// 不影响任何既有行为。
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "context.json")
|
||||
|
||||
// 写一份"存量格式"(无 id / media 字段)
|
||||
legacy := `[{"timestamp":"2026-09-04T10:00:00Z","source":"qq","input":"老数据","response":"回复"}]`
|
||||
if err := os.WriteFile(path, []byte(legacy), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
emb := memory.NewStaticEmbedder()
|
||||
rc := NewRelevanceContext(path, emb)
|
||||
if rc.Len() != 1 {
|
||||
t.Fatalf("应读回 1 条,实际 %d", rc.Len())
|
||||
}
|
||||
|
||||
// 新写入带媒体的事件,再读回
|
||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ms.Close()
|
||||
a := &Agent{mediaStore: ms, context: rc}
|
||||
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
||||
evt := ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "新数据"}
|
||||
a.bindEventMedia(&evt, []string{d})
|
||||
rc.Append(evt)
|
||||
rc.flush()
|
||||
|
||||
rc2 := NewRelevanceContext(path, emb)
|
||||
if rc2.Len() != 2 {
|
||||
t.Fatalf("应有 2 条,实际 %d", rc2.Len())
|
||||
}
|
||||
}
|
||||
@ -344,11 +344,6 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
}
|
||||
}
|
||||
if len(blocks) > 0 {
|
||||
// 先落进 CAS:无论下面走直视还是回退转写,媒体本体都该进记忆。
|
||||
// 不存的后果是 ToolResultItem.Output 只剩那句
|
||||
// "[已将图片注入后续对话] /tmp/x.png",文件一删线索就断了。
|
||||
a.stageMediaDigests(a.captureBlockMedia(blocks, tc.Name)...)
|
||||
|
||||
if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 {
|
||||
// 能直视:另起一条 user message 承载媒体,并补一句来源说明,
|
||||
// 否则模型会把它当成用户新发的图而不是工具拉回来的。
|
||||
|
||||
@ -150,14 +150,6 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName))
|
||||
}
|
||||
// 命中的关系若挂着媒体,把媒体说明附在结果末尾。
|
||||
//
|
||||
// 关系行只有实体名和关系类型,看不出"这条记忆当时还带了一张图"。
|
||||
// 媒体挂在句子上(graph_sentence owner),需经关系→句子→media_refs
|
||||
// 反查。不附上的后果:agent 显式查了图记忆,却仍然不知道有图。
|
||||
if mc := a.mediaContextForRelations(result.Relations); mc != "" {
|
||||
parts = append(parts, "", "关联媒体:", mc)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
|
||||
case "memory_block_merge":
|
||||
@ -185,16 +177,9 @@ 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"),
|
||||
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)
|
||||
Subject: getString(m, "subject"),
|
||||
Relation: getString(m, "relation"),
|
||||
Object: getString(m, "object"),
|
||||
}
|
||||
if t.Subject != "" && t.Relation != "" && t.Object != "" {
|
||||
triples = append(triples, t)
|
||||
@ -204,15 +189,10 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
if len(triples) == 0 {
|
||||
return "没有有效的三元组"
|
||||
}
|
||||
// remember 工具是用户/模型显式写入,不涉及归档删除,
|
||||
// 因此不需要 mediaBound——没有旧引用要释放。
|
||||
ec, rc, mb, err := a.commitTriplesWithMedia(triples, string(a.id), 0)
|
||||
ec, rc, err := a.memory.Commit(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":
|
||||
@ -531,11 +511,6 @@ 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",
|
||||
@ -566,26 +541,14 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
}
|
||||
|
||||
doc := &document.Doc{
|
||||
Summary: summary,
|
||||
Content: content,
|
||||
Tags: tags,
|
||||
Source: "manual",
|
||||
Summary: summary,
|
||||
Content: content,
|
||||
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:
|
||||
|
||||
@ -13,24 +13,6 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int) string {
|
||||
}
|
||||
injected := a.indexer.BuildContext(input)
|
||||
s := a.indexer.FormatContext(injected)
|
||||
|
||||
// 图库召回命中的实体若关联着带媒体的句子,把媒体说明一并注入。
|
||||
//
|
||||
// 不做这一步的后果:媒体描述进了 L3,agent 却拿不出来。图库句子里
|
||||
// 写着 [image/png a1b2c3d4e5f6] 这样的短标记,但没有任何东西告诉
|
||||
// 模型那份内容是否还在、能否重新查看——描述永存而 blob 可能已被
|
||||
// 容量 GC 淘汰,两者状态不同,必须显式告知。
|
||||
//
|
||||
// 注意不能直接用 injected.Relations:BuildContext 刻意把它置为 nil
|
||||
//(自动注入只给实体索引以省 token,细节留给 memory_recall)。
|
||||
// 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。
|
||||
if mc := a.mediaContextForInjectedEntities(injected); mc != "" {
|
||||
if s != "" {
|
||||
s += "\n"
|
||||
}
|
||||
s += "【关联媒体】\n" + mc
|
||||
}
|
||||
|
||||
if maxTokens > 0 {
|
||||
s = TruncateByTokens(s, maxTokens)
|
||||
}
|
||||
@ -126,8 +108,8 @@ func (a *Agent) buildToolCatalog() string {
|
||||
}
|
||||
// 仅注入插件/通道能力摘要,避免全量工具定义污染 system prompt。
|
||||
// 每个插件列:名称 + 能力描述 + 工具数。完整工具定义由 get_plugin_tools 按需拉取。
|
||||
byPlugin := map[string]int{} // plugin -> 工具数
|
||||
pluginDesc := map[string]string{} // plugin -> 首个工具描述(作能力概览)
|
||||
byPlugin := map[string]int{} // plugin -> 工具数
|
||||
pluginDesc := map[string]string{} // plugin -> 首个工具描述(作能力概览)
|
||||
var order []string
|
||||
for _, t := range defs {
|
||||
fn, ok := t.(map[string]interface{})["function"].(map[string]interface{})
|
||||
@ -320,7 +302,7 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "知识名称(用作目录名)"},
|
||||
"name": map[string]interface{}{"type": "string", "description": "知识名称(用作目录名)"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "知识内容,支持 Markdown"},
|
||||
},
|
||||
"required": []string{"name", "content"},
|
||||
@ -374,11 +356,6 @@ 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,24 +29,6 @@ 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
|
||||
|
||||
@ -544,7 +544,6 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
|
||||
set("core.memory.graph", filepath.Join(dataDir, "memory", "graph.db"))
|
||||
set("core.memory.text", filepath.Join(dataDir, "memory", "text"))
|
||||
set("core.memory.documents", filepath.Join(dataDir, "memory", "documents"))
|
||||
set("core.memory.media.dir", filepath.Join(dataDir, "memory", "media"))
|
||||
set("core.knowledge.path", filepath.Join(dataDir, "knowledge"))
|
||||
set("core.log.path", filepath.Join(dataDir, "log"))
|
||||
|
||||
@ -649,12 +648,6 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
|
||||
reg(ConfigDef{Key: "core.memory.graph", Default: filepath.Join(dataDir, "memory", "graph.db"), Type: "string", DisplayName: "图数据库路径", Description: "长期记忆(图数据库)存储路径", Category: "paths"})
|
||||
reg(ConfigDef{Key: "core.memory.text", Default: filepath.Join(dataDir, "memory", "text"), Type: "string", DisplayName: "文本记忆路径", Description: "短期文本记忆存储目录", Category: "paths"})
|
||||
reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"})
|
||||
reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频按内容摘要(sha256)落盘去重,记忆各层只记 digest。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.dir", Default: filepath.Join(dataDir, "memory", "media"), Type: "string", DisplayName: "媒体存储路径", Description: "媒体内容寻址存储目录(内含 media.db 与 blobs/)", Category: "paths"})
|
||||
reg(ConfigDef{Key: "core.memory.media.max_mb", Default: "2048", Type: "int", DisplayName: "媒体容量上限(MB)", Description: "超限时按最后访问时间淘汰无引用的媒体;被记忆引用的内容即使超限也不会删除(宁可超限也不断引用)。描述文本不受此限,淘汰后仍可检索", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.gc_interval", Default: "6h", Type: "duration", DisplayName: "媒体 GC 间隔", Description: "清理无引用媒体的周期;0 表示不自动清理", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.gc_min_age", Default: "1h", Type: "duration", DisplayName: "媒体 GC 保护期", Description: "新入库媒体在此时长内不被清理。刚落盘还没来得及挂到记忆上的项引用计数也是 0,靠这个保护期避免被误删", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.describe_on_ingest", Default: "false", Type: "bool", DisplayName: "自动描述媒体", Description: "后台用视觉/音频模型给未描述的媒体生成文字描述。**描述文本才是持久语义记忆**——blob 会被容量 GC 淘汰,描述会随记忆各层一直留存并可检索。代价是消耗视觉模型配额(单张图实测约 10s),故默认关闭;开启后每 30s 最多处理 4 条,不跟对话抢额度", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.knowledge.path", Default: filepath.Join(dataDir, "knowledge"), Type: "string", DisplayName: "知识库路径", Description: "知识库存储目录", Category: "paths"})
|
||||
reg(ConfigDef{Key: "core.log.path", Default: filepath.Join(dataDir, "log"), Type: "string", DisplayName: "日志目录", Description: "日志文件输出目录", Category: "paths"})
|
||||
|
||||
|
||||
@ -37,13 +37,13 @@ type Relation struct {
|
||||
}
|
||||
|
||||
type Triple struct {
|
||||
Subject string `json:"subject"`
|
||||
Relation string `json:"relation"`
|
||||
Object string `json:"object"`
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
SubjectType string `json:"subject_type,omitempty"`
|
||||
ObjectType string `json:"object_type,omitempty"`
|
||||
SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表
|
||||
Subject string `json:"subject"`
|
||||
Relation string `json:"relation"`
|
||||
Object string `json:"object"`
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
SubjectType string `json:"subject_type,omitempty"`
|
||||
ObjectType string `json:"object_type,omitempty"`
|
||||
SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表
|
||||
}
|
||||
|
||||
type GraphDB struct {
|
||||
@ -192,37 +192,13 @@ func (g *GraphDB) migrateRelationUnique(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Commit 把三元组写入图库,返回新建的实体数与关系数。
|
||||
func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) {
|
||||
_, ec, rc, err := g.commit(triples, sessionID, turnID, false)
|
||||
return ec, rc, err
|
||||
}
|
||||
|
||||
// CommitWithMedia 与 Commit 相同,但额外返回每条句子文本对应的 sentences.id。
|
||||
//
|
||||
// 为何单独开一个方法而不改 Commit 的签名:Commit 有十个非测试调用点
|
||||
// 加二十多个测试调用点,为了一个多数调用方都不需要的返回值去改全部签名
|
||||
// 不划算。这里让 Commit 内部转调,两者共享同一份落库逻辑。
|
||||
//
|
||||
// 返回的 map 只包含本次真正写入了 sentences 表的句子。调用方据此把媒体
|
||||
// 引用挂到 graph_sentence owner 上——句子是媒体描述在图库里的落点,
|
||||
// 关系行本身不持有媒体。
|
||||
func (g *GraphDB) CommitWithMedia(triples []Triple, sessionID string, turnID int) (map[string]int64, int, int, error) {
|
||||
return g.commit(triples, sessionID, turnID, true)
|
||||
}
|
||||
|
||||
func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSentences bool) (map[string]int64, int, int, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
var sentenceIDs map[string]int64
|
||||
if trackSentences {
|
||||
sentenceIDs = make(map[string]int64)
|
||||
}
|
||||
|
||||
tx, err := g.db.Begin()
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
return 0, 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
@ -253,24 +229,24 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe
|
||||
|
||||
ec, err := g.upsertEntity(tx, t.Subject, subjType)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
return 0, 0, err
|
||||
}
|
||||
entitiesCreated += ec
|
||||
|
||||
ec, err = g.upsertEntity(tx, t.Object, objType)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
return 0, 0, err
|
||||
}
|
||||
entitiesCreated += ec
|
||||
|
||||
var sourceID, targetID int64
|
||||
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Subject).Scan(&sourceID)
|
||||
if err != nil {
|
||||
return nil, 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err)
|
||||
return 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err)
|
||||
}
|
||||
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Object).Scan(&targetID)
|
||||
if err != nil {
|
||||
return nil, 0, 0, fmt.Errorf("object %q: %w", t.Object, err)
|
||||
return 0, 0, fmt.Errorf("object %q: %w", t.Object, err)
|
||||
}
|
||||
|
||||
// 写入/查找句子
|
||||
@ -279,13 +255,11 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe
|
||||
_, err = tx.Exec(
|
||||
`INSERT OR IGNORE INTO sentences (text) VALUES (?)`, t.SentenceText)
|
||||
if err != nil {
|
||||
return nil, 0, 0, fmt.Errorf("insert sentence: %w", err)
|
||||
return 0, 0, fmt.Errorf("insert sentence: %w", err)
|
||||
}
|
||||
err = tx.QueryRow("SELECT id FROM sentences WHERE text = ?", t.SentenceText).Scan(&sentenceID)
|
||||
if err != nil {
|
||||
sentenceID = 0
|
||||
} else if sentenceIDs != nil {
|
||||
sentenceIDs[t.SentenceText] = sentenceID
|
||||
}
|
||||
}
|
||||
|
||||
@ -301,11 +275,11 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe
|
||||
sourceID, targetID, t.Relation, confidence, sessionID, turnID, dateBucket, sentenceID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
return 0, 0, err
|
||||
}
|
||||
relationsCreated++
|
||||
} else if err != nil {
|
||||
return nil, 0, 0, err
|
||||
return 0, 0, err
|
||||
} else {
|
||||
// 同一(会话内)三元组已存在:仅刷新置信度与时间戳,不重复计数
|
||||
_, err = tx.Exec(
|
||||
@ -314,16 +288,16 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe
|
||||
confidence, sourceID, targetID, t.Relation, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
return 0, 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, 0, 0, err
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return sentenceIDs, entitiesCreated, relationsCreated, nil
|
||||
return entitiesCreated, relationsCreated, nil
|
||||
}
|
||||
|
||||
func validEntityName(name string) bool {
|
||||
|
||||
@ -174,9 +174,7 @@ func (idx *Indexer) BuildToolPrompt() string {
|
||||
### memory_commit
|
||||
将三元组写入图记忆。
|
||||
参数:
|
||||
- triples: [{"subject": "实体名", "relation": "关系类型", "object": "目标实体",
|
||||
"sentence_text": "原始句子(可选)", "media_digests": ["图片digest(可选)"]}]
|
||||
填了 media_digests,日后从这条记忆就能取回当时那张图/那段音频。
|
||||
- triples: [{"subject": "实体名", "relation": "关系类型", "object": "目标实体"}]
|
||||
|
||||
### memory_introspect
|
||||
查看记忆统计信息。
|
||||
@ -263,15 +261,6 @@ 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"},
|
||||
},
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
package media
|
||||
|
||||
import "encoding/base64"
|
||||
|
||||
// base64 编解码单独抽出来,让 media.go 的 import 块只留业务依赖。
|
||||
// 用 StdEncoding:data URL 规范用的是标准表(含 + / =),不是 URL-safe 表。
|
||||
|
||||
func base64Decode(s string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
func base64Encode(b []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
@ -1,780 +0,0 @@
|
||||
// Package media 是记忆系统的内容寻址媒体存储(CAS)。
|
||||
//
|
||||
// 为何需要它:此前四层记忆全是纯文本载体——L0 `ContextEvent`、L1 `text.Event`、
|
||||
// L2 `document.Doc`、L3 图库的 `sentences.text TEXT UNIQUE`——没有任何一层能
|
||||
// 存二进制。multimodal 插件注入的图片在本轮对话内可见(走 message 数组,不经
|
||||
// 记忆),下一轮起就只剩 `ToolResultItem.Output` 里那句
|
||||
// "[已将图片注入后续对话] /tmp/x.png",即一条路径字符串。那个文件被删或被
|
||||
// 覆盖之后连线索都断了。
|
||||
//
|
||||
// 为何是内容寻址而不是存路径:
|
||||
// - 路径会失效。/tmp 下的探针图、下载缓存、其他进程的临时产物,记忆里留个
|
||||
// 路径等于留个悬空指针。
|
||||
// - 同一张图往往被反复注入(用户连问几轮同一张截图、see_video 相邻帧高度
|
||||
// 相似)。按 sha256 寻址天然去重,引用计数记住被引了几次。
|
||||
// - 内容即身份,跟 L3 图库 `sentences.text UNIQUE` 的思路一致:文本节点用
|
||||
// 文本本身做身份,媒体节点用内容摘要做身份。
|
||||
package media
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// OwnerKind 是 media_refs.owner_kind 的取值,对应引用媒体的记忆层。
|
||||
//
|
||||
// 定义为常量而不是让调用方写字符串:owner_kind 进了主键,
|
||||
// 拼错一个字符就是一条永远对不上的孤立引用(AddRef 不会报错,
|
||||
// DropOwner 也永远匹配不到)。
|
||||
const (
|
||||
// OwnerContext 是 L0 对话上下文事件(ContextEvent.ID)。
|
||||
OwnerContext = "context"
|
||||
// OwnerDocument 是 L2 文档记忆(Doc.ID)。
|
||||
OwnerDocument = "document"
|
||||
// OwnerGraphSentence 是 L3 图库句子节点(sentences.id)。
|
||||
OwnerGraphSentence = "graph_sentence"
|
||||
)
|
||||
|
||||
// digestHexLen 是 sha256 的十六进制串长度。
|
||||
const digestHexLen = sha256.Size * 2
|
||||
|
||||
// Kind 是媒体大类。刻意只分三类而不细分具体格式:
|
||||
// 记忆检索关心的是“这是张图还是段音频”,具体编码交给 MIME 字段。
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindImage Kind = "image"
|
||||
KindAudio Kind = "audio"
|
||||
KindVideo Kind = "video"
|
||||
KindOther Kind = "other"
|
||||
)
|
||||
|
||||
// Item 是一条媒体记录。
|
||||
//
|
||||
// Digest 既是主键也是磁盘文件名,所以没有单独的 Path 字段——路径可由
|
||||
// Store 根据 Digest 推导,不落库(落了就又是个会失效的引用)。
|
||||
type Item struct {
|
||||
// Digest 是内容 sha256 的十六进制串(64 字符),媒体的唯一身份。
|
||||
Digest string `json:"digest"`
|
||||
// Kind 是大类,供检索时按模态筛选。
|
||||
Kind Kind `json:"kind"`
|
||||
// MIME 是原始 MIME 类型,如 image/png。
|
||||
MIME string `json:"mime"`
|
||||
// Size 是字节数。
|
||||
Size int64 `json:"size"`
|
||||
// Width/Height 是像素尺寸,未知或不适用时为 0。
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
// OriginPath 是首次入库时的来源路径,仅供人类溯源与调试。
|
||||
// **不可用于读取内容**——它随时可能失效,这正是本包存在的理由。
|
||||
OriginPath string `json:"origin_path,omitempty"`
|
||||
// Tool 是注入这条媒体的工具名(如 multimodal_see_picture)。
|
||||
Tool string `json:"tool,omitempty"`
|
||||
// Description 是视觉/音频模型生成的文字描述,供 L2/L3 检索。
|
||||
// 空表示未描述(未开启描述、模型不可用或描述失败)。
|
||||
Description string `json:"description,omitempty"`
|
||||
// DescribedBy 记录描述来自哪个源,让后续读者能判断可靠性。
|
||||
DescribedBy string `json:"described_by,omitempty"`
|
||||
// RefCount 是引用计数。GC 只清理归零的项。
|
||||
RefCount int `json:"ref_count"`
|
||||
// FirstSeen/LastSeen 是首末次入库时间。
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
}
|
||||
|
||||
// Store 管理媒体的元数据(SQLite)与内容(磁盘 CAS 目录)。
|
||||
//
|
||||
// 元数据与内容分离而不是把 blob 塞进 SQLite:单张图动辄几 MB,塞进库会让
|
||||
// 每次 VACUUM/备份都拖着几百 MB 走,也让 WAL 迅速膨胀。CAS 目录用两级
|
||||
// 前缀分桶(ab/cdef...)避免单目录几万文件。
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
blobDir string
|
||||
|
||||
// maxBytes 是内容目录的容量上限,0 表示不限。
|
||||
// 超限时 GC 按 LastSeen 从旧到新淘汰 RefCount=0 的项。
|
||||
maxBytes int64
|
||||
}
|
||||
|
||||
// New 打开(或初始化)媒体存储。
|
||||
// dir 下会建 media.db 与 blobs/ 两个条目。
|
||||
func New(dir string, maxBytes int64) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Join(dir, "blobs"), 0755); err != nil {
|
||||
return nil, fmt.Errorf("media: create blob dir: %w", err)
|
||||
}
|
||||
dbPath := filepath.Join(dir, "media.db")
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("media: open db: %w", err)
|
||||
}
|
||||
s := &Store{db: db, blobDir: filepath.Join(dir, "blobs"), maxBytes: maxBytes}
|
||||
if err := s.initSchema(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) initSchema() error {
|
||||
stmts := []string{
|
||||
// digest 作主键:内容即身份,重复 Put 同一内容只递增 ref_count。
|
||||
`CREATE TABLE IF NOT EXISTS media (
|
||||
digest TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
mime TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
width INTEGER DEFAULT 0,
|
||||
height INTEGER DEFAULT 0,
|
||||
origin_path TEXT,
|
||||
tool TEXT,
|
||||
description TEXT,
|
||||
described_by TEXT,
|
||||
ref_count INTEGER DEFAULT 0,
|
||||
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_kind ON media(kind)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_refcount ON media(ref_count)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_last_seen ON media(last_seen)`,
|
||||
// 反向索引:哪条记忆引用了哪个媒体。
|
||||
// owner_kind 取 context / document / graph_sentence,owner_id 是各层自己的标识。
|
||||
// 主键含三列,同一 owner 重复挂同一媒体是幂等的。
|
||||
`CREATE TABLE IF NOT EXISTS media_refs (
|
||||
digest TEXT NOT NULL,
|
||||
owner_kind TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (digest, owner_kind, owner_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_refs_owner ON media_refs(owner_kind, owner_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_refs_digest ON media_refs(digest)`,
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := s.db.Exec(q); err != nil {
|
||||
return fmt.Errorf("media: schema %q: %w", truncate(q, 60), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// blobPath 按两级前缀分桶推导内容路径。
|
||||
func (s *Store) blobPath(digest string) string {
|
||||
if len(digest) < 4 {
|
||||
return filepath.Join(s.blobDir, digest)
|
||||
}
|
||||
return filepath.Join(s.blobDir, digest[:2], digest[2:])
|
||||
}
|
||||
|
||||
// Put 落盘并登记一段媒体内容,返回其 digest。
|
||||
//
|
||||
// 幂等:同一内容重复 Put 不重复落盘,只更新 last_seen 与可选的新元数据
|
||||
// (描述、尺寸等——后来者可能带着前一次没有的信息)。
|
||||
func (s *Store) Put(data []byte, meta Item) (string, error) {
|
||||
if len(data) == 0 {
|
||||
return "", fmt.Errorf("media: empty content")
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
digest := hex.EncodeToString(sum[:])
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
path := s.blobPath(digest)
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return "", fmt.Errorf("media: mkdir: %w", err)
|
||||
}
|
||||
// 先写临时文件再 rename:中途崩溃不会留下半个 blob 被后续
|
||||
// 当成完整内容读走(digest 校验能发现,但那时已经把坏数据喂给模型了)。
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||
return "", fmt.Errorf("media: write blob: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return "", fmt.Errorf("media: commit blob: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if meta.Kind == "" {
|
||||
meta.Kind = KindFromMIME(meta.MIME)
|
||||
}
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO media (digest, kind, mime, size, width, height,
|
||||
origin_path, tool, description, described_by,
|
||||
ref_count, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||
ON CONFLICT(digest) DO UPDATE SET
|
||||
last_seen = excluded.last_seen,
|
||||
-- 只在原值为空时补写:先到的描述可能来自更强的模型,
|
||||
-- 后到的空值不该把它冲掉。
|
||||
description = CASE WHEN COALESCE(media.description,'') = '' THEN excluded.description ELSE media.description END,
|
||||
described_by = CASE WHEN COALESCE(media.described_by,'') = '' THEN excluded.described_by ELSE media.described_by END,
|
||||
width = CASE WHEN media.width = 0 THEN excluded.width ELSE media.width END,
|
||||
height = CASE WHEN media.height = 0 THEN excluded.height ELSE media.height END,
|
||||
tool = CASE WHEN COALESCE(media.tool,'') = '' THEN excluded.tool ELSE media.tool END
|
||||
`, digest, string(meta.Kind), meta.MIME, int64(len(data)), meta.Width, meta.Height,
|
||||
meta.OriginPath, meta.Tool, meta.Description, meta.DescribedBy, now, now)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("media: upsert meta: %w", err)
|
||||
}
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// Get 读取内容并校验 digest。
|
||||
//
|
||||
// 校验不是多余的:CAS 的全部保证建立在"文件名 == 内容摘要"上,磁盘位翻转
|
||||
// 或外部误改会让这条保证失效,而把损坏的图喂给模型只会得到无从追溯的幻觉。
|
||||
func (s *Store) Get(digest string) ([]byte, error) {
|
||||
s.mu.RLock()
|
||||
path := s.blobPath(digest)
|
||||
s.mu.RUnlock()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("media: read %s: %w", shortDigest(digest), err)
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
if got := hex.EncodeToString(sum[:]); got != digest {
|
||||
return nil, fmt.Errorf("media: digest mismatch for %s (content corrupted)", shortDigest(digest))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Stat 返回元数据,不读内容。
|
||||
func (s *Store) Stat(digest string) (*Item, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.scanOne(s.db.QueryRow(`
|
||||
SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
||||
description, described_by, ref_count, first_seen, last_seen
|
||||
FROM media WHERE digest = ?`, digest))
|
||||
}
|
||||
|
||||
// Describe 写入(或覆盖)文字描述。
|
||||
//
|
||||
// 与 Put 的"只在空时补写"不同:Describe 是显式操作,调用方明确想要这份
|
||||
// 描述生效(例如换了更强的视觉模型重新描述)。
|
||||
func (s *Store) Describe(digest, description, describedBy string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
res, err := s.db.Exec(`UPDATE media SET description = ?, described_by = ? WHERE digest = ?`,
|
||||
description, describedBy, digest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("media: describe: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return fmt.Errorf("media: describe: unknown digest %s", shortDigest(digest))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddRef 登记一条引用并递增计数。幂等:同一 (digest, owner) 重复调用不重复计数。
|
||||
func (s *Store) AddRef(digest, ownerKind, ownerID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(`INSERT OR IGNORE INTO media_refs (digest, owner_kind, owner_id) VALUES (?, ?, ?)`,
|
||||
digest, ownerKind, ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("media: add ref: %w", err)
|
||||
}
|
||||
// 只有真的插进去才递增:否则重复调用会让计数虚高,GC 永远不敢清。
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
if _, err := tx.Exec(`UPDATE media SET ref_count = ref_count + 1 WHERE digest = ?`, digest); err != nil {
|
||||
return fmt.Errorf("media: bump refcount: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DropRef 注销一条引用并递减计数。内容不立即删除,留给 GC。
|
||||
func (s *Store) DropRef(digest, ownerKind, ownerID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(`DELETE FROM media_refs WHERE digest = ? AND owner_kind = ? AND owner_id = ?`,
|
||||
digest, ownerKind, ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("media: drop ref: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
// MAX(0, ...) 兜底:历史数据或并发意外让计数与 refs 表不一致时,
|
||||
// 不让它掉成负数(负数会让容量 GC 的排序失去意义)。
|
||||
if _, err := tx.Exec(`UPDATE media SET ref_count = MAX(0, ref_count - 1) WHERE digest = ?`, digest); err != nil {
|
||||
return fmt.Errorf("media: lower refcount: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DropOwner 注销某个 owner 的全部引用(该条记忆被删/被归档替换时用)。
|
||||
func (s *Store) DropOwner(ownerKind, ownerID string) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
rows, err := s.db.Query(`SELECT digest FROM media_refs WHERE owner_kind = ? AND owner_id = ?`,
|
||||
ownerKind, ownerID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var digests []string
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if err := rows.Scan(&d); err == nil {
|
||||
digests = append(digests, d)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(digests) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`DELETE FROM media_refs WHERE owner_kind = ? AND owner_id = ?`, ownerKind, ownerID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, d := range digests {
|
||||
if _, err := tx.Exec(`UPDATE media SET ref_count = MAX(0, ref_count - 1) WHERE digest = ?`, d); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(digests), nil
|
||||
}
|
||||
|
||||
// Refs 返回某个 owner 引用的全部 digest。
|
||||
func (s *Store) Refs(ownerKind, ownerID string) ([]string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
rows, err := s.db.Query(`SELECT digest FROM media_refs WHERE owner_kind = ? AND owner_id = ? ORDER BY created_at`,
|
||||
ownerKind, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if err := rows.Scan(&d); err == nil {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Search 按描述文本做 LIKE 匹配,返回最近的若干条。
|
||||
//
|
||||
// 刻意不在这里做向量检索:媒体的语义检索走 L2 文档层的既有索引
|
||||
// (描述文字随记忆条目一起进 Doc.Content,复用那套 TF-IDF/embedding),
|
||||
// 本方法只是"按关键词直接翻媒体库"的补充入口。
|
||||
func (s *Store) Search(query string, kind Kind, limit int) ([]*Item, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
q := `SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
||||
description, described_by, ref_count, first_seen, last_seen
|
||||
FROM media WHERE COALESCE(description,'') != ''`
|
||||
args := []interface{}{}
|
||||
if strings.TrimSpace(query) != "" {
|
||||
q += ` AND description LIKE ?`
|
||||
args = append(args, "%"+query+"%")
|
||||
}
|
||||
if kind != "" {
|
||||
q += ` AND kind = ?`
|
||||
args = append(args, string(kind))
|
||||
}
|
||||
q += ` ORDER BY last_seen DESC LIMIT ?`
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := s.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Item
|
||||
for rows.Next() {
|
||||
it, err := s.scanRows(rows)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Pending 返回尚无描述的媒体,供后台描述任务消费。
|
||||
// Pending 返回尚无描述的媒体,供后台描述任务消费。
|
||||
//
|
||||
// 不只看 description 为空,还要求 described_by 也为空。
|
||||
// 因为“已尝试但无法描述”的项(如 kind=other 的二进制、blob 已丢失)
|
||||
// 会被标记为 described_by=unsupported/content-missing 而 description 仍为空——
|
||||
// 若只看 description,这些项每轮都会被取出来重试,永远卡在队列头部,
|
||||
// 真正需要描述的新项永远轮不到(LIMIT 只取前 N 条)。
|
||||
func (s *Store) Pending(limit int) ([]*Item, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
rows, err := s.db.Query(`
|
||||
SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
||||
description, described_by, ref_count, first_seen, last_seen
|
||||
FROM media
|
||||
WHERE COALESCE(description,'') = '' AND COALESCE(described_by,'') = ''
|
||||
ORDER BY last_seen DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Item
|
||||
for rows.Next() {
|
||||
it, err := s.scanRows(rows)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GC 清理无人引用的内容。
|
||||
//
|
||||
// 两段策略:
|
||||
// 1. ref_count=0 且 last_seen 早于 minAge 的一律清理。刚 Put 还没来得及
|
||||
// AddRef 的项 refcount 也是 0,minAge 保护它们不被立刻清掉。
|
||||
// 2. 清完仍超 maxBytes 时,继续按 last_seen 从旧到新淘汰 ref_count=0 的项。
|
||||
//
|
||||
// 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针,正是本包要避免的。
|
||||
func (s *Store) GC(minAge time.Duration) (removed int, freed int64, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cutoff := time.Now().Add(-minAge)
|
||||
rows, err := s.db.Query(`
|
||||
SELECT digest, size FROM media
|
||||
WHERE ref_count <= 0 AND last_seen < ?
|
||||
ORDER BY last_seen`, cutoff)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
type cand struct {
|
||||
digest string
|
||||
size int64
|
||||
}
|
||||
var cands []cand
|
||||
for rows.Next() {
|
||||
var c cand
|
||||
if err := rows.Scan(&c.digest, &c.size); err == nil {
|
||||
cands = append(cands, c)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, c := range cands {
|
||||
if e := os.Remove(s.blobPath(c.digest)); e != nil && !os.IsNotExist(e) {
|
||||
continue // 删不掉就留着元数据,下轮再试;不制造"元数据没了文件还在"的孤儿
|
||||
}
|
||||
if _, e := s.db.Exec(`DELETE FROM media WHERE digest = ?`, c.digest); e != nil {
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
freed += c.size
|
||||
}
|
||||
|
||||
if s.maxBytes > 0 {
|
||||
r2, f2 := s.enforceCapacityLocked()
|
||||
removed += r2
|
||||
freed += f2
|
||||
}
|
||||
return removed, freed, nil
|
||||
}
|
||||
|
||||
// enforceCapacityLocked 在超出 maxBytes 时继续淘汰无引用项(调用方已持锁)。
|
||||
func (s *Store) enforceCapacityLocked() (removed int, freed int64) {
|
||||
var total int64
|
||||
if err := s.db.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM media`).Scan(&total); err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
if total <= s.maxBytes {
|
||||
return 0, 0
|
||||
}
|
||||
need := total - s.maxBytes
|
||||
|
||||
rows, err := s.db.Query(`SELECT digest, size FROM media WHERE ref_count <= 0 ORDER BY last_seen`)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
type cand struct {
|
||||
digest string
|
||||
size int64
|
||||
}
|
||||
var cands []cand
|
||||
for rows.Next() {
|
||||
var c cand
|
||||
if err := rows.Scan(&c.digest, &c.size); err == nil {
|
||||
cands = append(cands, c)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, c := range cands {
|
||||
if freed >= need {
|
||||
break
|
||||
}
|
||||
if e := os.Remove(s.blobPath(c.digest)); e != nil && !os.IsNotExist(e) {
|
||||
continue
|
||||
}
|
||||
if _, e := s.db.Exec(`DELETE FROM media WHERE digest = ?`, c.digest); e != nil {
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
freed += c.size
|
||||
}
|
||||
return removed, freed
|
||||
}
|
||||
|
||||
// Stats 返回容量与条目统计,供 WebUI / healthcheck 展示。
|
||||
func (s *Store) Stats() map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
out := map[string]interface{}{"blob_dir": s.blobDir, "max_bytes": s.maxBytes}
|
||||
var count, described, orphan int
|
||||
var total int64
|
||||
s.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(size),0) FROM media`).Scan(&count, &total)
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE COALESCE(description,'') != ''`).Scan(&described)
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE ref_count <= 0`).Scan(&orphan)
|
||||
out["count"] = count
|
||||
out["total_bytes"] = total
|
||||
out["described"] = described
|
||||
out["unreferenced"] = orphan
|
||||
|
||||
byKind := map[string]int{}
|
||||
rows, err := s.db.Query(`SELECT kind, COUNT(*) FROM media GROUP BY kind`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var k string
|
||||
var n int
|
||||
if rows.Scan(&k, &n) == nil {
|
||||
byKind[k] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
out["by_kind"] = byKind
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// ---- 扫描辅助 ----
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
func (s *Store) scanOne(r rowScanner) (*Item, error) {
|
||||
it, err := scanItem(r)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("media: unknown digest")
|
||||
}
|
||||
return it, err
|
||||
}
|
||||
|
||||
func (s *Store) scanRows(r rowScanner) (*Item, error) { return scanItem(r) }
|
||||
|
||||
func scanItem(r rowScanner) (*Item, error) {
|
||||
var it Item
|
||||
var kind string
|
||||
var origin, tool, desc, by sql.NullString
|
||||
if err := r.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height,
|
||||
&origin, &tool, &desc, &by, &it.RefCount, &it.FirstSeen, &it.LastSeen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.Kind = Kind(kind)
|
||||
it.OriginPath = origin.String
|
||||
it.Tool = tool.String
|
||||
it.Description = desc.String
|
||||
it.DescribedBy = by.String
|
||||
return &it, nil
|
||||
}
|
||||
|
||||
// ---- 工具函数 ----
|
||||
|
||||
// KindFromMIME 把 MIME 归到大类。
|
||||
func KindFromMIME(mime string) Kind {
|
||||
m := strings.ToLower(strings.TrimSpace(mime))
|
||||
switch {
|
||||
case strings.HasPrefix(m, "image/"):
|
||||
return KindImage
|
||||
case strings.HasPrefix(m, "audio/"):
|
||||
return KindAudio
|
||||
case strings.HasPrefix(m, "video/"):
|
||||
return KindVideo
|
||||
default:
|
||||
return KindOther
|
||||
}
|
||||
}
|
||||
|
||||
// ParseDataURL 从 data:<mime>;base64,<data> 提取 MIME 与原始字节。
|
||||
//
|
||||
// 与 agent/api 里的 parseAudioDataURL 分开实现:那个只认音频且只回 base64
|
||||
// 串(它要把串塞回 OpenAI 的 input_audio 字段),这里要的是解码后的字节。
|
||||
func ParseDataURL(url string) (mime string, data []byte, ok bool) {
|
||||
const prefix = "data:"
|
||||
if !strings.HasPrefix(url, prefix) {
|
||||
return "", nil, false
|
||||
}
|
||||
rest := url[len(prefix):]
|
||||
comma := strings.IndexByte(rest, ',')
|
||||
if comma < 0 {
|
||||
return "", nil, false
|
||||
}
|
||||
head := rest[:comma]
|
||||
payload := rest[comma+1:]
|
||||
if !strings.HasSuffix(strings.ToLower(head), ";base64") {
|
||||
return "", nil, false
|
||||
}
|
||||
mime = head[:len(head)-len(";base64")]
|
||||
if mime == "" || payload == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
decoded, err := base64Decode(payload)
|
||||
if err != nil {
|
||||
return "", nil, false
|
||||
}
|
||||
return mime, decoded, true
|
||||
}
|
||||
|
||||
// DataURL 把内容编回 data URL,供重新注入模型对话。
|
||||
func DataURL(mime string, data []byte) string {
|
||||
return "data:" + mime + ";base64," + base64Encode(data)
|
||||
}
|
||||
|
||||
// CopyFrom 从 reader 读全部内容后 Put,用于大文件不便一次性构造 []byte 的场合。
|
||||
func (s *Store) CopyFrom(r io.Reader, meta Item) (string, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("media: read source: %w", err)
|
||||
}
|
||||
return s.Put(data, meta)
|
||||
}
|
||||
|
||||
// MarshalItems 序列化条目列表,供工具返回给模型。
|
||||
func MarshalItems(items []*Item) string {
|
||||
b, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func shortDigest(d string) string {
|
||||
if len(d) > 12 {
|
||||
return d[:12]
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
// ResolvePrefix 把 digest 前缀补全为完整 digest。
|
||||
//
|
||||
// 日志、事件摘要与图库句子里出现的都是 shortDigest(前 12 位),
|
||||
// 因为完整的 64 位 sha256 会把一行文字撑爆、也无助于人眼辨认。
|
||||
// 反查时需要这个补全,否则那些短标记只能看不能用。
|
||||
//
|
||||
// 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容,
|
||||
// 宁可这次绑定失败。12 位十六进制的碰撞概率极低,真撞上说明该用更长前缀。
|
||||
func (s *Store) ResolvePrefix(prefix string) (string, error) {
|
||||
prefix = strings.ToLower(strings.TrimSpace(prefix))
|
||||
if len(prefix) < 8 {
|
||||
return "", fmt.Errorf("digest 前缀过短(至少 8 位): %q", prefix)
|
||||
}
|
||||
if len(prefix) == digestHexLen {
|
||||
// 已是完整 digest:仍要确认存在,否则调用方会挂一条孤儿引用
|
||||
if _, err := s.Stat(prefix); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prefix, nil
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
rows, err := s.db.Query(
|
||||
`SELECT digest FROM media WHERE digest LIKE ? || '%' LIMIT 2`, prefix)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var found []string
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if err := rows.Scan(&d); err != nil {
|
||||
return "", err
|
||||
}
|
||||
found = append(found, d)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch len(found) {
|
||||
case 0:
|
||||
return "", fmt.Errorf("digest 前缀 %q 未匹配到媒体", prefix)
|
||||
case 1:
|
||||
return found[0], nil
|
||||
default:
|
||||
return "", fmt.Errorf("digest 前缀 %q 有歧义(至少匹配 %s 和 %s)",
|
||||
prefix, found[0][:16], found[1][:16])
|
||||
}
|
||||
}
|
||||
@ -1,513 +0,0 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T, maxBytes int64) *Store {
|
||||
t.Helper()
|
||||
s, err := New(t.TempDir(), maxBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestPut_ContentAddressedDedup(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
data := []byte("fake-png-bytes")
|
||||
|
||||
d1, err := s.Put(data, Item{MIME: "image/png", OriginPath: "/tmp/a.png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d2, err := s.Put(data, Item{MIME: "image/png", OriginPath: "/tmp/b.png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d1 != d2 {
|
||||
t.Fatalf("同一内容应得同一 digest:%s vs %s", d1, d2)
|
||||
}
|
||||
|
||||
// 去重的意义:同一张图反复注入(连问几轮同一截图、see_video 相邻帧)
|
||||
// 只占一份磁盘。
|
||||
st := s.Stats()
|
||||
if st["count"].(int) != 1 {
|
||||
t.Fatalf("同一内容应只有 1 条记录,实际 %v", st["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPut_KindInferredFromMIME(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
cases := map[string]Kind{
|
||||
"image/png": KindImage,
|
||||
"image/jpeg": KindImage,
|
||||
"audio/wav": KindAudio,
|
||||
"video/mp4": KindVideo,
|
||||
"text/plain": KindOther,
|
||||
}
|
||||
for mime, want := range cases {
|
||||
d, err := s.Put([]byte("payload-"+mime), Item{MIME: mime})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
it, err := s.Stat(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if it.Kind != want {
|
||||
t.Fatalf("%s 应归为 %s,实际 %s", mime, want, it.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_DetectsCorruption(t *testing.T) {
|
||||
// CAS 的全部保证建立在「文件名 == 内容摘要」上。外部误改或位翻转必须
|
||||
// 被发现——把损坏的图喂给模型只会得到无从追溯的幻觉。
|
||||
s := newTestStore(t, 0)
|
||||
d, err := s.Put([]byte("original-content"), Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Get(d); err != nil {
|
||||
t.Fatalf("正常读取应成功: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(s.blobPath(d), []byte("tampered!"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = s.Get(d)
|
||||
if err == nil {
|
||||
t.Fatal("内容被改后应报 digest 不匹配,却读成功了")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "digest mismatch") {
|
||||
t.Fatalf("错误应指明 digest 不匹配,得到: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPut_NoPartialBlobOnDisk(t *testing.T) {
|
||||
// 先写 .tmp 再 rename:确认落地后目录里不留临时文件。
|
||||
s := newTestStore(t, 0)
|
||||
d, err := s.Put([]byte("some-bytes"), Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Dir(s.blobPath(d))
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".tmp") {
|
||||
t.Fatalf("落地后不该留临时文件: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefCount_AddIsIdempotent(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := s.AddRef(d, "context", "evt-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
it, _ := s.Stat(d)
|
||||
// 重复 AddRef 若都递增,计数会虚高,GC 永远不敢清。
|
||||
if it.RefCount != 1 {
|
||||
t.Fatalf("同一 owner 重复 AddRef 应只计 1,实际 %d", it.RefCount)
|
||||
}
|
||||
|
||||
if err := s.AddRef(d, "document", "doc-9"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
it, _ = s.Stat(d)
|
||||
if it.RefCount != 2 {
|
||||
t.Fatalf("不同 owner 应各计一次,实际 %d", it.RefCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefCount_DropAndNeverNegative(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
|
||||
s.AddRef(d, "context", "e1")
|
||||
|
||||
if err := s.DropRef(d, "context", "e1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
it, _ := s.Stat(d)
|
||||
if it.RefCount != 0 {
|
||||
t.Fatalf("应归零,实际 %d", it.RefCount)
|
||||
}
|
||||
|
||||
// 多余的 DropRef 不该把计数压成负数(负数会让容量 GC 的排序失去意义)
|
||||
for i := 0; i < 3; i++ {
|
||||
s.DropRef(d, "context", "e1")
|
||||
}
|
||||
it, _ = s.Stat(d)
|
||||
if it.RefCount != 0 {
|
||||
t.Fatalf("重复 DropRef 后仍应为 0,实际 %d", it.RefCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropOwner_RemovesAllItsRefs(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
d1, _ := s.Put([]byte("frame1"), Item{MIME: "image/jpeg"})
|
||||
d2, _ := s.Put([]byte("frame2"), Item{MIME: "image/jpeg"})
|
||||
s.AddRef(d1, "context", "evt-x")
|
||||
s.AddRef(d2, "context", "evt-x")
|
||||
s.AddRef(d1, "document", "doc-y") // 别的 owner 也引了 d1
|
||||
|
||||
n, err := s.DropOwner("context", "evt-x")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("应注销 2 条引用,实际 %d", n)
|
||||
}
|
||||
|
||||
it1, _ := s.Stat(d1)
|
||||
it2, _ := s.Stat(d2)
|
||||
if it1.RefCount != 1 {
|
||||
t.Fatalf("d1 仍被 document 引用,应剩 1,实际 %d", it1.RefCount)
|
||||
}
|
||||
if it2.RefCount != 0 {
|
||||
t.Fatalf("d2 应归零,实际 %d", it2.RefCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefs_ListsOwnerDigests(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
d1, _ := s.Put([]byte("a"), Item{MIME: "image/png"})
|
||||
d2, _ := s.Put([]byte("b"), Item{MIME: "image/png"})
|
||||
s.AddRef(d1, "context", "e1")
|
||||
s.AddRef(d2, "context", "e1")
|
||||
|
||||
got, err := s.Refs("context", "e1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("应返回 2 个 digest,实际 %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGC_KeepsReferencedContent(t *testing.T) {
|
||||
// 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针,
|
||||
// 正是本包要避免的。
|
||||
s := newTestStore(t, 0)
|
||||
kept, _ := s.Put([]byte("referenced"), Item{MIME: "image/png"})
|
||||
orphan, _ := s.Put([]byte("orphaned"), Item{MIME: "image/png"})
|
||||
s.AddRef(kept, "context", "e1")
|
||||
|
||||
// minAge=0 让刚 Put 的都算超龄
|
||||
removed, _, err := s.GC(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Fatalf("应只清 1 条无引用项,实际 %d", removed)
|
||||
}
|
||||
if _, err := s.Get(kept); err != nil {
|
||||
t.Fatalf("被引用的内容不该被清: %v", err)
|
||||
}
|
||||
if _, err := s.Stat(orphan); err == nil {
|
||||
t.Fatal("无引用项的元数据应已删除")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGC_MinAgeProtectsFreshUnreferenced(t *testing.T) {
|
||||
// 刚 Put 还没来得及 AddRef 的项 refcount 也是 0;
|
||||
// minAge 必须保护它们,否则「Put 完还没挂上就被 GC 清掉」。
|
||||
s := newTestStore(t, 0)
|
||||
d, _ := s.Put([]byte("just-arrived"), Item{MIME: "image/png"})
|
||||
|
||||
removed, _, err := s.GC(time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 0 {
|
||||
t.Fatalf("新入库项应被 minAge 保护,却清掉了 %d 条", removed)
|
||||
}
|
||||
if _, err := s.Get(d); err != nil {
|
||||
t.Fatalf("内容应还在: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGC_EnforcesCapacity(t *testing.T) {
|
||||
// 容量上限:清完超龄项后仍超限,继续按 last_seen 从旧到新淘汰无引用项。
|
||||
blob := make([]byte, 1024)
|
||||
s := newTestStore(t, 2048) // 只容 2KB
|
||||
|
||||
var digests []string
|
||||
for i := 0; i < 4; i++ {
|
||||
b := append([]byte{byte(i)}, blob...) // 内容各异,避免去重
|
||||
d, err := s.Put(b, Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digests = append(digests, d)
|
||||
time.Sleep(2 * time.Millisecond) // 拉开 last_seen
|
||||
}
|
||||
|
||||
// 保护最后一个,确认容量 GC 也不碰有引用的
|
||||
s.AddRef(digests[3], "context", "e1")
|
||||
|
||||
removed, freed, err := s.GC(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed == 0 {
|
||||
t.Fatal("超限应触发淘汰")
|
||||
}
|
||||
if _, err := s.Get(digests[3]); err != nil {
|
||||
t.Fatalf("有引用项即使超限也不该删: %v", err)
|
||||
}
|
||||
t.Logf("removed=%d freed=%d", removed, freed)
|
||||
|
||||
st := s.Stats()
|
||||
if total := st["total_bytes"].(int64); total > 2048 {
|
||||
// 有引用项可能让总量降不到线下,这是刻意的(宁可超限也不断引用)
|
||||
t.Logf("总量 %d 仍超 2048,因有引用项不可删(预期行为)", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribe_OverwritesExplicitly(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
|
||||
|
||||
if err := s.Describe(d, "一只橘猫", "vis-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
it, _ := s.Stat(d)
|
||||
if it.Description != "一只橘猫" || it.DescribedBy != "vis-a" {
|
||||
t.Fatalf("描述未写入: %+v", it)
|
||||
}
|
||||
|
||||
// Describe 是显式操作,允许覆盖(换更强模型重描述)
|
||||
if err := s.Describe(d, "一只橘色虎斑猫坐在窗台", "vis-b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
it, _ = s.Stat(d)
|
||||
if !strings.Contains(it.Description, "虎斑") || it.DescribedBy != "vis-b" {
|
||||
t.Fatalf("Describe 应覆盖旧描述: %+v", it)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribe_UnknownDigestErrors(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
err := s.Describe("deadbeef", "x", "y")
|
||||
if err == nil {
|
||||
t.Fatal("未知 digest 应报错而非静默成功")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPut_DoesNotClobberExistingDescription(t *testing.T) {
|
||||
// 先到的描述可能来自更强的模型;后到的空值不该把它冲掉。
|
||||
s := newTestStore(t, 0)
|
||||
data := []byte("img")
|
||||
d, _ := s.Put(data, Item{MIME: "image/png", Description: "详细描述", DescribedBy: "strong-model"})
|
||||
|
||||
// 第二次 Put 同内容但不带描述
|
||||
if _, err := s.Put(data, Item{MIME: "image/png"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
it, _ := s.Stat(d)
|
||||
if it.Description != "详细描述" || it.DescribedBy != "strong-model" {
|
||||
t.Fatalf("重复 Put 的空描述不该冲掉已有描述: %+v", it)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPut_BackfillsMissingDimensions(t *testing.T) {
|
||||
// 后来者可能带着前一次没有的信息(尺寸、工具名)
|
||||
s := newTestStore(t, 0)
|
||||
data := []byte("img")
|
||||
d, _ := s.Put(data, Item{MIME: "image/png"})
|
||||
|
||||
if _, err := s.Put(data, Item{MIME: "image/png", Width: 640, Height: 480, Tool: "multimodal_see_picture"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
it, _ := s.Stat(d)
|
||||
if it.Width != 640 || it.Height != 480 {
|
||||
t.Fatalf("尺寸应被补写: %dx%d", it.Width, it.Height)
|
||||
}
|
||||
if it.Tool != "multimodal_see_picture" {
|
||||
t.Fatalf("工具名应被补写: %q", it.Tool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_FiltersByDescriptionAndKind(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
di, _ := s.Put([]byte("chart-img"), Item{MIME: "image/png"})
|
||||
da, _ := s.Put([]byte("speech-aud"), Item{MIME: "audio/wav"})
|
||||
dn, _ := s.Put([]byte("no-desc"), Item{MIME: "image/png"})
|
||||
s.Describe(di, "一张蓝色的柱状图表", "vis")
|
||||
s.Describe(da, "一段关于图表的讲解录音", "aud")
|
||||
|
||||
all, err := s.Search("图表", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("两条描述都含「图表」,应返回 2,实际 %d", len(all))
|
||||
}
|
||||
|
||||
imgs, _ := s.Search("图表", KindImage, 10)
|
||||
if len(imgs) != 1 || imgs[0].Digest != di {
|
||||
t.Fatalf("按 image 过滤应只剩图片,实际 %d 条", len(imgs))
|
||||
}
|
||||
|
||||
// 无描述的项不该出现在语义检索结果里
|
||||
for _, it := range all {
|
||||
if it.Digest == dn {
|
||||
t.Fatal("无描述的项不该被 Search 返回")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPending_ReturnsUndescribed(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
described, _ := s.Put([]byte("has-desc"), Item{MIME: "image/png"})
|
||||
undescribed, _ := s.Put([]byte("needs-desc"), Item{MIME: "image/png"})
|
||||
s.Describe(described, "已有描述", "vis")
|
||||
|
||||
pending, err := s.Pending(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].Digest != undescribed {
|
||||
t.Fatalf("应只返回未描述项,实际 %d 条", len(pending))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDataURL(t *testing.T) {
|
||||
// 与 agent/api 的 parseAudioDataURL 不同:这里要解码后的字节。
|
||||
raw := []byte{0x89, 'P', 'N', 'G'}
|
||||
url := DataURL("image/png", raw)
|
||||
|
||||
mime, data, ok := ParseDataURL(url)
|
||||
if !ok {
|
||||
t.Fatal("应解析成功")
|
||||
}
|
||||
if mime != "image/png" {
|
||||
t.Fatalf("MIME 应为 image/png,得到 %q", mime)
|
||||
}
|
||||
if string(data) != string(raw) {
|
||||
t.Fatalf("字节应还原,得到 %v", data)
|
||||
}
|
||||
|
||||
for _, bad := range []string{
|
||||
"http://example.com/x.png", // 不是 data URL
|
||||
"data:image/png,notbase64", // 缺 ;base64
|
||||
"data:;base64,", // 空 MIME 与空载荷
|
||||
"data:image/png;base64,!!!", // 非法 base64
|
||||
} {
|
||||
if _, _, ok := ParseDataURL(bad); ok {
|
||||
t.Fatalf("%q 应解析失败", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStats_CountsByKindAndDescription(t *testing.T) {
|
||||
s := newTestStore(t, 4096)
|
||||
d1, _ := s.Put([]byte("i1"), Item{MIME: "image/png"})
|
||||
s.Put([]byte("i2"), Item{MIME: "image/jpeg"})
|
||||
s.Put([]byte("a1"), Item{MIME: "audio/wav"})
|
||||
s.Describe(d1, "描述", "vis")
|
||||
s.AddRef(d1, "context", "e1")
|
||||
|
||||
st := s.Stats()
|
||||
if st["count"].(int) != 3 {
|
||||
t.Fatalf("count 应为 3,实际 %v", st["count"])
|
||||
}
|
||||
if st["described"].(int) != 1 {
|
||||
t.Fatalf("described 应为 1,实际 %v", st["described"])
|
||||
}
|
||||
if st["unreferenced"].(int) != 2 {
|
||||
t.Fatalf("unreferenced 应为 2,实际 %v", st["unreferenced"])
|
||||
}
|
||||
byKind := st["by_kind"].(map[string]int)
|
||||
if byKind["image"] != 2 || byKind["audio"] != 1 {
|
||||
t.Fatalf("by_kind 不对: %v", byKind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPut_RejectsEmpty(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
if _, err := s.Put(nil, Item{MIME: "image/png"}); err == nil {
|
||||
t.Fatal("空内容应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReopen_PersistsAcrossRestart(t *testing.T) {
|
||||
// 记忆的意义就在于跨重启还在。
|
||||
dir := t.TempDir()
|
||||
s1, err := New(dir, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, _ := s1.Put([]byte("persistent-img"), Item{MIME: "image/png", OriginPath: "/tmp/x.png"})
|
||||
s1.Describe(d, "跨重启的描述", "vis")
|
||||
s1.AddRef(d, "context", "e1")
|
||||
s1.Close()
|
||||
|
||||
s2, err := New(dir, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Close()
|
||||
|
||||
it, err := s2.Stat(d)
|
||||
if err != nil {
|
||||
t.Fatalf("重开后应能查到: %v", err)
|
||||
}
|
||||
if it.Description != "跨重启的描述" || it.RefCount != 1 {
|
||||
t.Fatalf("元数据应持久化: %+v", it)
|
||||
}
|
||||
data, err := s2.Get(d)
|
||||
if err != nil || string(data) != "persistent-img" {
|
||||
t.Fatalf("内容应持久化: %v / %q", err, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPending_ExcludesAttemptedButUndescribable(t *testing.T) {
|
||||
// 「已尝试但无法描述」的项必须退出待描述队列。
|
||||
//
|
||||
// 这些项被标记为 described_by=unsupported/content-missing 而 description
|
||||
// 仍为空。若 Pending 只看 description,它们每轮都会被取出来重试、
|
||||
// 永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。
|
||||
s := newTestStore(t, 0)
|
||||
|
||||
fresh, _ := s.Put([]byte("needs-describe"), Item{MIME: "image/png"})
|
||||
unsupported, _ := s.Put([]byte("cannot-describe"), Item{MIME: "application/octet-stream"})
|
||||
described, _ := s.Put([]byte("已描述"), Item{MIME: "image/png"})
|
||||
|
||||
// 标记「尝试过但不支持」:description 空,described_by 非空
|
||||
if err := s.Describe(unsupported, "", "unsupported"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Describe(described, "一张图", "visionllm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pending, err := s.Pending(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 {
|
||||
var names []string
|
||||
for _, p := range pending {
|
||||
names = append(names, shortDigest(p.Digest))
|
||||
}
|
||||
t.Fatalf("应只剩 1 条待描述,实际 %d 条: %v", len(pending), names)
|
||||
}
|
||||
if pending[0].Digest != fresh {
|
||||
t.Fatalf("待描述的应是未处理项,实际 %s", shortDigest(pending[0].Digest))
|
||||
}
|
||||
}
|
||||
@ -1,316 +0,0 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 冒烟测试:走真实数据路径的端到端场景,而非孤立的 API 单测。
|
||||
//
|
||||
// 之前这套场景是 internal/memory/media/smoke/ 下一个带 //go:build smoke 的
|
||||
// 独立 main,得记着加 -tags smoke 才跑得到——那种早晚会被忘掉。搬成普通
|
||||
// 测试后它随 go test ./... 一起跑,冒烟的意义(每次改动都过一遍真实链路)
|
||||
// 才真正成立。
|
||||
|
||||
// makePNG 生成一张 w×h 的条带 PNG,用真 PNG 而不是随机字节,
|
||||
// 让入库/回读/digest 走的是与生产一致的数据形态。
|
||||
//
|
||||
// variant 注入到像素而不只用于选色:最初写的是
|
||||
// palette[(variant+y*3/h)%5],调色盘只 5 色,于是 variant=0 与 5 产出
|
||||
// 逐字节相同的 PNG——冒烟跑出「6 帧只得 5 条」,看着像存储丢了一帧,
|
||||
// 实际是 CAS 正确去重了两张真同图。冒烟要验的是「不同帧各存一份」,
|
||||
// 夹具就必须保证帧间真的不同。
|
||||
func makePNG(w, h, variant int) []byte {
|
||||
palette := [][3]byte{
|
||||
{255, 0, 0}, {0, 192, 0}, {0, 0, 255}, {255, 220, 0}, {160, 0, 200},
|
||||
}
|
||||
var raw bytes.Buffer
|
||||
for y := 0; y < h; y++ {
|
||||
raw.WriteByte(0) // 每行的滤波器字节
|
||||
c := palette[(variant+y*3/h)%len(palette)]
|
||||
for x := 0; x < w; x++ {
|
||||
raw.Write(c[:])
|
||||
}
|
||||
}
|
||||
// 把 variant 写进首行头几个像素,确保不同 variant 字节必然不同。
|
||||
b := raw.Bytes()
|
||||
if len(b) > 8 {
|
||||
b[1] = byte(variant)
|
||||
b[2] = byte(variant >> 8)
|
||||
}
|
||||
|
||||
var z bytes.Buffer
|
||||
zw := zlib.NewWriter(&z)
|
||||
zw.Write(b)
|
||||
zw.Close()
|
||||
|
||||
chunk := func(typ string, data []byte) []byte {
|
||||
var out bytes.Buffer
|
||||
binary.Write(&out, binary.BigEndian, uint32(len(data)))
|
||||
out.WriteString(typ)
|
||||
out.Write(data)
|
||||
binary.Write(&out, binary.BigEndian, crc32.ChecksumIEEE(append([]byte(typ), data...)))
|
||||
return out.Bytes()
|
||||
}
|
||||
var ihdr bytes.Buffer
|
||||
binary.Write(&ihdr, binary.BigEndian, uint32(w))
|
||||
binary.Write(&ihdr, binary.BigEndian, uint32(h))
|
||||
ihdr.Write([]byte{8, 2, 0, 0, 0}) // 8bit 深度、truecolor
|
||||
|
||||
var out bytes.Buffer
|
||||
out.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'})
|
||||
out.Write(chunk("IHDR", ihdr.Bytes()))
|
||||
out.Write(chunk("IDAT", z.Bytes()))
|
||||
out.Write(chunk("IEND", nil))
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func TestSmoke_SamePictureAcrossTurns(t *testing.T) {
|
||||
// 场景:用户连问几轮同一张截图。multimodal 每轮都会重新注入,
|
||||
// 磁盘上应该只有一份,但每轮的 context 事件各持一个引用。
|
||||
s := newTestStore(t, 50*1024*1024)
|
||||
png := makePNG(400, 400, 0)
|
||||
|
||||
var d0 string
|
||||
for turn := 1; turn <= 5; turn++ {
|
||||
// 走 data URL:这是 SetToolBlocks 实际给出的形态
|
||||
url := DataURL("image/png", png)
|
||||
mime, data, ok := ParseDataURL(url)
|
||||
if !ok {
|
||||
t.Fatalf("第 %d 轮 data URL 解析失败", turn)
|
||||
}
|
||||
d, err := s.Put(data, Item{
|
||||
MIME: mime, Width: 400, Height: 400,
|
||||
OriginPath: fmt.Sprintf("/tmp/probe_%d.png", turn),
|
||||
Tool: "multimodal_see_picture",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 轮 Put: %v", turn, err)
|
||||
}
|
||||
if d0 == "" {
|
||||
d0 = d
|
||||
} else if d != d0 {
|
||||
t.Fatalf("同一张图第 %d 轮 digest 变了", turn)
|
||||
}
|
||||
if err := s.AddRef(d, "context", fmt.Sprintf("evt-%d", turn)); err != nil {
|
||||
t.Fatalf("第 %d 轮 AddRef: %v", turn, err)
|
||||
}
|
||||
}
|
||||
|
||||
st := s.Stats()
|
||||
if st["count"].(int) != 1 {
|
||||
t.Fatalf("5 轮同图应只存 1 份,实际 %v 条", st["count"])
|
||||
}
|
||||
if total := st["total_bytes"].(int64); total != int64(len(png)) {
|
||||
t.Fatalf("字节数应等于单张原图 %d,实际 %d", len(png), total)
|
||||
}
|
||||
it, _ := s.Stat(d0)
|
||||
if it.RefCount != 5 {
|
||||
t.Fatalf("应有 5 个引用,实际 %d", it.RefCount)
|
||||
}
|
||||
t.Logf("同图 5 轮:条目=1 字节=%d refcount=%d", len(png), it.RefCount)
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestSmoke_VideoFramesDistinct(t *testing.T) {
|
||||
// 场景:see_video 抽 6 帧,帧间内容不同,应各存一份并共享一个 owner。
|
||||
s := newTestStore(t, 50*1024*1024)
|
||||
var frames []string
|
||||
for i := 0; i < 6; i++ {
|
||||
d, err := s.Put(makePNG(320, 240, i), Item{
|
||||
MIME: "image/jpeg", Width: 320, Height: 240, Tool: "multimodal_see_video",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 帧: %v", i, err)
|
||||
}
|
||||
frames = append(frames, d)
|
||||
if err := s.AddRef(d, "context", "evt-video"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
st := s.Stats()
|
||||
if st["count"].(int) != 6 {
|
||||
t.Fatalf("6 帧应各存一份,实际 %v 条", st["count"])
|
||||
}
|
||||
refs, err := s.Refs("context", "evt-video")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(refs) != 6 {
|
||||
t.Fatalf("evt-video 应引用 6 帧,实际 %d", len(refs))
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestSmoke_DescribeThenRetrieve(t *testing.T) {
|
||||
// 场景 C:视觉模型描述落库后,描述文字成为可检索的语义入口。
|
||||
// 这是本方案最关键的一环——blob 可能被淘汰,描述会长期留在记忆里。
|
||||
s := newTestStore(t, 50*1024*1024)
|
||||
|
||||
pic, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png", Tool: "multimodal_see_picture"})
|
||||
if err := s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var frames []string
|
||||
for i := 0; i < 6; i++ {
|
||||
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
|
||||
frames = append(frames, d)
|
||||
if err := s.Describe(d, fmt.Sprintf("视频第 %d 帧:测试图卡,含彩条与计数器", i+1), "visionllm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if hits, _ := s.Search("三色带", KindImage, 10); len(hits) != 1 {
|
||||
t.Fatalf("搜「三色带」应命中 1 条,实际 %d", len(hits))
|
||||
}
|
||||
if hits, _ := s.Search("计数器", KindImage, 10); len(hits) != 6 {
|
||||
t.Fatalf("搜「计数器」应命中 6 帧,实际 %d", len(hits))
|
||||
}
|
||||
pend, _ := s.Pending(100)
|
||||
if len(pend) != 0 {
|
||||
t.Fatalf("应全部已描述,仍有 %d 条待描述", len(pend))
|
||||
}
|
||||
_ = frames
|
||||
}
|
||||
|
||||
func TestSmoke_ArchiveTransfersOwnership(t *testing.T) {
|
||||
// 场景:L0 的 context 事件被 Prune 归档进 L2 文档,
|
||||
// 媒体引用需从 context owner 转到 document owner,期间内容不能被 GC 掉。
|
||||
s := newTestStore(t, 50*1024*1024)
|
||||
png := makePNG(400, 400, 0)
|
||||
d, _ := s.Put(png, Item{MIME: "image/png", Tool: "multimodal_see_picture"})
|
||||
for turn := 1; turn <= 5; turn++ {
|
||||
s.AddRef(d, "context", fmt.Sprintf("evt-%d", turn))
|
||||
}
|
||||
|
||||
// evt-1 被淘汰,其内容归档为一篇文档
|
||||
n, err := s.DropOwner("context", "evt-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("应注销 1 条引用,实际 %d", n)
|
||||
}
|
||||
if err := s.AddRef(d, "document", "doc_archived_001"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
it, _ := s.Stat(d)
|
||||
if it.RefCount != 5 {
|
||||
t.Fatalf("引用转移后总数应仍为 5(4 context + 1 document),实际 %d", it.RefCount)
|
||||
}
|
||||
// 归档过程中内容必须始终可读
|
||||
if got, err := s.Get(d); err != nil || !bytes.Equal(got, png) {
|
||||
t.Fatalf("归档后内容应完好: %v", err)
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestSmoke_GCSweepsToolLeftovers(t *testing.T) {
|
||||
// 场景:别的工具(cmd_run 之类)产出的一次性图片没人引用,
|
||||
// 应被 GC 清掉;而被记忆引用的媒体一个都不能少。
|
||||
s := newTestStore(t, 50*1024*1024)
|
||||
|
||||
keep, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png"})
|
||||
s.AddRef(keep, "document", "doc-1")
|
||||
var frames []string
|
||||
for i := 0; i < 6; i++ {
|
||||
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg"})
|
||||
s.AddRef(d, "context", "evt-video")
|
||||
frames = append(frames, d)
|
||||
}
|
||||
// 1000+i 保证与上面的帧、以及彼此都不重复
|
||||
for i := 0; i < 20; i++ {
|
||||
s.Put(makePNG(100, 100, 1000+i), Item{MIME: "image/png", Tool: "cmd_run"})
|
||||
}
|
||||
|
||||
before := s.Stats()["count"].(int)
|
||||
removed, freed, err := s.GC(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := s.Stats()["count"].(int)
|
||||
if removed != 20 {
|
||||
t.Fatalf("应清 20 条孤儿,实际 %d", removed)
|
||||
}
|
||||
if after != before-20 {
|
||||
t.Fatalf("条目数应从 %d 降到 %d,实际 %d", before, before-20, after)
|
||||
}
|
||||
if _, err := s.Get(keep); err != nil {
|
||||
t.Fatalf("被文档引用的图被误删: %v", err)
|
||||
}
|
||||
for i, f := range frames {
|
||||
if _, err := s.Get(f); err != nil {
|
||||
t.Fatalf("第 %d 帧被误删: %v", i, err)
|
||||
}
|
||||
}
|
||||
t.Logf("GC: %d 条 → 清 %d 条(%d 字节)→ %d 条", before, removed, freed, after)
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
|
||||
// 端到端:入库 → 描述 → 引用 → GC → 重启 → 检索,
|
||||
// 并确认磁盘与元数据不出现双向孤儿。记忆的意义就在于跨重启还在。
|
||||
dir := t.TempDir()
|
||||
s, err := New(dir, 50*1024*1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
png := makePNG(400, 400, 0)
|
||||
pic, _ := s.Put(png, Item{MIME: "image/png", Width: 400, Height: 400, Tool: "multimodal_see_picture"})
|
||||
s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm")
|
||||
s.AddRef(pic, "graph_sentence", "sent-42")
|
||||
for i := 0; i < 6; i++ {
|
||||
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
|
||||
s.Describe(d, fmt.Sprintf("视频第 %d 帧", i+1), "visionllm")
|
||||
s.AddRef(d, "context", "evt-video")
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"})
|
||||
}
|
||||
if _, _, err := s.GC(0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
beforeCount := s.Stats()["count"].(int)
|
||||
s.Close()
|
||||
|
||||
s2, err := New(dir, 50*1024*1024)
|
||||
if err != nil {
|
||||
t.Fatalf("重开失败: %v", err)
|
||||
}
|
||||
defer s2.Close()
|
||||
|
||||
if got := s2.Stats()["count"].(int); got != beforeCount {
|
||||
t.Fatalf("重开后条目数变了: %d → %d", beforeCount, got)
|
||||
}
|
||||
it, err := s2.Stat(pic)
|
||||
if err != nil {
|
||||
t.Fatalf("重开后查不到: %v", err)
|
||||
}
|
||||
if it.Description == "" || it.RefCount != 1 {
|
||||
t.Fatalf("元数据未持久化: %+v", it)
|
||||
}
|
||||
data, err := s2.Get(pic)
|
||||
if err != nil || !bytes.Equal(data, png) {
|
||||
t.Fatalf("重开后内容不一致: %v", err)
|
||||
}
|
||||
if refs, _ := s2.Refs("context", "evt-video"); len(refs) != 6 {
|
||||
t.Fatalf("重开后视频帧引用应为 6,实际 %d", len(refs))
|
||||
}
|
||||
if hits, _ := s2.Search("三色带", KindImage, 10); len(hits) != 1 {
|
||||
t.Fatal("重开后描述应仍可检索")
|
||||
}
|
||||
|
||||
// 磁盘文件数 == 元数据条数:无「元数据在文件没了」也无「文件在元数据没了」
|
||||
if n := blobFileCount(t, s2); n != beforeCount {
|
||||
t.Fatalf("磁盘 blob=%d 与元数据=%d 不一致", n, beforeCount)
|
||||
}
|
||||
checkRefIntegrity(t, s2)
|
||||
t.Logf("跨重启:%d 条目、描述与引用全部完好", beforeCount)
|
||||
}
|
||||
@ -1,180 +0,0 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestSoak_SustainedMixedLoad 长稳测试:持续混合负载下不变量不破。
|
||||
// 用 -run TestSoak -timeout 300s 单独跑,默认 short 模式跳过。
|
||||
func TestSoak_SustainedMixedLoad(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("long soak test; run with -run TestSoak")
|
||||
}
|
||||
dur := 60 * time.Second
|
||||
s := newTestStore(t, 8*1024*1024) // 8MB 上限,逼 GC 频繁工作
|
||||
|
||||
// 常驻受保护集
|
||||
const keepN = 20
|
||||
keep := make([]string, keepN)
|
||||
keepData := make([][]byte, keepN)
|
||||
for i := range keep {
|
||||
d := make([]byte, 4096)
|
||||
rand.Read(d)
|
||||
d = append([]byte(fmt.Sprintf("keep-%d-", i)), d...)
|
||||
dg, err := s.Put(d, Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.AddRef(dg, "graph_sentence", fmt.Sprintf("s-%d", i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keep[i] = dg
|
||||
keepData[i] = d
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
var puts, gets, gcs, describes, searches, refOps atomic.Int64
|
||||
var fatal atomic.Int64
|
||||
|
||||
worker := func(name string, fn func(iter int) error) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if err := fn(i); err != nil {
|
||||
fatal.Add(1)
|
||||
t.Errorf("%s 第 %d 次失败: %v", name, i, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 写入者 ×3
|
||||
for w := 0; w < 3; w++ {
|
||||
wid := w
|
||||
worker(fmt.Sprintf("put-%d", wid), func(i int) error {
|
||||
b := make([]byte, 2048)
|
||||
rand.Read(b)
|
||||
b = append([]byte(fmt.Sprintf("eph-%d-%d-", wid, i)), b...)
|
||||
d, err := s.Put(b, Item{MIME: "image/png", Tool: "cmd_run"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
puts.Add(1)
|
||||
// 三分之一挂上引用再立刻注销,模拟短命引用
|
||||
if i%3 == 0 {
|
||||
own := fmt.Sprintf("tmp-%d-%d", wid, i)
|
||||
if err := s.AddRef(d, "context", own); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.DropRef(d, "context", own); err != nil {
|
||||
return err
|
||||
}
|
||||
refOps.Add(2)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// 读取者 ×3:受保护集必须始终完好
|
||||
for r := 0; r < 3; r++ {
|
||||
worker("get", func(i int) error {
|
||||
idx := i % keepN
|
||||
got, err := s.Get(keep[idx])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !bytes.Equal(got, keepData[idx]) {
|
||||
return fmt.Errorf("内容被改 %s", shortDigest(keep[idx]))
|
||||
}
|
||||
gets.Add(1)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GC 者
|
||||
worker("gc", func(i int) error {
|
||||
if _, _, err := s.GC(0); err != nil {
|
||||
return err
|
||||
}
|
||||
gcs.Add(1)
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
return nil
|
||||
})
|
||||
|
||||
// 描述者
|
||||
worker("describe", func(i int) error {
|
||||
pend, err := s.Pending(5)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, it := range pend {
|
||||
// 忽略 unknown digest:GC 可能在 Pending 与 Describe 之间清掉它,
|
||||
// 这是正常竞态而非缺陷。
|
||||
_ = s.Describe(it.Digest, fmt.Sprintf("描述 %d 含图表与文字", i), "vis")
|
||||
describes.Add(1)
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
return nil
|
||||
})
|
||||
|
||||
// 检索者
|
||||
worker("search", func(i int) error {
|
||||
if _, err := s.Search("图表", KindImage, 20); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.Stat(keep[i%keepN]); err != nil {
|
||||
return err
|
||||
}
|
||||
searches.Add(1)
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
return nil
|
||||
})
|
||||
|
||||
time.Sleep(dur)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
if n := fatal.Load(); n > 0 {
|
||||
t.Fatalf("%d 个 worker 报致命错误", n)
|
||||
}
|
||||
|
||||
t.Logf("%v 内: put=%d get=%d gc=%d describe=%d search=%d refOps=%d",
|
||||
dur, puts.Load(), gets.Load(), gcs.Load(), describes.Load(), searches.Load(), refOps.Load())
|
||||
|
||||
// 收尾断言
|
||||
for i, d := range keep {
|
||||
got, err := s.Get(d)
|
||||
if err != nil {
|
||||
t.Fatalf("受保护项丢失 %s: %v", shortDigest(d), err)
|
||||
}
|
||||
if !bytes.Equal(got, keepData[i]) {
|
||||
t.Fatalf("受保护项内容变了 %s", shortDigest(d))
|
||||
}
|
||||
it, err := s.Stat(d)
|
||||
if err != nil || it.RefCount != 1 {
|
||||
t.Fatalf("受保护项引用计数应为 1: %+v", it)
|
||||
}
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
|
||||
st := s.Stats()
|
||||
t.Logf("收尾: 条目=%v 字节=%v 未引用=%v 已描述=%v",
|
||||
st["count"], st["total_bytes"], st["unreferenced"], st["described"])
|
||||
if total := st["total_bytes"].(int64); total > 8*1024*1024*3 {
|
||||
t.Fatalf("容量失控: %d 远超上限", total)
|
||||
}
|
||||
}
|
||||
@ -1,601 +0,0 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 压力测试与冒烟测试。
|
||||
//
|
||||
// 关注点不是吞吐数字,而是并发下的不变量是否被破坏:
|
||||
// 1. ref_count 与 media_refs 表的行数必须始终一致(错位会让 GC 误删或永不清)
|
||||
// 2. GC 与读写并发时,有引用的内容绝不能被删
|
||||
// 3. 同内容并发 Put 只落一份磁盘、digest 一致
|
||||
// 4. SQLite 在多 goroutine 下不出现 "database is locked"
|
||||
|
||||
func randBytes(t *testing.T, n int) []byte {
|
||||
t.Helper()
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// checkRefIntegrity 校验核心不变量:每个 digest 的 ref_count 等于
|
||||
// media_refs 里指向它的行数。这条对不上就意味着 GC 的判断依据是错的。
|
||||
func checkRefIntegrity(t *testing.T, s *Store) {
|
||||
t.Helper()
|
||||
rows, err := s.db.Query(`
|
||||
SELECT m.digest, m.ref_count, COUNT(r.digest)
|
||||
FROM media m LEFT JOIN media_refs r ON m.digest = r.digest
|
||||
GROUP BY m.digest, m.ref_count`)
|
||||
if err != nil {
|
||||
t.Fatalf("integrity query: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var bad int
|
||||
for rows.Next() {
|
||||
var d string
|
||||
var stored, actual int
|
||||
if err := rows.Scan(&d, &stored, &actual); err != nil {
|
||||
continue
|
||||
}
|
||||
if stored != actual {
|
||||
bad++
|
||||
if bad <= 5 {
|
||||
t.Errorf("ref 计数错位 %s: ref_count=%d 实际引用行=%d", shortDigest(d), stored, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
if bad > 0 {
|
||||
t.Fatalf("共 %d 条 digest 的 ref_count 与 media_refs 不一致", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// blobFileCount 统计 CAS 目录下的实际文件数(不含 .tmp)。
|
||||
func blobFileCount(t *testing.T, s *Store) int {
|
||||
t.Helper()
|
||||
n := 0
|
||||
filepath.Walk(s.blobDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info == nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(path, ".tmp") {
|
||||
t.Errorf("残留临时文件: %s", path)
|
||||
return nil
|
||||
}
|
||||
n++
|
||||
return nil
|
||||
})
|
||||
return n
|
||||
}
|
||||
|
||||
func TestStress_ConcurrentPutSameContent(t *testing.T) {
|
||||
// 同一内容被 N 个 goroutine 同时 Put:digest 必须一致,磁盘只一份。
|
||||
// 现实对应:see_video 抽出的相邻帧、用户连发同一张图。
|
||||
s := newTestStore(t, 0)
|
||||
data := randBytes(t, 64*1024)
|
||||
|
||||
const workers = 32
|
||||
var wg sync.WaitGroup
|
||||
digests := make([]string, workers)
|
||||
errs := make([]error, workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
d, err := s.Put(data, Item{MIME: "image/png", OriginPath: fmt.Sprintf("/tmp/%d.png", idx)})
|
||||
digests[idx] = d
|
||||
errs[idx] = err
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("worker %d Put 失败: %v", i, err)
|
||||
}
|
||||
}
|
||||
first := digests[0]
|
||||
for i, d := range digests {
|
||||
if d != first {
|
||||
t.Fatalf("worker %d digest 不一致: %s vs %s", i, d, first)
|
||||
}
|
||||
}
|
||||
if n := blobFileCount(t, s); n != 1 {
|
||||
t.Fatalf("同一内容应只落一份 blob,实际 %d 个文件", n)
|
||||
}
|
||||
if got, err := s.Get(first); err != nil || !bytes.Equal(got, data) {
|
||||
t.Fatalf("内容应可完整读回: err=%v len=%d", err, len(got))
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestStress_ConcurrentPutDistinctContent(t *testing.T) {
|
||||
// 大量不同内容并发入库:不丢条目、不串内容。
|
||||
s := newTestStore(t, 0)
|
||||
const workers = 16
|
||||
const perWorker = 25
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var failed atomic.Int64
|
||||
type rec struct {
|
||||
digest string
|
||||
data []byte
|
||||
}
|
||||
recCh := make(chan rec, workers*perWorker)
|
||||
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(wid int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < perWorker; i++ {
|
||||
data := []byte(fmt.Sprintf("w%d-i%d-", wid, i))
|
||||
data = append(data, randBytes(t, 512)...)
|
||||
d, err := s.Put(data, Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
failed.Add(1)
|
||||
continue
|
||||
}
|
||||
recCh <- rec{digest: d, data: data}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
close(recCh)
|
||||
|
||||
if n := failed.Load(); n > 0 {
|
||||
t.Fatalf("%d 次 Put 失败", n)
|
||||
}
|
||||
|
||||
var records []rec
|
||||
for r := range recCh {
|
||||
records = append(records, r)
|
||||
}
|
||||
if len(records) != workers*perWorker {
|
||||
t.Fatalf("应有 %d 条记录,实际 %d", workers*perWorker, len(records))
|
||||
}
|
||||
|
||||
// 逐条回读校验内容没串
|
||||
for _, r := range records {
|
||||
got, err := s.Get(r.digest)
|
||||
if err != nil {
|
||||
t.Fatalf("读 %s 失败: %v", shortDigest(r.digest), err)
|
||||
}
|
||||
if !bytes.Equal(got, r.data) {
|
||||
t.Fatalf("内容串了: %s", shortDigest(r.digest))
|
||||
}
|
||||
}
|
||||
|
||||
st := s.Stats()
|
||||
if st["count"].(int) != len(records) {
|
||||
t.Fatalf("库内条目应为 %d,实际 %v", len(records), st["count"])
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestStress_ConcurrentRefChurn(t *testing.T) {
|
||||
// 引用增删风暴:多 owner 对少量 digest 反复 AddRef/DropRef。
|
||||
// 核心断言是最终 ref_count 与 media_refs 行数一致——错位就意味着
|
||||
// GC 会误删(计数偏低)或永不清(计数虚高)。
|
||||
s := newTestStore(t, 0)
|
||||
|
||||
const digestCount = 8
|
||||
digests := make([]string, digestCount)
|
||||
for i := range digests {
|
||||
d, err := s.Put([]byte(fmt.Sprintf("payload-%d", i)), Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digests[i] = d
|
||||
}
|
||||
|
||||
const workers = 24
|
||||
const rounds = 40
|
||||
var wg sync.WaitGroup
|
||||
var addErr, dropErr atomic.Int64
|
||||
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(wid int) {
|
||||
defer wg.Done()
|
||||
owner := fmt.Sprintf("evt-%d", wid)
|
||||
for r := 0; r < rounds; r++ {
|
||||
d := digests[(wid+r)%digestCount]
|
||||
if err := s.AddRef(d, "context", owner); err != nil {
|
||||
addErr.Add(1)
|
||||
}
|
||||
// 故意重复 AddRef:幂等性在并发下也必须成立
|
||||
if err := s.AddRef(d, "context", owner); err != nil {
|
||||
addErr.Add(1)
|
||||
}
|
||||
if r%2 == 0 {
|
||||
if err := s.DropRef(d, "context", owner); err != nil {
|
||||
dropErr.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if n := addErr.Load(); n > 0 {
|
||||
t.Fatalf("AddRef 失败 %d 次", n)
|
||||
}
|
||||
if n := dropErr.Load(); n > 0 {
|
||||
t.Fatalf("DropRef 失败 %d 次", n)
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestStress_GCConcurrentWithWrites(t *testing.T) {
|
||||
// GC 与读写并发。最重要的断言:有引用的内容在整个过程中始终可读。
|
||||
// 这条一旦破,记忆里的 digest 就成了悬空指针。
|
||||
s := newTestStore(t, 0)
|
||||
|
||||
// 一批"受保护"的内容,全程持有引用
|
||||
const protectedCount = 10
|
||||
protected := make([]string, protectedCount)
|
||||
protectedData := make([][]byte, protectedCount)
|
||||
for i := range protected {
|
||||
data := append([]byte(fmt.Sprintf("protected-%d-", i)), randBytes(t, 256)...)
|
||||
d, err := s.Put(data, Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.AddRef(d, "document", fmt.Sprintf("doc-%d", i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
protected[i] = d
|
||||
protectedData[i] = data
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
var readErr atomic.Int64
|
||||
var gcRuns atomic.Int64
|
||||
var putCount atomic.Int64
|
||||
|
||||
// 写入者:持续 Put 一次性内容(不加引用,是 GC 的正常目标)
|
||||
for w := 0; w < 4; w++ {
|
||||
wg.Add(1)
|
||||
go func(wid int) {
|
||||
defer wg.Done()
|
||||
i := 0
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
data := append([]byte(fmt.Sprintf("ephemeral-%d-%d-", wid, i)), randBytes(t, 128)...)
|
||||
if _, err := s.Put(data, Item{MIME: "image/png"}); err == nil {
|
||||
putCount.Add(1)
|
||||
}
|
||||
i++
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
// 读取者:反复读受保护内容,任何一次失败都是致命的
|
||||
for r := 0; r < 4; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
for i, d := range protected {
|
||||
got, err := s.Get(d)
|
||||
if err != nil {
|
||||
readErr.Add(1)
|
||||
t.Errorf("受保护内容读失败 %s: %v", shortDigest(d), err)
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(got, protectedData[i]) {
|
||||
readErr.Add(1)
|
||||
t.Errorf("受保护内容被改 %s", shortDigest(d))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// GC 者:minAge=0 让所有无引用项立刻可清,最大化与写入的冲突
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if _, _, err := s.GC(0); err != nil {
|
||||
t.Errorf("GC 报错: %v", err)
|
||||
return
|
||||
}
|
||||
gcRuns.Add(1)
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
if n := readErr.Load(); n > 0 {
|
||||
t.Fatalf("受保护内容读取失败 %d 次——GC 误删了有引用的项", n)
|
||||
}
|
||||
t.Logf("并发窗口内: Put=%d GC=%d 轮", putCount.Load(), gcRuns.Load())
|
||||
|
||||
// 收尾确认:受保护的一个都没少
|
||||
for i, d := range protected {
|
||||
got, err := s.Get(d)
|
||||
if err != nil || !bytes.Equal(got, protectedData[i]) {
|
||||
t.Fatalf("收尾检查失败 %s: %v", shortDigest(d), err)
|
||||
}
|
||||
it, err := s.Stat(d)
|
||||
if err != nil || it.RefCount != 1 {
|
||||
t.Fatalf("受保护项引用计数应为 1: %+v err=%v", it, err)
|
||||
}
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestStress_DescribeConcurrentWithSearch(t *testing.T) {
|
||||
// 描述写入与检索并发。C 部分的后台描述任务会长期这样跑。
|
||||
s := newTestStore(t, 0)
|
||||
const n = 60
|
||||
digests := make([]string, n)
|
||||
for i := range digests {
|
||||
d, err := s.Put([]byte(fmt.Sprintf("img-%d", i)), Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digests[i] = d
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var descErr, searchErr atomic.Int64
|
||||
|
||||
// 描述写入者
|
||||
for w := 0; w < 4; w++ {
|
||||
wg.Add(1)
|
||||
go func(wid int) {
|
||||
defer wg.Done()
|
||||
for i := wid; i < n; i += 4 {
|
||||
desc := fmt.Sprintf("第 %d 张图,含蓝色图表与文字", i)
|
||||
if err := s.Describe(digests[i], desc, "vis-src"); err != nil {
|
||||
descErr.Add(1)
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
// 检索者 + Pending 消费者
|
||||
for r := 0; r < 3; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 50; i++ {
|
||||
if _, err := s.Search("图表", KindImage, 20); err != nil {
|
||||
searchErr.Add(1)
|
||||
}
|
||||
if _, err := s.Pending(10); err != nil {
|
||||
searchErr.Add(1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if v := descErr.Load(); v > 0 {
|
||||
t.Fatalf("Describe 失败 %d 次", v)
|
||||
}
|
||||
if v := searchErr.Load(); v > 0 {
|
||||
t.Fatalf("Search/Pending 失败 %d 次", v)
|
||||
}
|
||||
|
||||
// 全部应已描述完
|
||||
pending, err := s.Pending(1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("应全部描述完,仍有 %d 条未描述", len(pending))
|
||||
}
|
||||
got, err := s.Search("图表", KindImage, 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != n {
|
||||
t.Fatalf("应检索到 %d 条,实际 %d", n, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStress_CapacityGCUnderLoad(t *testing.T) {
|
||||
// 容量上限在持续写入下必须真正生效,且不碰有引用的项。
|
||||
const cap = 256 * 1024 // 256KB
|
||||
s := newTestStore(t, cap)
|
||||
|
||||
// 先放 3 个有引用的大项(合计约 96KB),它们永不可删
|
||||
const keepN = 3
|
||||
keep := make([]string, keepN)
|
||||
for i := range keep {
|
||||
data := append([]byte(fmt.Sprintf("keep-%d-", i)), randBytes(t, 32*1024)...)
|
||||
d, err := s.Put(data, Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.AddRef(d, "graph_sentence", fmt.Sprintf("sent-%d", i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keep[i] = d
|
||||
}
|
||||
|
||||
// 持续写入无引用内容,交替 GC
|
||||
for round := 0; round < 30; round++ {
|
||||
for i := 0; i < 3; i++ {
|
||||
data := append([]byte(fmt.Sprintf("tmp-%d-%d-", round, i)), randBytes(t, 16*1024)...)
|
||||
if _, err := s.Put(data, Item{MIME: "image/png"}); err != nil {
|
||||
t.Fatalf("round %d Put: %v", round, err)
|
||||
}
|
||||
}
|
||||
if _, _, err := s.GC(0); err != nil {
|
||||
t.Fatalf("round %d GC: %v", round, err)
|
||||
}
|
||||
}
|
||||
|
||||
st := s.Stats()
|
||||
total := st["total_bytes"].(int64)
|
||||
t.Logf("上限 %d,收尾总量 %d,条目 %v", cap, total, st["count"])
|
||||
|
||||
// 有引用的项必须都在
|
||||
for _, d := range keep {
|
||||
if _, err := s.Get(d); err != nil {
|
||||
t.Fatalf("有引用项被容量 GC 删了 %s: %v", shortDigest(d), err)
|
||||
}
|
||||
}
|
||||
// 无引用项应被压到上限附近:允许略超(有引用项本身可能就占了大头),
|
||||
// 但不该无界增长——30 轮 × 3 × 16KB = 1.4MB 若全留下就是失控。
|
||||
if total > cap*2 {
|
||||
t.Fatalf("容量 GC 未生效:总量 %d 远超上限 %d", total, cap)
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestStress_ReopenAfterHeavyChurn(t *testing.T) {
|
||||
// 大量写入 + GC 之后重开:元数据与磁盘不该出现互相不认的孤儿。
|
||||
dir := t.TempDir()
|
||||
s1, err := New(dir, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var kept []string
|
||||
for i := 0; i < 100; i++ {
|
||||
data := append([]byte(fmt.Sprintf("churn-%d-", i)), randBytes(t, 256)...)
|
||||
d, err := s1.Put(data, Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if i%5 == 0 {
|
||||
if err := s1.AddRef(d, "context", fmt.Sprintf("e-%d", i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept = append(kept, d)
|
||||
}
|
||||
}
|
||||
if _, _, err := s1.GC(0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
beforeStats := s1.Stats()
|
||||
s1.Close()
|
||||
|
||||
s2, err := New(dir, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("重开失败: %v", err)
|
||||
}
|
||||
defer s2.Close()
|
||||
|
||||
afterStats := s2.Stats()
|
||||
if beforeStats["count"] != afterStats["count"] {
|
||||
t.Fatalf("重开后条目数变了: %v → %v", beforeStats["count"], afterStats["count"])
|
||||
}
|
||||
|
||||
// 每条元数据都应有对应磁盘文件(无「元数据在文件没了」的孤儿)
|
||||
rows, err := s2.db.Query(`SELECT digest FROM media`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var missing int
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if rows.Scan(&d) != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(s2.blobPath(d)); err != nil {
|
||||
missing++
|
||||
if missing <= 3 {
|
||||
t.Errorf("元数据存在但 blob 缺失: %s", shortDigest(d))
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if missing > 0 {
|
||||
t.Fatalf("%d 条元数据没有对应文件", missing)
|
||||
}
|
||||
|
||||
// 磁盘文件数应等于元数据条数(无「文件在元数据没了」的孤儿)
|
||||
if n := blobFileCount(t, s2); n != afterStats["count"].(int) {
|
||||
t.Fatalf("磁盘文件 %d 与元数据 %v 不一致", n, afterStats["count"])
|
||||
}
|
||||
|
||||
for _, d := range kept {
|
||||
if _, err := s2.Get(d); err != nil {
|
||||
t.Fatalf("有引用项重开后读不到 %s: %v", shortDigest(d), err)
|
||||
}
|
||||
}
|
||||
checkRefIntegrity(t, s2)
|
||||
}
|
||||
|
||||
func TestStress_LargeBlob(t *testing.T) {
|
||||
// 单个大文件:see_video 10 帧 × 2MB 是现实上限附近。
|
||||
s := newTestStore(t, 0)
|
||||
data := randBytes(t, 4*1024*1024) // 4MB
|
||||
|
||||
d, err := s.Put(data, Item{MIME: "image/jpeg", Width: 1920, Height: 1080})
|
||||
if err != nil {
|
||||
t.Fatalf("4MB Put 失败: %v", err)
|
||||
}
|
||||
got, err := s.Get(d)
|
||||
if err != nil {
|
||||
t.Fatalf("4MB Get 失败: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Fatal("4MB 内容回读不一致")
|
||||
}
|
||||
it, _ := s.Stat(d)
|
||||
if it.Size != int64(len(data)) {
|
||||
t.Fatalf("Size 记录错: %d vs %d", it.Size, len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStress_DataURLRoundTripAtScale(t *testing.T) {
|
||||
// data URL 往返是插件注入的实际路径(SetToolBlocks 给的就是 data URL)。
|
||||
s := newTestStore(t, 0)
|
||||
for i := 0; i < 50; i++ {
|
||||
raw := randBytes(t, 2048)
|
||||
url := DataURL("image/png", raw)
|
||||
mime, decoded, ok := ParseDataURL(url)
|
||||
if !ok {
|
||||
t.Fatalf("第 %d 次解析失败", i)
|
||||
}
|
||||
if mime != "image/png" || !bytes.Equal(decoded, raw) {
|
||||
t.Fatalf("第 %d 次往返不一致", i)
|
||||
}
|
||||
d, err := s.Put(decoded, Item{MIME: mime})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := s.Get(d)
|
||||
if err != nil || !bytes.Equal(back, raw) {
|
||||
t.Fatalf("第 %d 次入库回读不一致: %v", i, err)
|
||||
}
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
@ -10,15 +10,10 @@ 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 上则是下一个未发布中版本
|
||||
//(见 docs/git-branching.md §2.1 与 §四)。
|
||||
Version = "1.1.1"
|
||||
// 1.0.1:多模态修复。仅内核与内置插件改动,插件 ABI/协议未变,
|
||||
// 1.0.0 编出的 plugin.bin 无需重编。
|
||||
Version = "1.0.3"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
Commit = "unknown"
|
||||
@ -30,11 +25,7 @@ var (
|
||||
KernelName = "HomeAgent"
|
||||
|
||||
// SDKCompatibleVersion 是此内核可兼容的最高 SDK 版本(semver)。
|
||||
//
|
||||
// 1.1.0:本内核实现了 SDK 1.1.0 的全部新增方法。
|
||||
// 用 SDK 1.0.0 编的存量插件照旧可用——新增方法由**插件调用、内核实现**,
|
||||
// 不调就不受影响,无需重编。
|
||||
SDKCompatibleVersion = "1.1.0"
|
||||
SDKCompatibleVersion = "1.0.0"
|
||||
)
|
||||
|
||||
// FullVersion 返回完整的版本字符串。
|
||||
|
||||
@ -101,11 +101,6 @@ var methodCapability = map[string]Capability{
|
||||
MethodIOInjectInterrupt: CapIO,
|
||||
MethodIOInjectTextNoMem: CapIO,
|
||||
MethodIOInjectSync: CapIO,
|
||||
// 带媒体的注入与纯文本注入同一权限组:能不能发起一轮对话是 IO 能力,
|
||||
// 带不带图不改变这个判断。
|
||||
MethodIOInjectMedia: CapIO,
|
||||
MethodIOInjectMediaSync: CapIO,
|
||||
MethodIOInjectInterruptMedia: CapIO,
|
||||
|
||||
// ---- 图记忆 ----
|
||||
MethodMemoryRecall: CapMemory,
|
||||
@ -119,8 +114,6 @@ var methodCapability = map[string]Capability{
|
||||
MethodDocInsert: CapDocMemory,
|
||||
MethodDocRemove: CapDocMemory,
|
||||
MethodDocStats: CapDocMemory,
|
||||
// 带媒体写入与普通写入同权限:都是往文档记忆里写东西。
|
||||
MethodDocInsertMedia: CapDocMemory,
|
||||
|
||||
// ---- 知识库 ----
|
||||
MethodKnowledgeSearch: CapKnowledge,
|
||||
|
||||
@ -34,12 +34,10 @@ 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,10 +91,6 @@ 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)
|
||||
}
|
||||
@ -167,30 +163,6 @@ 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 {
|
||||
@ -321,28 +293,6 @@ 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 {
|
||||
@ -555,17 +505,6 @@ 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,26 +34,21 @@ 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) 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) 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) RegisterTool(name string, def pubsdk.ToolDef, h pubsdk.ToolHandler) error {
|
||||
f.mu.Lock()
|
||||
|
||||
@ -74,13 +74,6 @@ 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"
|
||||
@ -100,9 +93,6 @@ 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,23 +142,6 @@ 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,7 +22,6 @@ 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"
|
||||
@ -89,17 +88,13 @@ type Registry struct {
|
||||
memDB *memory.GraphDB
|
||||
textMem *text.Memory
|
||||
docStore *doc.Store
|
||||
// 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
|
||||
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
|
||||
@ -189,7 +184,6 @@ 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 }
|
||||
@ -317,13 +311,11 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
|
||||
}
|
||||
|
||||
return sdk.New(name, sdk.SDKConfig{
|
||||
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),
|
||||
IOManager: r.iom,
|
||||
EventBus: r.evBus,
|
||||
Memory: sdk.NewGraphMemory(r.memDB),
|
||||
TextMemory: sdk.NewTextMemory(r.textMem),
|
||||
DocMemory: sdk.NewDocMemory(r.docStore),
|
||||
Knowledge: sdk.NewKnowledge(r.ks),
|
||||
LLM: sdk.NewLLM(r.mgr, r.cfgReg, r.lua, r.baseKey),
|
||||
Settings: sett,
|
||||
|
||||
@ -18,10 +18,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultTimeout = 5 * time.Minute
|
||||
ReadBufSize = 4096
|
||||
MaxOutputBuffer = 128 * 1024
|
||||
DefaultNotifyBytes = 2048 // 积累 2KB 未读输出再通知
|
||||
DefaultTimeout = 5 * time.Minute
|
||||
ReadBufSize = 4096
|
||||
MaxOutputBuffer = 128 * 1024
|
||||
DefaultNotifyBytes = 2048 // 积累 2KB 未读输出再通知
|
||||
DefaultNotifyInterval = 2 * time.Second // 同一终端两次通知的最小间隔(兜底)
|
||||
)
|
||||
|
||||
@ -68,12 +68,12 @@ type TerminalSession struct {
|
||||
done chan struct{}
|
||||
|
||||
// 通知节流字段
|
||||
unreadBytes int // 最近一次通知后积累的未读字节数
|
||||
lastNotify time.Time // 最近一次通知时间
|
||||
lastData time.Time // 最近一次读到的数据时间(用于判定输出停止)
|
||||
lastFeedback time.Time // 最近一次定时反馈时间
|
||||
backoff time.Duration // 输出风暴退避:持续高速输出时通知间隔翻倍
|
||||
watch terminalWatch // 该终端的提醒规则
|
||||
unreadBytes int // 最近一次通知后积累的未读字节数
|
||||
lastNotify time.Time // 最近一次通知时间
|
||||
lastData time.Time // 最近一次读到的数据时间(用于判定输出停止)
|
||||
lastFeedback time.Time // 最近一次定时反馈时间
|
||||
backoff time.Duration // 输出风暴退避:持续高速输出时通知间隔翻倍
|
||||
watch terminalWatch // 该终端的提醒规则
|
||||
|
||||
// 实时画面推流(terminal_output 事件)
|
||||
stream bytes.Buffer // 待推送的增量输出,由 readLoop 每 200ms flush 一次
|
||||
@ -226,7 +226,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
Description: "创建一个新的交互式终端会话。返回终端 ID,后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。" +
|
||||
"通知模式通过 notify 参数选择(默认 exit):exit=仅命令执行结束后提醒一次;interval=定时反馈(如 interval=30s 每 30 秒反馈一次状态摘要);" +
|
||||
"buffer=未读输出积累到指定字节数后提醒(如 buffer=8192);多个模式用逗号组合(如 interval=30s,buffer=8192)。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。",
|
||||
NoMemory: true,
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@ -283,7 +283,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
})
|
||||
|
||||
s.RegisterTool("terminal_read", sdk.ToolDef{
|
||||
Name: "terminal_read",
|
||||
Name: "terminal_read",
|
||||
Description: "读取指定终端的输出。mode=new(默认)返回自上次读取以来的新输出并清空缓冲;mode=now 返回终端当前显示的全部屏幕内容(不清空缓冲)。如需持续监控请多次调用。",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
@ -759,11 +759,11 @@ func (p *Plugin) handleList() (interface{}, error) {
|
||||
defer p.mu.Unlock()
|
||||
|
||||
type termInfo struct {
|
||||
ID string `json:"id"`
|
||||
Command string `json:"command"`
|
||||
Uptime string `json:"uptime"`
|
||||
ID string `json:"id"`
|
||||
Command string `json:"command"`
|
||||
Uptime string `json:"uptime"`
|
||||
ExpiresIn string `json:"expires_in"`
|
||||
Running bool `json:"running"`
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
|
||||
var terms []termInfo
|
||||
@ -787,8 +787,8 @@ func (p *Plugin) handleList() (interface{}, error) {
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"count": len(terms),
|
||||
"status": "ok",
|
||||
"count": len(terms),
|
||||
"terminals": terms,
|
||||
}, nil
|
||||
}
|
||||
@ -797,9 +797,6 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
defer p.wg.Done()
|
||||
defer close(t.done)
|
||||
|
||||
// reader 协程独享这个读缓冲:结果随 readResult 携带,
|
||||
// readLoop 不再从其中做 copy(见 reader 注释,那是对共享缓冲
|
||||
// 的并发读写,-race 实测触发)。
|
||||
buf := make([]byte, ReadBufSize)
|
||||
pollInterval := 200 * time.Millisecond
|
||||
|
||||
@ -819,7 +816,7 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
t.lastFeedback = now
|
||||
t.mu.Unlock()
|
||||
|
||||
// 硬上限:未读输出积累达到该值也通知一次(防大输出静默丢失),频率极低
|
||||
// 硬上限:未读输出积累达到该值也通知一次(防大输出静默丢失),频率极低
|
||||
hardNotifyBytes := 64 * 1024
|
||||
hardNotifyInterval := 10 * time.Second
|
||||
// 输出停止判定:超过该时长无新数据则视为输出停止
|
||||
@ -889,7 +886,9 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
return
|
||||
}
|
||||
if r.n > 0 {
|
||||
t.appendOutput(r.data)
|
||||
data := make([]byte, r.n)
|
||||
copy(data, buf[:r.n])
|
||||
t.appendOutput(data)
|
||||
|
||||
// 缓冲阈值通知(仅当 agent 显式选择 buffer 模式,或未读积累达到硬上限)。
|
||||
// 默认模式(仅 exit 提醒)下不随输出流通知,杜绝通知风暴。
|
||||
@ -952,28 +951,15 @@ func previewTail(s string, n int) string {
|
||||
}
|
||||
|
||||
type readResult struct {
|
||||
n int
|
||||
data []byte
|
||||
err error
|
||||
n int
|
||||
err error
|
||||
}
|
||||
|
||||
// reader 从终端读取输出并通过 channel 交给 readLoop。
|
||||
//
|
||||
// 读到的数据**随结果一起传**而不是复用外层共享的 buf:
|
||||
// reader 是唯一写 buf 的 goroutine,readLoop 又常在 reader 尚未
|
||||
// 写完下一段时就从 buf[:r.n] 做 copy——同一个 shared buf 被并发
|
||||
// 读写就是 data race(-race 实测触发)。改为每个结果自带切片后,
|
||||
// 读与拷贝天然隔离,不再共享可变状态。
|
||||
func (p *Plugin) reader(t *TerminalSession, buf []byte, ch chan<- readResult) {
|
||||
for {
|
||||
n, err := t.session.Read(buf)
|
||||
var data []byte
|
||||
if n > 0 {
|
||||
data = make([]byte, n)
|
||||
copy(data, buf[:n])
|
||||
}
|
||||
select {
|
||||
case ch <- readResult{n, data, err}:
|
||||
case ch <- readResult{n, err}:
|
||||
case <-t.stopCh:
|
||||
return
|
||||
}
|
||||
|
||||
@ -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,24 +422,6 @@ 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()
|
||||
|
||||
@ -213,68 +213,27 @@ func TestRealPlugin_CrashDoesNotKillKernel(t *testing.T) {
|
||||
t.Fatal("editdoc 未加载")
|
||||
}
|
||||
|
||||
// 找插件子进程并 SIGKILL。
|
||||
//
|
||||
// 必须拿 plgDir 限定范围:旧实现用全系统 pgrep -f plugin.bin 后
|
||||
// 只比“路径含 editdoc”,于是在跑着生产实例的机器上,它会把
|
||||
// /home/newqqagent/plugins/editdoc/plugin.bin 当成目标杀掉(实测 9 次,
|
||||
// 全部落在有人跑 go test 的时段)。更糟的是此时本测试仍会通过:
|
||||
// 它断言的是测试内核存活,而那个内核的插件压根没死——**它在测一件
|
||||
// 没发生的事**,同时还把生产环境打坏了。
|
||||
pid := findPluginPID(t, plgDir, "editdoc")
|
||||
// 找到插件子进程并 SIGKILL
|
||||
pid := findPluginPID(t, "editdoc")
|
||||
if pid == 0 {
|
||||
t.Skip("未找到本测试自己拉起的插件子进程")
|
||||
t.Skip("未找到插件子进程(进程名匹配失败)")
|
||||
}
|
||||
t.Logf("kill 插件进程 pid=%d (exe 在 %s 下)", pid, plgDir)
|
||||
t.Logf("kill 插件进程 pid=%d", pid)
|
||||
if err := syscall.Kill(pid, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill: %v", err)
|
||||
}
|
||||
|
||||
// 先确认目标进程真的死了。
|
||||
//
|
||||
// 这步不能省:旧版直接断言“内核存活”,而内核本来就活着——
|
||||
// 即使 SIGKILL 发错了对象(杀了生产实例的插件)测试也会结束。
|
||||
// 先验“目标真死”再验“内核未被连带”,两步都成立才能证明隔离生效。
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
dead := false
|
||||
for time.Now().Before(deadline) {
|
||||
if syscall.Kill(pid, 0) != nil {
|
||||
dead = true
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if !dead {
|
||||
t.Fatalf("pid=%d 在 SIGKILL 后 3s 内未退出,崩溃隔离无从验证", pid)
|
||||
}
|
||||
|
||||
// 内核(本测试进程)必须存活并能继续工作
|
||||
// 内核必须存活并能继续工作
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if env.pluginReg.List() == nil {
|
||||
t.Fatal("内核在插件崩溃后不可用")
|
||||
}
|
||||
t.Logf("插件进程已确认退出,内核存活,已加载插件数=%d", len(env.pluginReg.List()))
|
||||
t.Logf("插件崩溃后内核存活,已加载插件数=%d", len(env.pluginReg.List()))
|
||||
}
|
||||
|
||||
// findPluginPID 在**指定插件目录下**找插件子进程 pid。
|
||||
//
|
||||
// root 参数是硬约束,不是可选过滤器:本函数的唯一用途是给崩溃隔离
|
||||
// 测试提供一个“可以安全 SIGKILL 的 pid”,而安全的定义就是它必须属于
|
||||
// 本测试自己的临时目录。不带这个约束就会误杀同机生产实例的插件。
|
||||
//
|
||||
// 匹配依据是 /proc/<pid>/exe 的真实路径必须以 root 为前缀。
|
||||
// 用 exe 而不用 cmdline:cmdline 可被进程自行改写,而 exe 符链由内核维护。
|
||||
// root 先过一道 EvalSymlinks:/tmp 在部分发行版上是符链(如 macOS 的
|
||||
// /tmp -> /private/tmp),不归一化会让前缀比较永远不命中,退化成静默 Skip。
|
||||
func findPluginPID(t *testing.T, root, name string) int {
|
||||
// findPluginPID 按二进制路径找插件子进程 pid。
|
||||
func findPluginPID(t *testing.T, name string) int {
|
||||
t.Helper()
|
||||
if root == "" {
|
||||
t.Fatal("findPluginPID: root 不得为空(防止误杀全系统同名插件)")
|
||||
}
|
||||
realRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
realRoot = root
|
||||
}
|
||||
|
||||
out, err := exec.Command("pgrep", "-f", "plugin.bin").Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
@ -285,18 +244,15 @@ func findPluginPID(t *testing.T, root, name string) int {
|
||||
if pid == 0 {
|
||||
continue
|
||||
}
|
||||
// 校验 cwd 或 cmdline 含插件名
|
||||
exe, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
|
||||
if err != nil {
|
||||
continue
|
||||
if err == nil && strings.Contains(exe, name) {
|
||||
return pid
|
||||
}
|
||||
// 两道条件同时成立才算命中:在本测试的目录树内,且是目标插件
|
||||
if !strings.HasPrefix(exe, realRoot+string(os.PathSeparator)) {
|
||||
continue
|
||||
cwd, err := os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid))
|
||||
if err == nil && strings.Contains(cwd, name) {
|
||||
return pid
|
||||
}
|
||||
if !strings.Contains(exe, name) {
|
||||
continue
|
||||
}
|
||||
return pid
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@ -37,24 +37,6 @@ type DeviceMeta struct {
|
||||
type wconn struct {
|
||||
deviceID string
|
||||
w *bufio.Writer
|
||||
// wmu 序列化对该连接 bufio.Writer 的所有写。
|
||||
//
|
||||
// 两个角色会并发写同一连接:handleWS 主循环(读设备帧后的 hello_ack/
|
||||
// bind_ack/pong 回写)与 PushJSON/PushData(agent→设备的下发路径,可能
|
||||
// 来自任意 goroutine)。bufio.Writer 不是线程安全的,不加锁会在
|
||||
// WriteByte/Flush 上产生 data race(生产实测触发)。
|
||||
wmu sync.Mutex
|
||||
}
|
||||
|
||||
// lockWrite 对 wconn 加写锁并返回 writer;调用方必须 defer unlockWrite。
|
||||
// 单独写成方法而不是直接暴露字段,避免调用方绕过锁。
|
||||
func (c *wconn) lockWrite() *bufio.Writer {
|
||||
c.wmu.Lock()
|
||||
return c.w
|
||||
}
|
||||
|
||||
func (c *wconn) unlockWrite() {
|
||||
c.wmu.Unlock()
|
||||
}
|
||||
|
||||
// Registry 是设备接入网关的注册表:管理在线连接、设备元数据。线程安全。
|
||||
@ -337,9 +319,7 @@ func (r *Registry) PushJSON(deviceID string, payload map[string]interface{}) err
|
||||
if !ok {
|
||||
return fmt.Errorf("device %s not online", deviceID)
|
||||
}
|
||||
w := c.lockWrite()
|
||||
defer c.unlockWrite()
|
||||
return writeText(w, mustJSON(payload))
|
||||
return writeText(c.w, mustJSON(payload))
|
||||
}
|
||||
|
||||
// PushCmd 向设备发送命令执行请求。
|
||||
@ -368,11 +348,7 @@ func (r *Registry) PushData(deviceID, reqID, kind, mime string, data []byte) err
|
||||
if !ok {
|
||||
return fmt.Errorf("device %s not online", deviceID)
|
||||
}
|
||||
// 整条下发(start + N 个 chunk + end)持锁:设备侧按协议串行聚合,
|
||||
// 若中途被 handleWS 的 hello/pong 插帧会破坏协议顺序。
|
||||
w := c.lockWrite()
|
||||
defer c.unlockWrite()
|
||||
if err := writeText(w, mustJSON(map[string]interface{}{
|
||||
if err := writeText(c.w, mustJSON(map[string]interface{}{
|
||||
"op": "cmd_speech_start",
|
||||
"req_id": reqID,
|
||||
"kind": kind,
|
||||
@ -387,11 +363,11 @@ func (r *Registry) PushData(deviceID, reqID, kind, mime string, data []byte) err
|
||||
if end > len(data) {
|
||||
end = len(data)
|
||||
}
|
||||
if err := writeBinary(w, data[off:end]); err != nil {
|
||||
if err := writeBinary(c.w, data[off:end]); err != nil {
|
||||
return fmt.Errorf("push data chunk: %w", err)
|
||||
}
|
||||
}
|
||||
if err := writeText(w, mustJSON(map[string]interface{}{
|
||||
if err := writeText(c.w, mustJSON(map[string]interface{}{
|
||||
"op": "cmd_speech_end",
|
||||
"req_id": reqID,
|
||||
})); err != nil {
|
||||
@ -642,26 +618,6 @@ func (r *Registry) ServeWS(w http.ResponseWriter, req *http.Request) {
|
||||
go r.handleWS(conn, rw)
|
||||
}
|
||||
|
||||
// wsWriteLocked 在指定设备连接的写锁保护下执行写回调。
|
||||
//
|
||||
// handleWS 主循环与 Push* 是两条并发写同一 bufio.Writer 的路径,
|
||||
// 必须共用同一把锁。handleWS 里拿到的是 rw.Writer(与 conns 存储的是
|
||||
// 同一个对象),回写前必须经此函数取锁,否则跟 Push* 依然会撞。
|
||||
//
|
||||
// 注意设备已离线(conns 中已删除)时直接报错——设备断开后仍尝试
|
||||
// 回写没有意义,还可能在已关闭的 bufio 上写入。
|
||||
func (r *Registry) wsWriteLocked(deviceID string, fn func(w *bufio.Writer) error) error {
|
||||
r.mu.RLock()
|
||||
c, ok := r.conns[deviceID]
|
||||
r.mu.RUnlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("device %s not online", deviceID)
|
||||
}
|
||||
w := c.lockWrite()
|
||||
defer c.unlockWrite()
|
||||
return fn(w)
|
||||
}
|
||||
|
||||
func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
|
||||
defer conn.Close()
|
||||
var curID string
|
||||
@ -679,9 +635,7 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
|
||||
payload, isClose, opcode, err := readFrame(rw.Reader)
|
||||
if err != nil {
|
||||
if err == errPing {
|
||||
// pong 也走写锁:它可能在 Push* 持锁推送大块数据时到达。
|
||||
err := r.wsWriteLocked(curID, writePong)
|
||||
if err != nil {
|
||||
if werr := writePong(rw.Writer); werr != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
@ -725,13 +679,11 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
|
||||
r.mu.Lock()
|
||||
r.conns[meta.DeviceID] = &wconn{deviceID: meta.DeviceID, w: rw.Writer}
|
||||
r.mu.Unlock()
|
||||
if err := r.wsWriteLocked(meta.DeviceID, func(w *bufio.Writer) error {
|
||||
return writeText(w, mustJSON(map[string]interface{}{
|
||||
"op": "hello_ack",
|
||||
"device": meta.DeviceID,
|
||||
"online": true,
|
||||
}))
|
||||
}); err != nil {
|
||||
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{
|
||||
"op": "hello_ack",
|
||||
"device": meta.DeviceID,
|
||||
"online": true,
|
||||
})); err != nil {
|
||||
return
|
||||
}
|
||||
case "bind":
|
||||
@ -742,17 +694,11 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
|
||||
// 默认不授权:bind 仅验证 token + 登记设备;授权完全由用户手动
|
||||
// (GUI 设备页 / REST /api/v1/device/auth)控制,绝不自动授权。
|
||||
}
|
||||
err := r.wsWriteLocked(curID, func(w *bufio.Writer) error {
|
||||
return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true}))
|
||||
})
|
||||
if err != nil {
|
||||
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true})); err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err := r.wsWriteLocked(curID, func(w *bufio.Writer) error {
|
||||
return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": false, "error": "bad token"}))
|
||||
})
|
||||
if err != nil {
|
||||
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": false, "error": "bad token"})); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@ -3148,19 +3148,6 @@
|
||||
'" ' +
|
||||
'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 =
|
||||
@ -3942,13 +3929,7 @@
|
||||
role: "user",
|
||||
content: message,
|
||||
attachment: {
|
||||
// 与服务端的 attType 判定保持一致(image/audio/file),
|
||||
// 否则乐观渲染的卡片会在 SSE 回流后变成另一种样式。
|
||||
type: /^image\//.test(fileObj.type)
|
||||
? "image"
|
||||
: /^audio\//.test(fileObj.type)
|
||||
? "audio"
|
||||
: "file",
|
||||
type: /^image\//.test(fileObj.type) ? "image" : "file",
|
||||
url: URL.createObjectURL(fileObj),
|
||||
name: fileObj.name,
|
||||
size: fileObj.size,
|
||||
@ -4597,11 +4578,7 @@
|
||||
if (p.kind === "channel_output") {
|
||||
// 附件输出(output_type=image/file):渲染为图片预览/下载卡片
|
||||
var att = null;
|
||||
if (
|
||||
p.output_type === "image" ||
|
||||
p.output_type === "audio" ||
|
||||
p.output_type === "file"
|
||||
) {
|
||||
if (p.output_type === "image" || p.output_type === "file") {
|
||||
att = {
|
||||
type: p.output_type,
|
||||
url: p.url || p.content,
|
||||
|
||||
@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@ -1447,13 +1446,6 @@ 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 等工具按路径消费。
|
||||
@ -1519,45 +1511,8 @@ func (h *Handler) handleChatFile(w http.ResponseWriter, r *http.Request) {
|
||||
dlURL := "/uploads/" + filepath.Base(savePath)
|
||||
attType := "file"
|
||||
ct := hdr.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
// 部分客户端(curl -F、某些移动端)不带 Content-Type,退回按扩展名判定。
|
||||
// 判错的后果不只是卡片样式:图片被当普通文件就走不进视觉链路,模型看不到图。
|
||||
ct = contentTypeByExt(strings.ToLower(filepath.Ext(savePath)))
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(ct, "image/"):
|
||||
if 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,
|
||||
@ -1569,17 +1524,8 @@ 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 等工具读取此路径处理。",
|
||||
typeLabel, base, humanSize, savePath)
|
||||
// 媒体已随本轮发给模型时不再叫它去读文件:那只会读到一堆二进制字节。
|
||||
if len(mediaBlocks) > 0 {
|
||||
fileNote = fmt.Sprintf("[用户通过 webui 发送了%s: %s (%s)]\n原文件保存在: %s",
|
||||
typeLabel, base, humanSize, savePath)
|
||||
}
|
||||
map[string]string{"image": "图片", "file": "文件"}[attType], base, humanSize, savePath)
|
||||
if message != "" {
|
||||
text := message
|
||||
go func() {
|
||||
@ -1592,15 +1538,14 @@ func (h *Handler) handleChatFile(w http.ResponseWriter, r *http.Request) {
|
||||
if clientMsgID != "" {
|
||||
payload2["client_msg_id"] = clientMsgID + "-note"
|
||||
}
|
||||
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)
|
||||
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
|
||||
}())
|
||||
}()
|
||||
time.Sleep(100 * time.Millisecond) // 保证附言先入队
|
||||
h.sdk.InjectInterrupt(source, "webui", "text", map[string]interface{}{"content": fileNote, "no_memory": true})
|
||||
@ -1618,9 +1563,6 @@ 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,9 +24,5 @@ type TextMemoryAPI interface {
|
||||
|
||||
type TextEvent = pubsdk.TextEvent
|
||||
|
||||
// MediaAttachment 是记忆附件(媒体)在插件边界上的表示。
|
||||
// 与公共 SDK 同一类型,内置插件与外部插件用同一套字段。
|
||||
type MediaAttachment = pubsdk.MediaAttachment
|
||||
|
||||
type DocMemoryAPI = pubsdk.DocMemoryAPI
|
||||
type Doc = pubsdk.Doc
|
||||
|
||||
@ -1,587 +1,110 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 插件侧记忆接口的实现(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
|
||||
}
|
||||
type graphMemory struct{ db *memory.GraphDB }
|
||||
|
||||
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,
|
||||
Confidence: r.Confidence,
|
||||
}
|
||||
relations[i] = Relation{SourceName: r.SourceName, TargetName: r.TargetName, RelationType: r.RelationType}
|
||||
}
|
||||
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, 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)
|
||||
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}
|
||||
}
|
||||
_, _, 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
|
||||
ms *media.Store
|
||||
plugin string
|
||||
}
|
||||
type textMemoryImpl struct{ tm *text.Memory }
|
||||
|
||||
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
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.tm == nil { return nil }
|
||||
return m.tm.Append(text.Event{
|
||||
Timestamp: evt.Timestamp, Source: evt.Role, Input: content, AgentID: evt.Channel,
|
||||
Timestamp: evt.Timestamp, Source: evt.Role, Input: evt.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,
|
||||
Attachments: sdkAttachmentsFromText(m.ms, e.Input),
|
||||
}
|
||||
out[i] = TextEvent{Role: e.Source, Content: e.Input, Timestamp: e.Timestamp, Channel: e.AgentID}
|
||||
}
|
||||
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
|
||||
ms *media.Store
|
||||
plugin string
|
||||
}
|
||||
type docMemoryImpl struct{ ds *doc.Store }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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) 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})
|
||||
}
|
||||
|
||||
// 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) Remove(id string) { if m.ds != nil { 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()
|
||||
}
|
||||
|
||||
|
||||
@ -1,434 +0,0 @@
|
||||
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,57 +195,6 @@ 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
|
||||
|
||||
32
third_party/homeagent-sdk/meta/meta.go
vendored
32
third_party/homeagent-sdk/meta/meta.go
vendored
@ -6,31 +6,10 @@ var (
|
||||
// Version 是 HomeAgent SDK 版本号。
|
||||
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
|
||||
//
|
||||
// 版本号语义:**SDK 版本跟随核心的中版本,patch 位恒为 .0**。
|
||||
// 整条核心 1.1.x 线(1.1.0、1.1.1、1.1.7…)共用 SDK 1.1.0;
|
||||
// 只有核心进入 1.2.0 这种中版本跃迁时 SDK 才升到 1.2.0。
|
||||
// 这样插件开发者只需关心「我在为哪个中版本写插件」,
|
||||
// 不必跟着核心的每个 bugfix 换 SDK 依赖(见 核心仓 docs/git-branching.md §七)。
|
||||
//
|
||||
// 1.0.0:插件运行模型从 C ABI 动态库改为子进程 + 共享内存。
|
||||
// 公开 SDK 接口零改动,但产物形态变了(plugin.so → plugin.bin)。
|
||||
// 1.1.0:多模态贯通插件边界。**全部是新增,无签名变更**:
|
||||
// - Triple.SentenceText / Triple.MediaDigests
|
||||
// - Doc.MediaDigests / Doc.Attachments、MediaAttachment
|
||||
// - TextEvent.Attachments
|
||||
// - DocMemoryAPI.InsertWithMedia
|
||||
// - IOInjector 的 InjectInputMedia / InjectInputMediaSync /
|
||||
// InjectInterruptMedia;PluginSDK 补上缺失的 SetToolBlocks 包装
|
||||
// 同版修掉两处并发竞态(sdk/stress_test.go 的 -race 实证,不是理论风险):
|
||||
// PluginSDK 的 API 字段与 autoRestart 标志此前无锁,而写方
|
||||
// (内核注入 API、插件 SetAutoRestart)与读方(插件后台 goroutine
|
||||
// 注入、内核 registry 读 AutoRestart)天然跨 goroutine。
|
||||
// 存量插件不需要改一行也不需要重编:新增方法由**插件调用、内核实现**,
|
||||
// 不调就不受影响。想用新字段的插件重编即可。
|
||||
//
|
||||
// ❗发布分支上此值是**本条发布线的 SDK 定版**;main 上则是下一个未发布中版本
|
||||
// (见 核心仓 docs/git-branching.md §2.1 与 §七.1)。
|
||||
Version = "1.1.0"
|
||||
// 公开 SDK 接口(sdk/ 目录)**零改动**——插件业务代码不需要改一行,
|
||||
// 但产物形态变了(plugin.so → plugin.bin),必须用新版 plugindev 重编。
|
||||
Version = "1.0.0"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
Commit = "unknown"
|
||||
@ -48,11 +27,6 @@ var (
|
||||
//
|
||||
// 1.0.0 是硬下限而非建议值:0.9.x 内核只会 dlopen `.so`,
|
||||
// 本版工具链产出的 `plugin.bin` 在旧内核上根本不会被识别。
|
||||
//
|
||||
// ⚠️ 1.1.0 新增的媒体接口需要核心 **1.1.1+**(更早的核心没有
|
||||
// doc.insertWithMedia / io.injectMedia* 这些 RPC,调用会返回 unknown method)。
|
||||
// 这里仍写 1.0.0,因为它是「SDK 能在其上运行」的下限;
|
||||
// 媒体接口是可选能力,不用就不受影响。
|
||||
CoreVersion = "1.0.0"
|
||||
)
|
||||
|
||||
|
||||
64
third_party/homeagent-sdk/sdk/memory.go
vendored
64
third_party/homeagent-sdk/sdk/memory.go
vendored
@ -25,18 +25,13 @@ type Relation struct {
|
||||
}
|
||||
|
||||
// Triple represents a subject-relation-object triple for the knowledge graph.
|
||||
//
|
||||
// SentenceText 是这条三元组的原句,会写进 sentences 表;媒体引用挂在句子上,
|
||||
// 所以 MediaDigests 非空时内核会保证句子存在(不给就自动合成一句)。
|
||||
type Triple struct {
|
||||
Subject string `json:"subject"`
|
||||
Relation string `json:"relation"`
|
||||
Object string `json:"object"`
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
SubjectType string `json:"subject_type,omitempty"`
|
||||
ObjectType string `json:"object_type,omitempty"`
|
||||
SentenceText string `json:"sentence_text,omitempty"`
|
||||
MediaDigests []string `json:"media_digests,omitempty"`
|
||||
Subject string `json:"subject"`
|
||||
Relation string `json:"relation"`
|
||||
Object string `json:"object"`
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
SubjectType string `json:"subject_type,omitempty"`
|
||||
ObjectType string `json:"object_type,omitempty"`
|
||||
}
|
||||
|
||||
// TextMemoryAPI provides access to chronological text event storage.
|
||||
@ -45,52 +40,27 @@ type TextMemoryAPI interface {
|
||||
}
|
||||
|
||||
// TextEvent represents a single text memory event.
|
||||
// MediaAttachment 描述一份与记忆关联的媒体。
|
||||
//
|
||||
// 两个方向共用一个类型:
|
||||
// - 写入(InsertWithMedia):给 Data + MIME 就是新内容;只给 Digest 则是引用已有内容。
|
||||
// - 读出(Query):内核只填 Digest/MIME/Description,**不回 Data**——
|
||||
// 一次检索可能命中几十张图,把字节全塞回插件会把 ABI 消息撑爆。
|
||||
// 需要字节时拿 Digest 单独取。
|
||||
type MediaAttachment struct {
|
||||
Digest string `json:"digest,omitempty"`
|
||||
MIME string `json:"mime,omitempty"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type TextEvent struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Attachments []MediaAttachment `json:"attachments,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// DocMemoryAPI provides access to the document vector store.
|
||||
type DocMemoryAPI interface {
|
||||
Query(text string, topK int) []*Doc
|
||||
Insert(doc *Doc) error
|
||||
// InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进
|
||||
// 内容寻址存储(相同字节只存一份),只带 Digest 的直接引用已有内容。
|
||||
// 插件无需自己拼标记:内核会把 `[mime <短 digest>] <描述>` 补进 Content,
|
||||
// 让向量检索和后续蒸馏都能看到这份媒体。
|
||||
InsertWithMedia(doc *Doc, attachments []MediaAttachment) error
|
||||
Remove(id string)
|
||||
Stats() map[string]interface{}
|
||||
}
|
||||
|
||||
// Doc represents a document in the document store.
|
||||
//
|
||||
// MediaDigests / Attachments 在 Query 返回时由内核填充(仅元数据,不带字节)。
|
||||
type Doc struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
MediaDigests []string `json:"media_digests,omitempty"`
|
||||
Attachments []MediaAttachment `json:"attachments,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
// SocialAPI provides read-only access to the social graph (person profiles and relationships).
|
||||
@ -105,9 +75,9 @@ type SocialAPI interface {
|
||||
|
||||
// PersonProfile represents a person's complete profile (traits + social relations).
|
||||
type PersonProfile struct {
|
||||
Name string `json:"name"`
|
||||
Traits map[string]string `json:"traits,omitempty"`
|
||||
Relations []SocialRelation `json:"relations,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Traits map[string]string `json:"traits,omitempty"`
|
||||
Relations []SocialRelation `json:"relations,omitempty"`
|
||||
}
|
||||
|
||||
// SocialRelation represents a social relationship between two persons.
|
||||
|
||||
257
third_party/homeagent-sdk/sdk/plugin.go
vendored
257
third_party/homeagent-sdk/sdk/plugin.go
vendored
@ -61,18 +61,14 @@ type StageContext struct {
|
||||
Memory []MemItem
|
||||
NoMemory bool
|
||||
Extra map[string]interface{}
|
||||
Errors []string // 阶段处理过程中的错误信息
|
||||
Errors []string // 阶段处理过程中的错误信息
|
||||
}
|
||||
|
||||
func (c *StageContext) RLock() { c.mu.RLock() }
|
||||
func (c *StageContext) RUnlock() { c.mu.RUnlock() }
|
||||
func (c *StageContext) Lock() { c.mu.Lock() }
|
||||
func (c *StageContext) Unlock() { c.mu.Unlock() }
|
||||
func (c *StageContext) IsResponded() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.Response != nil
|
||||
}
|
||||
func (c *StageContext) RLock() { c.mu.RLock() }
|
||||
func (c *StageContext) RUnlock() { c.mu.RUnlock() }
|
||||
func (c *StageContext) Lock() { c.mu.Lock() }
|
||||
func (c *StageContext) Unlock() { c.mu.Unlock() }
|
||||
func (c *StageContext) IsResponded() bool { c.mu.RLock(); defer c.mu.RUnlock(); return c.Response != nil }
|
||||
|
||||
// MemItem represents a memory item in stage context.
|
||||
type MemItem struct {
|
||||
@ -104,8 +100,8 @@ type ToolDef struct {
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
|
||||
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
|
||||
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
|
||||
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
|
||||
}
|
||||
|
||||
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
|
||||
@ -121,9 +117,6 @@ type IOInjector interface {
|
||||
// SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条
|
||||
// tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。
|
||||
SetToolBlocks(blocks []ContentBlock)
|
||||
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
|
||||
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
|
||||
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
|
||||
}
|
||||
|
||||
// EventType identifies the kind of system event.
|
||||
@ -228,24 +221,6 @@ type PluginSDK struct {
|
||||
events EventSubscriber
|
||||
plgMgr PluginMgrAPI
|
||||
|
||||
// apiMu 保护上面这些由内核注入的 API 字段,以及 autoRestart。
|
||||
//
|
||||
// 这些字段的写方与读方天然跨 goroutine:
|
||||
// - 写方是内核(加载/重载插件时注入 API)与插件自己(SetAutoRestart);
|
||||
// - 读方是插件在 Start() 里起的后台 goroutine(轮询、监听、定时器
|
||||
// 都要拿 injector 往管道里注消息),以及内核 registry —— 它在
|
||||
// 另一个 goroutine 读 AutoRestart() 决定崩溃后是否重启。
|
||||
// SetAutoRestart 的文档用法本身就是「连接建立后再决定能否自动重启」,
|
||||
// 而连接建立通常发生在后台 goroutine 里,于是这对读写必然并发。
|
||||
//
|
||||
// sdk/stress_test.go 的 -race 实测确认这是真竞态,不是理论风险。
|
||||
// 未加锁时的生产表现是偶发 nil 解引用崩溃(读到半个接口值)。
|
||||
//
|
||||
// 约定:只在持锁期间取字段值,取完立刻释放再调用。
|
||||
// 持锁调用会把 InjectInputSync 这类阻塞到 agent 回复(可达数分钟)的
|
||||
// 方法与 SetIOInjector 串到一起,让插件重载卡死。
|
||||
apiMu sync.RWMutex
|
||||
|
||||
autoRestart bool
|
||||
|
||||
stopMu sync.Mutex
|
||||
@ -272,57 +247,28 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
||||
func (s *PluginSDK) PluginName() string { return s.name }
|
||||
|
||||
// Settings returns the settings API for reading/writing plugin configuration.
|
||||
// sett 在 New 时一次性写入且无 setter,故不需要加锁。
|
||||
func (s *PluginSDK) Settings() SettingsAPI { return s.sett }
|
||||
|
||||
// Memory returns the graph memory API (may be nil if not available).
|
||||
func (s *PluginSDK) Memory() MemoryAPI {
|
||||
s.apiMu.RLock()
|
||||
defer s.apiMu.RUnlock()
|
||||
return s.mem
|
||||
}
|
||||
func (s *PluginSDK) Memory() MemoryAPI { return s.mem }
|
||||
|
||||
// TextMemory returns the text memory API (may be nil if not available).
|
||||
func (s *PluginSDK) TextMemory() TextMemoryAPI {
|
||||
s.apiMu.RLock()
|
||||
defer s.apiMu.RUnlock()
|
||||
return s.textMem
|
||||
}
|
||||
func (s *PluginSDK) TextMemory() TextMemoryAPI { return s.textMem }
|
||||
|
||||
// DocMemory returns the document memory API (may be nil if not available).
|
||||
func (s *PluginSDK) DocMemory() DocMemoryAPI {
|
||||
s.apiMu.RLock()
|
||||
defer s.apiMu.RUnlock()
|
||||
return s.docMem
|
||||
}
|
||||
func (s *PluginSDK) DocMemory() DocMemoryAPI { return s.docMem }
|
||||
|
||||
// Knowledge returns the knowledge store API (may be nil if not available).
|
||||
func (s *PluginSDK) Knowledge() KnowledgeAPI {
|
||||
s.apiMu.RLock()
|
||||
defer s.apiMu.RUnlock()
|
||||
return s.know
|
||||
}
|
||||
func (s *PluginSDK) Knowledge() KnowledgeAPI { return s.know }
|
||||
|
||||
// LLM returns the LLM provider API (may be nil if not available).
|
||||
func (s *PluginSDK) LLM() LLMAPI {
|
||||
s.apiMu.RLock()
|
||||
defer s.apiMu.RUnlock()
|
||||
return s.llm
|
||||
}
|
||||
func (s *PluginSDK) LLM() LLMAPI { return s.llm }
|
||||
|
||||
// Social returns the social graph API (may be nil if not available).
|
||||
func (s *PluginSDK) Social() SocialAPI {
|
||||
s.apiMu.RLock()
|
||||
defer s.apiMu.RUnlock()
|
||||
return s.social
|
||||
}
|
||||
func (s *PluginSDK) Social() SocialAPI { return s.social }
|
||||
|
||||
// Events returns the event subscriber for listening to kernel events (may be nil if not available).
|
||||
func (s *PluginSDK) Events() EventSubscriber {
|
||||
s.apiMu.RLock()
|
||||
defer s.apiMu.RUnlock()
|
||||
return s.events
|
||||
}
|
||||
func (s *PluginSDK) Events() EventSubscriber { return s.events }
|
||||
|
||||
// RegisterTool registers a tool that the LLM can call.
|
||||
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error {
|
||||
@ -336,9 +282,8 @@ func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler)
|
||||
}
|
||||
|
||||
// RegisterStage registers a handler for a pipeline stage.
|
||||
//
|
||||
// scope: StageScopeGlobal (default) — receives all stage events.
|
||||
// StageScopeOwnTools — only before_toolcall/after_toolcall for this plugin's tools.
|
||||
// scope: StageScopeGlobal (default) — receives all stage events.
|
||||
// StageScopeOwnTools — only before_toolcall/after_toolcall for this plugin's tools.
|
||||
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler, scope ...StageScope) {
|
||||
if s.regStage == nil {
|
||||
return
|
||||
@ -388,11 +333,8 @@ func (s *PluginSDK) RegisterPluginAPI(name string) error {
|
||||
// def: 通道在记忆计算层的行为(NoMemory/Cleaner)
|
||||
// handler: receives args map with keys: payload (string), type (string), meta (string|optional)
|
||||
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error {
|
||||
s.apiMu.RLock()
|
||||
reg := s.regOutput
|
||||
s.apiMu.RUnlock()
|
||||
if reg != nil {
|
||||
return reg(name, caps, desc, def, handler)
|
||||
if s.regOutput != nil {
|
||||
return s.regOutput(name, caps, desc, def, handler)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -401,126 +343,57 @@ func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, de
|
||||
// def.NoMemory: 此通道输入不参与记忆计算
|
||||
// def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/提关键词
|
||||
func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error {
|
||||
s.apiMu.RLock()
|
||||
reg := s.regInput
|
||||
s.apiMu.RUnlock()
|
||||
if reg != nil {
|
||||
return reg(name, def)
|
||||
if s.regInput != nil {
|
||||
return s.regInput(name, def)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 以下 setter 由内核在启动/重载时调用,与插件后台 goroutine 的读并发,故加锁。
|
||||
|
||||
// SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup).
|
||||
func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) {
|
||||
s.apiMu.Lock()
|
||||
s.regOutput = r
|
||||
s.apiMu.Unlock()
|
||||
}
|
||||
func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) { s.regOutput = r }
|
||||
|
||||
// SetInputChannelRegistrar sets the input channel registrar (called by the core at startup).
|
||||
func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar) {
|
||||
s.apiMu.Lock()
|
||||
s.regInput = r
|
||||
s.apiMu.Unlock()
|
||||
}
|
||||
func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar) { s.regInput = r }
|
||||
|
||||
// SetIOInjector sets the IO injector (called by the core at startup).
|
||||
func (s *PluginSDK) SetIOInjector(io IOInjector) {
|
||||
s.apiMu.Lock()
|
||||
s.io = io
|
||||
s.apiMu.Unlock()
|
||||
}
|
||||
func (s *PluginSDK) SetIOInjector(io IOInjector) { s.io = io }
|
||||
|
||||
// SetMemoryAPI sets the memory API (called by the core at startup).
|
||||
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) {
|
||||
s.apiMu.Lock()
|
||||
s.mem = mem
|
||||
s.apiMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI) {
|
||||
s.apiMu.Lock()
|
||||
s.textMem = tm
|
||||
s.apiMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI) {
|
||||
s.apiMu.Lock()
|
||||
s.docMem = dm
|
||||
s.apiMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI) {
|
||||
s.apiMu.Lock()
|
||||
s.know = kn
|
||||
s.apiMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *PluginSDK) SetLLMAPI(llm LLMAPI) {
|
||||
s.apiMu.Lock()
|
||||
s.llm = llm
|
||||
s.apiMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *PluginSDK) SetSocialAPI(social SocialAPI) {
|
||||
s.apiMu.Lock()
|
||||
s.social = social
|
||||
s.apiMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) {
|
||||
s.apiMu.Lock()
|
||||
s.events = es
|
||||
s.apiMu.Unlock()
|
||||
}
|
||||
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) { s.mem = mem }
|
||||
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI) { s.textMem = tm }
|
||||
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI) { s.docMem = dm }
|
||||
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI) { s.know = kn }
|
||||
func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm }
|
||||
func (s *PluginSDK) SetSocialAPI(social SocialAPI) { s.social = social }
|
||||
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) { s.events = es }
|
||||
|
||||
// SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup).
|
||||
func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI) {
|
||||
s.apiMu.Lock()
|
||||
s.plgMgr = pm
|
||||
s.apiMu.Unlock()
|
||||
}
|
||||
func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI) { s.plgMgr = pm }
|
||||
|
||||
// PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list).
|
||||
// May be nil if the host did not wire it.
|
||||
func (s *PluginSDK) PluginMgr() PluginMgrAPI {
|
||||
s.apiMu.RLock()
|
||||
defer s.apiMu.RUnlock()
|
||||
return s.plgMgr
|
||||
}
|
||||
func (s *PluginSDK) PluginMgr() PluginMgrAPI { return s.plgMgr }
|
||||
|
||||
// ---- IO Convenience Methods ----
|
||||
|
||||
// injector 取当前 injector 的快照。
|
||||
//
|
||||
// 取完即释放锁再调用:InjectInputSync 会阻塞到 agent 回复(可达数分钟),
|
||||
// 若持锁调用,插件重载时的 SetIOInjector 会一起卡住。
|
||||
func (s *PluginSDK) injector() IOInjector {
|
||||
s.apiMu.RLock()
|
||||
defer s.apiMu.RUnlock()
|
||||
return s.io
|
||||
}
|
||||
|
||||
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
|
||||
func (s *PluginSDK) InjectInterruptText(source, channel, text string) {
|
||||
if io := s.injector(); io != nil {
|
||||
io.InjectInterruptText(source, channel, text)
|
||||
if s.io != nil {
|
||||
s.io.InjectInterruptText(source, channel, text)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectText injects a text message into the agent pipeline.
|
||||
func (s *PluginSDK) InjectText(source, channel, text string) {
|
||||
if io := s.injector(); io != nil {
|
||||
io.InjectText(source, channel, text)
|
||||
if s.io != nil {
|
||||
s.io.InjectText(source, channel, text)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectTextNoMemory injects a text message without generating memory.
|
||||
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
|
||||
if io := s.injector(); io != nil {
|
||||
io.InjectTextNoMemory(source, channel, text)
|
||||
if s.io != nil {
|
||||
s.io.InjectTextNoMemory(source, channel, text)
|
||||
}
|
||||
}
|
||||
|
||||
@ -528,62 +401,18 @@ func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
|
||||
// returning the reply text (empty string if none). Replies must be dispatched back
|
||||
// to the source channel by the caller.
|
||||
func (s *PluginSDK) InjectInputSync(source, channel, text string) string {
|
||||
io := s.injector()
|
||||
if io == nil {
|
||||
if s.io == nil {
|
||||
return ""
|
||||
}
|
||||
return io.InjectInputSync(source, channel, text)
|
||||
}
|
||||
|
||||
// InjectInputMedia 注入带媒体内容块(image_url/audio_url)的输入。
|
||||
// blocks 会落进媒体存储被记忆引用捕获,同时作为当前轮 content 数组
|
||||
// 发给 LLM,让模型在「本轮」就看到图/听到音频——区别于 SetToolBlocks
|
||||
// 的「下一轮 tool message」语义。
|
||||
func (s *PluginSDK) InjectInputMedia(source, channel, text string, blocks []ContentBlock) {
|
||||
if io := s.injector(); io != nil {
|
||||
io.InjectInputMedia(source, channel, text, blocks)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectInputMediaSync 注入带媒体内容块的输入并同步等待 agent 回复。
|
||||
func (s *PluginSDK) InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string {
|
||||
io := s.injector()
|
||||
if io == nil {
|
||||
return ""
|
||||
}
|
||||
return io.InjectInputMediaSync(source, channel, text, blocks)
|
||||
}
|
||||
|
||||
// InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。
|
||||
// blocks 随中断消息一起发给模型。
|
||||
func (s *PluginSDK) InjectInterruptMedia(source, channel, text string, blocks []ContentBlock) {
|
||||
if io := s.injector(); io != nil {
|
||||
io.InjectInterruptMedia(source, channel, text, blocks)
|
||||
}
|
||||
}
|
||||
|
||||
// SetToolBlocks 在工具处理函数内注入多模态内容块,内核在下一条 tool message
|
||||
// 的 content 数组里带上它们。需要「本轮就让模型看到」时用 InjectInputMedia。
|
||||
func (s *PluginSDK) SetToolBlocks(blocks []ContentBlock) {
|
||||
if io := s.injector(); io != nil {
|
||||
io.SetToolBlocks(blocks)
|
||||
}
|
||||
return s.io.InjectInputSync(source, channel, text)
|
||||
}
|
||||
|
||||
// SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)。
|
||||
// 默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。
|
||||
func (s *PluginSDK) SetAutoRestart(enabled bool) {
|
||||
s.apiMu.Lock()
|
||||
s.autoRestart = enabled
|
||||
s.apiMu.Unlock()
|
||||
}
|
||||
func (s *PluginSDK) SetAutoRestart(enabled bool) { s.autoRestart = enabled }
|
||||
|
||||
// AutoRestart 返回插件是否允许自动重启。
|
||||
func (s *PluginSDK) AutoRestart() bool {
|
||||
s.apiMu.RLock()
|
||||
defer s.apiMu.RUnlock()
|
||||
return s.autoRestart
|
||||
}
|
||||
func (s *PluginSDK) AutoRestart() bool { return s.autoRestart }
|
||||
|
||||
// RegisterStopHandler 注册插件停止阶段的清理回调。
|
||||
// 注册的 handler 会在插件 Stop() 之前按"后注册先执行"的顺序调用,
|
||||
|
||||
725
third_party/homeagent-sdk/sdk/stress_test.go
vendored
725
third_party/homeagent-sdk/sdk/stress_test.go
vendored
@ -1,725 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// SDK 公开接口的并发压力测试(1.1.0 媒体接口上线后新增)。
|
||||
//
|
||||
// 为什么这一层需要压测:SDK 是**被多个 goroutine 同时使用的共享对象**。
|
||||
// 一个插件的典型形态是 Start() 里起若干后台 goroutine(轮询、监听、定时器),
|
||||
// 它们各自持同一个 *PluginSDK 往里注入消息;内核侧同时还有 stage 扇出、
|
||||
// 工具调用、以及读 AutoRestart() 决定崩溃后是否重启。
|
||||
// 单线程单测全绿不代表这些并发路径成立。
|
||||
//
|
||||
// 关注点不是吞吐数字,而是不变量:
|
||||
// 1. 注入调用不丢、不串(媒体块必须与文本配对,不能张冠李戴)
|
||||
// 2. 状态字段的读写不产生数据竞争(-race 下必须干净)
|
||||
// 3. handler 注册/执行在并发下"恰好一次"
|
||||
// 4. 跨进程 JSON 序列化对新媒体类型必须字节级往返一致
|
||||
//
|
||||
// 媒体接口尤其需要 3 与 4:媒体块要经 JSON 过子进程边界,
|
||||
// 而 []byte 在 JSON 里是 base64,往返不一致的后果是图片静默损坏。
|
||||
|
||||
// ---------- 测试替身 ----------
|
||||
|
||||
// recordingInjector 记录每一次注入调用,用于验证"不丢不串"。
|
||||
type recordingInjector struct {
|
||||
mu sync.Mutex
|
||||
calls []injectCall
|
||||
|
||||
// 计数用原子量:并发路径上只增不减,可在不持锁时安全读。
|
||||
nText, nMedia, nInterrupt, nSync atomic.Int64
|
||||
}
|
||||
|
||||
type injectCall struct {
|
||||
kind string // text / media / interruptMedia / sync ...
|
||||
source string
|
||||
channel string
|
||||
text string
|
||||
blocks []ContentBlock
|
||||
}
|
||||
|
||||
func (r *recordingInjector) record(c injectCall) {
|
||||
r.mu.Lock()
|
||||
r.calls = append(r.calls, c)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *recordingInjector) InjectInterruptText(s, c, t string) {
|
||||
r.nInterrupt.Add(1)
|
||||
r.record(injectCall{kind: "interruptText", source: s, channel: c, text: t})
|
||||
}
|
||||
|
||||
func (r *recordingInjector) InjectText(s, c, t string) {
|
||||
r.nText.Add(1)
|
||||
r.record(injectCall{kind: "text", source: s, channel: c, text: t})
|
||||
}
|
||||
|
||||
func (r *recordingInjector) InjectTextNoMemory(s, c, t string) {
|
||||
r.nText.Add(1)
|
||||
r.record(injectCall{kind: "textNoMem", source: s, channel: c, text: t})
|
||||
}
|
||||
|
||||
func (r *recordingInjector) InjectInputSync(s, c, t string) string {
|
||||
r.nSync.Add(1)
|
||||
r.record(injectCall{kind: "sync", source: s, channel: c, text: t})
|
||||
return "reply:" + t
|
||||
}
|
||||
|
||||
func (r *recordingInjector) SetToolBlocks(blocks []ContentBlock) {
|
||||
r.record(injectCall{kind: "toolBlocks", blocks: blocks})
|
||||
}
|
||||
|
||||
func (r *recordingInjector) InjectInputMedia(s, c, t string, b []ContentBlock) {
|
||||
r.nMedia.Add(1)
|
||||
r.record(injectCall{kind: "media", source: s, channel: c, text: t, blocks: b})
|
||||
}
|
||||
|
||||
func (r *recordingInjector) InjectInputMediaSync(s, c, t string, b []ContentBlock) string {
|
||||
r.nMedia.Add(1)
|
||||
r.nSync.Add(1)
|
||||
r.record(injectCall{kind: "mediaSync", source: s, channel: c, text: t, blocks: b})
|
||||
return "reply:" + t
|
||||
}
|
||||
|
||||
func (r *recordingInjector) InjectInterruptMedia(s, c, t string, b []ContentBlock) {
|
||||
r.nMedia.Add(1)
|
||||
r.record(injectCall{kind: "interruptMedia", source: s, channel: c, text: t, blocks: b})
|
||||
}
|
||||
|
||||
func (r *recordingInjector) snapshot() []injectCall {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]injectCall{}, r.calls...)
|
||||
}
|
||||
|
||||
var _ IOInjector = (*recordingInjector)(nil)
|
||||
|
||||
// imageBlock 构造一个带可识别 URL 的图片块。
|
||||
func imageBlock(tag string) ContentBlock {
|
||||
return ContentBlock{
|
||||
Type: "image_url",
|
||||
ImageURL: &ImageURL{URL: "data:image/png;base64," + tag, Detail: "auto"},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 1. 媒体注入并发不丢不串 ----------
|
||||
|
||||
// 三个媒体注入方法在高并发下必须:调用数精确、且每次调用的 text 与 blocks 配对不错。
|
||||
//
|
||||
// "不串"是这里的关键断言。注入是插件里最容易被后台 goroutine 并发调用的入口,
|
||||
// 若实现里出现任何共享中间状态(比如把 blocks 暂存到 SDK 字段再读出),
|
||||
// 高并发下就会出现 A 的文本配上 B 的图——而两者单独看都"成功"了,不报错。
|
||||
func TestStress_MediaInjectionConcurrentNoCrossTalk(t *testing.T) {
|
||||
const workers, perWorker = 32, 200
|
||||
|
||||
inj := &recordingInjector{}
|
||||
s := &PluginSDK{name: "stress"}
|
||||
s.SetIOInjector(inj)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < perWorker; i++ {
|
||||
// tag 唯一标识这次调用,文本与图片 URL 里都带上它。
|
||||
tag := fmt.Sprintf("w%d-i%d", w, i)
|
||||
switch i % 3 {
|
||||
case 0:
|
||||
s.InjectInputMedia("src", "ch", tag, []ContentBlock{imageBlock(tag)})
|
||||
case 1:
|
||||
if got := s.InjectInputMediaSync("src", "ch", tag, []ContentBlock{imageBlock(tag)}); got != "reply:"+tag {
|
||||
t.Errorf("同步注入回复错位: got %q want %q", got, "reply:"+tag)
|
||||
}
|
||||
default:
|
||||
s.InjectInterruptMedia("src", "ch", tag, []ContentBlock{imageBlock(tag)})
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
total := int64(workers * perWorker)
|
||||
if got := inj.nMedia.Load(); got != total {
|
||||
t.Fatalf("媒体注入调用数 = %d,期望 %d(有调用丢失)", got, total)
|
||||
}
|
||||
|
||||
// 逐条校验文本与媒体块配对:URL 必须含该次调用自己的 tag。
|
||||
seen := map[string]bool{}
|
||||
for _, c := range inj.snapshot() {
|
||||
if len(c.blocks) == 0 {
|
||||
continue
|
||||
}
|
||||
if c.blocks[0].ImageURL == nil {
|
||||
t.Fatalf("媒体块 ImageURL 丢失: %+v", c.blocks[0])
|
||||
}
|
||||
if !strings.HasSuffix(c.blocks[0].ImageURL.URL, c.text) {
|
||||
t.Fatalf("文本与媒体块错位: text=%q url=%q", c.text, c.blocks[0].ImageURL.URL)
|
||||
}
|
||||
if seen[c.text] {
|
||||
t.Fatalf("同一次调用被记录两次: %s", c.text)
|
||||
}
|
||||
seen[c.text] = true
|
||||
}
|
||||
if len(seen) != int(total) {
|
||||
t.Fatalf("去重后调用数 = %d,期望 %d", len(seen), total)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 2. 注入期间热替换 injector ----------
|
||||
|
||||
// 内核在插件运行期间可能重新注入 API(重载、恢复、子进程重连握手)。
|
||||
// 此时插件的后台 goroutine 仍在注入。这条路径若无同步就是对 s.io 的数据竞争,
|
||||
// 在 -race 下会被抓出;生产表现是偶发 nil 解引用崩溃。
|
||||
func TestStress_InjectorSwapDuringInjection(t *testing.T) {
|
||||
s := &PluginSDK{name: "stress"}
|
||||
s.SetIOInjector(&recordingInjector{})
|
||||
|
||||
stop := make(chan struct{})
|
||||
var injectors, swapper sync.WaitGroup
|
||||
|
||||
// 注入方:持续打直到 stop
|
||||
for w := 0; w < 8; w++ {
|
||||
injectors.Add(1)
|
||||
go func() {
|
||||
defer injectors.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
s.InjectInputMedia("src", "ch", "x", []ContentBlock{imageBlock("x")})
|
||||
s.InjectText("src", "ch", "y")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 替换方:反复换 injector(含换成 nil——内核卸载 API 时的真实状态)
|
||||
swapper.Add(1)
|
||||
go func() {
|
||||
defer swapper.Done()
|
||||
for i := 0; i < 500; i++ {
|
||||
if i%7 == 0 {
|
||||
s.SetIOInjector(nil)
|
||||
} else {
|
||||
s.SetIOInjector(&recordingInjector{})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 先等替换跑完,再告知注入方退出。
|
||||
// 顺序写反了就是死锁:注入方只依 close(stop) 退出。
|
||||
swapper.Wait()
|
||||
close(stop)
|
||||
injectors.Wait()
|
||||
// 断言就是「没崩、-race 没报」。nil injector 时必须静默跳过而非 panic。
|
||||
}
|
||||
|
||||
// ---------- 3. autoRestart 标志的并发读写 ----------
|
||||
|
||||
// SetAutoRestart 的文档用途是"插件有无法恢复的状态(如外部连接)时设为 false"——
|
||||
// 而连接建立本身通常是异步的,所以这个写入天然发生在后台 goroutine。
|
||||
// 内核侧 registry 在另一个 goroutine 读 AutoRestart() 决定崩溃后是否重启。
|
||||
// 这是一对跨 goroutine 的读写,必须同步。
|
||||
func TestStress_AutoRestartFlagConcurrent(t *testing.T) {
|
||||
s := &PluginSDK{name: "stress", autoRestart: true}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < 16; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 500; i++ {
|
||||
s.SetAutoRestart(i%2 == 0)
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
// 读方模拟内核 registry
|
||||
for r := 0; r < 8; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 500; i++ {
|
||||
_ = s.AutoRestart()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// ---------- 4. stop / onRemove handler 的"恰好一次" ----------
|
||||
|
||||
// RunStopHandlers 的契约是"执行后清空,幂等"。内核在停止插件时可能并发触发
|
||||
// (超时强杀与正常 Stop 竞争),handler 里往往是关连接、落盘——
|
||||
// 执行两次的后果从"重复写文件"到"close 已关闭的 channel 直接 panic"。
|
||||
func TestStress_StopHandlersExactlyOnce(t *testing.T) {
|
||||
const n = 300
|
||||
s := &PluginSDK{name: "stress"}
|
||||
|
||||
var counters [n]atomic.Int64
|
||||
for i := 0; i < n; i++ {
|
||||
i := i
|
||||
s.RegisterStopHandler(func() { counters[i].Add(1) })
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < 16; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.RunStopHandlers()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
if got := counters[i].Load(); got != 1 {
|
||||
t.Fatalf("handler %d 执行 %d 次,期望恰好 1 次", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 注册与执行并发:已注册的 handler 一次都不能多跑,未跑到的也不能被丢。
|
||||
// 断言用"每个 handler 的执行次数 <= 1"而非"总数相等"——
|
||||
// 与 RunStopHandlers 竞争的注册可能落在快照之后,那属于合法的未执行。
|
||||
func TestStress_StopHandlersRegisterWhileRunning(t *testing.T) {
|
||||
s := &PluginSDK{name: "stress"}
|
||||
const n = 500
|
||||
var counters [n]atomic.Int64
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < n; i++ {
|
||||
i := i
|
||||
s.RegisterStopHandler(func() { counters[i].Add(1) })
|
||||
}
|
||||
}()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 50; i++ {
|
||||
s.RunStopHandlers()
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
s.RunStopHandlers() // 收尾:把剩下的都跑掉
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
if got := counters[i].Load(); got > 1 {
|
||||
t.Fatalf("handler %d 被执行 %d 次(重复执行)", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStress_OnRemoveHandlersExactlyOnce(t *testing.T) {
|
||||
const n = 200
|
||||
s := &PluginSDK{name: "stress"}
|
||||
|
||||
var counters [n]atomic.Int64
|
||||
for i := 0; i < n; i++ {
|
||||
i := i
|
||||
s.RegisterOnRemoveHandler(func() { counters[i].Add(1) })
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < 12; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.RunOnRemoveHandlers()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
if got := counters[i].Load(); got != 1 {
|
||||
t.Fatalf("onRemove handler %d 执行 %d 次,期望恰好 1 次", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 5. StageContext 并发读改写 ----------
|
||||
|
||||
// StageContext 是全部 stage handler 共享的可变状态,字段全导出、靠调用方自觉
|
||||
// 持 Lock/RLock。媒体链路让 Extra 成为新热点(media_blocks 挂在这里),
|
||||
// 而 map 的并发写在 Go 里是直接 fatal,recover 都接不住。
|
||||
//
|
||||
// 这条测试锁定的不变量:按约定持锁的并发读改写不丢更新、不 fatal。
|
||||
func TestStress_StageContextConcurrentExtraAndFinalText(t *testing.T) {
|
||||
ctx := &StageContext{Extra: map[string]interface{}{}}
|
||||
|
||||
const workers, rounds = 16, 200
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < rounds; i++ {
|
||||
// 写:模拟插件往 Extra 塞媒体块并追加文本(读-改-写)
|
||||
ctx.Lock()
|
||||
ctx.Extra[fmt.Sprintf("k%d-%d", w, i)] = []ContentBlock{imageBlock("x")}
|
||||
ctx.FinalText += "."
|
||||
ctx.Unlock()
|
||||
|
||||
// 读:模拟另一个 handler 检查是否已被响应
|
||||
_ = ctx.IsResponded()
|
||||
ctx.RLock()
|
||||
_ = len(ctx.Extra)
|
||||
ctx.RUnlock()
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
ctx.RLock()
|
||||
defer ctx.RUnlock()
|
||||
if len(ctx.Extra) != workers*rounds {
|
||||
t.Fatalf("Extra 键数 = %d,期望 %d(出现 lost update)", len(ctx.Extra), workers*rounds)
|
||||
}
|
||||
if len(ctx.FinalText) != workers*rounds {
|
||||
t.Fatalf("FinalText 长度 = %d,期望 %d(出现 lost update)", len(ctx.FinalText), workers*rounds)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 6. OwnTools scope 包装器的并发正确性 ----------
|
||||
|
||||
// StageScopeOwnTools 的包装闭环里要读 ctx.ToolCalls 判断归属。
|
||||
// 并发下若判断与执行之间状态被改写,就会出现"别人的工具触发了我的 handler"——
|
||||
// 后果是插件对不属于自己的工具结果动手,且没有任何错误。
|
||||
func TestStress_OwnToolsScopeNoCrossPluginLeak(t *testing.T) {
|
||||
var registered StageHandler
|
||||
s := &PluginSDK{
|
||||
name: "mine",
|
||||
regStage: func(stage Stage, h StageHandler) { registered = h },
|
||||
}
|
||||
|
||||
var fired atomic.Int64
|
||||
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error {
|
||||
fired.Add(1)
|
||||
ctx.RLock()
|
||||
defer ctx.RUnlock()
|
||||
// 触发了就必须确实是自己的工具
|
||||
if len(ctx.ToolCalls) == 0 || ctx.ToolCalls[0].Plugin != "mine" {
|
||||
t.Errorf("handler 被别的插件的工具触发: %+v", ctx.ToolCalls)
|
||||
}
|
||||
return nil
|
||||
}, StageScopeOwnTools)
|
||||
|
||||
if registered == nil {
|
||||
t.Fatal("handler 未注册")
|
||||
}
|
||||
|
||||
const workers, rounds = 16, 100
|
||||
var wg sync.WaitGroup
|
||||
var mineCount atomic.Int64
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < rounds; i++ {
|
||||
// 每个 goroutine 用自己的 ctx——真实内核里 stage 扇出共享同一个
|
||||
// ctx,但那部分的并发由内核 host 仲裁;这里验证包装器本身。
|
||||
owner := "other"
|
||||
if (w+i)%2 == 0 {
|
||||
owner = "mine"
|
||||
mineCount.Add(1)
|
||||
}
|
||||
ctx := &StageContext{Extra: map[string]interface{}{}}
|
||||
ctx.ToolCalls = []ToolCall{{Plugin: owner, Name: "t"}}
|
||||
if err := registered(ctx); err != nil {
|
||||
t.Errorf("handler 返回错误: %v", err)
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got, want := fired.Load(), mineCount.Load(); got != want {
|
||||
t.Fatalf("handler 触发 %d 次,期望 %d 次(漏触发或跨插件触发)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 7. 媒体类型的 JSON 往返(跨进程边界的真实形态) ----------
|
||||
|
||||
// 媒体块与附件要经 JSON 过子进程边界。[]byte 在 JSON 里是 base64,
|
||||
// 往返不一致的后果是图片字节静默损坏——落进 CAS 后 digest 校验才会发现,
|
||||
// 而那时已经无从追查是谁改坏的。
|
||||
func TestStress_MediaTypesJSONRoundTripAtScale(t *testing.T) {
|
||||
// 覆盖真实会遇到的边界:空、单字节、含 0x00、全 0xFF、超过 base64 分组边界的长度
|
||||
sizes := []int{0, 1, 2, 3, 255, 256, 1023, 4096, 65537}
|
||||
for _, n := range sizes {
|
||||
data := make([]byte, n)
|
||||
for i := range data {
|
||||
data[i] = byte(i * 7 % 256)
|
||||
}
|
||||
att := MediaAttachment{
|
||||
Digest: strings.Repeat("a", 64),
|
||||
MIME: "image/png",
|
||||
Data: data,
|
||||
Name: "图片-名字 with space & 符号.png",
|
||||
Description: "一张紫蓝红三色带图,含 emoji 🎨 与换行\n第二行",
|
||||
}
|
||||
b, err := json.Marshal(att)
|
||||
if err != nil {
|
||||
t.Fatalf("size=%d marshal: %v", n, err)
|
||||
}
|
||||
var back MediaAttachment
|
||||
if err := json.Unmarshal(b, &back); err != nil {
|
||||
t.Fatalf("size=%d unmarshal: %v", n, err)
|
||||
}
|
||||
if len(back.Data) != n {
|
||||
t.Fatalf("size=%d 往返后长度 = %d", n, len(back.Data))
|
||||
}
|
||||
for i := range data {
|
||||
if back.Data[i] != data[i] {
|
||||
t.Fatalf("size=%d 第 %d 字节损坏: %02x != %02x", n, i, back.Data[i], data[i])
|
||||
}
|
||||
}
|
||||
if back.Name != att.Name || back.Description != att.Description || back.MIME != att.MIME || back.Digest != att.Digest {
|
||||
t.Fatalf("size=%d 元数据往返不一致: %+v", n, back)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// omitempty 必须真的生效:读路径上内核不回 Data,若序列化仍产出 "data":null
|
||||
// 之类的键,跨进程消息会凭空变大,且插件侧无法区分"没有字节"与"空字节"。
|
||||
func TestStress_MediaTypesOmitEmpty(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
v interface{}
|
||||
absent []string
|
||||
present []string
|
||||
}{
|
||||
{
|
||||
name: "Triple 无媒体",
|
||||
v: Triple{Subject: "甲方", Relation: "签署", Object: "合同"},
|
||||
absent: []string{"media_digests", "sentence_text", "confidence", "subject_type", "object_type"},
|
||||
present: []string{"subject", "relation", "object"},
|
||||
},
|
||||
{
|
||||
name: "Triple 带媒体",
|
||||
v: Triple{Subject: "甲方", Relation: "包含", Object: "图", MediaDigests: []string{"abc12345"}, SentenceText: "句子"},
|
||||
absent: []string{"confidence"},
|
||||
present: []string{"media_digests", "sentence_text"},
|
||||
},
|
||||
{
|
||||
name: "Doc 读路径无字节",
|
||||
v: Doc{ID: "d1", Title: "标题", Content: "正文", Attachments: []MediaAttachment{{Digest: "abc12345", MIME: "image/png"}}},
|
||||
absent: []string{"\"data\"", "media_digests", "score"},
|
||||
present: []string{"attachments", "digest", "mime"},
|
||||
},
|
||||
{
|
||||
name: "TextEvent 无附件",
|
||||
v: TextEvent{Role: "user", Content: "hi"},
|
||||
absent: []string{"attachments", "channel"},
|
||||
present: []string{"role", "content"},
|
||||
},
|
||||
{
|
||||
name: "ContentBlock 纯文本",
|
||||
v: ContentBlock{Type: "text", Text: "hi"},
|
||||
absent: []string{"image_url", "audio_url"},
|
||||
present: []string{"type", "text"},
|
||||
},
|
||||
{
|
||||
name: "ContentBlock 图片",
|
||||
v: imageBlock("AAA"),
|
||||
absent: []string{"audio_url", "\"text\""},
|
||||
present: []string{"image_url", "detail"},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
b, err := json.Marshal(c.v)
|
||||
if err != nil {
|
||||
t.Fatalf("%s marshal: %v", c.name, err)
|
||||
}
|
||||
s := string(b)
|
||||
for _, k := range c.absent {
|
||||
if strings.Contains(s, k) {
|
||||
t.Errorf("%s: 不该出现的键 %s —— %s", c.name, k, s)
|
||||
}
|
||||
}
|
||||
for _, k := range c.present {
|
||||
if !strings.Contains(s, k) {
|
||||
t.Errorf("%s: 缺少键 %s —— %s", c.name, k, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 媒体块在并发序列化下必须各自独立:ImageURL/AudioURL 是指针,
|
||||
// 若某处复用同一个指针再改写,序列化结果会互相污染。
|
||||
func TestStress_ContentBlockConcurrentMarshal(t *testing.T) {
|
||||
const workers, rounds = 16, 300
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < rounds; i++ {
|
||||
tag := fmt.Sprintf("w%d-i%d", w, i)
|
||||
blocks := []ContentBlock{
|
||||
{Type: "text", Text: tag},
|
||||
imageBlock(tag),
|
||||
{Type: "audio_url", AudioURL: &AudioURL{URL: "data:audio/wav;base64," + tag}},
|
||||
}
|
||||
b, err := json.Marshal(blocks)
|
||||
if err != nil {
|
||||
t.Errorf("marshal: %v", err)
|
||||
return
|
||||
}
|
||||
var back []ContentBlock
|
||||
if err := json.Unmarshal(b, &back); err != nil {
|
||||
t.Errorf("unmarshal: %v", err)
|
||||
return
|
||||
}
|
||||
if len(back) != 3 {
|
||||
t.Errorf("块数 = %d", len(back))
|
||||
return
|
||||
}
|
||||
if back[0].ImageURL != nil || back[0].AudioURL != nil {
|
||||
t.Errorf("文本块被填了媒体指针: %+v", back[0])
|
||||
}
|
||||
if back[1].ImageURL == nil || !strings.HasSuffix(back[1].ImageURL.URL, tag) {
|
||||
t.Errorf("图片块 URL 错位: %+v", back[1].ImageURL)
|
||||
}
|
||||
if back[1].AudioURL != nil {
|
||||
t.Errorf("图片块被填了音频指针")
|
||||
}
|
||||
if back[2].AudioURL == nil || !strings.HasSuffix(back[2].AudioURL.URL, tag) {
|
||||
t.Errorf("音频块 URL 错位: %+v", back[2].AudioURL)
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// ---------- 8. 注册面的并发 ----------
|
||||
|
||||
// 插件在 Start() 里起多个 goroutine 分别注册工具是常见写法。
|
||||
// def.Plugin 的默认填充若不是每次调用独立的,就会出现工具归属错乱——
|
||||
// 表现是 OwnTools scope 失效、WebUI 里工具挂在别的插件名下。
|
||||
func TestStress_RegisterToolConcurrentPluginDefaulting(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
got := map[string]string{} // toolName -> def.Plugin
|
||||
|
||||
s := &PluginSDK{
|
||||
name: "mine",
|
||||
regTool: func(name string, def ToolDef, h ToolHandler) error {
|
||||
mu.Lock()
|
||||
got[name] = def.Plugin
|
||||
mu.Unlock()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
const workers, perWorker = 16, 100
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < perWorker; i++ {
|
||||
name := fmt.Sprintf("tool_w%d_i%d", w, i)
|
||||
def := ToolDef{Description: "d", Parameters: map[string]interface{}{}}
|
||||
// 一半显式指定归属,一半靠 SDK 填默认值
|
||||
if i%2 == 0 {
|
||||
def.Plugin = "explicit"
|
||||
}
|
||||
if err := s.RegisterTool(name, def, func(map[string]interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
}); err != nil {
|
||||
t.Errorf("RegisterTool: %v", err)
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if len(got) != workers*perWorker {
|
||||
t.Fatalf("注册工具数 = %d,期望 %d", len(got), workers*perWorker)
|
||||
}
|
||||
for name, owner := range got {
|
||||
want := "mine"
|
||||
if isEvenSuffix(name) {
|
||||
want = "explicit"
|
||||
}
|
||||
if owner != want {
|
||||
t.Fatalf("工具 %s 归属 = %q,期望 %q", name, owner, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isEvenSuffix 判断 tool_wX_iY 里的 Y 是否为偶数。
|
||||
func isEvenSuffix(name string) bool {
|
||||
idx := strings.LastIndex(name, "_i")
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
n := 0
|
||||
if _, err := fmt.Sscanf(name[idx+2:], "%d", &n); err != nil {
|
||||
return false
|
||||
}
|
||||
return n%2 == 0
|
||||
}
|
||||
|
||||
// nil 依赖下所有便捷方法必须静默降级而非 panic。
|
||||
//
|
||||
// 这是"媒体存储可关闭"在 SDK 层的对应物:内核未注入某个 API 时
|
||||
// (精简部署、插件权限不足、子进程握手尚未完成),插件的调用不该崩。
|
||||
func TestStress_NilDependenciesDegradeSilently(t *testing.T) {
|
||||
s := &PluginSDK{name: "bare"}
|
||||
|
||||
const workers = 16
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 200; i++ {
|
||||
s.InjectText("s", "c", "t")
|
||||
s.InjectTextNoMemory("s", "c", "t")
|
||||
s.InjectInterruptText("s", "c", "t")
|
||||
if got := s.InjectInputSync("s", "c", "t"); got != "" {
|
||||
t.Errorf("无 injector 时同步注入应返回空串,got %q", got)
|
||||
}
|
||||
s.InjectInputMedia("s", "c", "t", []ContentBlock{imageBlock("x")})
|
||||
if got := s.InjectInputMediaSync("s", "c", "t", nil); got != "" {
|
||||
t.Errorf("无 injector 时媒体同步注入应返回空串,got %q", got)
|
||||
}
|
||||
s.InjectInterruptMedia("s", "c", "t", nil)
|
||||
|
||||
// getter 全部应返回 nil 而非 panic
|
||||
_ = s.Memory()
|
||||
_ = s.TextMemory()
|
||||
_ = s.DocMemory()
|
||||
_ = s.Knowledge()
|
||||
_ = s.LLM()
|
||||
_ = s.Social()
|
||||
_ = s.Events()
|
||||
_ = s.PluginMgr()
|
||||
_ = s.Settings()
|
||||
|
||||
// 注册面无 registrar 时应返回 nil error
|
||||
if err := s.RegisterTool("t", ToolDef{}, nil); err != nil {
|
||||
t.Errorf("无 registrar 时 RegisterTool 应返回 nil,got %v", err)
|
||||
}
|
||||
if err := s.RegisterPluginAPI("a"); err != nil {
|
||||
t.Errorf("无 registrar 时 RegisterPluginAPI 应返回 nil,got %v", err)
|
||||
}
|
||||
s.RegisterStage(StageOnInput, func(*StageContext) error { return nil })
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
Reference in New Issue
Block a user