Files
Agent-System-Backend/internal/vision/images.go
T
weichenandCursor cfde38de31 Send current-turn images and keep vision fallbacks capable
Why: Uploaded images never reached the model, so a text-only chat model could be asked to read a picture it cannot see.

Evidence: go test ./internal/... passed locally before this commit. Image-link rejection is covered by vision package tests.

Impact: Current-turn attachments and fetched image links become image input. History keeps a text placeholder. Image turns swap to the configured vision model when the chat model is not in the capable list, and fallbacks are filtered the same way.

Refs: Agent-System-Dev feature/four-model-vision
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 17:51:17 +00:00

137 lines
4.2 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 (
"encoding/base64"
"fmt"
"path/filepath"
"strings"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
// PrepareImage 把上传的图片收成一条 image-ref。Ref 是标准 base64(无换行)。
// 不支持的类型和超限图片返回警告且 ok 为 false,调用方必须把警告展示出来。
func PrepareImage(filename, mime string, content []byte) (core.DocBlock, string, bool) {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
kind, known := sendableExt[ext]
if !known {
return core.DocBlock{}, fmt.Sprintf("图片 .%s 不作为图像发送(请转为 png、jpeg、gif 或 webp)", ext), false
}
if len(content) == 0 {
return core.DocBlock{}, "图片内容为空,本轮不发送图像", false
}
if len(content) > MaxImageBytes {
return core.DocBlock{}, fmt.Sprintf("图片超过 %dMB,本轮不发送图像(请压缩后重传)", MaxImageBytes>>20), false
}
detected := sniffImage(content)
if detected == "" || detected != kind {
return core.DocBlock{}, "图片内容与扩展名不符,本轮不发送图像", false
}
if mime != "" && !strings.EqualFold(strings.TrimSpace(strings.Split(mime, ";")[0]), kind) && !strings.EqualFold(mime, "application/octet-stream") {
return core.DocBlock{}, "图片类型与内容不符,本轮不发送图像", false
}
return core.DocBlock{
Type: core.BlockImageRef,
Ref: base64.StdEncoding.EncodeToString(content),
Text: filename,
}, "图片按图像发送给模型,不转成文字", true
}
// ImagesFromDoc 取出文档里已编码的图片。MIME 来自文件名,不信任外部声明。
func ImagesFromDoc(doc core.ParsedDocument) []agentllm.Image {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(doc.Meta.Filename), "."))
kind := sendableExt[ext]
if kind == "" {
return nil
}
var out []agentllm.Image
for _, b := range doc.Blocks {
if b.Type != core.BlockImageRef || strings.TrimSpace(b.Ref) == "" {
continue
}
out = append(out, agentllm.Image{MIME: kind, Data: b.Ref})
}
return out
}
// HasVisualInput 判断这一轮是否带图:已解析的图片附件,或正文里的图片链接。
func HasVisualInput(docs []core.ParsedDocument, text string) bool {
for _, doc := range docs {
if len(ImagesFromDoc(doc)) > 0 {
return true
}
}
return len(ImageLinks(text)) > 0
}
// Admit 按张数和总字节数收下一批图片。被拒绝的每一项都有一句说明。
func Admit(existing []agentllm.Image, incoming []agentllm.Image) (kept []agentllm.Image, notes []string) {
count, total := usage(existing)
for _, img := range incoming {
n := decodedLen(img.Data)
switch {
case n <= 0:
notes = append(notes, "一张图片没有可用内容,已跳过")
case n > MaxImageBytes:
notes = append(notes, fmt.Sprintf("一张图片超过 %dMB,已跳过", MaxImageBytes>>20))
case count >= MaxImages:
notes = append(notes, fmt.Sprintf("本轮图片超过 %d 张,其余未发送", MaxImages))
return kept, notes
case total+n > MaxTotalBytes:
notes = append(notes, fmt.Sprintf("本轮图片合计超过 %dMB,其余未发送", MaxTotalBytes>>20))
return kept, notes
default:
kept = append(kept, img)
count++
total += n
}
}
return kept, notes
}
func usage(images []agentllm.Image) (count, total int) {
for _, img := range images {
n := decodedLen(img.Data)
if n > 0 {
count++
total += n
}
}
return count, total
}
func decodedLen(data string) int {
raw, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return 0
}
return len(raw)
}
var sendableExt = map[string]string{
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
"webp": "image/webp",
}
func sniffImage(b []byte) string {
switch {
case len(b) >= 8 && string(b[:8]) == "\x89PNG\r\n\x1a\n":
return "image/png"
case len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF:
return "image/jpeg"
case len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a"):
return "image/gif"
case len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP":
return "image/webp"
default:
return ""
}
}
// MIMEOf 返回嗅探结果,供下载后的响应校验。
func MIMEOf(content []byte) string { return sniffImage(content) }