package vision import ( "context" "fmt" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // ModelInfoProvider 提供主模型的视觉能力,使解析器 // 能按 vision Router 的判定(design/06 §0)路由图像。它按 // 图像逐个调用;同一次解析中共享同一个 provider。 type ModelInfoProvider func() ModelInfo // ToolInvoker 按名字与给定参数调用一个已注册的工具(视觉或 OCR // 工具)。由 *tool.Dispatcher.Invoke 满足。 type ToolInvoker func(ctx context.Context, name string, args map[string]any) (any, error) // MessageResolver 是 loop 的具体 ImageResolver。对用户消息中的 // 每个图像部件,它向 vision Router 询问如何路由:当主模型 // 支持视觉且有信心时,保留该图像部件让主模型看到 // 图像;否则调用 vision/OCR 工具,用其文字结果替换 // 图像部件,使纯文本主模型也能对图像推理(「先看图再 // 对话」)。它可安全并发使用。 type MessageResolver struct { router *Router info ModelInfoProvider invoke ToolInvoker cache *imageCache } // NewMessageResolver 构造一个 MessageResolver。router 决定 // 分层;info 报告主模型的视觉能力;invoke 运行一个已注册的 vision/OCR // 工具。invoke 为 nil 时任何工具路由都会失败(此时图像被保留), // 因此没有工具的解析器会降级到主模型路径。 // // 解析器自带一个按内容哈希 memo 化的取回缓存:解析器在接线处构造一次并 // 长期复用,同一 URL 图因此只在首个回合真正取回一次(见 cache.go)。 func NewMessageResolver(router *Router, info ModelInfoProvider, invoke ToolInvoker) *MessageResolver { return &MessageResolver{ router: router, info: info, invoke: invoke, cache: newImageCache(), } } // Resolve 改写 msg,使每个图像部件要么被保留(主模型能看到它), // 要么被 vision/OCR 工具产出的文字描述替换。文本部件 // 按原顺序保留。它仅在图像无法路由或工具 // 调用失败时报错;此时调用方(loop)保留原始消息。 func (r *MessageResolver) Resolve(ctx context.Context, msg model.Message) (model.Message, error) { if r.router == nil { return msg, nil } out := make([]model.Content, 0, len(msg.Content)) for _, part := range msg.Content { if part.Type != model.ContentTypeImage { out = append(out, part) continue } replacement, err := r.resolveImage(ctx, part.ImageURL) if err != nil { return model.Message{}, err } out = append(out, replacement) } return model.Message{Role: msg.Role, Content: out}, nil } // describePrompt 是走 vision 工具路由时给视觉模型的默认指令。它是**命名常量** // 而不是散在两处的字面量:`resolveImage` 与 `ShotAttacher` 必须用同一句, // 否则同一种图会因为「谁发的」而得到两种提问方式(结果不可比)。 const describePrompt = "Describe this image in detail." // decision 是路由判定的唯一读取点:把「主模型能力 → Router」这一步收在一处, // 使 resolveImage(用户附件)与 ShotAttacher(截图附件)不可能各自算一次而分叉。 func (r *MessageResolver) decision() Decision { if r == nil || r.router == nil { return Decision{Kind: KindUnsupported, Reason: "no vision router configured"} } target := ModelInfo{} if r.info != nil { target = r.info() } return r.router.Route(target, RouteRequest{}) } // resolveImage 选出一条路由:要么保留图像(KindMain), // 要么经 vision/OCR 工具把它折叠为文字描述。 func (r *MessageResolver) resolveImage(ctx context.Context, imageURL string) (model.Content, error) { decision := r.decision() if decision.Kind == KindMain { // 支持视觉的主模型直接看到这张图;但 URL 与本地路径必须先由服务端 // 取回并内联为 data: URI——云 provider 取不到自部署的局域网地址 // (如 http://192.168.x.x:10942/api/v1/files/…),Anthropic 协议更是 // 只接受 base64 data: URI(model/anthropic_encode.go 会拒绝裸 URL)。 // 取回失败时返回结构化错误(带来源主机与原因),由 loop 记录一条 // 可诊断的 WARN 并保留原消息(该轮不因此中断);失败不进缓存, // 下一回合会照常重试,因此不会「永久看不到图」。 src, err := inlineImageCached(ctx, r.cache, imageURL) if err != nil { return model.Content{}, err } return model.Content{Type: model.ContentTypeImage, ImageURL: src}, nil } return r.resolveToolRoute(ctx, imageURL, describePrompt, decision) } // resolveToolRoute 执行工具形态的那两条路由(vision / OCR):图像不进主模型 // 上下文,只把工具产出的文字折叠成一个文本部件。没有工具可用时如实报 // ErrUnsupportedSource(**绝不**悄悄把图像留给一个读不了图的主模型)。 func (r *MessageResolver) resolveToolRoute( ctx context.Context, imageURL, prompt string, decision Decision, ) (model.Content, error) { switch decision.Kind { case KindVisionTool: return r.invokeTool(ctx, decision.Tool, map[string]any{ "image": imageURL, "prompt": prompt, }, "description") case KindOCRTool: return r.invokeTool(ctx, decision.Tool, map[string]any{ "image": imageURL, }, "text") default: return model.Content{}, fmt.Errorf("image route: %w: %s", ErrUnsupportedSource, decision.Reason) } } // invokeTool 运行一个已注册的 vision/OCR 工具,并把其文字结果转成 // 文本内容部件。field 是结果 map 的键("description" 或 "text")。 func (r *MessageResolver) invokeTool(ctx context.Context, toolName string, args map[string]any, field string) (model.Content, error) { if r.invoke == nil { return model.Content{}, fmt.Errorf("%s: %w", toolName, ErrNotConfigured) } result, err := r.invoke(ctx, toolName, args) if err != nil { return model.Content{}, fmt.Errorf("image tool %s: %w", toolName, err) } return model.Content{Type: model.ContentTypeText, Text: descriptionFragment(fieldText(result, field))}, nil } // fieldText 从工具结果的 map 中取出字符串字段,兼容 // vision/OCR 工具返回的 map[string]any 形状。 func fieldText(result any, field string) string { m, ok := result.(map[string]any) if !ok { return "" } s, _ := m[field].(string) return s } // descriptionFragment 给工具产出的描述加上标注,使模型能看出它 // 指的是用户附上的那张图。 func descriptionFragment(desc string) string { if desc == "" { return "[图片内容] 无法识别该图片" } return "[图片内容] " + desc }