跳转至

16 - 图片生命周期与私人图床(方案 · 实现规格)

状态:后端与 WebUI 已发布(image-host 1.0.15);插件侧已随 4.28.0 发布(含清理/对账/来源上报;AI 看图/批量/工具图类型标签见 17-图片引用标准与AI图库交互方案),经 483 个插件单测 + 57 个后端单测,见 §35。 全文三段:第一部分 = 方案(决策 / 契约 / 规则 / 分期);第二部分 = 实现细化(文件划分 / 路由表 / SQL / 伪码 / 函数签名 / PR 级任务拆解);第三部分 = 批量能力与插件同步(批量上传 / 存图库 / 标签 / 删除 / 变更日志 / 性能实测)。规则与写法冲突时以第一部分为准。 一句话目标:图片在源链接失效前被捕获;字节不落进海豹存储;图床既是 AI 的取图源,也是可管理的私人图库。 关联:03-核心模块详解、04-工具系统、06-数据存储、08-相关后端项目、09-注意事项与常见问题。 涉及两个仓库:本仓库(插件)与 aiplugin4-backends(后端,源码在 shop 分支 backends/ 下)。 4.27.0 起:「后端」页签的地址项已合并为「后端配置」JSON(v1),本文中的「后端 → 图床」「图床Token」「图片转base64」等均为旧版称呼;当前配置以 08-相关后端项目 与 README 为准。


0. 决策摘要

