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.
This commit is contained in:
Max 2026-05-06 10:59:35 +08:00
parent 20fc9c24df
commit af0d4edd74
23 changed files with 1066 additions and 55 deletions

View file

@ -12,7 +12,7 @@ import (
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
searchTypes "github.com/yaoapp/yao/agent/search/types" searchTypes "github.com/yaoapp/yao/agent/search/types"
"github.com/yaoapp/yao/attachment" "github.com/yaoapp/yao/attachment"
"github.com/yaoapp/yao/tools/vision" toolsImage "github.com/yaoapp/yao/tools/image"
) )
// Image handles image content // Image handles image content
@ -375,7 +375,7 @@ func (h *Image) readImageWithTools(ctx *agentContext.Context, content agentConte
loadingID := h.sendLoading(ctx, i18n.T(ctx.Locale, "content.image.analyzing")) loadingID := h.sendLoading(ctx, i18n.T(ctx.Locale, "content.image.analyzing"))
resp, err := vision.ReadImage(ctx.Context, src, "Please describe this image in detail.", 1080, ctx.Authorized) resp, err := toolsImage.ReadImage(ctx.Context, src, "Please describe this image in detail.", 1080, ctx.Authorized, "")
h.sendLoadingDone(ctx, loadingID) h.sendLoadingDone(ctx, loadingID)

View file

@ -14,6 +14,9 @@ type LlmAPI interface {
// Returns *llm.Result or error information // Returns *llm.Result or error information
Stream(connector string, messages []interface{}, opts map[string]interface{}) interface{} Stream(connector string, messages []interface{}, opts map[string]interface{}) interface{}
// GenerateImage generates an image from a text prompt using an image generation model
GenerateImage(connector string, prompt string, opts map[string]interface{}) interface{}
// Parallel LLM call methods - inspired by JavaScript Promise // Parallel LLM call methods - inspired by JavaScript Promise
// All waits for all LLM calls to complete (like Promise.all) // All waits for all LLM calls to complete (like Promise.all)
All(requests []interface{}) []interface{} All(requests []interface{}) []interface{}
@ -68,6 +71,9 @@ func (ctx *Context) newLlmObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
// Single LLM call method // Single LLM call method
llmObj.Set("Stream", ctx.llmStreamMethod(iso)) llmObj.Set("Stream", ctx.llmStreamMethod(iso))
// Image generation method
llmObj.Set("GenerateImage", ctx.llmGenerateImageMethod(iso))
// Parallel LLM call methods - inspired by JavaScript Promise // Parallel LLM call methods - inspired by JavaScript Promise
llmObj.Set("All", ctx.llmAllMethod(iso)) llmObj.Set("All", ctx.llmAllMethod(iso))
llmObj.Set("Any", ctx.llmAnyMethod(iso)) llmObj.Set("Any", ctx.llmAnyMethod(iso))
@ -163,6 +169,54 @@ func (ctx *Context) llmStreamMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
}) })
} }
// llmGenerateImageMethod implements ctx.llm.GenerateImage(connector, prompt, options?)
// Usage: const result = ctx.llm.GenerateImage("dall-e-3", "A sunset over mountains", { size: "1024x1024" })
// Returns: { connector, image (base64), format, error }
func (ctx *Context) llmGenerateImageMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
args := info.Args()
if len(args) < 2 {
return bridge.JsException(v8ctx, "GenerateImage requires connector and prompt parameters")
}
if !args[0].IsString() {
return bridge.JsException(v8ctx, "connector must be a string")
}
connectorID := args[0].String()
if !args[1].IsString() {
return bridge.JsException(v8ctx, "prompt must be a string")
}
prompt := args[1].String()
var opts map[string]interface{}
if len(args) >= 3 && !args[2].IsUndefined() && !args[2].IsNull() {
goVal, err := bridge.GoValue(args[2], v8ctx)
if err == nil {
if optsMap, ok := goVal.(map[string]interface{}); ok {
opts = optsMap
}
}
}
llmAPI := ctx.Llm()
if llmAPI == nil {
return bridge.JsException(v8ctx, "LLM API not available")
}
result := llmAPI.GenerateImage(connectorID, prompt, opts)
jsVal, err := bridge.JsValue(v8ctx, result)
if err != nil {
return bridge.JsException(v8ctx, "failed to convert result: "+err.Error())
}
return jsVal
})
}
// llmAllMethod implements ctx.llm.All(requests, options?) // llmAllMethod implements ctx.llm.All(requests, options?)
// Usage: const results = ctx.llm.All([ // Usage: const results = ctx.llm.All([
// //

View file

@ -23,3 +23,22 @@ entries:
return: return:
type: object type: object
desc: "OpenAI-compatible response: { id, object, created, model, choices: [{ index, message: { role, content, tool_calls? }, finish_reason }], usage? }" desc: "OpenAI-compatible response: { id, object, created, model, choices: [{ index, message: { role, content, tool_calls? }, finish_reason }], usage? }"
- name: ImageGeneration
desc: Generate an image from a text prompt using an image generation model
args:
- name: connector
type: string
required: true
desc: Connector ID for an image generation model (e.g. dall-e-3)
- name: prompt
type: string
required: true
desc: Text description of the image to generate
- name: opts
type: object
required: false
desc: "Generation options: size (1024x1024), quality, style, n, etc."
return:
type: object
desc: "Image generation result: { image (base64), format (png) }"

183
agent/llm/image.go Normal file
View file

@ -0,0 +1,183 @@
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)
}

197
agent/llm/image_test.go Normal file
View file

