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 {