yao/agent/llm/image.go
Max af0d4edd74 feat(image): add image generation and enhanced reading capabilities
- Introduced `image_generate` tool for generating images from text prompts, with options for specifying output file paths and image dimensions.
- Updated `image_read` functionality to allow optional provider specification for enhanced image analysis.
- Implemented new `GenerateImage` method in the LLM API for seamless integration of image generation capabilities.
- Enhanced documentation to include detailed usage examples for both image reading and generation tools.
- Updated tests to validate new image generation features and ensure robust functionality across image tools.
2026-05-06 10:59:35 +08:00

183 lines
4.8 KiB
Go

package llm
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/yaoapp/gou/connector"
gouhttp "github.com/yaoapp/gou/http"
goullm "github.com/yaoapp/gou/llm"
)
// ImageGenResponse holds the result of an image generation call.
// Image is always base64 encoded; if the provider returns a URL, it is downloaded and converted.
type ImageGenResponse struct {
Image string `json:"image"` // base64 encoded image data
Format string `json:"format"` // image format, e.g. "png", "jpeg"
}
// GenerateImage calls the /images/generations endpoint through the connector.
// options may include: size, n, quality, style, model, etc.
func GenerateImage(conn connector.Connector, prompt string, options map[string]interface{}) (*ImageGenResponse, error) {
host, key, authMode := resolveConnSettings(conn)
if host == "" {
return nil, fmt.Errorf("no host found in connector settings")
}
if key == "" {
return nil, fmt.Errorf("API key is not set")
}
if options == nil {
options = map[string]interface{}{}
}
options["prompt"] = prompt
if _, ok := options["model"]; !ok {
if lc, ok := conn.(goullm.LLMConnector); ok {
if m := lc.GetModel(); m != "" {
options["model"] = m
}
}
}
url := connector.BuildAPIURL(host, "/images/generations")
req := gouhttp.New(url)
req.SetHeader("Content-Type", "application/json")
setImageAuthHeaders(req, authMode, key)
resp := req.Post(options)
if resp.Status != 200 {
errMsg := extractAPIError(resp.Data)
return nil, fmt.Errorf("image generation failed (status %d, url %s): %s", resp.Status, url, errMsg)
}
return extractImageFromResponse(resp.Data)
}
func resolveConnSettings(conn connector.Connector) (host, key string, authMode goullm.AuthMode) {
authMode = goullm.AuthBearer
if lc, ok := conn.(goullm.LLMConnector); ok {
host = lc.GetURL()
key = lc.GetKey()
authMode = lc.GetAuthMode()
}
if host == "" || key == "" {
setting := conn.Setting()
if host == "" {
host, _ = setting["host"].(string)
}
if key == "" {
key, _ = setting["key"].(string)
}
}
return
}
func setImageAuthHeaders(req *gouhttp.Request, authMode goullm.AuthMode, key string) {
switch authMode {
case goullm.AuthAPIKey:
req.SetHeader("api-key", key)
case goullm.AuthXAPIKey:
req.SetHeader("x-api-key", key)
default:
req.SetHeader("Authorization", fmt.Sprintf("Bearer %s", key))
}
}
func extractImageFromResponse(data interface{}) (*ImageGenResponse, error) {
raw, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("marshal response: %w", err)
}
var parsed struct {
Data []struct {
B64JSON *string `json:"b64_json"`
URL *string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
if len(parsed.Data) == 0 {
return nil, fmt.Errorf("provider returned empty data array, no image was generated")
}
item := parsed.Data[0]
if item.B64JSON != nil && *item.B64JSON != "" {
return &ImageGenResponse{Image: *item.B64JSON, Format: "png"}, nil
}
if item.URL != nil && *item.URL != "" {
b64, format, err := downloadImageAsBase64(*item.URL)
if err != nil {
return nil, fmt.Errorf("provider returned url but download failed: %w", err)
}
return &ImageGenResponse{Image: b64, Format: format}, nil
}
return nil, fmt.Errorf("provider returned data but neither b64_json nor url field is present, the model may not support image generation")
}
func downloadImageAsBase64(imageURL string) (b64 string, format string, err error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(imageURL)
if err != nil {
return "", "", fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", "", fmt.Errorf("download returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("read body: %w", err)
}
if len(body) == 0 {
return "", "", fmt.Errorf("downloaded image is empty")
}
format = "png"
ct := resp.Header.Get("Content-Type")
switch {
case strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg"):
format = "jpeg"
case strings.Contains(ct, "webp"):
format = "webp"
case strings.Contains(ct, "gif"):
format = "gif"
default:
if strings.Contains(imageURL, ".jpeg") || strings.Contains(imageURL, ".jpg") {
format = "jpeg"
} else if strings.Contains(imageURL, ".webp") {
format = "webp"
}
}
b64 = base64.StdEncoding.EncodeToString(body)
return b64, format, nil
}
func extractAPIError(data interface{}) string {
raw, err := json.Marshal(data)
if err != nil {
return fmt.Sprintf("%v", data)
}
var parsed struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &parsed); err == nil && parsed.Error.Message != "" {
return parsed.Error.Message
}
return string(raw)
}