# 决策 理由
1 抓取时机与存储位置解耦:图床只解决"字节放哪儿",不解决"什么时候抓" 时机才是当前真正踩的坑(见 §2)
2 另开 image-host 后端,不扩展 image-url-to-base64 i2b64 是无状态转换器,可能做成公共 API;不能给它加持久化职责(见 §5.1)
3 图床接受 URL 自己抓(一跳),主链路不经 base64 base64 是纯过路费:后端多一份 +33% 字符串、插件 goja 再多一份(见 §5.2)
4 图床兼做 base64 出口(GET /i/<id>/b64) 只吃 base64 的模型直接向图床要,i2b64 在配了图床后完全退出(见 §5.2)
5 id = sha256(字节),/i/<id> 即可拼出 URL 哈希校验去重天然成立 + 不可猜 + 与扩展名无关(见 §5.4)
6 引入 SQLite 元数据层(Python 标准库,无额外依赖) 来源 / 标签 / 重命名 / 搜索必须有结构化存储;热路径 /i/<id> 不查库(见 §5.4)
7 PNG / GIF 原格式保存,不转码;另存缩略图 动画 GIF 不能丢帧;"体积够小、访问够快"靠去重 + 缩略图 + immutable 缓存(见 §5.6、§5.7)
8 两边都有删除权:插件按引用释放,图床按 cap/LRU 淘汰 两侧都主动清理才健康;安全性由引用计数保证,而不是把删除权收归一方(见 §5.9)
9 手动上传的图永不自动删(origin=manual) 人放进来的东西不能被机器清掉;WebUI 可把抓来的图"收进图库"转为 manual
10 一个服务三个面:REST(插件)+ MCP(AI)+ WebUI(人),共享同一份 SQLite 同一份数据,分进程只会带来跨服务鉴权与一致性成本
11 WebUI 必须鉴权;/i/*、/t/* 保持公开 画廊能列出全部图片 → "ID 不可猜"失效,必须用登录挡住(见 §5.8、§5.10)
12 插件新增 1 项配置:「后端 → 图床」地址(默认空 = 不启用) 与"没配图床保持现状"直接对应
13 图床必须做完整 SSRF 加固(决策 3 的代价) 接受 URL 就等于获得出网能力,这是唯一的非零攻击面(见 §5.10)

1. 目标与非目标

目标

  1. 图片在源链接失效前被捕获,AI 不再"看不见图"。
  2. 字节不落进海豹 storage.db(当前每张转过的图会带几百 KB base64)。
  3. 图床成为可管理的私人图库:按来源可查、可重命名、可打标签、可删除。
  4. AI 能通过 MCP 检索与管理图库。
  5. 两端存储都有界,且不会互相清掉对方还需要的东西。
  6. 图床故障不成为硬依赖(逐级回退,最坏退回现状)。

非目标

  • 不做通用图床/CDN:不做多租户、不做对外分享页、不做 S3 后端(P7 可选)。
  • 不做语义/视觉搜索(CLIP 之类):搜索限定在名称 / 标签 / 来源元数据。
  • 不做实时转码(?w=&h=&fmt= 那套):慢且吃 CPU;缩略图预生成,原图原样发。
  • 不改「识别图片时将url转换为base64」的语义(保持 永不 / 自动 / 总是 三态)。
  • 不改 image-url-to-base64 的任何行为(它的加固是关联但独立的事项,见 §5.1)。

2. 现状盘点

2.1 三条链路

链路 位置 行为
收到消息 src/resource/image.ts:404-443 → createUrlImage(:326-335) 只登记 URL + imageId,并立即落盘;不转 base64。识图条件命中才走 imageToText
识图 src/resource/image.ts:268-297 「总是」→ 先转 base64 再识图;「自动」→ 先识图,失败才转
组包给模型 src/utils/message.ts:499-536 URL 型图片调 urlToBase64()(10s 超时),不受「识别图片时将url转换为base64」配置控制

结论:转换点是"第一次真的把它当图片用"的那一刻,而不是"收到的那一刻"。

2.2 现有兜底(已实现,不要重复造)

  • 永久性失败判定:isPermanentUrlError 命中 4\d{2} Client Error / url has expired(src/resource/image.ts:191-195)。
  • 阻断:1 小时冷却、连续 3 次永久放弃(src/resource/image.ts:139-144)。
  • 模型报下载失败 → 标记 + 降级重发一轮(src/agent/agent.ts:323-360、markUrlBrokenByUrls src/resource/image.ts:172-184)。
  • 渲染期降级:上下文存的是 [img:id] 文本,死图自动退化为文本标签。

2.3 缺口

缺口 证据
image_<id> 只增不减 全仓库无删除路径;Image.save(src/resource/image.ts:361-363)只有 storageSet
无法枚举存储键 types/seal.d.ts:495-504 只有 storageInit / storageClose / storageSet / storageGet,没有 remove / list
base64 落进 storage JSON.stringify(img) 含 base64
转换时机滞后 见 §2.1
"图已删除"与"模型够不到"被混为一谈 现有降级一律标 urlBroken,会把"模型够不到图床"误判为"图没了"(§6.6 必修)
历史图片无法枚举回收 没有 storageList,只能靠 agent_<name> 里持久化的 sessionService.sessionMap(src/agent/agent.ts:601、src/session/session_service.ts:26)尽力枚举

2.4 一条硬约束:插件自己读不了二进制

海豹的 goja fetch 只有 text() / json(),text() 会按 UTF-8 解码把原始字节替换成 U+FFFD 永久损坏(08-相关后端项目)。插件永远无法自己下载图片字节。

推论:抓取必须由后端完成(决策 3);插件需要字节时也必须向后端要 base64(决策 4 的 /i/<id>/b64,或未配图床时的 i2b64)。


3. 设计原则

  1. 职责分离:无状态工具(i2b64)不持有数据;有状态服务(image-host)持有数据,但要为自己获得的能力付安全代价。
  2. 主链路不过 base64:能不中转就不中转。
  3. 一个真相:图片的元数据(来源/名称/标签)只存在于图床;插件只存 hostedUrl 与自己的回收索引。
  4. 两边都有删除权,靠引用计数保证安全:谁都可以清自己那一侧,但"删除文件"这件事必须看还有没有别人引用(§5.9)。
  5. 人放进来的东西机器不能清:origin=manual 永不自动删。
  6. 增强而非硬依赖:没配图床 = 完全现状;图床挂了 = 逐级回退。
  7. 判据同构:两端都是"最近使用 + 上限",预算不同(字节 vs 条目)。

4. 架构总览

QQ 消息(带 rkey 短时效直链)
   │
   ├─ 建 Image 条目 + 立即落盘(现状不变)
   │
   └─[图床已配置 且 是短时效链接]──► POST {图床}/api/upload
                                        { url, ref:插件imageId,
                                          source:{平台/群/用户/消息/会话/时间} }
                                          │
                                    流式下载 + 边下边算 sha256
                                          │
                        ┌─────────────────┴─────────────────┐
                        ▼                                   ▼
   200 { id, url: "/i/<id>", b64_url }          502 { upstream_status: 404 }
                        │                                   │
                        │                            标 urlBroken(现有规则)
                        ▼
        插件把 url 拼到对外地址上再用(后端只给相对路径,不给公网前缀):
        · image-host.publicUrl(IP/域名)可用 → 模型与协议端直接用该绝对地址
        · publicUrl 空且 url 是回环 → 发图取本地文件、模型回退内联 base64

图床的三个面(同一份 SQLite、同一个进程)
   REST  /api/*  /i/*  /t/*   ← 插件(抓取、取图、取 b64、释放引用、对账)
   MCP   /mcp                 ← AI(搜索 / 重命名 / 打标签)
   WebUI /                    ← 人(鉴权:画廊 / 来源 / 删除 / 重命名 / 标签 / 上传)

5. 后端方案:image-host

5.1 为什么另开,而不是扩展 i2b64

维度 扩展 i2b64 另开 image-host(采用)
公共服务可行性 被破坏:无状态工具一旦落盘用户图片,就不能作为公共 API 开放(滥用、合规、责任面) i2b64 保持纯净,随时可公开
发版节奏 图床改动会牵动公共 API 的版本 各自独立
安全面 抓取 + 持久化 + 分发 + 管理界面 + MCP 全耦合在一个进程 攻击面可单独评估与加固
依赖 需 Pillow / imageio Flask + requests + Pillow(+ 标准库 sqlite3)
代价 — 多一个端口与 registry 条目

i2b64 的加固(超时 / 大小上限 / scheme 校验 / 私网拦截)不属于本方案,但因为"可能做成公共 API",其优先级反而更高——列为关联独立事项,单独提 PR。

5.2 为什么图床自己抓,且兼做 b64 出口

维度 两跳(i2b64 → 图床) 一跳(图床自己抓,采用)
base64 开销 后端多一份 +33% 字符串;插件 goja 里再多一份;上传 body 里第三份 零:流式下载直接落盘,边下边算 sha256,全程不进内存
网络跳数 2 1
依赖数 i2b64 与图床都必须在线 只有图床必须在线
插件内存 需在 goja 里持有大字符串 只发一个 URL
安全面 图床可做到"零出网、SSRF 为零" 图床获得出网能力 → 必须完整 SSRF 加固(§5.10)

b64 出口为什么放图床:字节本来就在图床手上,GET /i/<id>/b64 只是把它 base64 一下;再让 i2b64 去下载一遍是重复劳动。于是:

配了图床之后,i2b64 完全退出——抓取、内联 b64、论坛上传所需字节,全部由图床提供。i2b64 只在未配置图床时继续承担现状职责。

5.3 服务概览

项 值
目录(shop 分支) backends/image-host/
文件 backend.json、requirements.txt、imagehost.py、webui.html、mcp.py
端口 46680(紧邻 46678 / 46679)
依赖 Flask + requests + Pillow(sqlite3 / hashlib / os 为标准库)
版本 1.0.0
数据 <data_dir>/image-host/:image.db(SQLite)+ shards/(原图)+ thumbs/(缩略图)+ tmp/
定位 私人图床 + 图库:抓取落库、只读分发、b64 出口、WebUI 管理、MCP 工具

5.4 数据模型(SQLite)

-- 一张图 = 一份字节(哈希校验去重:id 就是 sha256,同字节必然同 id)
CREATE TABLE blob (
  id             TEXT PRIMARY KEY,   -- sha256(原图字节),64 hex
  format         TEXT NOT NULL,      -- png / jpg / gif / webp / bmp
  mime           TEXT NOT NULL,      -- 由 magic bytes 判定,不信任声明
  size           INTEGER NOT NULL,
  created_at     INTEGER NOT NULL,
  last_access_at INTEGER NOT NULL,   -- LRU 依据
  name           TEXT,               -- 重命名(可空)
  origin         TEXT NOT NULL,      -- plugin = 插件抓的(可自动清)/ manual = 人放的(永不自动清)
  orphan_at      INTEGER,            -- 最后一次失去全部引用的时间(淘汰排序用)
  thumb_path     TEXT
);

-- 引用:一次上传 = 一条引用。同一张图被转发 N 次 = 1 份字节 + N 条引用
CREATE TABLE ref (
  id         TEXT NOT NULL,          -- → blob.id
  ref        TEXT NOT NULL,          -- 插件侧 imageId(全局唯一);手动上传时由后端生成
  platform   TEXT,                   -- QQ / DISCORD / ...
  group_id   TEXT, user_id TEXT, message_id TEXT, session_id TEXT,
  at         INTEGER,                -- 消息时间
  raw_url    TEXT,                   -- 原始(可能已失效的)链接,排障用
  PRIMARY KEY (id, ref)
);

CREATE TABLE tag (
  id TEXT NOT NULL, tag TEXT NOT NULL,
  PRIMARY KEY (id, tag)
);

CREATE INDEX idx_blob_access ON blob(last_access_at);
CREATE INDEX idx_blob_orphan ON blob(orphan_at);
CREATE INDEX idx_ref_blob     ON ref(id);
CREATE INDEX idx_ref_group    ON ref(group_id, at);
CREATE INDEX idx_tag_tag      ON tag(tag);

去重(哈希校验):id = sha256(字节),所以"同字节 → 同 id → 目标文件已存在 → 不再写第二份",只是追加一条 ref。这就是哈希校验去重,没有别的机制。它同时带来两个好处:不可猜(256 bit)与 URL 与扩展名无关(/i/<id> 自足)。

为什么按 blob 而不是按上传建条目:QQ 里同一张图被转发极常见,去重是"体积够小"的最大来源;名称/标签挂 blob(同一张图的不同转发不该有两套标签),来源挂 ref(转发史要留痕)。

引用为什么必须存在:它同时解决两件事——① 多个插件条目可能指向同一个 blob,删谁都不能连带删掉别人;② "两边都有删除权"必须有个不误删的判据(§5.9)。

5.5 接口契约

读写分离的鉴权边界:/i/*、/t/* 公开(模型与协议端不带 header);/api/*(除 health)、/、/mcp 需 token。

公开读

路由 说明
GET /i/<id> 原图。Content-Type 取自库;Cache-Control: public, max-age=31536000, immutable;支持 ETag / If-None-Match → 304
GET /i/<id>/b64 { "base64": "...", "format": "png" } —— 供只吃 base64 的模型(插件 ensureBytes 用)
GET /t/<id> 缩略图(WebUI 画廊用)
GET /api/health { ok, image_host:true, cap_mb, used_mb, count, trash, changes_head, version, webui }

鉴权写 / 管理

POST /api/upload —— 三种入参形态

// ① 抓取(插件主用):ref 必填 = 插件侧 imageId,作为这条引用的标识
{ "url": "https://multimedia.nt.qq.com.cn/...&rkey=...",
  "ref": "a1b2c3",
  "source": { "platform":"QQ", "group_id":"...", "user_id":"...",
              "message_id":"...", "session_id":"...", "at": 1730000000000 } }

// ② 已有字节(备用;插件主链路不使用)
{ "base64": "....", "format": "png", "ref": "a1b2c3", "source": { ... } }

// ③ multipart(WebUI 手动上传):ref 可省略,后端生成;origin 强制为 manual
file=<二进制> + source=<JSON 字符串>
// resp 200
{ "id": "9f2c...e1",                                    // sha256
  "url": "/i/9f2c...e1",                                // 相对路径,插件拼后端地址
  "b64_url": "/i/9f2c...e1/b64",
  "thumb_url": "/t/9f2c...e1",
  "format": "png", "size": 184320,
  "dedup": true,           // true = 哈希命中已有字节,只追加了一条 ref
  "ref": "a1b2c3" }

错误契约(插件靠 upstream_status 分类永久/可重试):

400 { "error": "bad_url" }                                  // scheme 非法 / 私网 / 解析失败
400 { "error": "unsupported_format" }                       // magic bytes 不在白名单
413 { "error": "too_large", "size": 20971520 }              // 超单文件上限(流式中断)
502 { "error": "fetch_failed", "upstream_status": 404 }     // 上游 4xx/5xx 原样透出
507 { "error": "disk_full" }                                // 淘汰后仍放不下(见 §5.9 手动图保护)
  • 图床不重试:一次尝试 + 超时即返回;重试策略归插件(复用现有冷却 / 退避 / 三次放弃)。
  • 上游 404 / 400 / QQ 的 download url has expired → 插件按现有 isPermanentUrlError 规则判永久 → urlBroken。
  • 上游 429 / 5xx → 可重试 → 走冷却。

POST /api/release(插件释放自己的引用 —— 插件的删除权)

// req —— 按 ref 释放,不是按 id!按 id 会连带删掉别人对这个 blob 的引用
{ "refs": ["a1b2c3", "d4e5f6"] }
// resp
{ "released": 2, "blobs_freed": 1 }   // blobs_freed = 释放后变成"零引用"的 blob 数

POST /api/exists(插件对账 —— 得知图床侧删了什么)

// req
{ "refs": ["a1b2c3", "..."] }
// resp
{ "missing": ["..."] }     // 已不存在的 ref(blob 被清 或 ref 已被释放);插件据此标 urlBroken / 清条目

PATCH /api/image/<id>(重命名 / 标签 / 收进图库)

{ "name": "群友发的猫",
  "add_tags": ["猫","表情包"], "remove_tags": ["待分类"],
  "origin": "manual" }     // "收进图库" = 改成 manual,从此不自动删

DELETE /api/image/<id>(手动删除,立即生效,连字节一起删)

GET /api/list(WebUI 与 MCP 共用)

?q=&tags=a,b&platform=QQ&group_id=&origin=manual&sort=created|access&order=desc&limit=50&offset=0

GET /(WebUI,鉴权)、/mcp(MCP Streamable HTTP,鉴权)

5.6 抓取与存储实现

<data_dir>/image-host/
  image.db                     # SQLite
  shards/ab/<id>.<ext>         # 原图(ab = id 前两位)
  thumbs/ab/<id>.webp          # 缩略图
  tmp/<uuid>.part              # 下载中;完成后 os.replace 原子落位

抓取流程(流式,不进内存):

  1. 校验 URL:scheme ∈ http/https;解析全部 A/AAAA 记录,拒绝私网 / 回环 / 链路本地(§5.10)。
  2. requests.get(url, stream=True, timeout=(5,20), allow_redirects=True),每一跳重定向都重做校验,上限 3 跳。
  3. 边读边算 sha256 并写 tmp/<uuid>.part;累计超 MAX_FILE 立即中断 → 413。
  4. 用已读前 8 字节判 magic bytes → format / mime;不在白名单 → 400。
  5. 哈希校验:目标文件已存在 → 删临时文件,dedup: true,不写第二份字节,只追加一条 ref。
  6. 否则 os.replace 原子落位,并生成缩略图(Pillow,最大边 THUMB_MAX 默认 512,等比缩放,输出 WebP;GIF 取首帧)。缩略图失败不阻断主流程,只记日志。
  7. 写入 blob(origin=plugin)+ ref 行。

PNG / GIF 处理约定:

  • 原图一律不转码:PNG 存 PNG、GIF 存 GIF,动画 GIF 不丢帧(这是"兼容 gif"的硬要求)。
  • 缩略图单独生成,GIF 取首帧 → WebP,不覆盖原图。
  • 可选(P7):JPEG 存前剥 EXIF(省体积 + 隐私);静态 GIF 的 b64 出口支持 ?format=png(复用 i2b64 既有语义,给不接受 GIF 的模型用)。

5.7 "体积够小、访问够快"的三条来源

手段 收益
哈希校验去重(id = sha256) 同一张图多群转发只存一份字节——省空间的最大头
预生成缩略图 画廊不必加载原图;/t/<id> 是几十 KB 级
immutable + ETag 内容寻址 URL 永不变内容,浏览器/反代/CDN 可永久缓存;304 省流量
(不做)实时转码 ?w=&h=&fmt= 每次请求都转码,慢且吃 CPU —— 明确不做

5.8 WebUI

项 设计
鉴权 登录页 + 密码(PASSWORD env);签名 cookie 保持会话。未设密码 = 关闭 WebUI(避免误暴露),只留 REST/MCP
画廊 瀑布流/网格,缩略图 /t/<id>,分页 + 懒加载
每张图展示 缩略图、名称、标签、来源(平台 / 群 / 用户 / 消息 ID / 时间,多条来源可展开)、origin(插件抓的 / 手动上传)、体积、格式、创建时间
筛选/搜索 单一搜索框(名称 / 标签 / ID / 群号统一模糊搜索)+ 来源筛选(全部 / 插件抓的 / 手动上传);点标签卡片把标签名填进搜索框
操作 上传(拖拽 / 选择文件 → origin=manual)、收进图库(把插件抓的图改成 origin=manual)、重命名、增删标签、删除(单张 + 批量圈选)
实现 后端直出单页 HTML + 少量原生 JS,不引入前端框架(保持"轻量")

权限三层:/i/* /t/* 公开 → 模型与协议端可用;/ 鉴权 → 人能浏览管理;/api/* 写操作鉴权 → 只有插件与管理员能改。

5.9 生命周期与交叉清理

两侧各自的清理权

谁 怎么删 作用范围
插件 POST /api/release { refs } 只释放自己的引用;字节是否消失由引用计数决定
图床(自动) cap + LRU 淘汰 见下面的删除顺序
图床(手动) WebUI / DELETE /api/image/<id> 立即删,无约束(人说了算)

引用计数是安全底线

blob 的引用数 = COUNT(ref WHERE id = blob.id)
插件 release 掉一个 ref  →  引用数 -1  →  若归零则置 orphan_at = now

为什么插件必须按 ref 释放而不是按 id:哈希去重意味着多个插件条目可能指向同一个 blob(同一张图被转发多次)。若按 id 释放,会连带抹掉其他条目的引用,那些条目就会莫名其妙变成死图。

图床的删除顺序(cap 压力下)

  1. origin=plugin 且零引用的,按 orphan_at 最旧优先 —— 插件已经明确不要了
  2. 仍不够时,origin=plugin 且仍有引用的,按 last_access_at 最旧优先 —— 图床有删除权,被删的引用由插件下次对账(exists)得知
  3. origin=manual 永不自动删。如果手动上传的内容本身就把 cap 占满 → 新上传直接 507,而不是删手动图

决策 9 的代价要认下来:origin=manual 的图只能由人删。极端情况下图床会因为"手动图占满"而拒绝新抓取,这是刻意的——机器不该清掉人放进来的东西。 无宽限期:release 立即生效。安全性不靠等待,而靠引用计数(还有别人引用就不会消失)+ origin=manual(人放的不会被清)。

交叉清理(双向信号)

插件 → 图床:POST /api/release { refs }
    时机:插件自己的索引淘汰掉某条时(它已不再引用这张图)
    效果:立即释放该引用;若归零则该 blob 进入"可淘汰"队列

图床 → 插件:POST /api/exists { refs }
    时机:插件启动时 + 每 6 小时 + 索引 trim 后
    效果:插件把 missing 的标 urlBroken / 清条目,不再产生 image_url

注意 exists 用 ref 而不是 id:插件关心的是"我那条引用还在不在",而不是"这个 blob 还在不在"。

已知泄漏与兜底

插件侧若发生数据丢失(重装、storage 被清、.ai forget 之后),它的 ref 永远不会被 release → 那些 blob 会长期留在图床里。兜底:WebUI 按 origin=plugin + 最后访问时间排序,人可批量清理;或 P7 提供"释放 N 天未访问的插件引用"的管理动作。

5.10 安全模型(决策 3 的代价,必修)

面 处理
scheme 仅 http/https;拒绝 file://、gopher:// 等
SSRF(IP) 解析全部 A/AAAA,任一命中私网 / 回环 / 链路本地 / 保留段 → 400
SSRF(重定向) 每一跳都重新校验;上限 3 跳(否则可用 302 绕过)
SSRF(DNS 重绑定) 私人部署 + token 下可接受;更强时开启 host allowlist
host allowlist(可选) 默认关闭;开启后只允许白名单域名(如 *.qpic.cn、multimedia.nt.qq.com.cn)。公网部署建议开启
鉴权边界 公开:/i/*、/t/*、/api/health;鉴权:/api/* 其余、/、/mcp
上传鉴权 AIPLUGIN4_BACKEND_TOKEN(Bearer 或 X-Token);MCP 走同一 token(插件 MCP 配置支持 headers)
WebUI 鉴权 密码 + 签名 cookie;未设密码则整个 WebUI 关闭
ID 不可猜 内容寻址 256 bit。但画廊可列出全部 → 不可猜性对"人"失效,故 WebUI 必须鉴权
路径穿越 只认 ^[0-9a-f]{64}$;shards/thumbs 路径由 id 推导,不接受外部路径
内容类型 magic bytes 复核,不接受声明即信
体积 流式大小上限 + 总容量上限 + 写入前淘汰;失败路径清理 tmp/*.part
资源占用 流式落盘(内存与图片大小无关);并发抓取信号量(默认 4);缩略图生成也走信号量
XSS(WebUI) 名称/标签渲染必须转义(用户可控内容)

公网部署必须:设 token + 设 WebUI 密码 + 开启 host allowlist + 反向代理限流 + HTTPS。未设 token 时后端等于公开的 SSRF 代理,README 需明确警告。 隐私提示:/i/ 一旦公网可读,等于"有链接就能看图",QQ 私聊图片也在内。不可猜 ID 只是缓解;画廊有密码才挡住了"翻全部"。README 需写明。

5.11 MCP 工具

后端在 /mcp 暴露 Streamable HTTP,当前 6 个工具:

工具 参数 返回
image_search query?(名称/标签/来源模糊匹配)、tags?、platform?、group_id?、tool?、server?、since?、scope?(gallery 默认长期图库 / plugin 插件抓取 / all 全部)、limit?、offset? 文本列表:id / 名称 / 标签 / 来源摘要 / 工具 / 服务器 / url / 时间;每条附 [img:ID];来源/工具/类型是元数据,不写标签
image_rename id、name 结果文本
image_tag id? / ids?[](二选一)、add?[]、remove?[] 结果文本(支持批量)
image_keep id? / ids?[] 收进图库(不再被自动清理)
image_unkeep id? / ids?[] 移出图库(重新进入自动淘汰候选)
image_tags q?(标签名模糊过滤)、limit?(≤200,默认 50)、offset? 标签清单:标签 ×次数,带总数与下一页 offset

[img:图床ID] 可直接发送:image_search 返回的是图床 sha256 id,插件 4.28.0 起会把它规范化为 [img:h<sha256>] 并解析成虚拟 Image(hostedId),发送时用图床稳定地址(publicUrl 或非回环的 url + /i/<id>);不落插件 storage、不重复上传。旧上下文里的图床图片发送也统一优先用该地址(原始 QQ 链接过期也不影响)。对外地址用 image-host.publicUrl(IP/域名,复制配置时自动生成);没有它且 url 是回环时自动降级(发图落本地文件、模型用 base64)。

  • 插件侧零代码:按 04-工具系统 的 MCP 方式在「MCP → MCP服务器」的 mcpServers 里加一台 image-host(url: http://127.0.0.1:46680/mcp + token header),工具自动 tools/list 发现并注册(来源分组 = image-host)。
  • 不做 image_delete 工具:删除属于人的决定,放在 WebUI(AI 误删的代价不对称)。
  • 工具返回只给 id 与元数据,不给大段 base64;AI 要看图用 image_view(多模态直接看,≤4 张)或 image_to_text(文字描述,≤3 张)。
  • 不做 image_delete 工具:删除属于人的决定,放在 WebUI(AI 误删的代价不对称)。
  • 工具返回只给 id 与元数据,不给大段 base64;AI 要看图时用 [img:图片ID] 走既有链路。

5.12 配置项(env,沿用 AIPLUGIN4_* 风格)

env 默认 说明
AIPLUGIN4_IMAGE_HOST_DIR <repo>/data/image-host 数据目录
AIPLUGIN4_IMAGE_HOST_CAP_MB 2048 总容量(原图 + 缩略图)
AIPLUGIN4_IMAGE_HOST_MAX_FILE_MB 8 单文件上限
AIPLUGIN4_IMAGE_HOST_WEBUI on WebUI 开关(on/off,重启生效)
AIPLUGIN4_IMAGE_HOST_PASSWORD 123456 WebUI 密码
AIPLUGIN4_IMAGE_HOST_ALLOW_HOSTS 空 域名白名单,逗号分隔;空 = 只做 IP 级 SSRF 拦截
AIPLUGIN4_IMAGE_HOST_THUMB_MAX 512 缩略图最大边
AIPLUGIN4_IMAGE_HOST_FETCH_TIMEOUT 5,20 连接/读取超时(秒)
AIPLUGIN4_IMAGE_HOST_FETCH_CONCURRENCY 4 并发抓取上限
AIPLUGIN4_BACKEND_TOKEN 空 复用,API 与 MCP 鉴权

5.13 后端侧差异清单

# 仓库 / 文件 改动
1 aiplugin4-backends(shop 分支)backends/image-host/imagehost.py 新增:REST + 流式抓取 + 哈希去重 + SQLite + 引用计数 + 缩略图 + cap/LRU + §5.10 全部加固
2 同上 backends/image-host/webui.html 新增:单页画廊
3 同上 backends/image-host/mcp.py 新增:3 个 MCP 工具
4 同上 backends/image-host/backend.json 新增
5 同上 backends/image-host/requirements.txt 新增(Flask + requests + Pillow)
6 同上 backends.json 新增条目(name image-host、port 46680、source shop 分支)
7 同上 CHANGELOG.md / README.md 新条目 + 后端清单 + 公网部署 / 隐私 / SSRF / WebUI 密码警告
— backends/image-url-to-base64/* 不动(加固另开 PR)

6. 插件方案

6.0 v1 实施范围(已全部落地)

做(全部完成):

  1. 配置三项:「图床」(地址,空 = 完全现状)、「图床Token」(后端 API Token)、「图片清理天数」(默认 30,0 = 不清理)。
  2. Image 新字段 hostedUrl / hostedId / hostedAt / lastUsedAt / sourceMeta(全部进 validKeysMap)。
  3. 生成图片对象即上传:createUrlImage(收消息建图)对所有 http(s) 图片 fire-and-forget 上传,不等会话触发;生图/表情包落地时同样立即转存。
  4. 生图拿到 base64 后也转存图床(src/media/task_queue.ts 落地处 + 表情包工具),成功后清空 base64。
  5. 有图床时不再持久化 base64:Image.save 在写 storage 前去掉 base64(内存里可留一份给本轮请求用)。
  6. 有图床时不依赖 i2b64:urlToBase64() 走图床 GET /i/<id>/b64;图片还没入库就先 POST /api/upload 再取;图床失败按现有冷却/永久失败规则降级,不回头调 i2b64。
  7. 模型侧统一用图床对外地址:image-host.publicUrl(IP/域名,复制配置时自动生成)可用时,textToMultimodalContent / 识图 / 以图生图直接用 remoteUrl,论坛发图仍取字节上传;只有 publicUrl 为空且 url 是回环地址时才回退 ensureBytes() 取内联字节,取不到就降级文本标签。
  8. 协议端发图(getSendFile())优先用「图床地址 + hostedUrl」,避免 QQ 链接过期;图床配成回环时取 /i/<id>/b64 落本地文件,绝不把回环地址发给协议端。
  9. 图片索引 + 定时清理(src/resource/image_index.ts / image_gc.ts):64 片索引,启动 60s 后跑一轮、之后每 6 小时一轮;超过「图片清理天数」未使用的记录删掉,并把 ref 交给 POST /api/release;失败进待补发队列,下次重试。
  10. 图床删除对账:增量拉 /api/changes(游标持久化),delete_ref / delete_blob / purge_blob 命中后把对应图片标记为不可用并清掉图床字段(不再白试 404)。
  11. 来源字段完整上报:platform / group_id / user_id / message_id / session_id / at / raw_url(消息 id 从 handleReceipt/deferReceipt 透传)。
  12. exists 引用对账:每轮清理前用 POST /api/exists 批量确认索引里带图床 id 的图片;缺失的标失效(软删除因 ref 仍在,不会误伤)。
  13. 上传并发上限 4:合并转发大量图片时不会一次打爆后端。

交叉清理约束(已实现并被测试覆盖):

  • 清理只删「插件自己这条时间线」的记录:内存里正在用的图片绝不删;删记录后才释放 ref。
  • 释放按 ref 幂等:重复释放只是 released=0;图床侧只有引用归零才可能回收字节。
  • 同哈希:多张图片共享一份字节,各自一条 ref;清理一张不影响另一张。
  • 图床侧删除(WebUI/容量淘汰/回收站过期)→ 插件通过 change_log + exists 双通道感知并标记失效,不会和 GC 互相踩。
  • 每轮顺序:变更对账 → exists 对账 → 清理(release)→ 补发待释放。

明确不做的(与图床无关/无收益):

  • i2b64 加固:独立事项;配了图床后插件不再调用它。
  • .ai forget --all 立即释放:清上下文不等于删图片,图片仍可能被后续引用;由定时清理按"最后使用时间"兜底。
  • 插件侧主动删除图片:删除权在图床 WebUI(AI 误删代价不对称),插件只做「过期清理 + 引用交还」。

6.1 数据模型

// Image 新增字段
hostedUrl: string;   // 图床路径(相对,如 "/i/<sha256>";空 = 未入库)
hostedId: string;    // 图床 id(= sha256),取 b64 / release 用
hostedAt: number;    // 入库时间(ms),0 = 未入库
lastUsedAt: number;  // 最后使用时间(ms),清理任务据此判断过期
sourceMeta: string;  // 来源信息 JSON(platform/group_id/user_id/message_id/at)

后端 1.0.5 起不再有 PUBLIC_BASE_URL 与 reachable:接口返回的 url 一律是相对路径,插件自己拼后端地址(Config.backend.IMAGE_HOST),需要公网域名时也在插件侧拼。因此没有"模型可达性"这个概念了——模型要图时由插件取字节转 base64,协议端要图时插件给「后端地址 + 相对路径」。

必须同步加进 validKeysMap(src/resource/image.ts:34-48),否则 revive 会静默丢弃(src/utils/utils.ts:383-390 只拷贝 validKeysMap 里列出的键)。

新增存储键:

键 内容
image_idx:<00..3f> 索引分片(64 片),每条 { id: {u: 最后使用时间, h?: 图床 blob id} }(已实现)
image_host_cursor 图床 change_log 消费游标(已实现)
image_release_pending 图床不可达时待补发的 ref 列表(已实现)

图片的名称/标签/来源不落插件——那是图床的真相(§3 原则 3)。插件只存 sourceMeta(上报用)与自己的索引。 插件侧的 imageId 就是图床里的 ref(§6.4),这是两边能对上账的唯一钥匙。

6.2 新增配置项(唯一一项)

配置 分组 默认 说明
图床 后端 空 私人图床地址(aiplugin4-backends 的 image-host)。url 是插件访问图床接口的地址(可为 http://127.0.0.1:46680);publicUrl 是给 QQ 协议端与模型的对外地址(IP 或域名,如 https://img.example.com,由启动器「图床对外地址」自动生成)。留空 = 完全现状(仍用「图片转base64」)
图床Token 后端 空 image-host 的 API Token(后端卡片里的 Token)。上传/取 b64 需要;留空时写接口 401

两项都可以用 .ai refresh 之类让配置缓存失效(Config.invalidate('backend'),TTL 60s)。

MCP 那台 image-host 走「MCP → MCP服务器配置」独立配置(不在本项内),与 md-html-render 同款。

6.3 渲染优先级(get src() / getSendFile(),src/resource/image.ts)

# 对外地址可用(image-host.publicUrl,或 url 本身非回环):直接给稳定地址
hostedUrl      →  publicUrl + hostedUrl        // 如 http://192.168.1.10:46680/i/<id>
url            →  url
# 对外地址不可用(publicUrl 空且 url 回环)才降级:模型取内联字节、发图取本地文件
ensureBytes()  →  GET 图床地址 + hostedB64Url  // 比走 i2b64 少一次外部下载
getSendFile()  →  GET b64 出口 → seal.base64ToImage() → 本地文件路径

CQCode(src/resource/image.ts:91-95)同步改为 hostedUrl 优先(NapCat 是本机/局域网,取得到本地图床),其次 url / path。

6.4 抓取时机(一跳 + 引用 + 来源上报)

if (图床已配置 && Image.isTemporaryUrl(url) && 无进行中请求)
    fire-and-forget  capture(url, ref, source)     // 不阻塞消息入库

capture(url, ref, source):

  1. POST {图床}/api/upload { url, ref, source } → { id, url: "/i/<id>", b64_url: "/i/<id>/b64", dedup }
  2. ref = 插件的 imageId。这是引用计数的钥匙:同一张图被转发两次会有两个 imageId,图床侧就是一份字节 + 两条 ref,谁都不会连带删掉谁。
  3. 返回的 url 是相对路径,插件存 hostedUrl,用时拼上 Config.backend.IMAGE_HOST。
  4. 写 hostedUrl / hostedId / hostedAt,清空 base64(图床已有字节);模型要图时 ensureBytes() 走 GET /i/<id>/b64。
  5. 失败分类(用 upstream_status):
  6. 400 unsupported_format / 502 + upstream_status 4xx(非 429)→ 永久 → 复用现有规则标 urlBroken
  7. 502 + upstream_status 429/5xx、连接错误、507 → 可重试 → 走现有冷却
  8. 任何失败都不改字段,退回现状链路

触发时机(v1):

场景 位置 行为
收到消息里的图片 Image.createUrlImage() 内 所有 http(s) 图片 fire-and-forget 上传(不只临时链接——图床本来就是给图库用的,统一入库更简单)
生图(url 结果) src/media/task_queue.ts 落地处 走 urlToBase64() → 已含入库;拿到字节后再清 base64
生图(base64 结果) 同上 + tool_meme.ts img.adoptBase64ToHost():POST base64 → 成功清空 base64、写 hosted 字段
表情包 tool_meme.ts 同上;并补 Image.imageMap + Image.save(当前不落库)

source 的来源字段(已全部上报):

字段 取值
platform getPlatform(sessionId)
group_id / user_id ctx.group / ctx.player
message_id getRecordMessageId(ctx, msg)(经 transformArrayToContent 透传)
session_id getSessionId(ctx)
at 建图时间
raw_url 原始链接(图床侧排障用)

规则:

  • 并发上限 4;复用现有冷却 / 三次放弃机制。
  • 图床未配置(Config.backend.IMAGE_HOST 为空)时完全不走这些分支,行为与现在一致。
  • 图床配置了就不再调 i2b64(哪怕图没入库):urlToBase64() 先尝试入库再取 b64;失败按冷却/永久失败降级,不回退 i2b64。

6.5 组包路径改动(src/utils/message.ts:746-783)

if (image.hosted && ImageHost.externalEnabled) → 协议端与模型都用「图床对外地址 + hostedUrl」
else if (image.hosted)                          → 发图 getSendFile() 落本地文件;模型 ensureBytes() 取内联字节
else if (type==='url' && !base64 && !isBase64RetryBlocked()) → 现状

imageToText 同样:image-host.publicUrl 可用时把 remoteUrl 交给识图模型;只有 publicUrl 为空且 url 是回环时才先 ensureBytes(),拿不到字节就跳过识图(回环地址模型够不到)。

6.6 降级:对外地址不可用才取字节 / 降级文本标签

图床对外地址用 image-host.publicUrl(IP/域名,复制配置时自动生成):模型与协议端都直接用 remoteUrl,没有 base64 过路费。只有 publicUrl 为空且 url 是回环地址(127.0.0.1 / localhost,仅插件自身可达)时才降级:发图走 getSendFile() 取 /i/<id>/b64 落本地文件;模型走 ensureBytes() 取内联字节,取不到(图床挂了 / 图被删 / 没入库成功)就退化成 [img:ID] 文本标签,绝不把回环 URL 交给模型(那会让整轮请求报错)。

模型要图 → ensureBytes() → 有 base64 用 base64
                        → 没图床 id:先 upload 再取 b64
                        → 失败:冷却/永久放弃(沿用现有规则),该轮只留文本标签

markUrlBrokenByUrls 同时匹配 img.url 与 img.remoteUrl(图床地址),避免模型报错时图床死链漏标、反复重发。

6.7 三级清理(插件侧,行使插件的删除权)

级 对象 规则 依赖
L1 urlBroken 的图 清 base64 + 清 raw,只留 description / rawDescription 无(零风险,先做)
L2 索引条目 lastUsedAt 最旧 → 超上限或超期限 → 删 image_<id> + 从 imageMap 剔除 + POST /api/release { refs: [imageId] } 索引
L3 有 hostedUrl 的图 base64 随时可丢(能重取) 图床
  • 按 ref 释放,不按 blob id(§5.9):同一张图的另一条转发引用不会被误伤。
  • 释放后不立即导致文件消失——图床还要看引用数、origin、cap 压力(§5.9)。
  • 若 release 请求失败(图床不可达),本地条目照删,但把 ref 记进 image_gc_state.pendingRelease,下次对账时补发(否则会漏引用,形成泄漏)。

删除写法:ext.storageSet('image_'+id, '')。这是本仓库的删除惯例(06-数据存储 中 session_sub 即"删除时写空串"),而 Image.get(src/resource/image.ts:349-350)遇空串返回 null,正好是"已删除"语义。seal 无 storageRemove。

6.8 touch 点、ensureBytes 与节流

touch 点(都是已在跑的路径):渲染成 image_url 内容块、imageToText、read_raw kind=image、resolve_special_id、论坛发帖、表情包、媒体任务结果发送、Image.ensureBytes()。

Image.ensureBytes(): Promise<string>(新增统一入口):

base64                            → 直接用
hostedB64Url                      → GET {图床}/i/<id>/b64        ← 配了图床时唯一路径
hostedUrl                         → GET {图床}/i/<id>/b64(同上,URL 已含 id)
url                               → POST {i2b64}/image-to-base64  ← 未配图床时的现状路径

配了图床后 i2b64 不再被调用。ensureBytes 是唯一需要字节的入口,替换现在各处直接读 image.base64 的地方:src/tool/tools/web/tool_forum.ts:108-121、src/tool/tools/image/tool_image.ts:30、tool_meme.ts 相关分支。

节流(必须做,否则写放大比现在更糟):内存里更新 lastUsedAt;仅当距上次落盘 > 6h 才 save,或搭其他 save 的顺风车。索引里的 u 跟着同一频率更新。

6.9 索引、trim 与对账

现成先例:src/agent/subagent/store.ts:118-139 的 trim——保留上限 + 清理最旧已结束记录,完全不扫描上下文,因为它维护了索引。

分片(解决写放大)

  • 键 image_idx:<00..3f>,bucket = parseInt(sha1(imageId).slice(0,2),16) % 64。
  • append:只重写所属那一片(≈ 30–50 KB),不是重写全量索引。
  • trim(低频):读全部 64 片 → 全局按 u 排序 → 淘汰 → 只重写受影响的片。

trim 触发:启动后延迟一次;距上次 > 1h 或 新增 ≥ 200 张。

对账:启动时 + 每 6h + trim 后,POST {图床}/api/exists { refs } → missing 的标 urlBroken / 清条目;同时补发 pendingRelease。

为什么必须有索引:seal 没有 storageList,无法枚举 image_<id> 键 → 不建索引就永远找不到要删的对象。

6.10 失败回退链(配了图床时)

hostedUrl(模型可达) → 图床 b64 内联 → 原始 url → 纯文本 [img:id:描述]

每级失败落下一级,任何情况下不静默丢图、不整轮 400。


7. 配置项汇总

7.1 插件侧

配置 分组 默认 新增?
图床 后端 空 新增
image-host(MCP 服务器) MCP 无 新增(照 md-html-render 的写法,含 token header)
图片转base64 后端 http://127.0.0.1:46678 不变(仅未配图床时使用)

7.2 后端侧

见 §5.12。


8. 失效模式矩阵

# 场景 判定 表现 自愈 日志
1 抓取前链接已死 502 + upstream_status 4xx(非 429) 文本标签 永久 urlBroken 图片x的URL已确认不可用
2 图床文件被清 exists 返回 missing 或 GET 404 文本标签 标 urlBroken,一次 [image-host] 已清理
3 需要内联但取不到字节 GET /i/<id>/b64 失败 首次失败一轮 按网络错误重试,仍失败本轮不带图 [image-host]
4 图床没启动 / 不可达 连接错误 走现状链路 冷却后重试 [image-host] 不可达
5 上游限速 / 5xx 502 + upstream_status 429/5xx 走现状链路 冷却后重试 [image-host] 上游可重试
6 磁盘满 / 超单文件 413 / 507 走现状链路 cap 淘汰后恢复;手动图占满则持续 507 [image-host] 落库失败
7 格式不在白名单 400 unsupported_format 走现状链路 不重试 [image-host] 格式不支持
8 未配置图床地址 启动探测跳过 完全现状 — [image-host] 未启用
9 同一张图被转发两次 dedup: true 正常 两条 ref 各自独立,release 一条不影响另一条 [image-host] 哈希命中
10 release 请求失败 网络错误 本地条目已删 记入 pendingRelease,对账时补发 [image-gc] release 待补发
11 WebUI 未设密码 启动检查 WebUI 路由关闭 设置密码 [image-host] WebUI 已关闭
12 MCP 未配置 / token 错 插件 MCP 连接失败 仅 AI 管理能力缺失,不影响取图 修配置 插件 MCP 日志

9. 迁移与兼容

项 处理
未配置图床 零行为变化(默认空)
老 Image 数据无新字段 revive 后取构造函数默认值(hostedUrl='')→ 行为 = 现状,零迁移
索引不存在 从上线时刻起增量维护
历史存量无法自动回收 seal 无 storageList,枚举不到旧 image_<id> 键
历史存量的一次性播种 唯一枚举源是 agent_<name> 里持久化的 sessionService.sessionMap(src/agent/agent.ts:601)→ 遍历其上下文抽 [img:id] 建初始索引。尽力而为,只覆盖"被引用过"的图
未被任何上下文引用的历史孤儿 永远留在 storage 里,物理上无法枚举 → 列入"已知取舍"
播种方式 默认不自动播种(避免大实例启动卡顿);.ai image adopt 手动触发(P7)
后端升级 image-host 是新增后端,不影响既有后端;backends.json 新增条目即可
SQLite 结构升级 PRAGMA user_version + 启动时按版本迁移(图床自己的事,与海豹存储无关)
插件数据丢失后的引用泄漏 ref 永不 release → blob 长期留存;兜底见 §5.9 末

10. 观测与日志

  • 插件日志 tag:[image-gc](trim:扫描/淘汰/剩余;release 待补发)、[image-host](探测/抓取/哈希命中)、[image-reconcile](对账:查询/缺失)。
  • 图床日志:抓取失败原因(含 upstream_status)、淘汰(零引用 / 有引用 / manual 跳过 计数)、缩略图失败。
  • 启动打印一行(插件):图床=启用/未启用 可达=是/否 索引=N 条。
  • P7 可选:.ai image stats → 条目数 / 带 base64 数 / 图床命中率 / 不可达模型列表。

11. 测试要点

插件单测

  1. src 优先级三态;CQCode 优先 hostedUrl。
  2. LRU trim 边界:等于上限 / 超上限 / 全部 urlBroken / 全部未超期。
  3. 分片索引:写入只改所属片、trim 只重写受影响片、分布均匀。
  4. touch 节流:6h 内重复使用只落盘一次。
  5. revive 新字段不丢(回归 validKeysMap 遗漏)。
  6. 可达性降级:GET hostedUrl 200 → 不标 urlBroken 且记模型;404 → 标 urlBroken。
  7. markUrlBrokenByUrls 能匹配 hostedUrl。
  8. ensureBytes 三级回退;配了图床时断言不调用 i2b64。
  9. 失败分类:upstream_status 4xx → 永久;429/5xx → 可重试。
  10. 交叉清理:trim 淘汰 → 触发 release { refs };release 失败 → 进 pendingRelease 并在对账时补发;对账 missing → 标死。
  11. 来源上报:source 六个字段齐全(含消息 ID 透传)。
  12. 未配置图床时全链路零变化(回归保护)。

后端单测(image-host)

  1. 流式落盘:内存占用与图片大小无关;sha256 与实际字节一致。
  2. PNG 原样保存;动画 GIF 不丢帧(帧数与上传一致);缩略图取 GIF 首帧。
  3. 哈希去重:同字节第二次上传 → dedup: true、只追加一条 ref、tmp 无残留、磁盘只多一份字节。
  4. 引用计数(核心):
  5. 同一 blob 两条 ref → release 一条 → 字节仍在、blobs_freed=0
  6. release 两条 → 归零 → 置 orphan_at、blobs_freed=1
  7. 按 ref 释放不会连带删掉另一条 ref
  8. cap 淘汰顺序:零引用 origin=plugin(按 orphan_at)→ 有引用 origin=plugin(按 last_access_at)→ origin=manual 跳过。
  9. 手动图占满 cap → 新上传 507,且手动图仍在。
  10. 单文件上限 413(流式中断);磁盘满 507。
  11. 鉴权边界:/i/*、/t/*、/api/health 免鉴权;/api/* 其余、/、/mcp 需 token。
  12. WebUI:未设密码时 / 关闭;设密码后未登录 401、登录后可浏览;"收进图库"把 origin 改为 manual 后不再被自动清。
  13. magic bytes 与声明不符 → 400;非白名单格式 → 400。
  14. 路径穿越与非法 id → 404。
  15. SSRF:file://、127.0.0.1、10.x、169.254.169.254、IPv6 回环 → 400;302 跳私网 → 400;超 3 跳 → 400。
  16. host allowlist 开启时非白名单域名 → 400。
  17. 上游 4xx/5xx → 502 + upstream_status,且不内部重试。
  18. /i/<id>/b64 返回的 base64 解码后与原图字节完全一致(含 GIF)。
  19. MCP:tools/list 恰好 6 个工具;image_search 默认 scope=gallery(插件抓取要 scope=plugin/all),能按标签/来源/工具/服务器命中;image_keep / image_unkeep / image_rename / image_tag 落库。
  20. WebUI XSS:名称/标签含 <script> 时被转义。
  21. ETag / If-None-Match → 304。

集成

  1. 起图床 + 配地址 → 发 QQ 图 → 断言:hostedUrl 有值、image_<id> 里 无 base64、组包把「后端地址 + hostedUrl」交给协议端、全程没有调用 i2b64、图床里能看到该图的来源。
  2. 停图床 → 断言回退现状且不报错。
  3. 手工删掉图床文件 → 断言走 §8 #2 并自愈。
  4. 断言模型收到的是 b64 内联而非任何 URL。
  5. 同一张图在群里转发两次 → 断言图床只有一份字节、两条 ref;插件淘汰其中一条 → 另一条仍可用。
  6. WebUI 把一张抓来的图"收进图库"→ 断言它在 cap 压力下不被自动删。

12. 实施分期(每期可独立上线)

期 内容 依赖 收益
P1 插件存储治理:新字段 + 分片索引 + L1 清理 + L2 LRU trim + touch 节流 + ensureBytes 无 止住 storage.db 无界增长(现在就疼的那部分)
P2 图床核心:REST(upload / /i/ / /i/<id>/b64 / /t/ / health)+ 流式抓取 + 哈希去重 + 引用计数 + SQLite + 缩略图 + cap/LRU + §5.10 加固;backends.json 注册 无 图床可用、可取图、可取 b64(可独立于插件验证)
P3 插件接图床:新增「图床」配置 + /api/health 探测 + 收到即抓(带 ref/source)+ src/CQCode 优先级 + 可达性降级 需要 P2 解决时机问题(AI 不再看不见图)
P4 交叉清理:release / exists + pendingRelease 补发 + 插件对账 需要 P2 P3 两端都能主动清理且不误删
P5 WebUI 画廊(鉴权 + 来源 + 重命名 + 标签 + 删除 + 上传 + 收进图库) 需要 P2 人能管理图库
P6 MCP 三个工具 + 插件 MCP 配置 需要 P2 P5(标签/名称已存在) AI 能检索管理图库
P7 可选:.ai image stats、.ai image adopt、S3 后端、EXIF 剥离、静态 GIF 的 b64 ?format=png、批量清理长期未访问的插件引用 — 运维/进阶
独立 i2b64 加固(超时 / 大小 / scheme / 私网) — 为"做成公共 API"扫清障碍;与本方案并行

P4/P5/P6 顺序可调:P5(WebUI)建议早于 P6(MCP),因为标签/名称先得有个顺手的入口,MCP 才有东西可操作。


13. 参数默认值

常量 / env 建议值 位置
IMAGE_GC_MAX_ENTRIES 20000 插件
IMAGE_GC_MAX_AGE_DAYS 180 插件
IMAGE_TOUCH_THROTTLE_MS 6h 插件
IMAGE_GC_TRIM_INTERVAL_MS 1h 插件
IMAGE_GC_TRIM_EVERY_N 200 插件
IMAGE_IDX_SHARDS 64 插件
IMAGE_CAPTURE_CONCURRENCY 4 插件
对账间隔 6h 插件
抓取请求超时(插件侧) 15s 插件
AIPLUGIN4_IMAGE_HOST_CAP_MB 2048 后端
AIPLUGIN4_IMAGE_HOST_MAX_FILE_MB 8 后端
AIPLUGIN4_IMAGE_HOST_THUMB_MAX 512 后端
AIPLUGIN4_IMAGE_HOST_FETCH_TIMEOUT 5,20 后端
AIPLUGIN4_IMAGE_HOST_FETCH_CONCURRENCY 4 后端
重定向上限 3 后端
淘汰目标水位 CAP × 0.9 后端
图床端口 46680 后端

待拍板:插件条目上限、LRU 期限、图床 cap。其余均有明确建议值。


14. 明确不做 / 已知取舍

项 原因
扩展 i2b64 它可能做成公共 API,不能持有用户数据(§5.1)
主链路经 base64 中转 三份 base64 的搬运成本换不来对等收益(§5.2)
实时转码(?w=&h=&fmt=) 每次请求都转码,慢且吃 CPU;缩略图预生成已够用
语义/视觉搜索(CLIP) 要引入本地模型、吃内存;搜索限定元数据(P7 可议)
image_delete MCP 工具 AI 误删代价不对称;删除留在 WebUI(人的决定)
插件保存名称/标签 元数据真相只在图床,避免双写不一致(§3 原则 3)
删除宽限期 release 立即生效;安全性靠引用计数 + origin=manual,不靠等待
引用计数 插件侧不维护全局引用计数(context_edit_replace 会改文本导致漂移);但图床侧的引用计数必须做(它就是 ref 表)
全量引用扫描 用 lastUsedAt 代理"还在被用",O(1) 且更准
URL 健康轮询 三种死法已有零成本判定;轮询证明不了模型侧可达
历史孤儿图片的自动回收 seal 无 storageList,物理上枚举不到
手动图占满时仍接受新上传 宁可 507 也不清人放进来的东西(§5.9)
图床零出网 与"主链路不过 base64"互斥,选择后者 + §5.10 加固

15. 改动点索引

插件(本仓库)

# 文件 改动
1 src/resource/image.ts 新字段 + validKeysMap;src / CQCode 优先级;markUrlBrokenByUrls 匹配 hostedUrl;imageToText 提前返回;ensureBytes()
2 src/resource/image.ts:404-443 收到即抓(一跳 + ref/source 上报 + fire-and-forget + 并发上限 + upstream_status 分类)
3 src/utils/string.ts:383、src/session/session.ts:339-360 把 msg / messageId 透传进 transformArrayToContent(来源上报需要)
4 src/resource/image_gc.ts(新增) 分片索引 + L1 清理 + L2 trim + touch 节流 + release { refs } + pendingRelease 补发 + exists 对账
5 src/utils/message.ts:499-536 hostedUrl 可达时直接交给模型,跳过转换
6 src/agent/agent.ts:323-360 图片下载失败分支:GET hostedUrl 区分 404 与"够不到"
7 src/config/configs/backend.ts 新增「图床」配置项(默认空)
8 src/tool/tools/web/tool_forum.ts、src/tool/tools/image/tool_image.ts、tool_meme.ts 改用 ensureBytes()
9 图床探测 /api/health 探测 + 缓存(可放 src/model 或新模块)
10 MCP 配置(05-命令与配置 的 MCP 分组) 新增 image-host 一台(url + token header)

后端(aiplugin4-backends,shop 分支)

# 文件 改动
1 backends/image-host/imagehost.py 新增:REST + 流式抓取 + 哈希去重 + SQLite + 引用计数 + 缩略图 + cap/LRU + §5.10 加固
2 backends/image-host/webui.html 新增:单页画廊(鉴权)
3 backends/image-host/mcp.py 新增:image_search / image_rename / image_tag
4 backends/image-host/backend.json 新增
5 backends/image-host/requirements.txt 新增(Flask + requests + Pillow)
6 backends.json 新增条目(name image-host、port 46680、files)
7 CHANGELOG.md / README.md 新条目 + 后端清单 + 公网部署 / 隐私 / SSRF / WebUI 密码警告
— backends/image-url-to-base64/* 不动

---

第二部分:实现细化

第一部分是"做成什么样"(决策、契约、规则、分期);第二部分是"具体怎么写"(文件划分、路由表、SQL、伪码、签名、任务拆解)。 实现时以本文为准;与第一部分冲突时以第一部分为准(规则优先于写法)。


16. 后端实现细化:backends/image-host/

16.1 文件与职责

文件 估计行数 职责
imagehost.py ~220 Flask app、路由表、鉴权、参数校验、错误码映射、启动
store.py ~260 SQLite schema/迁移、blob/ref/tag CRUD、容量与淘汰、缩略图、路径推导
fetch.py ~150 安全抓取:URL 校验、IP 黑名单、逐跳重定向、流式落盘、magic bytes
webui.html ~260 单页画廊(原生 JS,内联 CSS)
mcp.py ~180 MCP Streamable HTTP:initialize / tools/list / tools/call
backend.json 9 后端元数据
requirements.txt 3 flask / requests / pillow

多文件可行性已确认:launcher 以 cwd=backend.dir 运行 python <entry>(aiplugin4-backends/launcher.py:797-799),所以同目录 import store, fetch, mcp 可用。 backend.json 字段与 image-url-to-base64 一致(name / description / type / entry / deps / port / version,见其 backend.json:1-9);backends.json 的 files 必须列全 7 个文件,否则下载不全。 launcher 注入的环境变量:AIPLUGIN4_BACKEND_PORT、AIPLUGIN4_BACKEND_HOST、AIPLUGIN4_BACKEND_TOKEN(launcher.py:780-782)。

16.2 路由表(完整)

方法 路径 鉴权 用途
POST /api/upload token 抓取 / 上传(JSON 或 multipart)
POST /api/release token 释放引用(插件的删除权)
POST /api/exists token 对账(图床 → 插件)
PATCH /api/image/<id> token 名称 / 标签 / origin
DELETE /api/image/<id> token 手动删除
GET /api/list token 列表(WebUI 与 MCP 共用)
GET /api/health 公开 探测(精确匹配,不参与前缀)
GET /i/<id> 公开 原图
GET /i/<id>/b64 公开 base64 出口
GET /t/<id> 公开 缩略图
GET / cookie WebUI 页面
POST /login / /logout — / cookie 会话
GET/POST /mcp token MCP Streamable HTTP

鉴权判定(顺序敏感):

PUBLIC_PREFIX = ("/i/", "/t/")

def auth_ok(req) -> bool:
    p = req.path
    if p == "/api/health" or p.startswith(PUBLIC_PREFIX): return True
    if p == "/login": return True
    if p == "/" or p == "/logout": return cookie_ok(req)      # WebUI
    if p.startswith("/mcp"): return token_ok(req)             # MCP 走 token
    return token_ok(req)                                      # /api/* 其余
  • token_ok:Authorization: Bearer <t> 或 X-Token: <t>;AIPLUGIN4_BACKEND_TOKEN 为空时拒绝一切写操作(返回 401),而不是放行。

    与现有 i2b64 的差别:那边 token 为空 = 不鉴权;这里 token 为空 = 写操作全禁。因为本服务持有数据且能出网,默认拒绝比默认放行安全。

  • cookie_ok:ih=<hmac>,hmac = HMAC_SHA256(secret, "ih");secret 由 PASSWORD 派生(pbkdf2 固定盐,进程启动时算一次)。未设 PASSWORD → / 与 /login 直接 404。

16.3 DDL 与迁移

PRAGMA journal_mode=WAL;        -- 并发读不阻塞写
PRAGMA synchronous=NORMAL;
PRAGMA foreign_keys=ON;

CREATE TABLE blob (
  id             TEXT PRIMARY KEY,
  format         TEXT NOT NULL,
  mime           TEXT NOT NULL,
  size           INTEGER NOT NULL,
  created_at     INTEGER NOT NULL,
  last_access_at INTEGER NOT NULL,
  name           TEXT,
  origin         TEXT NOT NULL CHECK (origin IN ('plugin','manual')),
  orphan_at      INTEGER,
  thumb_path     TEXT
);

CREATE TABLE ref (
  id         TEXT NOT NULL REFERENCES blob(id) ON DELETE CASCADE,
  ref        TEXT NOT NULL,
  platform   TEXT, group_id TEXT, user_id TEXT, message_id TEXT, session_id TEXT,
  at         INTEGER, raw_url TEXT,
  PRIMARY KEY (id, ref)
);

CREATE TABLE tag (
  id  TEXT NOT NULL REFERENCES blob(id) ON DELETE CASCADE,
  tag TEXT NOT NULL,
  PRIMARY KEY (id, tag)
);

CREATE INDEX idx_blob_access ON blob(last_access_at);
CREATE INDEX idx_blob_orphan ON blob(orphan_at);
CREATE INDEX idx_blob_origin ON blob(origin);
CREATE INDEX idx_ref_blob     ON ref(id);
CREATE INDEX idx_ref_group    ON ref(group_id, at);
CREATE INDEX idx_tag_tag      ON tag(tag);

PRAGMA user_version = 1;

迁移:

SCHEMA_VERSION = 1
def migrate(con):
    con.executescript(PRAGMAS)
    v = con.execute("PRAGMA user_version").fetchone()[0]
    if v < 1:
        con.executescript(DDL_V1)
        con.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
    # 未来:if v < 2: ...(只追加,不改旧列)
  • ON DELETE CASCADE 保证删 blob 时 ref/tag 一起清,不会留孤儿行。
  • 每个请求一个连接(sqlite3.connect(db),WAL 下并发读安全);写操作用全局 threading.Lock 串行化,容量检查与淘汰必须在同一把锁里完成,否则并发上传会超卖磁盘。

16.4 核心 SQL(把 §5.9 的规则写成查询)

-- 淘汰候选 tier 1:插件抓的、零引用、按孤儿时间最旧
SELECT id, size FROM blob b
WHERE origin='plugin'
  AND NOT EXISTS (SELECT 1 FROM ref r WHERE r.id=b.id)
ORDER BY COALESCE(orphan_at, created_at) ASC
LIMIT ?;

-- 淘汰候选 tier 2:插件抓的(仍有引用),按最久未访问
SELECT id, size FROM blob b
WHERE origin='plugin'
ORDER BY last_access_at ASC
LIMIT ?;

-- 手动图占用(用于 507 判定与日志)
SELECT COALESCE(SUM(size),0) FROM blob WHERE origin='manual';

-- 引用归零判定(release 后)
SELECT COUNT(*) FROM ref WHERE id=?;

-- 列表 / 搜索(/api/list 与 MCP 共用)
SELECT b.*,
       (SELECT COUNT(*) FROM ref r WHERE r.id=b.id)      AS ref_count,
       (SELECT GROUP_CONCAT(t.tag) FROM tag t WHERE t.id=b.id) AS tags
FROM blob b
WHERE (:q     = '' OR b.name LIKE '%'||:q||'%'
       OR EXISTS (SELECT 1 FROM ref r WHERE r.id=b.id AND (r.group_id LIKE '%'||:q||'%'
                                                       OR  r.user_id  LIKE '%'||:q||'%'
                                                       OR  r.message_id LIKE '%'||:q||'%')))
  AND (:tag   = '' OR EXISTS (SELECT 1 FROM tag t WHERE t.id=b.id AND t.tag = :tag))
  AND (:group = '' OR EXISTS (SELECT 1 FROM ref r WHERE r.id=b.id AND r.group_id = :group))
  AND (:origin= '' OR b.origin = :origin)
ORDER BY CASE :sort WHEN 'access' THEN b.last_access_at ELSE b.created_at END DESC
LIMIT :limit OFFSET :offset;

-- 某图的所有来源(详情页/搜索返回用)
SELECT platform, group_id, user_id, message_id, session_id, at, raw_url
FROM ref WHERE id=? ORDER BY at DESC;

16.5 安全抓取(fetch.py)——§5.10 的具体写法

MAX_REDIRECTS = 3
BLOCKED = lambda ip: (ip.is_private or ip.is_loopback or ip.is_link_local
                      or ip.is_reserved or ip.is_multicast or ip.is_unspecified)

def safe_fetch(url, max_bytes, timeout, allow_hosts, tmp_dir):
    """返回 (tmp_path, sha256_hex, size, first8);抛 BadUrl / TooLarge / FetchFailed"""
    for hop in range(MAX_REDIRECTS + 1):
        u = urlparse(url)
        if u.scheme not in ("http", "https"):        raise BadUrl("scheme")
        if not u.hostname:                            raise BadUrl("no_host")
        if allow_hosts and not host_allowed(u.hostname, allow_hosts):
                                                      raise BadUrl("host_not_allowed")
        try:
            ips = [ip_address(s[4][0]) for s in socket.getaddrinfo(u.hostname, u.port or 80)]
        except Exception:                             raise BadUrl("dns")
        if any(BLOCKED(ip) for ip in ips):            raise BadUrl("private_ip")

        r = requests.get(url, stream=True, timeout=timeout, allow_redirects=False)
        if r.is_redirect:                             # 手动跳转:每跳都重做上面全部校验
            url = urljoin(url, r.headers["Location"]); continue
        if r.status_code >= 400:
            raise FetchFailed(r.status_code)

        h, n, tmp = hashlib.sha256(), 0, tempfile.mktemp(dir=tmp_dir)
        with open(tmp, "wb") as f:
            for chunk in r.iter_content(64 * 1024):
                n += len(chunk)
                if n > max_bytes:
                    f.close(); os.remove(tmp); raise TooLarge(n)
                h.update(chunk); f.write(chunk)
        first8 = open(tmp, "rb").read(8)
        return tmp, h.hexdigest(), n, first8
    raise BadUrl("too_many_redirects")

要点:

  • 必须 allow_redirects=False 手动循环:requests 的自动跳转不会给你逐跳校验的机会,302 → 127.0.0.1 会直接绕过 IP 拦截。这是 §5.10 里最容易写错的一条。
  • socket.getaddrinfo 取全部 A/AAAA(getaddrinfo 不指定 family 就返回两者),任一命中黑名单即拒绝。
  • 残留的 TOCTOU(校验用 IP 与连接用 IP 可能不同,DNS 重绑定)在"私人部署 + token"下可接受;要更严可把 URL 换成已校验 IP + Host 头连接,列为 P7。
  • TooLarge 必须删掉半截临时文件;所有异常路径都要清 tmp/*.part。

magic bytes 判定(不引入 Pillow 依赖,纯字节):

SIGNATURES = [
    (b"\x89PNG\r\n\x1a\n", "png", "image/png"),
    (b"\xff\xd8\xff",       "jpg", "image/jpeg"),
    (b"GIF87a",             "gif", "image/gif"),
    (b"GIF89a",             "gif", "image/gif"),
    (b"BM",                 "bmp", "image/bmp"),
]
# WEBP:RIFF....WEBP(偏移 8 处判 WEBP)

16.6 落库、去重与容量(store.py)

def ensure_capacity(con, need: int, cap: int, watermark=0.9):
    """写入前腾地方;返回淘汰条数。manual 永不自动删。"""
    dropped = 0
    while used_bytes(con) + need > cap * watermark:
        victim = pick_victim_tier1(con) or pick_victim_tier2(con)
        if victim is None:
            raise DiskFull(used=used_bytes(con), manual=manual_bytes(con))
        delete_blob(con, victim)          # 连带 ref/tag(CASCADE)+ 删文件 + 删缩略图
        dropped += 1
    return dropped

def put_blob(con, tmp, sha, fmt, mime, size, origin, ref, source):
    row = con.execute("SELECT id FROM blob WHERE id=?", (sha,)).fetchone()
    dedup = row is not None
    if dedup:
        os.remove(tmp)                                     # 哈希命中:不写第二份字节
        con.execute("UPDATE blob SET last_access_at=? WHERE id=?", (now_ms(), sha))
    else:
        ensure_capacity(con, size, CAP)                    # 锁内
        os.replace(tmp, blob_path(sha, fmt))               # 原子落位
        thumb = make_thumb(blob_path(sha, fmt), THUMB_MAX) # 失败 → None,不阻断
        con.execute("INSERT INTO blob(id,format,mime,size,created_at,last_access_at,origin,thumb_path)"
                    " VALUES(?,?,?,?,?,?,?,?)", (sha, fmt, mime, size, now_ms(), now_ms(), origin, thumb))
    con.execute("INSERT OR REPLACE INTO ref(id,ref,platform,group_id,user_id,message_id,session_id,at,raw_url)"
                " VALUES(?,?,?,?,?,?,?,?,?)",
                (sha, ref, source.platform, source.group_id, source.user_id,
                 source.message_id, source.session_id, source.at, source.raw_url))
    return sha, dedup
  • INSERT OR REPLACE on ref:同一个 ref 重复上传是幂等的(插件重试不会产生两条引用)。
  • origin:JSON 抓取路径恒为 plugin;multipart(WebUI)恒为 manual,不接受客户端指定。

释放引用:

def release_refs(con, refs) -> tuple[int, int]:
    released = freed = 0
    for ref in refs:
        rows = con.execute("SELECT id FROM ref WHERE ref=?", (ref,)).fetchall()
        for (bid,) in rows:
            con.execute("DELETE FROM ref WHERE id=? AND ref=?", (bid, ref))
            released += 1
            if con.execute("SELECT COUNT(*) FROM ref WHERE id=?", (bid,)).fetchone()[0] == 0:
                con.execute("UPDATE blob SET orphan_at=? WHERE id=? AND orphan_at IS NULL",
                            (now_ms(), bid))
                freed += 1
    return released, freed

必须按 ref 删,不能按 id 删:哈希去重下同一 blob 可能有多条 ref,按 id 删会连带抹掉别人的引用(§5.9)。

16.7 缩略图(Pillow)

def make_thumb(path, max_edge) -> str | None:
    try:
        im = Image.open(path)
        im.seek(0)                       # 动画 GIF 只取首帧
        if im.mode not in ("RGB", "RGBA"):
            im = im.convert("RGBA")
        im.thumbnail((max_edge, max_edge), Image.LANCZOS)
        out = thumb_path_for(path)
        im.save(out, "WEBP", quality=80, method=4)
        return out
    except Exception as e:
        log.warning(f"缩略图生成失败 {path}: {e}")
        return None                      # 不阻断主流程;画廊回退用原图
  • 原图不转码:PNG 存 PNG、GIF 存 GIF(动画不丢帧)。
  • 缩略图失败时 thumb_path=NULL,/t/<id> 回退到 /i/<id>(前端换 src 即可)。

16.8 并发模型

资源 处理
Flask app.run(host=..., port=..., threaded=True)
SQLite 每请求一个连接;WAL;写操作用全局 threading.Lock(容量检查+淘汰+插入必须原子)
抓取 threading.Semaphore(FETCH_CONCURRENCY) 包住 safe_fetch
缩略图 同一把抓取信号量(CPU 密集,别和抓取抢)
淘汰 与写操作同一把锁;不允许并发淘汰(否则可能删多)

16.9 错误码总表(插件侧分类的依据)

HTTP error 触发 插件侧处理
400 bad_url scheme 非法 / 私网 / DNS 失败 / 跳转过多 / 域名不在白名单 永久,标 urlBroken
400 unsupported_format magic bytes 不在白名单 永久
401 unauthorized token 缺失或错误;或 token 未配置 停用图床(视为未启用)+ 日志
404 not_found id / ref 不存在 视为已删 → urlBroken
413 too_large 超单文件上限(流式中断) 永久
502 fetch_failed 上游 4xx/5xx,带 upstream_status 4xx(≠429) → 永久;429/5xx → 可重试
507 disk_full 淘汰后仍放不下(手动图占满) 可重试(冷却)
500 internal 其它 可重试

响应体一律 {"error": "...", ...};502 额外带 upstream_status。

16.10 WebUI 细化(webui.html)

登录

GET  /         → 未设 PASSWORD: 404;未登录: 登录表单;已登录: 画廊
POST /login    {password} → 校验 → Set-Cookie: ih=<hmac>; HttpOnly; SameSite=Lax; Path=/
POST /logout   → 清 cookie

页面结构(单文件,内联 CSS/JS,无框架、无构建)

┌ 顶栏:搜索框 | 标签筛选 | origin 筛选 | 上传按钮 | 登出
├ 主体:CSS Grid 瀑布流,每卡:
│     <img src="/t/<id>">  (无缩略图时回退 /i/<id>)
│     名称(可点击就地编辑)
│     标签 chips(可点 × 删除,+ 添加)
│     来源摘要:平台 · 群 · 用户 · 时间(多条折叠展开)
│     origin 徽章:插件抓的 / 手动上传
│     体积 · 格式 · 创建时间
│     操作:重命名 | 标签 | 收进图库 | 删除
└ 底栏:加载更多(offset 分页)

交互实现

动作 调用
列表 / 搜索 / 筛选 GET /api/list?...
上传 FormData(file) → POST /api/upload(multipart)
重命名 PATCH /api/image/<id> {name}
加/删标签 PATCH /api/image/<id> {add_tags|remove_tags}
收进图库 PATCH /api/image/<id> {origin:"manual"}
删除 DELETE /api/image/<id>(批量 = 循环 + 进度)

安全:所有用户可控文本用 textContent 渲染,不用 innerHTML(名称/标签是用户输入,XSS 必修)。

16.11 MCP 细化(mcp.py)

传输:Streamable HTTP,POST /mcp,Accept: application/json;实现 initialize / tools/list / tools/call 三个方法(与 mcp-files-exec 同款,插件侧按标准 MCP 接入)。

// tools/list 返回(3 个,不增不减)
{
  "tools": [
    {
      "name": "image_search",
      "description": "搜索私人图库中的图片(按名称、标签、来源元数据)。返回 id/名称/标签/来源/时间/URL。",
      "inputSchema": {
        "type": "object",
        "properties": {
          "query":    { "type": "string",  "description": "名称/标签/来源模糊匹配" },
          "tags":     { "type": "array", "items": { "type": "string" } },
          "platform": { "type": "string" },
          "group_id": { "type": "string" },
          "since":    { "type": "string", "description": "ISO 时间,只返回此后的" },
          "limit":    { "type": "integer", "minimum": 1, "maximum": 50, "default": 20 },
          "offset":   { "type": "integer", "minimum": 0, "default": 0 }
        },
        "additionalProperties": false
      }
    },
    {
      "name": "image_rename",
      "description": "给图库中的图片重命名",
      "inputSchema": {
        "type": "object",
        "properties": { "id": { "type": "string" }, "name": { "type": "string" } },
        "required": ["id", "name"],
        "additionalProperties": false
      }
    },
    {
      "name": "image_tag",
      "description": "给图片增删标签",
      "inputSchema": {
        "type": "object",
        "properties": {
          "id":         { "type": "string" },
          "add":        { "type": "array", "items": { "type": "string" } },
          "remove":     { "type": "array", "items": { "type": "string" } }
        },
        "required": ["id"],
        "additionalProperties": false
      }
    }
  ]
}

tools/call 返回文本块(不返回大段 base64):

9f2c…e1 | 群友发的猫 | 标签:猫,表情包 | QQ 群12345 用户67890 2025-01-01 12:00 | https://img.example.com/i/9f2c…e1
  • 与 REST 共用 store.py,不重复实现查询。
  • 不做 image_delete(§5.11)。

16.12 后端启动顺序

读 env → 建目录 → 打开 SQLite + migrate → 启动时扫描 shards/ 校正 stats(可选,见 §16.13)
→ 打印监听地址与能力(image_host / webui 是否启用)
→ app.run(threaded=True)

16.13 启动自检(建议)

检查 动作
目录不存在 创建 shards/ thumbs/ tmp/
tmp/*.part 残留 启动时清空(上次崩溃的残骸)
blob 行指向的文件缺失 记 warning + 删该行(连带 ref/tag)——不自动重建,让插件对账发现
磁盘上的文件没有对应 blob 行 记 warning,保留(可能是手工放的),不自动删
PASSWORD 未设 日志明示 "WebUI 已关闭"
token 未设 日志明示 "写操作将全部拒绝(401)"
容量已超 cap 启动时跑一次 ensure_capacity(con, 0)

17. 插件实现细化

17.1 文件与职责

文件 类型 估计行数 职责
src/resource/image_host.ts 新增 ~180 图床客户端:探测 / 上传 / 释放 / 对账 / 取 b64;错误分类
src/resource/image_gc.ts 新增 ~220 分片索引、trim、touch 节流、pendingRelease、对账调度
src/resource/image.ts 改 +~120 新字段、src/CQCode 优先级、ensureBytes、收到即抓、markUrlBrokenByUrls
src/utils/string.ts 改 +~5 transformArrayToContent 透传 messageId
src/session/session.ts 改 +~5 handleReceipt / deferReceipt 传入 messageId
src/utils/message.ts 改 +~10 多模态直接用 hostedUrl
src/agent/agent.ts 改 +~25 下载失败分支区分 404 / 够不到
src/config/configs/backend.ts 改 +1 新增「图床」

17.2 图床客户端签名(image_host.ts)

export interface HostProbe { enabled: boolean; version: string; }
export interface ImageSource {
  platform?: string; groupId?: string; userId?: string;
  messageId?: string; sessionId?: string; at?: number; rawUrl?: string;
}
export interface UploadResult {
  id: string; url: string; b64Url: string;
  format: string; size: number; dedup: boolean;
}
export type HostErrorKind = 'permanent' | 'retryable' | 'unauthorized' | 'disabled';

export function hostBase(): string;                     // Config.backend.IMAGE_HOST,空 = 未配置
export function isHostEnabled(): boolean;               // 已配置 且 探测通过
export async function probeHost(force?: boolean): Promise<HostProbe>;
export async function uploadByUrl(url: string, ref: string, src: ImageSource): Promise<UploadResult>;
export async function releaseRefs(refs: string[]): Promise<{ released: number; freed: number }>;
export async function findMissingRefs(refs: string[]): Promise<string[]>;
export async function fetchBase64(id: string): Promise<string>;
export function classifyHostError(status: number, body: unknown): HostErrorKind;
export function idFromHostedUrl(url: string): string;   // 从 /i/<sha256> 里取 id

classifyHostError 规则(与 §16.9 一一对应):

function classifyHostError(status, body): HostErrorKind {
  const e = (body as any)?.error;
  if (status === 401) return 'unauthorized';
  if (status === 404) return 'permanent';
  if (status === 400) return 'permanent';                       // bad_url / unsupported_format
  if (status === 413) return 'permanent';
  if (status === 502) {
    const up = (body as any)?.upstream_status ?? 0;
    return (up >= 400 && up < 500 && up !== 429) ? 'permanent' : 'retryable';
  }
  return 'retryable';                                            // 507 / 5xx / 网络错误
}

17.3 索引结构与分片

interface ImageIdxEntry { i: string; c: number; u: number; s: string; b: 0|1; x: 0|1; h: 0|1 }
// i=imageId  c=createdAt  u=lastUsedAt  s=sourceSessionId
// b=有 base64  x=urlBroken  h=有 hostedUrl

分片哈希不能用 sha1——插件运行在 goja,没有 crypto,全仓库也没有哈希工具(已确认)。用纯 JS 的 FNV-1a:

const SHARDS = 64;
function fnv1a(s: string): number {
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
  return h >>> 0;
}
const bucketOf = (imageId: string) => fnv1a(imageId) % SHARDS;
const keyOf    = (b: number) => `image_idx:${b.toString(16).padStart(2, '0')}`;

读写:append 只重写 keyOf(bucketOf(id)) 那一键;trim 读全部 64 键。

17.4 trim 算法(伪码)

trim(now):
  if (now - lastTrimAt < TRIM_INTERVAL_MS && newSinceLastTrim < TRIM_EVERY_N) return
  entries = 读 64 个分片并合并
  victims = entries
              .filter(e => now - e.u > MAX_AGE_MS)          // 期限超期
              .sort((a,b) => a.u - b.u)                      // 最久未用优先
  overflow = entries.length - MAX_ENTRIES
  if (overflow > 0) victims = victims.concat(
      entries.filter(e => !victims.includes(e)).sort((a,b)=>a.u-b.u).slice(0, overflow))
  for v of victims:
      ext.storageSet(`image_${v.i}`, '')                     // 空串 = 删除(§6.7)
      delete Image.imageMap[v.i]
      pendingRelease.push(v.i)                               // 稍后统一释放引用
  writeBackChangedShards(entries - victims)
  lastTrimAt = now; newSinceLastTrim = 0

flushPendingRelease():
  if (pendingRelease.empty) return
  try   { await releaseRefs(pendingRelease); pendingRelease.clear() }
  catch { /* 保留,等下次对账补发 */ }

