165 lines
6.1 KiB
Go
165 lines
6.1 KiB
Go
package windsurf
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// CascadeImage 是发给 Windsurf Cascade gRPC 的图像载体。
|
||
// 对应 proto:message CodeiumImage { string base64Data = 1; string mimeType = 2; string caption = 3; }
|
||
// 通过静态分析 /Applications/Windsurf.app/Contents/Resources/app/node_modules/@exa/chat-client/index.js 得到。
|
||
type CascadeImage struct {
|
||
// Base64Data 原始 base64 字符串(不含 data: 前缀)。仅用于 replay/发送;不参与指纹。
|
||
Base64Data string `json:"base64_data,omitempty"`
|
||
// MimeType 例如 image/png。参与指纹。
|
||
MimeType string `json:"mime_type"`
|
||
// Caption 可选辅助说明,参与指纹。
|
||
Caption string `json:"caption,omitempty"`
|
||
// SHA256 是 decoded 二进制字节的 sha256 hex;指纹使用。
|
||
SHA256 string `json:"sha256,omitempty"`
|
||
// ByteLen decoded 字节数;指纹使用。
|
||
ByteLen int `json:"byte_len,omitempty"`
|
||
}
|
||
|
||
// ImageDigest 是 CascadeImage 的摘要视图。日志/指纹/conversation pool 使用这个,
|
||
// 永远不要把 Base64Data 带进哈希/持久化。
|
||
type ImageDigest struct {
|
||
MimeType string `json:"mime_type"`
|
||
SHA256 string `json:"sha256"`
|
||
ByteLen int `json:"byte_len"`
|
||
Caption string `json:"caption,omitempty"`
|
||
}
|
||
|
||
// 支持的图像 MIME(与 Windsurf 客户端一致)
|
||
var cascadeImageAllowedMime = map[string]struct{}{
|
||
"image/png": {},
|
||
"image/jpeg": {},
|
||
"image/gif": {},
|
||
"image/svg+xml": {},
|
||
}
|
||
|
||
// CascadeImageMaxPerTurn 单次 user turn 最多图片数(Windsurf 客户端限制)。
|
||
const CascadeImageMaxPerTurn = 5
|
||
|
||
// CascadeImageMaxBytes 单张解码后字节上限(Windsurf 客户端压缩目标 1MB)。
|
||
const CascadeImageMaxBytes = 1 * 1024 * 1024
|
||
|
||
// CascadeImageValidationOptions 校验开关,便于不同场景选择严格度。
|
||
type CascadeImageValidationOptions struct {
|
||
// EnforceByteSize 是否对单张做 1MB 上限校验。默认 true。
|
||
EnforceByteSize bool
|
||
// EnforceCountPerTurn 是否对张数做 <=5 校验。默认 true。
|
||
EnforceCountPerTurn bool
|
||
}
|
||
|
||
// DefaultCascadeImageValidationOptions 返回默认校验选项。
|
||
func DefaultCascadeImageValidationOptions() CascadeImageValidationOptions {
|
||
return CascadeImageValidationOptions{
|
||
EnforceByteSize: true,
|
||
EnforceCountPerTurn: true,
|
||
}
|
||
}
|
||
|
||
// ValidateCascadeImages 在发送前做确定性校验。
|
||
// 返回第一条校验失败的错误,上层据此生成 Anthropic 风格 400。
|
||
func ValidateCascadeImages(images []CascadeImage, opts CascadeImageValidationOptions) error {
|
||
if opts.EnforceCountPerTurn && len(images) > CascadeImageMaxPerTurn {
|
||
return fmt.Errorf("too many images: %d (max %d)", len(images), CascadeImageMaxPerTurn)
|
||
}
|
||
for i := range images {
|
||
img := &images[i]
|
||
if _, ok := cascadeImageAllowedMime[strings.ToLower(strings.TrimSpace(img.MimeType))]; !ok {
|
||
return fmt.Errorf("image[%d]: unsupported media_type %q", i, img.MimeType)
|
||
}
|
||
trimmed := strings.TrimSpace(img.Base64Data)
|
||
if trimmed == "" {
|
||
return fmt.Errorf("image[%d]: data must not be empty", i)
|
||
}
|
||
decoded, err := base64.StdEncoding.DecodeString(trimmed)
|
||
if err != nil {
|
||
// 尝试 RawStdEncoding 作为兜底(部分客户端省略 padding)
|
||
if decoded2, err2 := base64.RawStdEncoding.DecodeString(trimmed); err2 == nil {
|
||
decoded = decoded2
|
||
} else {
|
||
return fmt.Errorf("image[%d]: invalid base64 data", i)
|
||
}
|
||
}
|
||
if len(decoded) == 0 {
|
||
return fmt.Errorf("image[%d]: decoded data is empty", i)
|
||
}
|
||
if opts.EnforceByteSize && len(decoded) > CascadeImageMaxBytes {
|
||
return fmt.Errorf("image[%d]: decoded size %d exceeds 1MB limit", i, len(decoded))
|
||
}
|
||
// 顺手把 digest/byteLen 算好,后续指纹阶段直接用
|
||
if img.SHA256 == "" || img.ByteLen == 0 {
|
||
sum := sha256.Sum256(decoded)
|
||
img.SHA256 = hex.EncodeToString(sum[:])
|
||
img.ByteLen = len(decoded)
|
||
}
|
||
// 归一化 MIME(避免大小写差异)
|
||
img.MimeType = strings.ToLower(strings.TrimSpace(img.MimeType))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// BuildImageDigests 把 CascadeImage 转成只含摘要的 ImageDigest 切片。
|
||
// 前提:调用者已经通过 ValidateCascadeImages 或 ComputeImageDigest 确保 SHA256/ByteLen 已填。
|
||
func BuildImageDigests(images []CascadeImage) []ImageDigest {
|
||
if len(images) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]ImageDigest, len(images))
|
||
for i, img := range images {
|
||
out[i] = ImageDigest{
|
||
MimeType: img.MimeType,
|
||
SHA256: img.SHA256,
|
||
ByteLen: img.ByteLen,
|
||
Caption: img.Caption,
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ComputeImageDigest 按给定 base64 字符串计算 digest 字段(不做限额校验)。
|
||
// 给只需要摘要不需要发送场景使用(例如历史 replay 构造)。
|
||
func ComputeImageDigest(img *CascadeImage) error {
|
||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(img.Base64Data))
|
||
if err != nil {
|
||
decoded, err = base64.RawStdEncoding.DecodeString(strings.TrimSpace(img.Base64Data))
|
||
if err != nil {
|
||
return fmt.Errorf("invalid base64")
|
||
}
|
||
}
|
||
sum := sha256.Sum256(decoded)
|
||
img.SHA256 = hex.EncodeToString(sum[:])
|
||
img.ByteLen = len(decoded)
|
||
img.MimeType = strings.ToLower(strings.TrimSpace(img.MimeType))
|
||
return nil
|
||
}
|
||
|
||
// encodeCodeiumImage 对单张 CodeiumImage 消息进行 proto wire 编码:
|
||
// message CodeiumImage { string base64Data = 1; string mimeType = 2; string caption = 3; }
|
||
// 返回值是 message body 原始字节(不含外层 field tag/length)。
|
||
func encodeCodeiumImage(img CascadeImage) []byte {
|
||
var body []byte
|
||
// field 1:base64 字符串原样发(Windsurf 客户端也是原样放,不做二次编码)
|
||
body = append(body, encodeStringField(1, img.Base64Data)...)
|
||
body = append(body, encodeStringField(2, img.MimeType)...)
|
||
if img.Caption != "" {
|
||
body = append(body, encodeStringField(3, img.Caption)...)
|
||
}
|
||
return body
|
||
}
|
||
|
||
// appendSendUserCascadeImages 把一组图像追加到 SendUserCascadeMessageRequest.images(field 6, repeated)。
|
||
// repeated message 字段按"每条独立的 field 6 segment"编码,与 Windsurf 客户端保持一致。
|
||
func appendSendUserCascadeImages(body []byte, images []CascadeImage) []byte {
|
||
for _, img := range images {
|
||
body = append(body, encodeBytesField(6, encodeCodeiumImage(img))...)
|
||
}
|
||
return body
|
||
}
|