@ -0,0 +1,197 @@
package llm
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestExtractImageFromResponse_B64(t *testing.T) {
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"b64_json": "iVBORw0KGgoAAAANS...",
},
},
}
resp, err := extractImageFromResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Image != "iVBORw0KGgoAAAANS..." {
t.Errorf("got Image=%q, want %q", resp.Image, "iVBORw0KGgoAAAANS...")
}
if resp.Format != "png" {
t.Errorf("got Format=%q, want %q", resp.Format, "png")
}
}
func TestExtractImageFromResponse_URL(t *testing.T) {
fakeImage := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10} // fake JPEG header bytes
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
w.Write(fakeImage)
}))
defer srv.Close()
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"b64_json": nil,
"url": srv.URL + "/image_0.jpeg",
},
},
}
resp, err := extractImageFromResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := base64.StdEncoding.EncodeToString(fakeImage)
if resp.Image != expected {
t.Errorf("got Image=%q, want %q", resp.Image, expected)
}
if resp.Format != "jpeg" {
t.Errorf("got Format=%q, want %q", resp.Format, "jpeg")
}
}
func TestExtractImageFromResponse_URLPng(t *testing.T) {
fakeImage := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic bytes
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write(fakeImage)
}))
defer srv.Close()
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"url": srv.URL + "/output.png",
},
},
}
resp, err := extractImageFromResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Format != "png" {
t.Errorf("got Format=%q, want %q", resp.Format, "png")
}
if resp.Image == "" {
t.Error("expected non-empty base64 Image")
}
}
func TestExtractImageFromResponse_URLDownloadFail(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"url": srv.URL + "/missing.png",
},
},
}
_, err := extractImageFromResponse(data)
if err == nil {
t.Error("expected error for failed download")
}
}
func TestExtractImageFromResponse_Empty(t *testing.T) {
data := map[string]interface{}{
"data": []interface{}{},
}
_, err := extractImageFromResponse(data)
if err == nil {
t.Error("expected error for empty data array")
}
}
func TestExtractImageFromResponse_NoData(t *testing.T) {
data := map[string]interface{}{}
_, err := extractImageFromResponse(data)
if err == nil {
t.Error("expected error for missing data field")
}
}
func TestExtractImageFromResponse_NullBoth(t *testing.T) {
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"b64_json": nil,
"url": nil,
},
},
}
_, err := extractImageFromResponse(data)
if err == nil {
t.Error("expected error when both b64_json and url are null")
}
}
func TestDownloadImageAsBase64(t *testing.T) {
payload := []byte("fake-png-data")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write(payload)
}))
defer srv.Close()
b64, format, err := downloadImageAsBase64(srv.URL + "/test.png")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if format != "png" {
t.Errorf("got format=%q, want %q", format, "png")
}
decoded, _ := base64.StdEncoding.DecodeString(b64)
if string(decoded) != string(payload) {
t.Errorf("decoded content mismatch")
}
}
func TestDownloadImageAsBase64_FormatFromURL(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Write([]byte("data"))
}))
defer srv.Close()
_, format, err := downloadImageAsBase64(srv.URL + "/image.webp")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if format != "webp" {
t.Errorf("got format=%q, want %q (from URL fallback)", format, "webp")
}
}
func TestExtractAPIError_WithMessage(t *testing.T) {
data := map[string]interface{}{
"error": map[string]interface{}{
"message": "insufficient quota",
},
}
msg := extractAPIError(data)
if msg != "insufficient quota" {
t.Errorf("got %q, want %q", msg, "insufficient quota")
}
}
func TestExtractAPIError_NoMessage(t *testing.T) {
data := map[string]interface{}{
"something": "else",
}
msg := extractAPIError(data)
raw, _ := json.Marshal(data)
if msg != string(raw) {
t.Errorf("got %q, want raw JSON fallback", msg)
}
}

View file