pendingRelease 持久化在 image_gc_state.pendingRelease(必须落盘,否则重载后引用泄漏)。

17.5 touch 与节流

// 节流时间戳只放内存,不写进 Image(否则会被 Image.save 序列化进存储)
const lastFlushed = new Map<string, number>();
const TOUCH_THROTTLE_MS = 6 * 3600 * 1000;

export function touch(img: Image) {
  const now = Date.now();
  img.lastUsedAt = now;
  if (now - (lastFlushed.get(img.imageId) || 0) < TOUCH_THROTTLE_MS) return;
  lastFlushed.set(img.imageId, now);
  Image.save(img);
  updateIndexEntry(img);        // 同步分片索引里的 u
}

为什么不把 lastFlushedAt 放进 Image:Image.save 是 JSON.stringify(img),会把它一起写进存储;而 validKeysMap 又不含它,读回来会被丢弃——白写一遍。放模块级 Map 更干净。

17.6 收到即抓(调用点与并发)

handleImageMessageSegment(ctx, seg, messageId?)(src/resource/image.ts:404)内:

const image = this.createUrlImage(getSessionId(ctx), file);
// ... 现有 raw / 识图逻辑不变 ...
if (isHostEnabled() && Image.isTemporaryUrl(file)) {
    void captureInBackground(image, file, {
        platform: getPlatform(getSessionId(ctx)),
        groupId: /* ctx 群 ID */, userId: ctx.player?.userId,
        messageId, sessionId: getSessionId(ctx), at: Date.now(), rawUrl: file,
    });
}

