mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 11:37:20 +08:00
214 lines
5.4 KiB
Go
214 lines
5.4 KiB
Go
package modeladapter
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
contentPartTypeText = "text"
|
|
contentPartTypeImage = "image"
|
|
)
|
|
|
|
// ContentPart 表示一条消息中的结构化内容块。
|
|
type ContentPart struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text,omitempty"`
|
|
Image *ImageContent `json:"image,omitempty"`
|
|
}
|
|
|
|
// ImageContent 表示消息中携带的一张图片。
|
|
type ImageContent struct {
|
|
MIMEType string `json:"mime_type,omitempty"`
|
|
Path string `json:"path,omitempty"`
|
|
Data []byte `json:"data,omitempty"`
|
|
}
|
|
|
|
func hasImageContentParts(parts []ContentPart) bool {
|
|
for _, part := range parts {
|
|
if normalizeContentPartType(part.Type) == contentPartTypeImage {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func collapseTextContentParts(parts []ContentPart) string {
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
texts := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
if normalizeContentPartType(part.Type) != contentPartTypeText {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(part.Text) == "" {
|
|
continue
|
|
}
|
|
texts = append(texts, part.Text)
|
|
}
|
|
return strings.Join(texts, "")
|
|
}
|
|
|
|
func normalizeContentPartType(value string) string {
|
|
trimmed := strings.TrimSpace(strings.ToLower(value))
|
|
switch trimmed {
|
|
case "", contentPartTypeText:
|
|
return contentPartTypeText
|
|
case contentPartTypeImage:
|
|
return contentPartTypeImage
|
|
default:
|
|
return trimmed
|
|
}
|
|
}
|
|
|
|
func openAIContentValue(message Message) (any, error) {
|
|
if !hasImageContentParts(message.ContentParts) {
|
|
content := message.Content
|
|
if strings.TrimSpace(content) == "" && len(message.ContentParts) > 0 {
|
|
content = collapseTextContentParts(message.ContentParts)
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
parts := make([]map[string]any, 0, len(message.ContentParts)+1)
|
|
if len(message.ContentParts) == 0 && strings.TrimSpace(message.Content) != "" {
|
|
parts = append(parts, map[string]any{
|
|
"type": contentPartTypeText,
|
|
"text": message.Content,
|
|
})
|
|
}
|
|
for _, part := range message.ContentParts {
|
|
switch normalizeContentPartType(part.Type) {
|
|
case contentPartTypeText:
|
|
if part.Text == "" {
|
|
continue
|
|
}
|
|
parts = append(parts, map[string]any{
|
|
"type": contentPartTypeText,
|
|
"text": part.Text,
|
|
})
|
|
case contentPartTypeImage:
|
|
dataURL, err := imageContentDataURL(part.Image)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parts = append(parts, map[string]any{
|
|
"type": "image_url",
|
|
"image_url": map[string]any{
|
|
"url": dataURL,
|
|
},
|
|
})
|
|
default:
|
|
return nil, fmt.Errorf("unsupported openai content part type: %s", strings.TrimSpace(part.Type))
|
|
}
|
|
}
|
|
if len(parts) == 0 {
|
|
return message.Content, nil
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
func anthropicContentBlocks(message Message) ([]map[string]any, error) {
|
|
if len(message.ContentParts) == 0 {
|
|
if strings.TrimSpace(message.Content) == "" {
|
|
return nil, nil
|
|
}
|
|
return []map[string]any{{
|
|
"type": contentPartTypeText,
|
|
"text": message.Content,
|
|
}}, nil
|
|
}
|
|
|
|
blocks := make([]map[string]any, 0, len(message.ContentParts))
|
|
for _, part := range message.ContentParts {
|
|
switch normalizeContentPartType(part.Type) {
|
|
case contentPartTypeText:
|
|
if part.Text == "" {
|
|
continue
|
|
}
|
|
blocks = append(blocks, map[string]any{
|
|
"type": contentPartTypeText,
|
|
"text": part.Text,
|
|
})
|
|
case contentPartTypeImage:
|
|
payload, mediaType, err := resolveImageContent(part.Image)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
blocks = append(blocks, map[string]any{
|
|
"type": contentPartTypeImage,
|
|
"source": map[string]any{
|
|
"type": "base64",
|
|
"media_type": mediaType,
|
|
"data": base64.StdEncoding.EncodeToString(payload),
|
|
},
|
|
})
|
|
default:
|
|
return nil, fmt.Errorf("unsupported anthropic content part type: %s", strings.TrimSpace(part.Type))
|
|
}
|
|
}
|
|
if len(blocks) == 0 && strings.TrimSpace(message.Content) != "" {
|
|
blocks = append(blocks, map[string]any{
|
|
"type": contentPartTypeText,
|
|
"text": message.Content,
|
|
})
|
|
}
|
|
return blocks, nil
|
|
}
|
|
|
|
func imageContentDataURL(image *ImageContent) (string, error) {
|
|
payload, mediaType, err := resolveImageContent(image)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "data:" + mediaType + ";base64," + base64.StdEncoding.EncodeToString(payload), nil
|
|
}
|
|
|
|
func resolveImageContent(image *ImageContent) ([]byte, string, error) {
|
|
if image == nil {
|
|
return nil, "", fmt.Errorf("image content is required")
|
|
}
|
|
if len(image.Data) > 0 {
|
|
return image.Data, normalizeImageMIMEType(image.MIMEType, image.Path, image.Data), nil
|
|
}
|
|
path := strings.TrimSpace(image.Path)
|
|
if path == "" {
|
|
return nil, "", fmt.Errorf("image content is missing data and path")
|
|
}
|
|
payload, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("read image content failed: %w", err)
|
|
}
|
|
return payload, normalizeImageMIMEType(image.MIMEType, path, payload), nil
|
|
}
|
|
|
|
func normalizeImageMIMEType(mimeType string, path string, payload []byte) string {
|
|
trimmed := strings.TrimSpace(strings.ToLower(mimeType))
|
|
if trimmed != "" {
|
|
return trimmed
|
|
}
|
|
if len(payload) > 0 {
|
|
detected := strings.TrimSpace(strings.ToLower(http.DetectContentType(payload)))
|
|
if strings.HasPrefix(detected, "image/") {
|
|
return detected
|
|
}
|
|
}
|
|
switch strings.ToLower(filepath.Ext(strings.TrimSpace(path))) {
|
|
case ".jpg", ".jpeg":
|
|
return "image/jpeg"
|
|
case ".png":
|
|
return "image/png"
|
|
case ".gif":
|
|
return "image/gif"
|
|
case ".webp":
|
|
return "image/webp"
|
|
default:
|
|
return "image/png"
|
|
}
|
|
}
|