@ -31,6 +31,37 @@ func SetJSAPIFactory() {
} }
} }
// GenerateImage implements LlmAPI.GenerateImage - generates an image from a text prompt
func (api *JSAPI) GenerateImage(connectorID string, prompt string, opts map[string]interface{}) interface{} {
result := &ImageGenResult{
Connector: connectorID,
}
conn, err := connector.Select(connectorID)
if err != nil {
result.Error = fmt.Sprintf("failed to select connector %s: %v", connectorID, err)
return result
}
resp, err := GenerateImage(conn, prompt, opts)
if err != nil {
result.Error = fmt.Sprintf("image generation failed: %v", err)
return result
}
result.Image = resp.Image
result.Format = resp.Format
return result
}
// ImageGenResult is the return type for GenerateImage JSAPI
type ImageGenResult struct {
Connector string `json:"connector"`
Image string `json:"image,omitempty"`
Format string `json:"format,omitempty"`
Error string `json:"error,omitempty"`
}
// Stream implements LlmAPI.Stream - calls LLM with streaming output to ctx.Writer // Stream implements LlmAPI.Stream - calls LLM with streaming output to ctx.Writer
func (api *JSAPI) Stream(connectorID string, messages []interface{}, opts map[string]interface{}) interface{} { func (api *JSAPI) Stream(connectorID string, messages []interface{}, opts map[string]interface{}) interface{} {
return api.StreamWithHandler(connectorID, messages, opts, nil) return api.StreamWithHandler(connectorID, messages, opts, nil)

View file

@ -16,6 +16,7 @@ import (
func init() { func init() {
process.Register("llm.ChatCompletions", ProcessChatCompletions) process.Register("llm.ChatCompletions", ProcessChatCompletions)
process.Register("llm.ImageGeneration", ProcessImageGeneration)
} }
// ProcessChatCompletions implements the llm.ChatCompletions Process. // ProcessChatCompletions implements the llm.ChatCompletions Process.
@ -155,6 +156,55 @@ func ProcessChatCompletions(p *process.Process) interface{} {
return toOpenAIFormat(response) return toOpenAIFormat(response)
} }
// ProcessImageGeneration implements the llm.ImageGeneration Process.
//
// Usage:
//
// Process("llm.ImageGeneration", connectorID, prompt)
// Process("llm.ImageGeneration", connectorID, prompt, opts)
//
// Args:
// - connectorID (string): Connector ID for an image generation model
// - prompt (string): Text description of the image to generate
// - opts (map): Optional. size, quality, style, n, etc.
//
// Returns: { image (base64), format (png) }
func ProcessImageGeneration(p *process.Process) interface{} {
p.ValidateArgNums(2)
connectorID := p.ArgsString(0)
if connectorID == "" {
return newErrorResponse("llm.ImageGeneration: connector is required")
}
prompt := p.ArgsString(1)
if prompt == "" {
return newErrorResponse("llm.ImageGeneration: prompt is required")
}
var opts map[string]interface{}
if p.NumOfArgs() > 2 && p.Args[2] != nil {
if o, ok := p.Args[2].(map[string]interface{}); ok {
opts = o
}
}
conn, _, err := selectWithCapabilities(connectorID)
if err != nil {
return newErrorResponse(fmt.Sprintf("llm.ImageGeneration: connector %s not found: %v", connectorID, err))
}
resp, err := GenerateImage(conn, prompt, opts)
if err != nil {
return newErrorResponse(fmt.Sprintf("llm.ImageGeneration: %v", err))
}
return map[string]interface{}{
"image": resp.Image,
"format": resp.Format,
}
}
// toOpenAIFormat converts CompletionResponse to OpenAI chat.completions format // toOpenAIFormat converts CompletionResponse to OpenAI chat.completions format
// for backward compatibility with code that consumed openai.chat.Completions. // for backward compatibility with code that consumed openai.chat.Completions.
func toOpenAIFormat(resp *agentContext.CompletionResponse) map[string]interface{} { func toOpenAIFormat(resp *agentContext.CompletionResponse) map[string]interface{} {

View file

@ -388,7 +388,7 @@ func buildModelCapabilityPrompt(req *types.StreamRequest) string {
if !primaryHasVision { if !primaryHasVision {
if _, hasVisionRole := req.Roles["vision"]; hasVisionRole { if _, hasVisionRole := req.Roles["vision"]; hasVisionRole {
guidance = append(guidance, guidance = append(guidance,
"**Image/Vision**: Your current model cannot process images directly. Use the `image_read` system tool (`tai tool image_read`) to analyze images — see the yao-vision skill for details", "**Image/Vision**: Your current model cannot process images directly. Use the `image_read` system tool (`tai tool image_read`) to analyze images — see the yao-image skill for details",
) )
} }
} }

View file

@ -87,7 +87,7 @@
enabled: false enabled: false
- id: tencent-hy3-preview - id: tencent-hy3-preview
model: tencent/hy3-preview:free model: tencent/hy3-preview:free
name: 混元 3 Preview (Free) name: Hunyuan 3 Preview (Free)
max_input_tokens: 262144 max_input_tokens: 262144
max_output_tokens: 262144 max_output_tokens: 262144
capabilities: [tool_calls, streaming, json] capabilities: [tool_calls, streaming, json]
@ -97,7 +97,7 @@
enabled: false enabled: false
- id: tencent-hy3-preview-reasoning-high - id: tencent-hy3-preview-reasoning-high
model: tencent/hy3-preview:free model: tencent/hy3-preview:free
name: 混元 3 Preview Reasoning (Free) name: Hunyuan 3 Preview Reasoning (Free)
max_input_tokens: 262144 max_input_tokens: 262144
max_output_tokens: 262144 max_output_tokens: 262144
capabilities: [tool_calls, streaming, json, reasoning] capabilities: [tool_calls, streaming, json, reasoning]
@ -193,7 +193,7 @@
enabled: false enabled: false
# ─── OpenAI ───────────────────────────────────────────── # ─── OpenAI ─────────────────────────────────────────────
# reasoning_effort 档位: low | medium | high (5.4-mini/o4-mini) # reasoning_effort levels: low | medium | high (5.4-mini/o4-mini)
# low | medium | high | xhigh (5.5/5.4/codex/5) # low | medium | high | xhigh (5.5/5.4/codex/5)
- key: openai - key: openai
name: OpenAI name: OpenAI
@ -471,6 +471,23 @@
name: TTS-1 HD name: TTS-1 HD
capabilities: [audio] capabilities: [audio]
enabled: false enabled: false
# --- Image Generation ---
- id: gpt-image-2
name: GPT Image 2
capabilities: [image_generation]
enabled: false
- id: gpt-image-1.5
name: GPT Image 1.5
capabilities: [image_generation]
enabled: false
- id: gpt-image-1
name: GPT Image 1
capabilities: [image_generation]
enabled: false
- id: gpt-image-1-mini
name: GPT Image 1 Mini
capabilities: [image_generation]
enabled: false
# ─── DeepSeek (OpenAI) ────────────────────────────────── # ─── DeepSeek (OpenAI) ──────────────────────────────────
- key: deepseek - key: deepseek
@ -856,6 +873,22 @@
max_output_tokens: 65536 max_output_tokens: 65536
capabilities: [vision, tool_calls, streaming, json] capabilities: [vision, tool_calls, streaming, json]
enabled: false enabled: false
# --- Image Generation ---
- id: imagen-4
model: models/imagen-4.0-generate-001
name: Imagen 4
capabilities: [image_generation]
enabled: false
- id: imagen-4-ultra
model: models/imagen-4.0-ultra-generate-001
name: Imagen 4 Ultra
capabilities: [image_generation]
enabled: false
- id: imagen-4-fast
model: models/imagen-4.0-fast-generate-001
name: Imagen 4 Fast
capabilities: [image_generation]
enabled: false
# ─── xAI (Grok) ──────────────────────────────────────── # ─── xAI (Grok) ────────────────────────────────────────
# grok-4.x: reasoning.enabled true|false # grok-4.x: reasoning.enabled true|false
@ -921,6 +954,19 @@
options: options:
reasoning_effort: high reasoning_effort: high
enabled: false enabled: false
# --- Image Generation ---
- id: grok-imagine-image
name: Grok Imagine
capabilities: [image_generation]
enabled: false
- id: grok-imagine-image-pro
name: Grok Imagine Pro
capabilities: [image_generation]
enabled: false
- id: grok-imagine-image-quality
name: Grok Imagine Quality
capabilities: [image_generation]
enabled: false
# ─── MiniMax (International) ──────────────────────────── # ─── MiniMax (International) ────────────────────────────
- key: minimax_intl - key: minimax_intl
@ -1390,6 +1436,19 @@
max_input_tokens: 4096 max_input_tokens: 4096
capabilities: [embedding] capabilities: [embedding]
enabled: false enabled: false
# --- 图片生成 ---
- id: doubao-seedream-5-0-260128
name: 豆包 Seedream 5.0
capabilities: [image_generation]
enabled: false
- id: doubao-seedream-4-5-251128
name: 豆包 Seedream 4.5
capabilities: [image_generation]
enabled: false
- id: doubao-seedream-4-0-250828
name: 豆包 Seedream 4.0
capabilities: [image_generation]
enabled: false
# ─── 腾讯混元 MaaS ───────────────────────────────────── # ─── 腾讯混元 MaaS ─────────────────────────────────────
# hy3-preview: reasoning.level disabled | low | high # hy3-preview: reasoning.level disabled | low | high

54
tools/image/generate.go Normal file
View file

@ -0,0 +1,54 @@
package image
import (
_ "embed"
"fmt"
"github.com/yaoapp/gou/process"
agentLLM "github.com/yaoapp/yao/agent/llm"
"github.com/yaoapp/yao/openapi/oauth/authorized"
)
//go:embed generate_schema.json
var GenerateSchemaJSON []byte
// GenerateHandler is the tools.image_generate process handler.
func GenerateHandler(proc *process.Process) interface{} {
prompt := proc.ArgsString(0)
if prompt == "" {
return map[string]interface{}{"error": "prompt is required"}
}
provider := proc.ArgsString(1)
size := proc.ArgsString(2, "1024x1024")
authInfo := authorized.ProcessAuthInfo(proc)
if authInfo == nil {
return map[string]interface{}{"error": "unauthorized: no auth info in request"}
}
connectorID := provider
if connectorID == "" {
connectorID = findFirstImageGenConnector(authInfo)
if connectorID == "" {
return map[string]interface{}{"error": "no image generation provider available; configure one or specify a provider"}
}
}
conn, _, err := agentLLM.ResolveConnector(connectorID, authInfo)
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("resolve connector: %v", err)}
}
options := map[string]interface{}{"size": size}
resp, err := agentLLM.GenerateImage(conn, prompt, options)
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("image generation failed: %v", err)}
}
return map[string]interface{}{
"image": resp.Image,
"format": resp.Format,
"size": size,
}
}

View file

@ -0,0 +1,25 @@
{
"name": "image_generate",
"description": "Generate an image from a text prompt using an image generation model (e.g. DALL-E, Seedream).",
"process": "tools.image_generate",
"inputSchema": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "Text description of the image to generate."
},
"provider": {
"type": "string",
"description": "Provider connector ID (e.g. 'llm.my-openai:dall-e-3'). Use image_providers to list available options. If omitted, the first available image generation provider is used."
},
"size": {
"type": "string",
"description": "Image dimensions (default: 1024x1024). Common values: 1024x1024, 1024x1792, 1792x1024.",
"default": "1024x1024"
}
},
"required": ["prompt"]
},
"x-process-args": ["$args.prompt", "$args.provider", "$args.size"]
}