captureInBackground(image_host.ts 或 image.ts):

async function captureInBackground(img: Image, url: string, src: ImageSource) {
    await acquireCaptureSlot();                       // 信号量,上限 4
    try {
        const r = await withTimeout(() => uploadByUrl(url, img.imageId, src), 15000);
        img.hostedUrl = r.url; img.hostedB64Url = r.b64Url;
        img.base64 = '';                              // 不落 base64,模型要图时取 b64 出口
        Image.save(img);
    } catch (e) {
        const kind = /* classifyHostError 或网络错误 */;
        if (kind === 'permanent') markUrlBroken(img);  // 复用现有 urlBroken 语义
        // retryable / unauthorized / disabled:不改字段,退回现状链路
    } finally { releaseCaptureSlot(); }
}

失败一律不改 hostedUrl:宁可这张图走现状(懒转换),也不要留一个半残状态。

17.7 ensureBytes(唯一取字节入口)

async ensureBytes(): Promise<string> {
    if (this.base64) return this.base64;
    if (this.hostedB64Url) {                            // 配了图床
        try { return await fetchBase64(idFromHostedUrl(this.hostedUrl)); }
        catch (e) { log.warning(...); }                 // 落到下一级
    }
    if (this.type === 'url') await this.urlToBase64();  // 现状(i2b64)
    return this.base64;
}

