perf(vision): 图片 URL 的「取回 + 内联」按内容哈希 memo(协同 H1:每回合重复付费/取回)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 起因(Android 注册的成本项 H1):`resolveImages` 每回合都跑,而 `inlineImageSrc` 对 `data:` 短路、
  对 **URL 每回合重新取回并内联** ⇒ ① 同一张图反复网络 I/O;② 来源一旦失效则该回合解析失败,
  而 loop 保留原消息不中断回合 ⇒ **模型这轮静默看不到图**
- 修法:按**内容哈希**记忆「URL → data URI」结果(有界 + 并发安全,独立小文件承载);
  **失败不写缓存**(不把坏结果钉住,第二次仍可重试);命中即不再发起网络请求
- **来源失效不再静默**:取回失败产生可诊断信号(含主机与原因,**不含图片内容/凭据**),回合照旧继续
- 复核:vision 包 build/vet/测试绿(含 `-race`);断言覆盖「同一 URL 连续解析 → 上游只被取回 1 次且结果逐字节相同」
  「内容变化不被旧缓存命中」「失败不写缓存且第二次仍重试」「容量上限生效」,以及既有断言
  (非图片 `ErrNotImage` / SSRF 被拒 / 长度预检)不回归;变异反证已还原
This commit is contained in:
AskaEth committed 2026-10-04 16:32:13 +08:00
1 parent b65fe05635
commit 94b882f4d9
5 files changed
+682 -8

No files matched your search

+34 -1
View File
@@ -1,6 +1,9 @@
package vision
import "errors"
import (
"errors"
"fmt"
)
// vision 包返回的哨兵错误。每个错误向外传递时都以 %w
// 包装,使调用方能按类别判断,同时保留原始原因。
@@ -29,3 +32,33 @@ var (
// 判定在解码与短路之前完成,错误信息只带上限与本次长度。
ErrImageTooLarge = errors.New("vision: inline image is too large")
)
// SourceFailure 描述一次图像来源取回失败。它只携带主机与底层原因:完整
// URL 可能带着查询串里的凭据、图像字节可能含隐私内容,两者都不进错误
// 文案。Host 取不到(URL 无法解析)时为空串。
//
// 它实现 Unwrap,因此底层哨兵(ErrFetch / ErrNotImage /
// ErrBlockedAddress / ErrImageTooLarge …)仍可用 errors.Is 判定;
// 调用方用 errors.As(*SourceFailure) 或结构断言 ImageSourceHost()
// 取出主机,把「来源失效」记成 WARN 而不是静默丢图(见 loop/vision.go)。
type SourceFailure struct {
// Host 是来源 URL 的主机(含端口),不含 userinfo、路径与查询串。
Host string
// Reason 是取回失败的底层原因,保留哨兵链供 errors.Is 判定。
Reason error
}
// Error 渲染主机与原因;Host 为空时只呈现原因。
func (e *SourceFailure) Error() string {
if e.Host == "" {
return e.Reason.Error()
}
return fmt.Sprintf("图像来源取回失败(主机 %s):%v", e.Host, e.Reason)
}
// Unwrap 暴露底层原因,使哨兵判定与具体错误类型照常可用。
func (e *SourceFailure) Unwrap() error { return e.Reason }
// ImageSourceHost 报告来源主机,供调用方按结构断言取用(loop 的解析点),
// 使 loop 无需依赖本包的取回实现。
func (e *SourceFailure) ImageSourceHost() string { return e.Host }