View file

@ -0,0 +1,35 @@
package image
import (
"testing"
"github.com/yaoapp/gou/process"
)
func TestGenerateHandler_NoPrompt(t *testing.T) {
proc := &process.Process{
Args: []interface{}{""},
}
result := GenerateHandler(proc)
m, ok := result.(map[string]interface{})
if !ok {
t.Fatal("expected map result")
}
if errMsg, _ := m["error"].(string); errMsg != "prompt is required" {
t.Errorf("expected 'prompt is required', got %q", errMsg)
}
}
func TestGenerateHandler_NoAuth(t *testing.T) {
proc := &process.Process{
Args: []interface{}{"A sunset", "", "1024x1024"},
}
result := GenerateHandler(proc)
m, ok := result.(map[string]interface{})
if !ok {
t.Fatal("expected map result")
}
if _, hasErr := m["error"]; !hasErr {
t.Error("expected error when no auth info")
}
}

142
tools/image/providers.go Normal file
View file

@ -0,0 +1,142 @@
package image
import (
_ "embed"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/yao/llmprovider"
"github.com/yaoapp/yao/openapi/oauth/authorized"
oauthTypes "github.com/yaoapp/yao/openapi/oauth/types"
)
//go:embed providers_schema.json
var ProvidersSchemaJSON []byte
type providerResult struct {
Key string `json:"key"`
Name string `json:"name"`
Models []modelResult `json:"models"`
}
type modelResult struct {
ID string `json:"id"`
Name string `json:"name"`
ConnectorID string `json:"connector_id"`
}
// ProvidersHandler is the tools.image_providers process handler.
func ProvidersHandler(proc *process.Process) interface{} {
capability := proc.ArgsString(0, "image_generation")
authInfo := authorized.ProcessAuthInfo(proc)
if authInfo == nil {
return map[string]interface{}{"error": "unauthorized: no auth info in request"}
}
if llmprovider.Global == nil {
return map[string]interface{}{"error": "llmprovider registry not initialized"}
}
providers, err := listProvidersByCapability(capability, authInfo)
if err != nil {
return map[string]interface{}{"error": err.Error()}
}
return map[string]interface{}{
"capability": capability,
"providers": providers,
}
}
// listProvidersByCapability returns providers matching the given capability,
// filtered by owner scope (builtin always included, dynamic filtered by auth).
func listProvidersByCapability(capability string, authInfo *oauthTypes.AuthorizedInfo) ([]providerResult, error) {
enabled := true
filter := &llmprovider.ProviderFilter{
Capabilities: []string{capability},
Enabled: &enabled,
Source: llmprovider.ProviderSourceAll,
}
allProviders, err := llmprovider.Global.List(filter)
if err != nil {
return nil, err
}
var results []providerResult
for _, p := range allProviders {
if p.Source == llmprovider.ProviderSourceDynamic && authInfo != nil {
if !dynamicOwnerMatch(&p.Owner, authInfo) {
continue
}
}
var models []modelResult
for _, m := range p.Models {
if !m.Enabled {
continue
}
if !modelHasCapability(m.Capabilities, capability) {
continue
}
cid := p.ConnectorID
if p.Source == llmprovider.ProviderSourceDynamic && m.ID != "" {
cid = p.ConnectorID + ":" + m.ID
}
name := m.Name
if name == "" {
name = m.ID
}
models = append(models, modelResult{
ID: m.ID,
Name: name,
ConnectorID: cid,
})
}
if len(models) == 0 {
continue
}
results = append(results, providerResult{
Key: p.Key,
Name: p.Name,
Models: models,
})
}
return results, nil
}
// findFirstImageGenConnector returns the connector ID of the first available
// image generation provider, or empty string if none found.
func findFirstImageGenConnector(authInfo *oauthTypes.AuthorizedInfo) string {
if llmprovider.Global == nil {
return ""
}
providers, err := listProvidersByCapability("image_generation", authInfo)
if err != nil || len(providers) == 0 {
return ""
}
if len(providers[0].Models) == 0 {
return ""
}
return providers[0].Models[0].ConnectorID
}
func dynamicOwnerMatch(owner *llmprovider.ProviderOwner, authInfo *oauthTypes.AuthorizedInfo) bool {
if authInfo.GetTeamID() != "" {
return owner.Type == "team" && owner.TeamID == authInfo.GetTeamID()
}
return owner.Type == "user" && owner.UserID == authInfo.GetUserID()
}
func modelHasCapability(caps []string, target string) bool {
for _, c := range caps {
if c == target {
return true
}
}
return false
}