替换点:tool_forum.ts:108-121、tool_image.ts:30、tool_meme.ts 相关分支。

17.8 组包路径(src/utils/message.ts:522)

if (image.hostedUrl) {
    parts.push({ type: 'image_url', image_url: { url: hostBase() + image.hostedUrl } });   // 拼后端地址
    touch(image);
    continue;
}
// 以下现状不变:urlToBase64 + isUrlUnusable 判定

imageToText(src/resource/image.ts:272)加同样的提前返回。

17.9 下载失败分支(src/agent/agent.ts:323-360)

const urls = collectImageUrls(messages);
const hosted = urls.filter(u => isHostedUrl(u));           // 图床 URL
const others = urls.filter(u => !isHostedUrl(u));

// 1) hosted 的 URL 只在本机解析:探一下确认文件还在不在
for (const u of hosted) {
    if (!(await headOk(u))) markUrlBrokenByUrls([u]);       // 真没了
}
// 2) 非图床 URL 走现有逻辑
if (others.length) markUrlBrokenByUrls(others);
// 3) 降级重发(现有机制)

markUrlBrokenByUrls 必须同时匹配 img.url 与 img.hostedUrl(src/resource/image.ts:176-178)。

17.10 对账与清理调度(image_gc.ts,已实现)

runOnce():
  1. syncChanges()    // /api/changes 增量:delete_ref / delete_blob / purge_blob → 标失效
  2. reconcile()      // /api/exists 批量核对(500/批):图床已不存在的 ref → 标失效
                      //   (软删除因 ref 仍在,不会误伤;网络失败本轮跳过)
  3. trim()           // 超过「图片清理天数」未使用的记录 → 删记录 + release
  4. flushPending()   // 补发之前失败的 release
