- 原缺陷: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 可达)→ 需收紧
132 lines
4.9 KiB
Go
132 lines
4.9 KiB
Go
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
|
||
}
|