View file

@ -0,0 +1,17 @@
{
"name": "image_providers",
"description": "List available image providers. Default lists image generation providers; pass capability='vision' to list vision (image reading) providers.",
"process": "tools.image_providers",
"inputSchema": {
"type": "object",
"properties": {
"capability": {
"type": "string",
"description": "Filter by capability: 'image_generation' (default) or 'vision'.",
"default": "image_generation",
"enum": ["image_generation", "vision"]
}
}
},
"x-process-args": ["$args.capability"]
}

View file

@ -0,0 +1,54 @@
package image
import (
"testing"
"github.com/yaoapp/gou/process"
)
func TestModelHasCapability_Found(t *testing.T) {
caps := []string{"chat", "image_generation", "vision"}
if !modelHasCapability(caps, "image_generation") {
t.Error("expected true for image_generation")
}
if !modelHasCapability(caps, "vision") {
t.Error("expected true for vision")
}
}
func TestModelHasCapability_NotFound(t *testing.T) {
caps := []string{"chat", "embedding"}
if modelHasCapability(caps, "image_generation") {
t.Error("expected false for image_generation")
}
}
func TestModelHasCapability_Empty(t *testing.T) {
if modelHasCapability(nil, "image_generation") {
t.Error("expected false for nil caps")
}
if modelHasCapability([]string{}, "image_generation") {
t.Error("expected false for empty caps")
}
}
func TestProvidersHandler_NoAuth(t *testing.T) {
proc := &process.Process{
Args: []interface{}{"image_generation"},
}
result := ProvidersHandler(proc)
m, ok := result.(map[string]interface{})
if !ok {
t.Fatal("expected map result")
}
if _, hasErr := m["error"]; !hasErr {
t.Error("expected error when no auth info")
}
}
func TestFindFirstImageGenConnector_NoGlobal(t *testing.T) {
result := findFirstImageGenConnector(nil)
if result != "" {
t.Errorf("expected empty string, got %q", result)
}
}

View file