触发:启动后延迟 60s、之后每 6h

启动时额外探一次 /api/health(只记日志,不改变启用状态;周期重探不做,避免抖动)。

17.11 配置与探测(已实现部分)

  • 「图床」为空 → 不启用,全链路现状,不做任何探测。
  • 非空 → 启动后 5s 探一次 /api/health,失败只打一条 warning(不因此禁用图床:单次探测失败不代表不可用,长期失败会在每轮的 capture/release 日志里体现)。
  • 未做:30 分钟周期重探 / 401 退避——当前没有"按探测结果禁用"的逻辑,加周期探测只增加日志噪音。

18. 端到端时序

18.1 一次带图消息(图床已配、可达)

协议端 ──图片段(含 rkey 直链)──► 插件
插件: createUrlImage + Image.save(现状)        [同步]
插件: captureInBackground(ref=imageId)          [异步,不阻塞]
   ├─ POST {图床}/api/upload {url, ref, source}
   │     图床: 校验→抓取→sha256→落库→缩略图→写 blob+ref
   │     图床: 200 {id, url: "/i/<id>", b64_url, dedup}
   └─ 插件: 写 hostedUrl/hostedB64Url, 不写 base64
插件: 组包 → [img:xxx] → 后端地址 + hostedUrl(给协议端)
模型: 需要字节时插件取 /i/<id>/b64 内联

18.2 同一张图(图床对外地址可用)

插件: 组包 → 直接用「image-host.publicUrl + /i/<id>」交给模型(不再取字节)
publicUrl 空且 url 是回环: GET /i/<id>/b64 → base64 → 内联给模型
失败 → 降级为 [img:ID] 文本标签

18.3 插件淘汰与交叉清理

插件 trim: 选中 victim → storageSet('image_x','') → pendingRelease.push('x')
插件 flushPendingRelease: POST {图床}/api/release {refs:['x']}
图床: 删 ref('x') → 该 blob 引用数=0 → orphan_at=now
图床 cap 压力: 优先淘汰零引用且 origin=plugin 的(按 orphan_at 最旧)
插件 对账: POST {图床}/api/exists {refs:[...]} → missing → 标 urlBroken / 清条目

19. 实施任务拆解(PR 级)

P1 插件存储治理(无外部依赖,可先合)

  • src/resource/image.ts:加 5 个字段 + validKeysMap 补齐(先做这条,否则后面全是静默丢字段)
  • src/resource/image_gc.ts:FNV-1a 分片、索引读写、trim、touch 节流、pendingRelease 持久化
  • L1 清理:urlBroken 时清 base64 + raw
  • Image.ensureBytes() + 替换 tool_forum / tool_image / tool_meme
  • 单测:§11 插件单测 1–5、12

P2 图床核心

  • fetch.py:safe_fetch(逐跳校验、流式、magic bytes)
  • store.py:DDL/迁移、put_blob、ensure_capacity、make_thumb、查询
  • imagehost.py:路由 + 鉴权 + 错误码
  • backend.json / requirements.txt
  • backends.json 注册(port 46680,files 列全)
  • 单测:§11 后端 1–3、5–7、10–14、18

P3 插件接图床

  • src/config/configs/backend.ts:新增「图床」
  • src/resource/image_host.ts:probe / upload / classifyHostError
  • src/resource/image.ts:收到即抓 + src/CQCode 优先级 + imageToText 提前返回
  • src/utils/string.ts + src/session/session.ts:透传 messageId
  • src/utils/message.ts:多模态直接用 hostedUrl
  • src/agent/agent.ts:下载失败分支
  • 单测:§11 插件 6–9、11;集成 1–4

P4 交叉清理

  • image_host.ts:releaseRefs / findMissingRefs
  • image_gc.ts:pendingRelease 补发 + reconcile 调度
  • 单测:§11 插件 10;后端 4;集成 5

P5 WebUI

  • webui.html + /login /logout cookie
  • 列表 / 上传 / 重命名 / 标签 / 收进图库 / 删除
  • 单测:§11 后端 9、17

P6 MCP

  • mcp.py:3 工具 + tools/list / tools/call
  • 插件侧 MCP 配置(不改代码)
  • 单测:§11 后端 16;集成:AI 能 image_search 命中

P7 可选

  • .ai image stats / .ai image adopt
  • S3 后端、EXIF 剥离、静态 GIF 的 b64 ?format=png
  • 严格 DNS 绑定(把校验过的 IP 用于连接)

独立事项(不阻塞本方案)

  • i2b64 加固:timeout=(5,20)、流式大小上限、scheme 校验、私网拦截

20. 实现期需要核对的事实

实现前建议先确认(本文已尽量标注,但以下几条依赖运行时行为):

# 待核对 影响
1 seal.MsgContext 上群 ID / 用户 ID / 消息时间的准确字段名 source 上报(§17.6)
2 handleReceipt / deferReceipt 里 getRecordMessageId(ctx, msg) 的返回值是否适合作为 message_id 同上
3 goja 是否支持 Math.imul(FNV-1a 用)——若不支持,退化为 h = (h * 31 + c) \| 0 分片哈希(§17.3)
4 launcher 对 files 列表的下载是否按目录递归 backends.json 条目(§16.1)
5 md-html-render 的 MCP 接入写法(照抄配置格式) P6(§16.11)

---

第三部分:批量能力与插件同步

覆盖批量上传(多选 / 拖拽 / 粘贴 / URL 列表)、批量存图库、批量标签、批量删除(含回收站)等全部批量入口。 重点回答一个问题:图床这边批量动了图,插件那边怎么同步?


21. 范围与结论

21.1 批量能力总览

能力 入口 动作 危险级 需插件同步 可撤销
多选上传 顶栏「上传」→ 文件多选 POST /api/upload ×N 低 否 删除即撤销
拖拽上传 整页拖入文件 同上 低 否 同上
粘贴上传 Ctrl/Cmd+V 同上 低 否 同上
URL 列表导入 粘贴多行链接 POST /api/upload {url} ×N 低 否 同上
批量存图库 勾选 → 收进图库 keep:origin → manual 中 否 是
批量移出图库 勾选 → 移出图库 unkeep:origin → plugin 高 否 是
批量加标签 勾选 → 加标签 tag 低 否 否(重做即可)
批量删标签 勾选 → 删标签 untag 低 否 否(重做即可)
批量删除 勾选 → 删除 delete(进回收站) 高 是 是(回收站)
批量恢复 / 彻底删除 回收站视图 restore / purge 低 / 极高 是 / 是 是 / 否
批量复制链接 勾选 → 复制 URL 纯前端(剪贴板) 无 否 —

21.2 三条跨侧结论

  1. 只有"删除"需要同步到插件。上传、标签、改名、存/移出图库都不改变"图还在不在",插件不需要知道。
  2. 同步不能靠 6 小时的 exists 全量对账——批量删 200 张会留下 200 个死引用最长 6 小时。改用增量变更日志 + 游标(§27)。
  3. 所有批量动作都必须幂等、可部分失败:一条坏 id 不能拖垮整批。

22. 批量上传

