Files
Cyrene_v2/internal/runtime/multimodal/vision/resolver.go
T
AskaEth 983bc49218
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(vision): 服务端自己取回图片 URL 并内联为 data URI,修自部署下取不到图(F3,优先级「高」)
- 原缺陷:vision 适配器把 http(s) URL **原样转给上游 provider**,而 upload 返回的是同源局域网地址
  ⇒ 云 provider 根本取不到 ⇒ **B1(大图绕开 WS 4096 字节上限)在自部署形态下等于没做**
- 修法:① `imageURL` → `localizeRef`:**彻底不再直传 URL**;同源 upload 产物走**本地读取**,
  其余 http(s) 由服务端自己取回 → 一律内联为 `data:` URI 再交 provider(OpenAI 与 Anthropic 双协议都能收图)
- 顺手修「类型瞎猜」:新增 `vision/mime.go`,**字节嗅探是唯一裁决**——判得出白名单光栅图就用字节结果;
  字节是文本(HTML/PROXY 错误页、SVG)→ `ErrNotImage`;判不定但来源明确声明 `image/*` 才采信声明。
  错误信息只含类型名,**不含响应体/URL**(不打印图片内容)
- 防护:取回复用既有 http/https 白名单 + 50MiB 上限,新增 **30s 取回超时**;
  本地读取路径 `localUploadPath` 严格认定 `<host>/api/v1/files/<name>`(无 query/fragment、单段路径)+
  32 位小写十六进制名 + `filepath.Dir` 包含性第二道 ⇒ `../`、`%2e%2e%2f`、反斜杠等编码变体在触碰 FS 前即拒
- 生产接线(主会话补):`cmd/cyrene/vision.go` 传 `vision.WithUploadsDir(<DataDir>/uploads)`
- 复核:build/vet 绿;新增 51 条断言(含 httptest 真取回 → 断言上游收到 data URI 且不含源 URL、
  同源 upload 用不可达 host 证明走本地、HTML 谎报 image/png 被字节否决、越界名 19 例);
  **3 条变异反证**(恢复 URL 直传 8 红 / 文本静默回退 png 红 / 去掉穿越检查 12 红)均已还原
- 未做(已登记):历史里 URL 形态图片每轮重新取回(缓解=持久化时内联/缓存);
  Anthropic 只认 png/jpeg/gif/webp 而白名单含 bmp;`fetchURL` 无 IP 过滤(链路本地 169.254.169.254 可达)→ 需收紧
2026-10-04 14:19:28 +08:00

132 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
// NewMessageResolver 构造一个 MessageResolver。router 决定
// 分层;info 报告主模型的视觉能力;invoke 运行一个已注册的 vision/OCR
// 工具。invoke 为 nil 时任何工具路由都会失败(此时图像被保留),
// 因此没有工具的解析器会降级到主模型路径。
func NewMessageResolver(router *Router, info ModelInfoProvider, invoke ToolInvoker) *MessageResolver {
return &MessageResolver{
router: router,
info: info,
invoke: invoke,
}
}
// 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
}
// resolveImage 选出一条路由:要么保留图像(KindMain),
// 要么经 vision/OCR 工具把它折叠为文字描述。
func (r *MessageResolver) resolveImage(ctx context.Context, imageURL string) (model.Content, error) {
target := ModelInfo{}
if r.info != nil {
target = r.info()
}
decision := r.router.Route(target, RouteRequest{})
switch decision.Kind {
case KindMain:
// 支持视觉的主模型直接看到这张图;但 URL 与本地路径必须先由服务端
// 取回并内联为 data: URI——云 provider 取不到自部署的局域网地址
// (如 http://192.168.x.x:10942/api/v1/files/…),Anthropic 协议更是
// 只接受 base64 data: URI(model/anthropic_encode.go 会拒绝裸 URL)。
// 取回失败时返回错误,由 loop 保留原消息(该轮不因此中断)。
src, err := inlineImageSrc(ctx, imageURL)
if err != nil {
return model.Content{}, err
}
return model.Content{Type: model.ContentTypeImage, ImageURL: src}, nil
case KindVisionTool:
return r.invokeTool(ctx, decision.Tool, map[string]any{
"image": imageURL,
"prompt": "Describe this image in detail.",
}, "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
}