@ -1,4 +1,4 @@
package vision package image
import ( import (
"bytes" "bytes"
@ -6,7 +6,7 @@ import (
_ "embed" _ "embed"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"image" stdimage "image"
_ "image/gif" _ "image/gif"
"image/jpeg" "image/jpeg"
_ "image/png" _ "image/png"
@ -28,8 +28,8 @@ import (
ws "github.com/yaoapp/yao/workspace" ws "github.com/yaoapp/yao/workspace"
) )
//go:embed schema.json //go:embed read_schema.json
var SchemaJSON []byte var ReadSchemaJSON []byte
// ImageReadResponse is the return type for image_read. // ImageReadResponse is the return type for image_read.
type ImageReadResponse struct { type ImageReadResponse struct {
@ -40,7 +40,8 @@ type ImageReadResponse struct {
// ReadImage reads and analyzes an image using the vision model. // ReadImage reads and analyzes an image using the vision model.
// It resolves the image from src, finds a vision-capable connector via llmprovider, // It resolves the image from src, finds a vision-capable connector via llmprovider,
// and returns the model's text description. // and returns the model's text description.
func ReadImage(goCtx context.Context, src string, prompt string, maxSize int, authInfo *oauthTypes.AuthorizedInfo) (*ImageReadResponse, error) { // provider is optional; when non-empty it overrides the default "use::vision" role.
func ReadImage(goCtx context.Context, src string, prompt string, maxSize int, authInfo *oauthTypes.AuthorizedInfo, provider string) (*ImageReadResponse, error) {
if prompt == "" { if prompt == "" {
prompt = "Please describe this image in detail." prompt = "Please describe this image in detail."
} }
@ -53,7 +54,11 @@ func ReadImage(goCtx context.Context, src string, prompt string, maxSize int, au
return nil, fmt.Errorf("resolve image: %w", err) return nil, fmt.Errorf("resolve image: %w", err)
} }
conn, caps, err := agentLLM.ResolveConnector("use::vision", authInfo) connectorRole := "use::vision"
if provider != "" {
connectorRole = provider
}
conn, caps, err := agentLLM.ResolveConnector(connectorRole, authInfo)
if err != nil { if err != nil {
return nil, fmt.Errorf("resolve vision connector: %w", err) return nil, fmt.Errorf("resolve vision connector: %w", err)
} }
@ -87,8 +92,8 @@ func ReadImage(goCtx context.Context, src string, prompt string, maxSize int, au
}, nil }, nil
} }
// Handler is the tools.image_read process handler. // ReadHandler is the tools.image_read process handler.
func Handler(proc *process.Process) interface{} { func ReadHandler(proc *process.Process) interface{} {
src := proc.ArgsString(0) src := proc.ArgsString(0)
if src == "" { if src == "" {
return map[string]interface{}{"error": "image_path is required: provide a file path, URL, or URI"} return map[string]interface{}{"error": "image_path is required: provide a file path, URL, or URI"}
@ -103,6 +108,7 @@ func Handler(proc *process.Process) interface{} {
proc.ArgsString(1, "Please describe this image in detail."), proc.ArgsString(1, "Please describe this image in detail."),
proc.ArgsInt(2, 1080), proc.ArgsInt(2, 1080),
authInfo, authInfo,
proc.ArgsString(3),
) )
if err != nil { if err != nil {
return map[string]interface{}{"error": err.Error()} return map[string]interface{}{"error": err.Error()}
@ -188,7 +194,7 @@ func httpGet(rawURL string) ([]byte, error) {
// resizeImage decodes, resizes (longest edge <= maxSize), re-encodes as JPEG. // resizeImage decodes, resizes (longest edge <= maxSize), re-encodes as JPEG.
// Returns original bytes unchanged when already small enough or if decode fails. // Returns original bytes unchanged when already small enough or if decode fails.
func resizeImage(data []byte, maxSize int) ([]byte, string) { func resizeImage(data []byte, maxSize int) ([]byte, string) {
img, _, err := image.Decode(bytes.NewReader(data)) img, _, err := stdimage.Decode(bytes.NewReader(data))
if err != nil { if err != nil {
return data, http.DetectContentType(data) return data, http.DetectContentType(data)
} }
@ -205,7 +211,7 @@ func resizeImage(data []byte, maxSize int) ([]byte, string) {
if newH < 1 { if newH < 1 {
newH = 1 newH = 1
} }
dst := image.NewRGBA(image.Rect(0, 0, newW, newH)) dst := stdimage.NewRGBA(stdimage.Rect(0, 0, newW, newH))
draw.BiLinear.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil) draw.BiLinear.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil)
var buf bytes.Buffer var buf bytes.Buffer
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85}); err != nil { if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85}); err != nil {

View file

@ -18,9 +18,13 @@
"type": "integer", "type": "integer",
"description": "Max dimension in pixels for the longest edge. Image is resized (preserving aspect ratio) before sending to the vision model. Default 1080.", "description": "Max dimension in pixels for the longest edge. Image is resized (preserving aspect ratio) before sending to the vision model. Default 1080.",
"default": 1080 "default": 1080
},
"provider": {
"type": "string",
"description": "Vision provider connector ID (e.g. from image_providers with capability='vision'). If omitted, uses the default vision model."
} }
}, },
"required": ["image_path"] "required": ["image_path"]
}, },
"x-process-args": ["$args.image_path", "$args.prompt", "$args.max_size"] "x-process-args": ["$args.image_path", "$args.prompt", "$args.max_size", "$args.provider"]
} }

View file