结论:后端几乎不用改,客户端做队列。 一次请求传一个文件,客户端并发 2,逐文件进度与失败重试。

22.1 入口

入口 实现 备注
多选 <input type="file" multiple accept="image/*"> 一次可选多个
拖拽 画廊整页 dragenter/dragover/drop,遮罩提示「松开上传 N 个文件(2.1 MB)」 支持从资源管理器拖
粘贴 document.addEventListener('paste') → e.clipboardData.files 截图直接 Ctrl+V
URL 列表 顶栏「导入链接」→ 多行文本框 → 逐行入队 复用 POST /api/upload {url}
文件夹拖入(P2) DataTransferItem.webkitGetAsEntry() 递归 设文件数上限,防误拖整个盘

22.2 客户端队列

type UploadItem = {
  key: string;                    // name+size+lastModified,批内去重
  file?: File;                    // 本地文件
  url?: string;                   // URL 导入
  status: 'queued' | 'uploading' | 'done' | 'failed';
  id?: string;                    // 成功返回的 blob id
  error?: string;                 // 失败原因(已翻译)
};
  • 入队预检(不发请求):扩展名非图片 / 大小 > MAX_FILE → 直接 failed 并标注原因。
  • 批内去重:key 相同只留一条。
  • 并发 2:MAX_CONTENT_LENGTH 按请求算,多请求不叠加;落库有写锁,并发再高也没收益。
  • 逐条状态:成功计数、失败原因、单条「重试」(重新入队,不重新选文件)。
  • 全部结束 → load(false) 刷新 + toast「成功 8 / 失败 2」。

22.3 上传面板

左下角固定小面板(与右下 toast 分开),仅在有任务时出现,可折叠;关闭不中断上传:

上传队列  3/9        [全部重试] [折叠]
──────────────────────────────
IMG_2031.png     ✓ 已上传
IMG_2032.png     ⟳ 上传中…
clip.png         ! 超过 8MB
https://…/a.png  ! 上游 404(链接已失效)

失败文案映射:unsupported_format → 不是支持的图片格式;too_large → 超过 8MB;fetch_failed → 原链接取不到;disk_full → 图床空间已满。

22.4 URL 列表导入

每行一个图片直链,可粘贴多行:
https://…/a.png
https://…/b.jpg
  • 逐行入队(同队列);空行与重复行忽略。
  • 走 POST /api/upload {url, via:"webui"} → origin=manual(见 22.6)。
  • 失败原因直接展示上游状态(upstream_status)。
  • 适合把别的图床/相册的图批量搬进来;也顺带验证了图床的抓取链路。

22.5 后端改动(很小)

改动 内容
文件名 → 默认名称 multipart 无 name 时,用文件名去扩展名作为 blob.name;dedup 命中时只在原 name 为空才补(COALESCE(name, ?))
via="webui" 允许 WebUI 会话把 JSON url/base64 路径也判为 manual
其余不变 仍是单文件接口,多文件由客户端循环
# imagehost.py multipart 分支
name = (request.form.get("name") or "").strip()
if not name and fh.filename:
    name = os.path.splitext(os.path.basename(fh.filename))[0][:80]

22.6 origin 判定(防篡改)

入口 判定依据 origin
插件抓取(JSON {url},token) token plugin
WebUI 多选/拖拽/粘贴(multipart) 传输形态 manual
WebUI URL 导入(JSON {url} + via:"webui") cookie 会话 + via manual

origin 由服务端按"传输形态 + 调用身份"决定,不接受裸的客户端指定。只有 cookie 会话 + 显式 via="webui" 才把 JSON 路径判为 manual;token 调用永远 plugin。

22.7 边界

  • 单文件上限 MAX_FILE(默认 8 MB)→ 413;客户端预检一次避免白发。
  • 非白名单格式 → 400 unsupported_format。
  • 同内容不同文件名 → 哈希去重(dedup:true),但仍记一条 ref;第二张图的文件名不会覆盖已有 name。
  • 一次拖 50 张 = 50 个请求,本地回环 < 2 秒;不做多文件批量接口(避免 multipart 半成功语义)。

23. 批量存图库(keep / unkeep)

23.1 语义

action 效果 对图床内部
keep origin: plugin → manual 该图立刻退出自动淘汰候选;orphan_at 保留但不再生效
unkeep origin: manual → plugin 该图重新进入自动淘汰候选;若 orphan_at 非空(零引用)则立刻变成可淘汰
  • 天然幂等:已是 manual 的再 keep 不计入 affected。
  • 返回 changed_ids(真正发生变化的),供前端做精确撤销。

23.2 混合选择

选中里同时有 plugin 和 manual 时:

  • 操作栏显示「其中 40 张已是手动上传,将被跳过」。
  • keep 只作用于 plugin 的;unkeep 只作用于 manual 的。
  • affected 只统计真正翻转的,requested 是选中总数——前端用它展示「已更新 97 / 选中 137(40 张本就如此)」。

23.3 与 cap / 引用的交互

场景 行为
keep 与 cap 淘汰并发 cap 在事务前已删掉某张 → 该 id 返回 not_found,其余照做;绝不因为一张没了整批失败
keep 后插件 release 掉全部引用 零引用 + manual → 仍然不自动删(决策 9)。这是刻意的:人收进图库的东西机器不能清
unkeep 后零引用 立刻进入 tier-1 淘汰候选(按 orphan_at 最旧优先)
大量 keep 导致手动图占满 cap 新上传按 §5.9 返回 507,而不是删手动图

23.4 撤销(仅这一对动作提供)

  • keep / unkeep 的响应带 changed_ids。
  • 客户端记一条撤销项:{action: 'unkeep', ids: changed_ids}(或反向)。
  • 操作完成后 30 秒内,toast 上出现「撤销」;点击即对 changed_ids 执行反向动作。
  • 撤销只影响这次真正改变过的 id,不会误伤本来就如此的图。

标签操作不提供撤销:精确撤销需要 per-item 的"之前有哪些标签"快照,成本高于收益;加错标签再删掉同样快。


24. 批量标签

24.1 加 / 删

  • tag:给所有选中图加同一组标签(INSERT OR IGNORE,幂等)。
  • untag:从所有选中图移除同一组标签(不存在无害)。
  • 单批可带多个标签(add_tags: ["猫","表情包"])。

24.2 交互

  • 加标签(批量):弹层 = 「待添加 chips」+「输入框 + 加号」+「已有标签可点」。
  • 输入框有内容时加号才亮,点加号(或 Enter)把标签加入待添加集合;
  • 待添加集合非空时「确定」才可点(避免"点了确定却提示先输入"的反向体验);
  • 输入框下直接列出已有标签(标签 ×次数,点一下加入,已选的不再出现);
  • 提示文案不提 Enter(Enter 只是快捷方式)。不用浏览器 datalist/prompt。
  • 加标签(卡片上的「+ 标签」):同样「输入框 + 加号 + 下方已有标签」;点建议或加号即刻生效、可连续加多个;Esc 或失焦关闭;已存在的标签点/输会提示「已有该标签」。
  • 删标签:弹出面板,列出选中图标签的并集 + 出现次数:
选中的 137 张图共有 12 个标签,点一下即从这 137 张里全部移除:
 [猫 ×96] [表情包 ×40] [风景 ×12] [待分类 ×137] …

这比让人手打标签名可靠得多,也避免"删了个根本不存在的标签"。 - 两个操作都走 POST /api/batch。

24.3 与筛选的联动

  • 若用户先按标签 风景 筛选再全选全部,删标签 风景 会让这批图从当前视图消失——操作后强制刷新列表是预期行为。
  • 删标签的确认框提示:「移除后这 137 张将不再匹配当前筛选」。

25. 批量删除与回收站

25.1 为什么必须配回收站

批量删除不可逆,一次手滑 200 张;且插件侧会立刻收到 200 条失效通知——恢复成本很高。软删除把危险操作变成可回退操作。

25.2 数据与接口

ALTER TABLE blob ADD COLUMN deleted_at INTEGER;      -- 非空 = 在回收站
CREATE INDEX idx_blob_deleted ON blob(deleted_at);
接口 行为
GET /api/list?trash=1 回收站视图(默认列表排除 deleted_at IS NOT NULL)
POST /api/batch {action:"restore"} 清 deleted_at,写 restore_blob;文件已不在则 410 gone
POST /api/batch {action:"purge"} 硬删文件 + 行,写 purge_blob
自动清理 超过 TRASH_DAYS(默认 7)每小时 purge 一批

25.3 与其它机制的关系

机制 行为
插件同步 delete 时就写 delete_ref(模型此刻已取不到图),不等 purge
与 cap 回收站仍占盘;cap 压力下先清回收站(按 deleted_at 最旧),再淘汰 plugin 图;manual 图仍不自动删
与恢复 恢复前检查文件在不在(cap 可能已清掉);不在 → 410
与 LRU 回收站内容不参与 last_access_at 淘汰排序
与打包/导出 回收站内容不导出

25.4 建议

TRASH_DAYS=0 可关闭回收站(delete 即硬删)。但只要做批量删除,建议默认开启 7 天——它只多一列、两个 action,却决定了这个功能能不能安全上线。


26. 批量 API

26.1 端点

POST /api/batch            // 执行
POST /api/batch {dry_run}  // 预演(同一个端点,dry_run=true)

两种寻址方式(二选一):

// ① ids 模式(普通勾选)
{ "action": "untag", "ids": ["9f2c…", "a031…"], "remove_tags": ["待分类"] }

// ② filter 模式("全选全部":按当前筛选条件操作)
{ "action": "keep", "filter": { "q": "", "tag": "风景", "origin": "plugin" },
  "expected_count": 137 }

26.2 dry_run 预演(推荐先做这个)

// req
{ "action": "delete", "filter": { "tag": "风景" }, "dry_run": true }
// resp(不写任何数据)
{ "ok": true, "dry_run": true,
  "requested": 142,          // 命中多少张
  "affected": 142,           // 真正会被改变的(keep 已是的会少)
  "skipped": 3,              // 已是目标状态的
  "refs_affected": 168,      // 会被判死的插件引用数(前端确认框要显示这个)
  "sample": ["9f2c…", "a031…"] }

为什么要有预演:确认框里显示的是真实数字,而不是前端估算;用户确认的是"142 张、168 个引用",服务端再按 expected_count=142 校验一次,双重防呆。

26.3 响应与部分失败

// 200
{
  "ok": true, "action": "keep",
  "requested": 137, "affected": 97,
  "changed_ids": ["9f2c…", "…"],            // keep/unkeep 用于撤销
  "failed": [{ "id": "…", "reason": "not_found" }],
  "cursor": 1043                             // 本次后的变更日志游标
}

// 409 —— filter 模式下目标数量与确认时不一致
{ "error": "count_mismatch", "expected": 137, "actual": 142 }
// 413 —— 超单批上限
{ "error": "too_many", "count": 2400, "max": 2000 }
// 400 —— 请求本身不合法
{ "error": "bad_request", "reason": "need_ids_or_filter" }
  • 逐条失败进 failed[],绝不整批 4xx。
  • affected 只统计真正发生变化的行(幂等语义)。
  • cursor 让调用方可以立刻接着拉变更日志(一般用不上,插件是独立轮询的)。

26.4 限额与分片

项 值 说明
ids 单批上限 500 请求体 + 事务时长
filter 命中上限 2000 超出 413,提示缩小筛选
UI 分片 选中 > 500 → 自动拆 500/批 进度可见、单批失败不牵连其余
expected_count filter 模式必填 缺失 → 400

26.5 服务端实现要点

@app.post("/api/batch")
def api_batch():
    _require_auth()
    body = request.get_json(silent=True) or {}
    action = body.get("action")
    if action not in ACTIONS: raise ApiError("bad_request", 400, reason="bad_action")
    targets = _resolve_targets(body)          # ids 模式直取;filter 模式解析 + 校验 expected_count
    if len(targets) > MAX_BATCH: raise ApiError("too_many", 413, count=len(targets), max=MAX_BATCH)
    if body.get("dry_run"): return jsonify(store.preview(action, targets, body))
    return jsonify(store.batch(action, targets, body))

store.batch() 的实现要点:

要点 做法
事务 整批一个 BEGIN…COMMIT;持写锁
批量 SQL executemany,不要逐条 execute
变更日志 delete/restore/purge 在同一事务内写入,保证"删了必留痕"
文件删除 提交后再 unlink(尽力而为,失败只记日志;启动自检清残留)
affected 用 cursor.rowcount 累计,幂等动作自然为 0

27. 变更日志与插件同步(重点)

27.1 为什么 exists 不够

维度 POST /api/exists(现状) GET /api/changes(本方案)
成本 O(插件持有的全部引用),每轮全报 O(被删数量),通常为 0
延迟 6 小时 可压到 1–5 分钟
批量删除 一次返回 N 个 missing 一次返回 N 条事件,插件一次标完
cap 自动淘汰 下一轮才发现 同一条链路覆盖
断线/重装 只能全量对 有游标可增量;游标过期走 reset 兜底

exists 不删,降级为兜底(reset 时 + 24h 体检),日常走 changes。

27.2 表结构

CREATE TABLE change_log (
  seq     INTEGER PRIMARY KEY AUTOINCREMENT,   -- 单调游标
  at      INTEGER NOT NULL,
  kind    TEXT NOT NULL,                       -- delete_ref | delete_blob | restore_blob | purge_blob
  blob_id TEXT,
  ref     TEXT,                                -- delete_ref 带插件侧 imageId
  extra   TEXT
);
CREATE INDEX idx_change_seq ON change_log(seq);
CREATE INDEX idx_change_at  ON change_log(at);

27.3 写入点(漏一处就漏一次同步)

触发 写入
DELETE /api/image/<id> 读全部 ref → 每条 delete_ref → 一条 delete_blob
POST /api/batch {action:"delete"} 同上,逐 blob
cap/LRU 自动淘汰(_delete_blob) 同上
restore restore_blob
purge purge_blob
插件 POST /api/release 不写(插件自己发起的)

唯一例外:多插件实例共用同一图床时,A 的 release 会让 B 的引用消失 → AIPLUGIN4_IMAGE_HOST_MULTI_CLIENT=1 时 release 也写 delete_ref。

27.4 拉取

GET /api/changes?since=<cursor>&limit=500
{ "cursor": 1043, "reset": false,
  "events": [
    { "seq": 1041, "kind": "delete_ref", "ref": "a1b2c3", "blob_id": "9f2c…", "at": 1792… },
    { "seq": 1042, "kind": "delete_blob", "ref": null, "blob_id": "9f2c…", "at": 1792… }
  ] }
// 游标已过期
{ "cursor": 2098, "reset": true, "events": [] }

27.5 插件侧消费

状态(image_host_state 新增):lastChangeSeq、lastChangePollAt。

轮询(image_gc.ts):启动后 30s 一次,之后每 5 分钟;events.length == limit 时继续拉直到空;reset=true → 走 exists 全量兜底。

标记:

function markRefGone(ref: string) {
  const img = Image.get(ref);              // ref == 插件 imageId
  if (!img) return;                        // 已被 GC,跳过
  img.urlBroken = true;                    // 复用现有语义:渲染期只留文本标签
  // 保留 hostedUrl —— 万一之后被恢复,还能复活
  Image.save(img); touchIndex(img);
}
function markBlobBack(blobId: string) {
  for (const img of Object.values(Image.imageMap)) {
    if (img.hostedUrl?.includes(blobId) && img.urlBroken) {
      img.urlBroken = false; Image.save(img); touchIndex(img);
    }
  }
}
  • 标记后组包路径(§6.5)自动不再产生 image_url,退化成 [img:id:描述]——与"模型够不到"共用同一套兜底。
  • 之后由 L2 trim 按 lastUsedAt 正常回收,无需额外逻辑。

