From 127c32b440d0b95ba9f0e8de2050ae60b682d971 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 16:46:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(vision):=20=E5=9B=BE=E5=83=8F=20URL=20?= =?UTF-8?q?=E5=8F=96=E5=9B=9E=E5=A4=B1=E8=B4=A5=E7=9A=84=20WARN=20?= =?UTF-8?q?=E5=B8=A6=E6=9D=A5=E6=BA=90=E4=B8=BB=E6=9C=BA=EF=BC=88=E5=8F=AF?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E4=B8=94=E4=B8=8D=E5=A4=96=E6=B3=84=E8=B7=AF?= =?UTF-8?q?=E5=BE=84/=E6=9F=A5=E8=AF=A2=E4=B8=B2/=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loop 解析点用结构断言取 SourceFailure 的主机(errors.As 穿透 %w 包装), 不导入 vision 包、不引入方向性依赖;拿不到来源时留空串而不编造。 - 新增 TestImageSourceHost 四例(nil/普通错误/包装后来源/空主机), 变异验证:把返回值改成 "" 该用例必红(已复跑确认)。 --- internal/runtime/loop/loop_vision_test.go | 35 +++++++++++++++++++++++ internal/runtime/loop/vision.go | 18 +++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/internal/runtime/loop/loop_vision_test.go b/internal/runtime/loop/loop_vision_test.go index e54067c..eebaae2 100644 --- a/internal/runtime/loop/loop_vision_test.go +++ b/internal/runtime/loop/loop_vision_test.go @@ -2,11 +2,46 @@ package loop import ( "context" + "errors" + "fmt" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) +// fakeSourceErr 模拟 vision 包的 SourceFailure:错误链上能报出来源主机。 +type fakeSourceErr struct{ host string } + +func (e *fakeSourceErr) Error() string { return "取回失败" } +func (e *fakeSourceErr) ImageSourceHost() string { + return e.host +} + +// TestImageSourceHost 钉住「日志能定位到来源主机」这条契约:错误链上 +// 带 ImageSourceHost 的证据要穿透 %w 包装被取出;其它错误一律空串 +// (空串表示无从定位,而不是假装有来源)。 +func TestImageSourceHost(t *testing.T) { + t.Parallel() + cases := []struct { + name string + err error + want string + }{ + {name: "nil 错误", err: nil, want: ""}, + {name: "普通错误", err: errors.New("解析失败"), want: ""}, + {name: "包装后的来源错误", err: fmt.Errorf("resolve: %w", &fakeSourceErr{host: "cdn.example.com"}), want: "cdn.example.com"}, + {name: "包装但主机为空", err: fmt.Errorf("resolve: %w", &fakeSourceErr{}), want: ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + if got := imageSourceHost(c.err); got != c.want { + t.Fatalf("imageSourceHost = %q, want %q", got, c.want) + } + }) + } +} + func TestRun_ImageResolverFoldsToText(t *testing.T) { t.Parallel() ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Model: "m", Content: "Done."}}}} diff --git a/internal/runtime/loop/vision.go b/internal/runtime/loop/vision.go index 6fb04c7..bc5fb92 100644 --- a/internal/runtime/loop/vision.go +++ b/internal/runtime/loop/vision.go @@ -2,6 +2,7 @@ package loop import ( "context" + "errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) @@ -47,7 +48,10 @@ func (l *Loop) resolveImages(ctx context.Context, messages []model.Message) []mo } resolved, err := l.imageResolver.Resolve(ctx, m) if err != nil { - l.log.Warn("image resolve", "error", err) + // 图像解析失败(尤其:URL 来源已失效)在这里留下可诊断信号 —— + // 原消息照旧保留、回合继续,但不再静默:host 是来源 URL 的主机, + // 不含路径、查询串与凭据(由 SourceFailure 保证)。 + l.log.Warn("image resolve", "error", err, "host", imageSourceHost(err)) out[i] = m continue } @@ -60,6 +64,18 @@ func (l *Loop) resolveImages(ctx context.Context, messages []model.Message) []mo return out } +// imageSourceHost 从图像解析失败错误里取出来源主机(vision 的取回失败 +// 错误以结构断言暴露它),使 WARN 能定位到具体来源。错误不带这个信息 +// (解析失败、非图像、无解析器等)时返回空串。用结构断言而不是导入 +// vision 包:loop 只需知道「有个东西能报主机」,不需要依赖取回实现。 +func imageSourceHost(err error) string { + var withHost interface{ ImageSourceHost() string } + if errors.As(err, &withHost) { + return withHost.ImageSourceHost() + } + return "" +} + // messageHasImage 报告 msg 是否有任何内容部件是图像部件。 func messageHasImage(m model.Message) bool { for _, part := range m.Content {