@ -1,8 +1,8 @@
package vision package image
import ( import (
"encoding/base64" "encoding/base64"
"image" stdimage "image"
"image/color" "image/color"
_ "image/jpeg" _ "image/jpeg"
"image/png" "image/png"
@ -15,7 +15,7 @@ import (
) )
func makePNG(w, h int) []byte { func makePNG(w, h int) []byte {
img := image.NewRGBA(image.Rect(0, 0, w, h)) img := stdimage.NewRGBA(stdimage.Rect(0, 0, w, h))
for y := 0; y < h; y++ { for y := 0; y < h; y++ {
for x := 0; x < w; x++ { for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{R: 255, G: 0, B: 0, A: 255}) img.Set(x, y, color.RGBA{R: 255, G: 0, B: 0, A: 255})
@ -45,7 +45,7 @@ func TestResizeImage_LargeImage(t *testing.T) {
if mime != "image/jpeg" { if mime != "image/jpeg" {
t.Errorf("mime = %q, want image/jpeg", mime) t.Errorf("mime = %q, want image/jpeg", mime)
} }
img, _, err := image.Decode(strings.NewReader(string(data))) img, _, err := stdimage.Decode(strings.NewReader(string(data)))
if err != nil { if err != nil {
t.Fatalf("failed to decode resized image: %v", err) t.Fatalf("failed to decode resized image: %v", err)
} }

View file

@ -1,8 +1,10 @@
{ {
"name": "yao-vision", "name": "yao-image",
"transport": "process", "transport": "process",
"description": "Vision tools for image understanding", "description": "Image tools for reading, generating, and managing images",
"tools": { "tools": {
"image_read": "tools.image_read" "image_read": "tools.image_read",
"image_generate": "tools.image_generate",
"image_providers": "tools.image_providers"
} }
} }

View file

@ -4,13 +4,13 @@
These environment variables are set by the Yao sandbox. **Always use these variables — never hardcode paths.** These environment variables are set by the Yao sandbox. **Always use these variables — never hardcode paths.**
| Variable | Purpose | Example | | Variable | Purpose | Example |
|----------|---------|---------| | ------------------- | ------------------------------------------ | -------------------------------------- |
| `$WORKDIR` | Sandbox working directory (project root) | `/workspace` | | `$WORKDIR` | Sandbox working directory (project root) | `/workspace` |
| `$HOME` | Same as `$WORKDIR` (redirected by sandbox) | `/workspace` | | `$HOME` | Same as `$WORKDIR` (redirected by sandbox) | `/workspace` |
| `$CTX_SKILLS_DIR` | Skills directory for this assistant | `$WORKDIR/.yao/assistants/<id>/skills` | | `$CTX_SKILLS_DIR` | Skills directory for this assistant | `$WORKDIR/.yao/assistants/<id>/skills` |
| `$CTX_ASSISTANT_ID` | Current assistant ID | `yao.agent-smith` | | `$CTX_ASSISTANT_ID` | Current assistant ID | `yao.agent-smith` |
| `$CTX_WORKSPACE_ID` | Current workspace ID | `ws-abc123` | | `$CTX_WORKSPACE_ID` | Current workspace ID | `ws-abc123` |
### Path Rules ### Path Rules
@ -21,6 +21,33 @@ These environment variables are set by the Yao sandbox. **Always use these varia
Resolve first: `echo "$WORKDIR"`, then use the printed value. Resolve first: `echo "$WORKDIR"`, then use the printed value.
- On Windows, use `$env:WORKDIR` / `$env:CTX_SKILLS_DIR` syntax instead. - On Windows, use `$env:WORKDIR` / `$env:CTX_SKILLS_DIR` syntax instead.
### Workspace Path in Replies
When replying to users, **never expose raw `/workspace/...` paths**. Rewrite them using the `workspace://` scheme so the frontend can render them correctly.
**Format**: `workspace://<workspace-id>/relative/path`
**Step 1 — resolve the real workspace ID** (do this once per session):
```bash
echo "$CTX_WORKSPACE_ID"
```
Use the **actual printed value** (e.g. `ws-bfc4c2de-b53...`) in all subsequent replies.
**Step 2 — rewrite paths in replies**:
- `/workspace/output/result.png``workspace://<real-id>/output/result.png`
**Common mistakes** (all wrong):
- `workspace://$CTX_WORKSPACE_ID/...` ← shell variable literally in reply
- `workspace://ws-bfc4c2de-b53/...` ← example/placeholder ID instead of the real one
- `workspace://<workspace-id>/...` ← template placeholder instead of the real one
- `/workspace/output/...` ← raw path without `workspace://` scheme
You **must** run `echo "$CTX_WORKSPACE_ID"` and use the exact output.
### Attachments ### Attachments
User-uploaded files are placed in `$WORKDIR/.attachments/{chatID}/`. User-uploaded files are placed in `$WORKDIR/.attachments/{chatID}/`.
@ -40,15 +67,17 @@ You have access to Yao system tools via the `tai` command in bash.
**Calling convention**: `tai tool <name> '<json_args>'` **Calling convention**: `tai tool <name> '<json_args>'`
| Tool | Skill (auto-loaded) | Description | | Tool | Skill (auto-loaded) | Description |
|------|---------------------|-------------| | ----------------- | ------------------- | --------------------------------------------------- |
| `web_search` | yao-web | Search the web for real-time information | | `web_search` | yao-web | Search the web for real-time information |
| `web_fetch` | yao-web | Fetch and read a web page by URL | | `web_fetch` | yao-web | Fetch and read a web page by URL |
| `process_call` | yao-process | Execute a Yao Process (server-side function) | | `process_call` | yao-process | Execute a Yao Process (server-side function) |
| `process_allowed` | yao-process | Check which processes are allowed | | `process_allowed` | yao-process | Check which processes are allowed |
| `doc_list` | yao-doc | Search/list available process documentation | | `doc_list` | yao-doc | Search/list available process documentation |
| `doc_inspect` | yao-doc | Get detailed docs for a specific process | | `doc_inspect` | yao-doc | Get detailed docs for a specific process |
| `doc_validate` | yao-doc | Validate a process name and get suggestions | | `doc_validate` | yao-doc | Validate a process name and get suggestions |
| `image_read` | yao-vision | Read and analyze images using a vision model | | `image_read` | yao-image | Read and analyze images using a vision model |
| `image_generate` | yao-image | Generate images from text prompts |
| `image_providers` | yao-image | List available image generation or vision providers |
The system skills (`yao-web`, `yao-process`, `yao-doc`, `yao-vision`) in `$HOME/.claude/skills/` are **auto-discovered** — they contain detailed parameter docs and workflow guidance. You do not need to manually read them; they are loaded automatically when your task matches their description. The system skills (`yao-web`, `yao-process`, `yao-doc`, `yao-image`) in `$HOME/.claude/skills/` are **auto-discovered** — they contain detailed parameter docs and workflow guidance. You do not need to manually read them; they are loaded automatically when your task matches their description.

View file

@ -1,11 +1,11 @@
--- ---
name: yao-vision name: yao-image
description: Image understanding expert. ALWAYS invoke this skill when you need to read, analyze, or describe an image that you cannot process directly. Use for screenshots, photos, charts, diagrams, or any visual content. description: Image expert. ALWAYS invoke this skill when you need to read, analyze, describe, or generate images. Use for screenshots, photos, charts, diagrams, AI-generated images, or any visual content.
--- ---
# Vision Tools # Image Tools
Use when you encounter images you cannot read natively (e.g., as a text-only model). Use these tools when you encounter images you cannot read natively, or when you need to generate new images.
## image_read ## image_read
@ -31,9 +31,9 @@ tai tool image_read '{"image_path": "workspace://ws-id/path/to/image.png", "prom
tai tool image_read '{"image_path": "attach://__yao.attachment/file-id-123", "prompt": "Describe"}' tai tool image_read '{"image_path": "attach://__yao.attachment/file-id-123", "prompt": "Describe"}'
``` ```
### Yao data file: ### With a specific vision provider:
```bash ```bash
tai tool image_read '{"image_path": "yao://uploads/photo.jpg", "prompt": "Describe"}' tai tool image_read '{"image_path": "/path/to/image.png", "prompt": "Describe", "provider": "llm.my-openai:gpt-4o"}'
``` ```
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
@ -41,6 +41,52 @@ tai tool image_read '{"image_path": "yao://uploads/photo.jpg", "prompt": "Descri
| image_path | string | yes | File path, URL, workspace://, attach://, or yao:// URI | | image_path | string | yes | File path, URL, workspace://, attach://, or yao:// URI |
| prompt | string | no | Analysis instruction (default: describe in detail) | | prompt | string | no | Analysis instruction (default: describe in detail) |
| max_size | integer | no | Max dimension in pixels for longest edge (default: 1080) | | max_size | integer | no | Max dimension in pixels for longest edge (default: 1080) |
| provider | string | no | Vision provider connector ID. If omitted, uses default vision model |
Images are automatically resized (preserving aspect ratio) before sending to the vision model. Images are automatically resized (preserving aspect ratio) before sending to the vision model.
Supported formats: PNG, JPEG, GIF, WebP. Supported formats: PNG, JPEG, GIF, WebP.
## image_generate
Generate an image from a text prompt and save it to a file.
### Basic usage (always specify output):
```bash
tai tool image_generate '{"prompt": "A serene mountain landscape at sunset", "output": "landscape.png"}'
```
### With specific provider and size:
```bash
tai tool image_generate '{"prompt": "A futuristic city skyline", "provider": "llm.my-openai:dall-e-3", "size": "1792x1024", "output": "output/city.png"}'
```
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------------------------------------------------------------- |
| prompt | string | yes | Text description of the image to generate |
| output | string | yes | File path to save the generated image (parent dirs created automatically) |
| provider | string | no | Provider connector ID (use `image_providers` to list). Auto-selects if omitted |
| size | string | no | Image dimensions (default: 1024x1024). Common: 1024x1024, 1024x1792, 1792x1024 |
**Important**: Always pass `output`. The tool saves the image directly and returns only the file path and size. Without `output`, the raw base64 data is returned which may exceed output limits.
Use relative paths (e.g. `"output": "fox.png"`) — they resolve relative to the current working directory (`$WORKDIR`). No need to prepend `$WORKDIR` manually.
## image_providers
List available image providers filtered by capability.
### List image generation providers (default):
```bash
tai tool image_providers '{}'
```
### List vision (image reading) providers:
```bash
tai tool image_providers '{"capability": "vision"}'
```
| Parameter | Type | Required | Description |
| ---------- | ------ | -------- | ----------------------------------------------------------- |
| capability | string | no | `image_generation` (default) or `vision` |
Returns a list of providers with their available models and connector IDs that can be passed to `image_generate` or `image_read`.

View file

@ -6,11 +6,12 @@ import (
"testing" "testing"
) )
func TestSkillsFS_ContainsThreeSkills(t *testing.T) { func TestSkillsFS_ContainsAllSkills(t *testing.T) {
expected := map[string]bool{ expected := map[string]bool{
"skills/yao-web/SKILL.md": false, "skills/yao-web/SKILL.md": false,
"skills/yao-process/SKILL.md": false, "skills/yao-process/SKILL.md": false,
"skills/yao-doc/SKILL.md": false, "skills/yao-doc/SKILL.md": false,
"skills/yao-image/SKILL.md": false,
} }
err := fs.WalkDir(SkillsFS, "skills", func(path string, d fs.DirEntry, err error) error { err := fs.WalkDir(SkillsFS, "skills", func(path string, d fs.DirEntry, err error) error {
@ -41,6 +42,7 @@ func TestSkillsFS_FrontmatterFields(t *testing.T) {
{"skills/yao-web/SKILL.md", "yao-web"}, {"skills/yao-web/SKILL.md", "yao-web"},
{"skills/yao-process/SKILL.md", "yao-process"}, {"skills/yao-process/SKILL.md", "yao-process"},
{"skills/yao-doc/SKILL.md", "yao-doc"}, {"skills/yao-doc/SKILL.md", "yao-doc"},
{"skills/yao-image/SKILL.md", "yao-image"},
} }
for _, s := range skills { for _, s := range skills {

View file

@ -9,8 +9,8 @@ import (
"github.com/yaoapp/gou/process" "github.com/yaoapp/gou/process"
"github.com/yaoapp/kun/log" "github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/tools/docs" "github.com/yaoapp/yao/tools/docs"
"github.com/yaoapp/yao/tools/image"
"github.com/yaoapp/yao/tools/proc" "github.com/yaoapp/yao/tools/proc"
"github.com/yaoapp/yao/tools/vision"
"github.com/yaoapp/yao/tools/webfetch" "github.com/yaoapp/yao/tools/webfetch"
"github.com/yaoapp/yao/tools/websearch" "github.com/yaoapp/yao/tools/websearch"
) )
@ -24,8 +24,8 @@ var mcpProcessDSL []byte
//go:embed mcps/doc.json //go:embed mcps/doc.json
var mcpDocDSL []byte var mcpDocDSL []byte
//go:embed mcps/vision.json //go:embed mcps/image.json
var mcpVisionDSL []byte var mcpImageDSL []byte
func init() { func init() {
process.RegisterGroup("tools", map[string]process.Handler{ process.RegisterGroup("tools", map[string]process.Handler{
@ -36,7 +36,9 @@ func init() {
"doc_list": docs.ListHandler, "doc_list": docs.ListHandler,
"doc_inspect": docs.InspectHandler, "doc_inspect": docs.InspectHandler,
"doc_validate": docs.ValidateHandler, "doc_validate": docs.ValidateHandler,
"image_read": vision.Handler, "image_read": image.ReadHandler,
"image_generate": image.GenerateHandler,
"image_providers": image.ProvidersHandler,
}) })
registerMCPServer(mcpWebDSL, "yao-web", registerMCPServer(mcpWebDSL, "yao-web",
@ -45,8 +47,8 @@ func init() {
proc.SchemaJSON, proc.AllowedSchemaJSON) proc.SchemaJSON, proc.AllowedSchemaJSON)
registerMCPServer(mcpDocDSL, "yao-doc", registerMCPServer(mcpDocDSL, "yao-doc",
docs.ListSchemaJSON, docs.InspectSchemaJSON, docs.ValidateSchemaJSON) docs.ListSchemaJSON, docs.InspectSchemaJSON, docs.ValidateSchemaJSON)
registerMCPServer(mcpVisionDSL, "yao-vision", registerMCPServer(mcpImageDSL, "yao-image",
vision.SchemaJSON) image.ReadSchemaJSON, image.GenerateSchemaJSON, image.ProvidersSchemaJSON)
} }
func registerMCPServer(dsl []byte, id string, schemas ...[]byte) { func registerMCPServer(dsl []byte, id string, schemas ...[]byte) {