兜底:reset → 现有 exists 分批对账;另保留 24h 一次的低频体检,防"日志写漏"。

27.6 保留期

项 默认 说明
CHANGE_KEEP_DAYS 30 超期删除;插件离线 >30 天 → reset 全量兜底
清理时机 启动 + 每小时 远大于 5 分钟轮询间隔,reset 应极少发生

27.7 批量删除的完整链路

用户勾选 200 张 → 删除 →(dry_run 显示 200 张 / 231 个引用)→ 二次确认
 ① POST /api/batch {action:"delete", ids:[…200]}
    图床:一个事务写 231 条 delete_ref + 200 条 delete_blob,blob 进回收站
 ② 插件 5 分钟内拉一次 changes(231 条事件,一次拿完)
    231 个 Image 标 urlBroken,索引 x=1
 ③ 下一轮对话组包:这些图自动退化为文本标签,不产生 400

对比现状:不做变更日志时,这 231 张要等 6 小时对账,期间每次用到都是一轮失败重试。


28. WebUI 交互

28.0 视图与上传面板

三个视图(顶栏 tab 切换,同一页面内):

视图 数据源 说明
图库 GET /api/list 默认视图;卡片、批量选择、上传入口
回收站 GET /api/list?trash=1 卡片标「回收站」;批量栏换成 恢复 / 彻底删除;缩略图与全图预览仅登录态可见(模型侧 404)
标签 GET /api/tags?q=&limit=&offset= 标签清单:按使用次数降序的 chip(标签 ×次数),分页(120/页,显示 已加载 X / 共 N 个标签 + 加载更多);顶部有标签过滤框;点标签即切回图库并按该标签筛选
  • 顶栏的来源筛选用分段控件(全部 / 插件抓的 / 手动上传),与视图 tab 同一套样式,不用浏览器原生 <select>。
  • 没有「筛选」按钮:搜索框与标签筛选都是输入即生效(300ms 防抖),Enter 立即生效;来源分段控件点选即生效。
  • 搜索框 / 标签筛选 / 标签视图过滤框右侧都有 「×」一键清空(仅在有内容时出现,清空后立即重新加载)。
  • / 以 Cache-Control: no-store 返回,避免前端改版后用户看到缓存的旧页面。

标签气泡一律有上限,不会把页面撑爆:

位置 上限 超出时
卡片上的标签 8 个 折叠成 +N,悬停显示全部
加标签的「已有标签」建议 24 个 末尾提示「还有 N 个,继续输入可缩小范围」
删标签的并集面板 60 个 末尾提示「还有 N 个标签未列出」
待添加 chips 区 — .pop .chips 限高 120px 内部滚动
标签清单视图 120 个/页 「加载更多」分页

上传面板(左下角,仅在有任务时出现):

  • 标题栏:上传队列 x/y、全部重试、收起/展开、×(关闭只隐藏面板,不取消上传)。
  • 收起后只留标题栏;关闭后左下角出现 上传队列 x/y 小按钮,点击恢复。
  • 实现坑:面板列表 .qlist 与拖拽遮罩 .dropovl 自带 display:flex,会覆盖 UA 的 [hidden]{display:none} —— 必须补 [hidden]{display:none!important},否则"收起"点了没反应(已修)。

28.1 选择模型

state.selection = {
  ids: Set<string>,                     // 跨页保持
  allMatching: boolean,                 // true = filter 模式(全选全部)
  filter: { q, tag, origin },           // 进入 allMatching 时的筛选快照
  total: number,                        // 快照时的命中数
};
控件 行为
全选本页 当前页 24 个 id 加入 ids
全选全部(共 N) allMatching=true,记 filter 快照与 N
取消选择 清空
手动取消某一项 从 allMatching 退回 ids 模式(提示「已取消全选,仅保留当前勾选」)

28.2 布局

  • 批量模式下顶栏换成工具栏:已选 137 张 · 全选本页 · 全选全部(137) · 取消
  • 底部浮动操作栏(移动端变 bottom sheet):
[加标签] [删标签] [存图库] [移出图库] [复制链接] [删除]

删除/移出图库 用危险色;其余中性。未选中时整条隐藏。 - 卡片左上角复选框;选中卡片加高亮描边;点击卡片任意处=切换选中(不再打开大图)。

28.3 危险操作确认

两段式:

  1. 先 dry_run,弹框显示真实数字: 「将删除 142 张图片,其中 168 个插件引用会失效(可在回收站保留 7 天)」 allMatching 时附筛选摘要:「筛选:标签=风景 · 插件抓的」。
  2. requested > 50 → 要求输入 删除 两字才启用按钮。

28.4 键盘与无障碍

交互 行为
Esc 退出批量模式 / 关闭弹窗
Ctrl/Cmd + A 全选本页(不劫持输入框内的全选)
Shift + 点击 本页范围选择
Delete 触发删除确认(不直达)
复选框 真 <input type=checkbox>,aria-checked 同步
弹窗 焦点陷阱 + role=dialog + aria-modal
动效 尊重 prefers-reduced-motion

28.5 移动端

  • 长按卡片进入选择模式;底部操作栏变 bottom sheet。
  • 上传面板折叠成一个小进度气泡(「3/9」)。

28.6 多标签页并发

  • id 集合天然幂等;expected_count 防筛选漂移。
  • 任一操作后强制 load(false) 刷新,避免看到过期状态。
  • 另一个标签页的上传队列不可见——可接受(不需要跨页同步)。

29. MCP 批量

只放开"安全"的批量,删除仍不给 AI:

工具 变更
image_tag 参数从 id 扩展为 id? / ids?[](二选一);add / remove 不变
image_search 不变
image_rename 保持单张(批量同名无意义)
image_keep 新增:ids[] → origin=manual(AI 可以把"这张别删了"表达出来)
~~image_delete~~ 不做(§5.11)

返回文本示例:已更新 2 张图的标签:添加 表情包 / 已把 3 张图收进图库。


30. 新增接口汇总

接口 方法 用途
/api/batch POST 批量执行 / dry_run 预演
/api/changes GET 变更日志增量拉取
/api/tags GET 标签词表 + 计数(补全、筛选下拉)
/api/upload POST 扩展:multipart 支持 name、via="webui"
/api/list GET 扩展:trash=1 回收站视图

31. 新增参数

配置 默认 位置 说明
MAX_BATCH_IDS 500 后端常量 单批 ids 上限
MAX_FILTER_MATCH 2000 后端常量 filter 模式命中上限
AIPLUGIN4_IMAGE_HOST_CHANGE_KEEP_DAYS 30 后端 env 变更日志保留
AIPLUGIN4_IMAGE_HOST_TRASH_DAYS 7 后端 env 回收站保留(0=关闭)
AIPLUGIN4_IMAGE_HOST_MULTI_CLIENT 0 后端 env 多实例共用时 release 也记日志
IMAGE_CHANGE_POLL_MS 5 分钟 插件常量 变更轮询间隔
IMAGE_CHANGE_POLL_TIMEOUT 10s 插件常量 单次拉取超时
IMAGE_EXISTS_AUDIT_MS 24h 插件常量 低频全量体检
UPLOAD_CONCURRENCY 2 前端常量 上传并发
UNDO_TTL_MS 30s 前端常量 存图库撤销窗口

32. 任务拆解与验收

T1 后端:批量 API

  • store.preview() / store.batch():单事务、幂等、affected 统计、部分失败
  • POST /api/batch + _resolve_targets(ids / filter + expected_count)
  • 限额(500 / 2000)、409 count_mismatch、413 too_many
  • GET /api/tags
  • 验收:批量 untag 重复执行 affected=0;filter 模式计数变化 → 409;超限 → 413

T2 后端:变更日志

  • change_log 表 + 迁移(user_version 1 → 2)
  • 5 个写入点全覆盖(手动删 / 批量删 / cap 淘汰 / restore / purge)
  • GET /api/changes(游标、limit、reset)
  • 保留期清理(启动 + 每小时)
  • 验收:批量删 200 条 → 事件数 = blob + ref;游标单调;日志过期触发 reset

T3 后端:回收站

  • deleted_at 迁移、列表默认排除、trash=1
  • restore / purge、TTL 清理、cap 优先清回收站
  • 验收:删除不可见 → 恢复可见 → 过 TTL 不可恢复

T4 插件:增量同步

  • image_host.ts:fetchChanges(since, limit)
  • image_gc.ts:轮询、lastChangeSeq 持久化、reset → exists 兜底、24h 体检
  • Image.markRefGone / markBlobBack
  • 验收:收到 delete_ref → urlBroken 且组包不再产生 image_url;restore_blob → 恢复可渲染;reset → 触发全量

T5 前端:批量上传

  • 多选、拖拽遮罩、粘贴、URL 列表导入
  • 队列(并发 2、批内去重、预检、逐条状态、重试)、上传面板
  • 验收:拖 10 张(含 1 超限、1 非图片)→ 8 成功 / 2 失败且可重试;粘贴截图成功

T6 前端:批量管理

  • 选择模式、跨页保持、allMatching
  • 底部操作栏、>500 自动分片、进度
  • 存图库/移出图库 + 30s 撤销
  • 标签并集面板、自动补全
  • dry_run 驱动的两段式确认 + 超 50 输入确认词
  • 验收:全选全部 137 → 存图库 → 刷新后徽章全变「手动上传」;删除 137 → 确认 → 回收站可见 → 恢复

T7 MCP 扩展

  • image_tag 支持 ids[];新增 image_keep
  • 验收:AI 调 image_tag(ids=[...]) → 多张同时更新

33. 明确不做

项 原因
多文件批量上传接口 客户端并发更简单,避免 multipart 半成功语义
AI 批量删除 误删代价不对称;删除只留给人
批量重命名 多张同名无意义
标签操作撤销 精确撤销需 per-item 快照,成本 > 收益;重做很快
跨页"反选全部" 补集会随筛选变化,语义易错;只提供全选本页/全部
批量导出 zip 与图床定位无关,P4 再议
批量操作的审计日志(谁改了什么) 当前只有共享密码、没有用户身份,"谁"记不准;change_log 只服务删除同步

34. 性能预算与实测(v1.0.0 已实现)

实测环境:Windows 11 + Windows Defender 实时防护、Python 3.13、SQLite WAL、单机回环。 每项取 10–50 次的中位数;客户端用 requests.Session 保持长连接,避免 TCP 建连噪声。

34.1 实测结果

场景 优化前 优化后 说明
上传 · 哈希去重命中 14–16 ms 2.4 ms 一份字节都不写
上传 · 新图 800×600(约 9 KB) 27–52 ms 3.1 ms 直写终态,跳过缩略图
上传 · 1.9 MB 大图(含缩略图) — 183 ms ≈96 ms/MB,主要是缩略图 + 大 body
列表 24 条/页 2.9 ms 2.1 ms 三查询取全页
GET /i/<id> 热 2.1 ms 1.9 ms 内存 LRU 命中
GET /t/<id> 缩略图 68 ms(首次) 1.7–3.0 ms 首次读新文件会被杀软扫
批量 tag 50 张 × 3 标签 7.2 ms 2.4 ms 单事务 + executemany
批量 delete 50 张(软删 + 100 条日志) 23.2 ms 2.7 ms 同上
GET /api/changes 500 条事件 19.9 ms 3.1 ms 自增 seq 主键,O(delta)

34.2 关键优化(按收益排序)

# 优化 收益 原因
1 SQLite 连接池(queue.LifoQueue + teardown_request 归还) 单次写 conn 6–19 ms → 0.01 ms Flask 是「每请求一个线程」,thread-local 等于每请求新建连接;常驻多连接时新建连接极慢
2 每连接重设 PRAGMA(synchronous / foreign_keys 是连接级) 消除多余 fsync 新建连接会退回默认 FULL
3 小图跳过缩略图(≤64 KB 不打开 Pillow) 新图上传 27 → 3.1 ms 省一次「刚写完就立刻读」的磁盘操作
4 hash-first:base64 先算 sha,命中则零文件写入;新图直写终态 去重 15 → 2.4 ms 省掉 tmp 文件 + rename
5 缩略图生成移出写事务 并发上传不再互相阻塞 CPU 不占写锁
6 meta 表维护 used_bytes 每次写入少一次全表 SUM 容量检查 O(1)
7 不可变元数据内存 LRU(4096 条) GET /i/<id> 少一次 DB 查询 内容寻址 ⇒ 条目不可变,只需在删除时失效
8 列表消除 N+1(blob / tags / sources 各一条) 24 条/页 3 → 49 次查询 原来每张图各查一次标签与来源
9 批量操作单事务 + executemany 删除 50 张 23 → 2.7 ms 200 次单独 execute → 4 次批量
10 变更日志自增 seq 主键 + 游标查询 500 条事件 20 → 3.1 ms O(delta) 而非 O(全表)

34.3 环境噪声(别误判为代码问题)

  • Windows Defender 会扫描刚创建的文件:首次读一个新写入的图片约 7–12 ms,第二次起 1.9 ms。 这也是"冷读 6.9/12.4 ms、热读 1.9 ms"的原因——同理 GET /t/ 首次 68 ms。
  • Linux 部署没有这笔开销;上表"优化后"的数字里,新图/首次读仍含这部分。
  • Flask 开发服务器(app.run(threaded=True))单进程;家庭场景足够,若要跑公网建议换 waitress(waitress-serve 一行命令,无需改代码)。

34.4 规模推算

指标 估算
单张平均占用 9 KB(示例图)~ 数百 KB(照片)
CAP=2 GB 可存 约 20 万张 9 KB / 约 1 万张 200 KB
列表与取图延迟 与总张数无关(主键 / 索引);COUNT(*) 在 10 万级仍是毫秒内
SQLite 文件 1 万张约 3–5 MB 元数据 + 字节文件
change_log 每条约 60 B;30 天保留,日常图床量级 < 1 万条

结论:不需要引入数据库外的任何缓存层;瓶颈只在磁盘和缩略图 CPU,且都已错峰。


状态:后端已发布(image-host 1.0.15,6 个 MCP 工具);插件侧已全部实现并通过单测(483 个插件单测 + 57 个后端单测;真后端联调可用 IMAGE_HOST_E2E),见 §6.0/§35。

35. 实现状态(截至本次)

部分 状态
后端 image-host(抓取 / 哈希去重 / 引用计数 / b64 出口 / 回收站 / 批量 / 变更日志 / 标签清单分页 / ref 元数据 / MCP 6 工具) ✅ 已发布(1.0.15:ref tool/server/via、upload tags/source、image_search SQL 过滤/total/since/多标签、image_unkeep、tag 元数据边界)
WebUI(外观切换 / 画廊 / 来源 / 重命名 / 标签 / 标签清单视图 / 上传队列(收起·关闭·恢复) / 拖拽 / 粘贴 / URL 导入 / 批量选择与操作 / 回收站) ✅ 已实现并本地跑通
性能优化(§34) ✅ 已落地并实测
插件侧(图床客户端 / Image.hosted*+lastUsedAt+sourceMeta / 图片索引 / 定时清理 + release 补发 / changes + exists 双通道对账 / 上传并发上限 4 / 收图与生图自动入库 / ensureBytes 走 b64 出口 / 有图床时不依赖 i2b64 / 不再持久化 base64 / 来源字段完整上报 / CIR v1 + 虚拟图 + image_view / 工具图片英文类型标签 / 模型可达性与 urlBroken 自愈) ✅ 全部实现,483 个插件单测通过(含真后端联调开关)
独立事项:i2b64 加固 ⏳ 未开始(与图床无关;配了图床后插件不再调用它)
可选增强:.ai image stats 命令(条目数/图床命中率等) 未做(P7 可选,不影响主链路)