From a00d9f8d28e0d2392a739ceddc2d6fdbd61d155e Mon Sep 17 00:00:00 2001 From: paoloanzn Date: Sun, 22 Mar 2026 22:11:37 +0100 Subject: [PATCH 1/4] feat: add workers ai support --- README.md | 31 ++++++ internal/app/app.go | 2 + internal/cli/ai.go | 58 +++++++++++ internal/cli/aliases.go | 2 +- internal/cli/deps.go | 2 + internal/cli/project.go | 2 +- internal/cli/root.go | 1 + internal/domain/config/project.go | 12 ++- internal/domain/config/wrangler.go | 7 ++ internal/service/ai/service.go | 93 +++++++++++++++++ internal/service/ai/service_test.go | 85 ++++++++++++++++ internal/service/doctor/service.go | 7 ++ internal/service/project/service.go | 6 ++ internal/service/project/service_test.go | 60 +++++++++++ internal/service/project/templates.go | 116 ++++++++++++++++++++++ internal/service/teardown/service.go | 2 + internal/service/teardown/service_test.go | 13 ++- 17 files changed, 495 insertions(+), 4 deletions(-) create mode 100644 internal/cli/ai.go create mode 100644 internal/service/ai/service.go create mode 100644 internal/service/ai/service_test.go create mode 100644 internal/service/project/service_test.go diff --git a/README.md b/README.md index 360247b..c8be534 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ This means `flare-edge-cli` is not just a deploy wrapper. It is an agent-oriente - Validates Go code against a Workers/Wasm compatibility profile - Builds `.wasm` artifacts and the JavaScript Worker shim - Runs local or remote dev sessions through Wrangler +- Configures Workers AI bindings for Go-based AI Workers - Deploys versioned Workers and manages routes, secrets, KV, D1, R2, and releases - Tears down Workers and optional side-effect resources cleanly @@ -235,10 +236,28 @@ Supported templates: - `edge-http` - `edge-json` - `scheduled` +- `ai-text` - `kv-api` - `d1-api` - `r2-api` +`ai-text` scaffolds a Go/Wasm Worker that reads `env.AI` through `syscall/js`, calls a default Workers AI model, and returns the model response as JSON. + +### `ai` + +Manage the Workers AI binding stored in local project config. + +```bash +./flare-edge-cli ai binding set [--path ] [--binding ] [--remote] [--json] +./flare-edge-cli ai binding clear [--path ] [--json] +``` + +Notes: + +- `ai binding set` updates both `flare-edge.json` and `wrangler.jsonc` +- the default binding name is `AI` +- `--remote` should remain enabled for Workers AI development + ### `compat` Run the Go/Wasm compatibility analyzer or inspect built-in rules. @@ -427,6 +446,18 @@ Scaffold a JSON worker: ./flare-edge-cli init test-project --module-path github.com/example/test-project --template edge-json ``` +Scaffold a Go-based AI Worker: + +```bash +./flare-edge-cli init ai-worker --module-path github.com/example/ai-worker --template ai-text +``` + +Add Workers AI to an existing project: + +```bash +./flare-edge-cli ai binding set --path ./ai-worker --binding AI --remote +``` + Check compatibility and emit JSON: ```bash diff --git a/internal/app/app.go b/internal/app/app.go index 4b7f9de..47308e2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,6 +11,7 @@ import ( "github.com/paolo/flare-edge-cli/internal/infra/toolchain" "github.com/paolo/flare-edge-cli/internal/infra/wrangler" "github.com/paolo/flare-edge-cli/internal/logging" + aisvc "github.com/paolo/flare-edge-cli/internal/service/ai" authsvc "github.com/paolo/flare-edge-cli/internal/service/auth" buildsvc "github.com/paolo/flare-edge-cli/internal/service/build" compatsvc "github.com/paolo/flare-edge-cli/internal/service/compat" @@ -43,6 +44,7 @@ func Run() int { deps := cli.Dependencies{ Services: cli.Services{ + AI: aisvc.NewService(store, filesystem), Auth: authsvc.NewService(wranglerClient, authState), Build: buildService, Compat: compatsvc.NewService(), diff --git a/internal/cli/ai.go b/internal/cli/ai.go new file mode 100644 index 0000000..1dbaaea --- /dev/null +++ b/internal/cli/ai.go @@ -0,0 +1,58 @@ +package cli + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/paolo/flare-edge-cli/internal/output" + aisvc "github.com/paolo/flare-edge-cli/internal/service/ai" +) + +func newAICommand(deps Dependencies) *cobra.Command { + cmd := &cobra.Command{Use: "ai", Short: "Manage Workers AI bindings"} + binding := &cobra.Command{Use: "binding", Short: "Manage Workers AI binding config"} + binding.AddCommand(newAIBindingSetCommand(deps), newAIBindingClearCommand(deps)) + cmd.AddCommand(binding) + return cmd +} + +func newAIBindingSetCommand(deps Dependencies) *cobra.Command { + var options aisvc.SetBindingOptions + var jsonOutput bool + cmd := &cobra.Command{ + Use: "set", + Short: "Configure the Workers AI binding", + RunE: func(cmd *cobra.Command, _ []string) error { + result, err := deps.Services.AI.SetBinding(context.Background(), options) + if err != nil { + return err + } + return output.NewPrinter(cmd.OutOrStdout(), cmd.ErrOrStderr(), jsonOutput).Print(result) + }, + } + cmd.Flags().StringVar(&options.Dir, "path", ".", "Project path") + cmd.Flags().StringVar(&options.Binding, "binding", "AI", "Workers AI binding name") + cmd.Flags().BoolVar(&options.Remote, "remote", true, "Use the remote Workers AI binding") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Emit machine-readable JSON") + return cmd +} + +func newAIBindingClearCommand(deps Dependencies) *cobra.Command { + var options aisvc.ClearBindingOptions + var jsonOutput bool + cmd := &cobra.Command{ + Use: "clear", + Short: "Remove the Workers AI binding from local config", + RunE: func(cmd *cobra.Command, _ []string) error { + result, err := deps.Services.AI.ClearBinding(context.Background(), options) + if err != nil { + return err + } + return output.NewPrinter(cmd.OutOrStdout(), cmd.ErrOrStderr(), jsonOutput).Print(result) + }, + } + cmd.Flags().StringVar(&options.Dir, "path", ".", "Project path") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Emit machine-readable JSON") + return cmd +} diff --git a/internal/cli/aliases.go b/internal/cli/aliases.go index 983effe..7d065f5 100644 --- a/internal/cli/aliases.go +++ b/internal/cli/aliases.go @@ -30,7 +30,7 @@ func newInitAliasCommand(deps Dependencies) *cobra.Command { cmd.Flags().StringVar(&options.Dir, "cwd", ".", "Target parent directory") cmd.Flags().StringVar(&options.ModulePath, "module-path", "", "Go module path") cmd.Flags().StringVar(&options.PackageName, "package", "", "Package name") - cmd.Flags().StringVar(&options.Template, "template", "edge-http", "Starter template") + cmd.Flags().StringVar(&options.Template, "template", "edge-http", "Starter template: edge-http|edge-json|scheduled|kv-api|d1-api|r2-api|ai-text") cmd.Flags().StringVar(&options.CompatDate, "compat-date", "", "Compatibility date") cmd.Flags().StringVar(&options.Env, "env", "", "Wrangler environment") cmd.Flags().BoolVar(&options.UseJSONC, "use-jsonc", false, "Generate wrangler.jsonc") diff --git a/internal/cli/deps.go b/internal/cli/deps.go index 7848363..40141c0 100644 --- a/internal/cli/deps.go +++ b/internal/cli/deps.go @@ -1,6 +1,7 @@ package cli import ( + aisvc "github.com/paolo/flare-edge-cli/internal/service/ai" authsvc "github.com/paolo/flare-edge-cli/internal/service/auth" buildsvc "github.com/paolo/flare-edge-cli/internal/service/build" compatsvc "github.com/paolo/flare-edge-cli/internal/service/compat" @@ -19,6 +20,7 @@ import ( ) type Services struct { + AI *aisvc.Service Auth *authsvc.Service Build *buildsvc.Service Compat *compatsvc.Service diff --git a/internal/cli/project.go b/internal/cli/project.go index 14ed34f..d3bd311 100644 --- a/internal/cli/project.go +++ b/internal/cli/project.go @@ -41,7 +41,7 @@ func newProjectInitCommand(deps Dependencies) *cobra.Command { cmd.Flags().StringVar(&options.ModulePath, "module-path", "", "Go module path to initialize") cmd.Flags().StringVar(&options.PackageName, "package", "", "Package name for generated Go entrypoint") - cmd.Flags().StringVar(&options.Template, "template", "edge-http", "Starter template: edge-http|edge-json|scheduled|kv-api|d1-api|r2-api") + cmd.Flags().StringVar(&options.Template, "template", "edge-http", "Starter template: edge-http|edge-json|scheduled|kv-api|d1-api|r2-api|ai-text") cmd.Flags().StringVar(&options.CompatDate, "compat-date", "", "Cloudflare compatibility date") cmd.Flags().StringVar(&options.Env, "env", "", "Default Wrangler environment") cmd.Flags().BoolVar(&options.UseJSONC, "use-jsonc", false, "Generate wrangler.jsonc output") diff --git a/internal/cli/root.go b/internal/cli/root.go index d5f9dac..d85660c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -23,6 +23,7 @@ func NewRootCommand(deps Dependencies) *cobra.Command { } cmd.PersistentFlags().StringVar(&accountID, "account-id", "", "Cloudflare account ID override for all commands") + cmd.AddCommand(newAICommand(deps)) cmd.AddCommand(newAuthCommand(deps)) cmd.AddCommand(newBuildCommand(deps)) cmd.AddCommand(newCompatCommand(deps)) diff --git a/internal/domain/config/project.go b/internal/domain/config/project.go index eddf011..dd0f660 100644 --- a/internal/domain/config/project.go +++ b/internal/domain/config/project.go @@ -32,6 +32,7 @@ type Project struct { type ProjectBindings struct { Vars map[string]string `json:"vars,omitempty"` Secrets []string `json:"secrets,omitempty"` + AI *AIBinding `json:"ai,omitempty"` KV []KVBinding `json:"kv,omitempty"` D1 []D1Binding `json:"d1,omitempty"` R2 []R2Binding `json:"r2,omitempty"` @@ -47,6 +48,11 @@ type Environment struct { Bindings ProjectBindings `json:"bindings"` } +type AIBinding struct { + Binding string `json:"binding"` + Remote bool `json:"remote,omitempty"` +} + type KVBinding struct { Binding string `json:"binding"` ID string `json:"id,omitempty"` @@ -74,7 +80,7 @@ func DefaultProject(name, modulePath, packageName, template, compatDate, env str } entry := "./cmd/worker" - return Project{ + project := Project{ SchemaVersion: 1, ProjectName: name, ModulePath: modulePath, @@ -96,6 +102,10 @@ func DefaultProject(name, modulePath, packageName, template, compatDate, env str WasmExecSource: "internal/generated/wasm_exec.js", }, } + if template == "ai-text" { + project.Bindings.AI = &AIBinding{Binding: "AI", Remote: true} + } + return project } func (p Project) ArtifactPath() string { diff --git a/internal/domain/config/wrangler.go b/internal/domain/config/wrangler.go index d1a86b1..783853f 100644 --- a/internal/domain/config/wrangler.go +++ b/internal/domain/config/wrangler.go @@ -5,6 +5,7 @@ type WranglerConfig struct { Main string `json:"main"` CompatibilityDate string `json:"compatibility_date"` Observability *WranglerObservability `json:"observability,omitempty"` + AI *WranglerAIBinding `json:"ai,omitempty"` Vars map[string]string `json:"vars,omitempty"` KVNamespaces []WranglerKVNamespace `json:"kv_namespaces,omitempty"` D1Databases []WranglerD1Database `json:"d1_databases,omitempty"` @@ -19,6 +20,7 @@ type WranglerObservability struct { type WranglerEnvConfig struct { Name string `json:"name,omitempty"` + AI *WranglerAIBinding `json:"ai,omitempty"` Vars map[string]string `json:"vars,omitempty"` KVNamespaces []WranglerKVNamespace `json:"kv_namespaces,omitempty"` D1Databases []WranglerD1Database `json:"d1_databases,omitempty"` @@ -26,6 +28,11 @@ type WranglerEnvConfig struct { Routes []WranglerRoute `json:"routes,omitempty"` } +type WranglerAIBinding struct { + Binding string `json:"binding"` + Remote bool `json:"remote,omitempty"` +} + type WranglerKVNamespace struct { Binding string `json:"binding"` ID string `json:"id,omitempty"` diff --git a/internal/service/ai/service.go b/internal/service/ai/service.go new file mode 100644 index 0000000..eb6ca04 --- /dev/null +++ b/internal/service/ai/service.go @@ -0,0 +1,93 @@ +package ai + +import ( + "context" + + "github.com/paolo/flare-edge-cli/internal/domain/config" + "github.com/paolo/flare-edge-cli/internal/infra/configstore" + "github.com/paolo/flare-edge-cli/internal/support/fs" +) + +type Service struct { + store *configstore.Store + fs *fs.FileSystem +} + +type SetBindingOptions struct { + Dir string + Binding string + Remote bool +} + +type ClearBindingOptions struct { + Dir string +} + +type BindingResult struct { + Binding string `json:"binding,omitempty"` + Remote bool `json:"remote,omitempty"` + Cleared bool `json:"cleared,omitempty"` +} + +func NewService(store *configstore.Store, fs *fs.FileSystem) *Service { + return &Service{store: store, fs: fs} +} + +func (s *Service) SetBinding(_ context.Context, options SetBindingOptions) (BindingResult, error) { + project, wranglerCfg, err := loadProjectAndWrangler(options.Dir, s.store) + if err != nil { + return BindingResult{}, err + } + + project.Bindings.AI = &config.AIBinding{ + Binding: options.Binding, + Remote: options.Remote, + } + wranglerCfg.AI = &config.WranglerAIBinding{ + Binding: options.Binding, + Remote: options.Remote, + } + + if err := s.store.SaveProject(options.Dir, project); err != nil { + return BindingResult{}, err + } + if err := s.store.SaveWrangler(options.Dir, project.WranglerConfig, wranglerCfg); err != nil { + return BindingResult{}, err + } + + return BindingResult{ + Binding: options.Binding, + Remote: options.Remote, + }, nil +} + +func (s *Service) ClearBinding(_ context.Context, options ClearBindingOptions) (BindingResult, error) { + project, wranglerCfg, err := loadProjectAndWrangler(options.Dir, s.store) + if err != nil { + return BindingResult{}, err + } + + project.Bindings.AI = nil + wranglerCfg.AI = nil + + if err := s.store.SaveProject(options.Dir, project); err != nil { + return BindingResult{}, err + } + if err := s.store.SaveWrangler(options.Dir, project.WranglerConfig, wranglerCfg); err != nil { + return BindingResult{}, err + } + + return BindingResult{Cleared: true}, nil +} + +func loadProjectAndWrangler(dir string, store *configstore.Store) (config.Project, config.WranglerConfig, error) { + project, err := store.LoadProject(dir) + if err != nil { + return config.Project{}, config.WranglerConfig{}, err + } + wranglerCfg, err := store.LoadWrangler(dir, project.WranglerConfig) + if err != nil { + return config.Project{}, config.WranglerConfig{}, err + } + return project, wranglerCfg, nil +} diff --git a/internal/service/ai/service_test.go b/internal/service/ai/service_test.go new file mode 100644 index 0000000..8871624 --- /dev/null +++ b/internal/service/ai/service_test.go @@ -0,0 +1,85 @@ +package ai + +import ( + "context" + "path/filepath" + "testing" + + "github.com/paolo/flare-edge-cli/internal/domain/config" + "github.com/paolo/flare-edge-cli/internal/infra/configstore" + "github.com/paolo/flare-edge-cli/internal/support/fs" +) + +func TestSetAndClearBinding(t *testing.T) { + t.Parallel() + + filesystem := fs.New() + store := configstore.New(filesystem) + service := NewService(store, filesystem) + dir := t.TempDir() + + project := config.DefaultProject("ai-worker", "github.com/example/ai-worker", "main", "edge-http", config.DefaultCompatibilityDate, "") + wranglerCfg := config.WranglerConfig{ + Name: project.WorkerName, + Main: filepath.ToSlash(filepath.Join(project.OutDir, project.ShimFile)), + CompatibilityDate: project.CompatibilityDate, + } + if err := store.SaveProject(dir, project); err != nil { + t.Fatalf("save project: %v", err) + } + if err := store.SaveWrangler(dir, project.WranglerConfig, wranglerCfg); err != nil { + t.Fatalf("save wrangler: %v", err) + } + + result, err := service.SetBinding(context.Background(), SetBindingOptions{ + Dir: dir, + Binding: "AI", + Remote: true, + }) + if err != nil { + t.Fatalf("set binding: %v", err) + } + if result.Binding != "AI" || !result.Remote { + t.Fatalf("unexpected set result: %+v", result) + } + + project, err = store.LoadProject(dir) + if err != nil { + t.Fatalf("load project after set: %v", err) + } + if project.Bindings.AI == nil || project.Bindings.AI.Binding != "AI" || !project.Bindings.AI.Remote { + t.Fatalf("project ai binding not persisted: %+v", project.Bindings.AI) + } + + wranglerCfg, err = store.LoadWrangler(dir, project.WranglerConfig) + if err != nil { + t.Fatalf("load wrangler after set: %v", err) + } + if wranglerCfg.AI == nil || wranglerCfg.AI.Binding != "AI" || !wranglerCfg.AI.Remote { + t.Fatalf("wrangler ai binding not persisted: %+v", wranglerCfg.AI) + } + + clearResult, err := service.ClearBinding(context.Background(), ClearBindingOptions{Dir: dir}) + if err != nil { + t.Fatalf("clear binding: %v", err) + } + if !clearResult.Cleared { + t.Fatalf("expected cleared result, got %+v", clearResult) + } + + project, err = store.LoadProject(dir) + if err != nil { + t.Fatalf("load project after clear: %v", err) + } + if project.Bindings.AI != nil { + t.Fatalf("expected project ai binding to be cleared") + } + + wranglerCfg, err = store.LoadWrangler(dir, project.WranglerConfig) + if err != nil { + t.Fatalf("load wrangler after clear: %v", err) + } + if wranglerCfg.AI != nil { + t.Fatalf("expected wrangler ai binding to be cleared") + } +} diff --git a/internal/service/doctor/service.go b/internal/service/doctor/service.go index 20bcee6..26b711f 100644 --- a/internal/service/doctor/service.go +++ b/internal/service/doctor/service.go @@ -81,6 +81,13 @@ func (s *Service) Run(ctx context.Context, options Options) (Result, error) { } else { checks = append(checks, Check{Name: "compatibility-date", Status: "ok", Details: project.CompatibilityDate}) } + if project.Bindings.AI != nil { + if project.Bindings.AI.Remote { + checks = append(checks, Check{Name: "ai-binding", Status: "ok", Details: project.Bindings.AI.Binding + " (remote)"}) + } else { + checks = append(checks, Check{Name: "ai-binding", Status: "warning", Details: "Workers AI should use remote=true for development"}) + } + } if _, err := s.build.Wasm(ctx, buildsvc.WasmOptions{Path: options.Dir, NoShim: true, OutDir: filepath.Join(options.Dir, ".doctor-dist"), Clean: true}); err != nil { checks = append(checks, Check{Name: "wasm-build", Status: "error", Details: err.Error()}) } else { diff --git a/internal/service/project/service.go b/internal/service/project/service.go index c43cf8e..4d70be8 100644 --- a/internal/service/project/service.go +++ b/internal/service/project/service.go @@ -87,6 +87,12 @@ func (s *Service) Init(_ context.Context, options InitOptions) (InitResult, erro CompatibilityDate: project.CompatibilityDate, Observability: &config.WranglerObservability{Enabled: true}, } + if project.Bindings.AI != nil { + wrangler.AI = &config.WranglerAIBinding{ + Binding: project.Bindings.AI.Binding, + Remote: project.Bindings.AI.Remote, + } + } files := scaffoldFiles(project) for path, content := range files { diff --git a/internal/service/project/service_test.go b/internal/service/project/service_test.go new file mode 100644 index 0000000..39f094d --- /dev/null +++ b/internal/service/project/service_test.go @@ -0,0 +1,60 @@ +package project + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/paolo/flare-edge-cli/internal/infra/configstore" + "github.com/paolo/flare-edge-cli/internal/support/fs" +) + +func TestInitAITemplate(t *testing.T) { + t.Parallel() + + filesystem := fs.New() + store := configstore.New(filesystem) + service := NewService(store, filesystem) + root := t.TempDir() + + result, err := service.Init(context.Background(), InitOptions{ + Dir: root, + Name: "ai-worker", + ModulePath: "github.com/example/ai-worker", + Template: "ai-text", + WithGit: true, + }) + if err != nil { + t.Fatalf("init project: %v", err) + } + + project, err := store.LoadProject(result.ProjectDir) + if err != nil { + t.Fatalf("load project: %v", err) + } + if project.Bindings.AI == nil || project.Bindings.AI.Binding != "AI" || !project.Bindings.AI.Remote { + t.Fatalf("expected project ai binding to be scaffolded, got %+v", project.Bindings.AI) + } + + wranglerCfg, err := store.LoadWrangler(result.ProjectDir, project.WranglerConfig) + if err != nil { + t.Fatalf("load wrangler: %v", err) + } + if wranglerCfg.AI == nil || wranglerCfg.AI.Binding != "AI" || !wranglerCfg.AI.Remote { + t.Fatalf("expected wrangler ai binding to be scaffolded, got %+v", wranglerCfg.AI) + } + + workerMainPath := filepath.Join(result.ProjectDir, "cmd/worker/main.go") + workerMain, err := filesystem.ReadFile(workerMainPath) + if err != nil { + t.Fatalf("read worker main: %v", err) + } + content := string(workerMain) + if !strings.Contains(content, `defaultAIModel = "@cf/meta/llama-3.1-8b-instruct"`) { + t.Fatalf("expected ai worker template to include default model, got %s", content) + } + if !strings.Contains(content, `env.Get("AI")`) { + t.Fatalf("expected ai worker template to read env.AI") + } +} diff --git a/internal/service/project/templates.go b/internal/service/project/templates.go index 1bdae33..0cc9ed2 100644 --- a/internal/service/project/templates.go +++ b/internal/service/project/templates.go @@ -23,6 +23,10 @@ func readmeTemplate(project config.Project) string { } func workerMainTemplate(project config.Project) string { + if project.Template == "ai-text" { + return aiWorkerMainTemplate() + } + message := "hello from flare-edge-cli" switch project.Template { case "edge-json": @@ -53,6 +57,118 @@ func main() { `, message) } +func aiWorkerMainTemplate() string { + return `package main + +import "syscall/js" + +const defaultAIModel = "@cf/meta/llama-3.1-8b-instruct" + +func main() { + js.Global().Set("handleRequest", js.FuncOf(handleRequest)) + js.Global().Set("handleScheduled", js.FuncOf(func(this js.Value, args []js.Value) any { + return nil + })) + select {} +} + +func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + + url := js.Global().Get("URL").New(request.Get("url")) + prompt := url.Get("searchParams").Call("get", "prompt").String() + if prompt == "" { + prompt = "Say hello from a Go-based Cloudflare AI Worker." + } + model := url.Get("searchParams").Call("get", "model").String() + if model == "" { + model = defaultAIModel + } + + input := js.Global().Get("Object").New() + input.Set("prompt", prompt) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + + payload := "null" + if len(args) > 0 { + payload = stringify(args[0]) + } + resolve.Invoke(jsonResponse(payload)) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + + message := "Workers AI request failed" + if len(args) > 0 { + message = stringify(args[0]) + } + resolve.Invoke(textResponse(502, message)) + return nil + }) + + ai.Call("run", model, input).Call("then", onResolve).Call("catch", onReject) + }) +} + +func promise(fn func(resolve js.Value, reject js.Value)) js.Value { + executor := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) >= 2 { + fn(args[0], args[1]) + } + return nil + }) + defer executor.Release() + + return js.Global().Get("Promise").New(executor) +} + +func stringify(value js.Value) string { + output := js.Global().Get("JSON").Call("stringify", value) + if output.IsUndefined() || output.IsNull() { + return "null" + } + return output.String() +} + +func jsonResponse(body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "application/json") + options := js.Global().Get("Object").New() + options.Set("status", 200) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} + +func textResponse(status int, body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "text/plain; charset=utf-8") + options := js.Global().Get("Object").New() + options.Set("status", status) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} +` +} + func workerShimTemplate(wasmFile string) string { return fmt.Sprintf(`import "./wasm_exec.js"; import wasmModule from "./%s"; diff --git a/internal/service/teardown/service.go b/internal/service/teardown/service.go index 390fb5c..2af0c62 100644 --- a/internal/service/teardown/service.go +++ b/internal/service/teardown/service.go @@ -247,9 +247,11 @@ func resetLocalState(project config.Project, wranglerCfg config.WranglerConfig, if keepBindings { return project, wranglerCfg } + project.Bindings.AI = nil project.Bindings.KV = nil project.Bindings.D1 = nil project.Bindings.R2 = nil + wranglerCfg.AI = nil wranglerCfg.KVNamespaces = nil wranglerCfg.D1Databases = nil wranglerCfg.R2Buckets = nil diff --git a/internal/service/teardown/service_test.go b/internal/service/teardown/service_test.go index 7bae275..a79ec54 100644 --- a/internal/service/teardown/service_test.go +++ b/internal/service/teardown/service_test.go @@ -14,12 +14,14 @@ func TestResetLocalState(t *testing.T) { Bindings: config.ProjectBindings{ Vars: map[string]string{"KEEP": "1"}, Secrets: []string{"SECRET"}, + AI: &config.AIBinding{Binding: "AI", Remote: true}, KV: []config.KVBinding{{Binding: "KV", ID: "kv-id"}}, D1: []config.D1Binding{{Binding: "DB", DatabaseName: "db"}}, R2: []config.R2Binding{{Binding: "BUCKET", BucketName: "bucket"}}, }, } wrangler := config.WranglerConfig{ + AI: &config.WranglerAIBinding{Binding: "AI", Remote: true}, Routes: []config.WranglerRoute{{Pattern: "example.com/*"}}, KVNamespaces: []config.WranglerKVNamespace{{Binding: "KV", ID: "kv-id"}}, D1Databases: []config.WranglerD1Database{{Binding: "DB", DatabaseName: "db"}}, @@ -33,10 +35,13 @@ func TestResetLocalState(t *testing.T) { if len(cleanProject.Bindings.Secrets) != 0 { t.Fatalf("expected secrets to be cleared") } + if cleanProject.Bindings.AI != nil { + t.Fatalf("expected ai binding to be cleared") + } if got := cleanProject.Bindings.Vars["KEEP"]; got != "1" { t.Fatalf("expected vars to be preserved, got %q", got) } - if len(cleanWrangler.Routes) != 0 || len(cleanWrangler.KVNamespaces) != 0 || len(cleanWrangler.D1Databases) != 0 || len(cleanWrangler.R2Buckets) != 0 { + if cleanWrangler.AI != nil || len(cleanWrangler.Routes) != 0 || len(cleanWrangler.KVNamespaces) != 0 || len(cleanWrangler.D1Databases) != 0 || len(cleanWrangler.R2Buckets) != 0 { t.Fatalf("expected wrangler resources to be cleared") } @@ -47,9 +52,15 @@ func TestResetLocalState(t *testing.T) { if len(keptProject.Bindings.Secrets) != 0 { t.Fatalf("expected secrets to be cleared even when keeping bindings") } + if keptProject.Bindings.AI == nil { + t.Fatalf("expected ai binding to be preserved when keep-bindings is true") + } if len(keptWrangler.Routes) != 0 { t.Fatalf("expected routes to be cleared") } + if keptWrangler.AI == nil { + t.Fatalf("expected ai binding to be preserved when keep-bindings is true") + } if len(keptWrangler.KVNamespaces) != 1 || len(keptWrangler.D1Databases) != 1 || len(keptWrangler.R2Buckets) != 1 { t.Fatalf("expected wrangler bindings to be preserved when keep-bindings is true") } From 19981553035917d03ceb54f2ff5047a7cd87c63e Mon Sep 17 00:00:00 2001 From: paoloanzn Date: Sun, 22 Mar 2026 22:25:19 +0100 Subject: [PATCH 2/4] fix: handle missing ai query params --- internal/service/project/templates.go | 73 +++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/internal/service/project/templates.go b/internal/service/project/templates.go index 0cc9ed2..01ffd90 100644 --- a/internal/service/project/templates.go +++ b/internal/service/project/templates.go @@ -85,11 +85,11 @@ func handleRequest(this js.Value, args []js.Value) any { } url := js.Global().Get("URL").New(request.Get("url")) - prompt := url.Get("searchParams").Call("get", "prompt").String() + prompt := searchParam(url, "prompt") if prompt == "" { prompt = "Say hello from a Go-based Cloudflare AI Worker." } - model := url.Get("searchParams").Call("get", "model").String() + model := searchParam(url, "model") if model == "" { model = defaultAIModel } @@ -118,10 +118,14 @@ func handleRequest(this js.Value, args []js.Value) any { defer onReject.Release() message := "Workers AI request failed" + status := 502 if len(args) > 0 { - message = stringify(args[0]) + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } } - resolve.Invoke(textResponse(502, message)) + resolve.Invoke(jsonTextResponse(status, message)) return nil }) @@ -149,6 +153,58 @@ func stringify(value js.Value) string { return output.String() } +func errorPayload(value js.Value) string { + payload := js.Global().Get("Object").New() + payload.Set("name", value.Get("name")) + payload.Set("message", value.Get("message")) + payload.Set("code", firstDefined(value, "code", "status")) + payload.Set("internalCode", firstDefined(value, "internalCode", "internal_code")) + + cause := value.Get("cause") + if !cause.IsUndefined() && !cause.IsNull() { + causePayload := js.Global().Get("Object").New() + causePayload.Set("name", cause.Get("name")) + causePayload.Set("message", cause.Get("message")) + causePayload.Set("code", firstDefined(cause, "code", "status")) + causePayload.Set("internalCode", firstDefined(cause, "internalCode", "internal_code")) + payload.Set("cause", causePayload) + } + + text := stringify(payload) + if text == "{}" || text == "null" { + return "{\"message\":\"Workers AI request failed\"}" + } + return text +} + +func errorStatus(value js.Value) int { + for _, key := range []string{"status", "statusCode", "code"} { + field := value.Get(key) + if !field.IsUndefined() && !field.IsNull() && field.Type() == js.TypeNumber { + return field.Int() + } + } + return 0 +} + +func firstDefined(value js.Value, keys ...string) js.Value { + for _, key := range keys { + field := value.Get(key) + if !field.IsUndefined() && !field.IsNull() { + return field + } + } + return js.Null() +} + +func searchParam(url js.Value, key string) string { + value := url.Get("searchParams").Call("get", key) + if value.IsUndefined() || value.IsNull() { + return "" + } + return value.String() +} + func jsonResponse(body string) js.Value { headers := js.Global().Get("Headers").New() headers.Call("set", "content-type", "application/json") @@ -158,6 +214,15 @@ func jsonResponse(body string) js.Value { return js.Global().Get("Response").New(body, options) } +func jsonTextResponse(status int, body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "application/json") + options := js.Global().Get("Object").New() + options.Set("status", status) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} + func textResponse(status int, body string) js.Value { headers := js.Global().Get("Headers").New() headers.Call("set", "content-type", "text/plain; charset=utf-8") From 6b4c504b155513107336c16b83dbfceb5d4a878c Mon Sep 17 00:00:00 2001 From: paoloanzn Date: Sun, 22 Mar 2026 22:41:27 +0100 Subject: [PATCH 3/4] feat: add ai worker templates --- README.md | 28 +- internal/cli/aliases.go | 2 +- internal/cli/project.go | 2 +- internal/domain/config/project.go | 11 +- internal/service/project/service_test.go | 103 ++- internal/service/project/templates.go | 942 ++++++++++++++++++++++- 6 files changed, 1037 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index c8be534..5bb19a5 100644 --- a/README.md +++ b/README.md @@ -237,11 +237,25 @@ Supported templates: - `edge-json` - `scheduled` - `ai-text` +- `ai-chat` +- `ai-vision` +- `ai-stt` +- `ai-tts` +- `ai-image` +- `ai-embeddings` - `kv-api` - `d1-api` - `r2-api` -`ai-text` scaffolds a Go/Wasm Worker that reads `env.AI` through `syscall/js`, calls a default Workers AI model, and returns the model response as JSON. +AI templates: + +- `ai-text`: backward-compatible simple text inference scaffold, now using `@cf/moonshotai/kimi-k2.5` +- `ai-chat`: chat-style text generation scaffold using `@cf/moonshotai/kimi-k2.5` +- `ai-vision`: vision scaffold using `@cf/moonshotai/kimi-k2.5` +- `ai-stt`: speech-to-text scaffold using `@cf/deepgram/nova-3` +- `ai-tts`: text-to-speech scaffold using `@cf/deepgram/aura-2-en` +- `ai-image`: text-to-image scaffold using `@cf/black-forest-labs/flux-2-klein-9b` +- `ai-embeddings`: embeddings scaffold using `@cf/qwen/qwen3-embedding-0.6b` ### `ai` @@ -452,6 +466,18 @@ Scaffold a Go-based AI Worker: ./flare-edge-cli init ai-worker --module-path github.com/example/ai-worker --template ai-text ``` +Scaffold a vision Worker: + +```bash +./flare-edge-cli init ai-vision-worker --module-path github.com/example/ai-vision-worker --template ai-vision +``` + +Scaffold a speech-to-text Worker: + +```bash +./flare-edge-cli init ai-stt-worker --module-path github.com/example/ai-stt-worker --template ai-stt +``` + Add Workers AI to an existing project: ```bash diff --git a/internal/cli/aliases.go b/internal/cli/aliases.go index 7d065f5..0e1096b 100644 --- a/internal/cli/aliases.go +++ b/internal/cli/aliases.go @@ -30,7 +30,7 @@ func newInitAliasCommand(deps Dependencies) *cobra.Command { cmd.Flags().StringVar(&options.Dir, "cwd", ".", "Target parent directory") cmd.Flags().StringVar(&options.ModulePath, "module-path", "", "Go module path") cmd.Flags().StringVar(&options.PackageName, "package", "", "Package name") - cmd.Flags().StringVar(&options.Template, "template", "edge-http", "Starter template: edge-http|edge-json|scheduled|kv-api|d1-api|r2-api|ai-text") + cmd.Flags().StringVar(&options.Template, "template", "edge-http", "Starter template: edge-http|edge-json|scheduled|kv-api|d1-api|r2-api|ai-text|ai-chat|ai-vision|ai-stt|ai-tts|ai-image|ai-embeddings") cmd.Flags().StringVar(&options.CompatDate, "compat-date", "", "Compatibility date") cmd.Flags().StringVar(&options.Env, "env", "", "Wrangler environment") cmd.Flags().BoolVar(&options.UseJSONC, "use-jsonc", false, "Generate wrangler.jsonc") diff --git a/internal/cli/project.go b/internal/cli/project.go index d3bd311..706d56c 100644 --- a/internal/cli/project.go +++ b/internal/cli/project.go @@ -41,7 +41,7 @@ func newProjectInitCommand(deps Dependencies) *cobra.Command { cmd.Flags().StringVar(&options.ModulePath, "module-path", "", "Go module path to initialize") cmd.Flags().StringVar(&options.PackageName, "package", "", "Package name for generated Go entrypoint") - cmd.Flags().StringVar(&options.Template, "template", "edge-http", "Starter template: edge-http|edge-json|scheduled|kv-api|d1-api|r2-api|ai-text") + cmd.Flags().StringVar(&options.Template, "template", "edge-http", "Starter template: edge-http|edge-json|scheduled|kv-api|d1-api|r2-api|ai-text|ai-chat|ai-vision|ai-stt|ai-tts|ai-image|ai-embeddings") cmd.Flags().StringVar(&options.CompatDate, "compat-date", "", "Cloudflare compatibility date") cmd.Flags().StringVar(&options.Env, "env", "", "Default Wrangler environment") cmd.Flags().BoolVar(&options.UseJSONC, "use-jsonc", false, "Generate wrangler.jsonc output") diff --git a/internal/domain/config/project.go b/internal/domain/config/project.go index dd0f660..8cc70c8 100644 --- a/internal/domain/config/project.go +++ b/internal/domain/config/project.go @@ -102,7 +102,7 @@ func DefaultProject(name, modulePath, packageName, template, compatDate, env str WasmExecSource: "internal/generated/wasm_exec.js", }, } - if template == "ai-text" { + if UsesAIBinding(template) { project.Bindings.AI = &AIBinding{Binding: "AI", Remote: true} } return project @@ -111,3 +111,12 @@ func DefaultProject(name, modulePath, packageName, template, compatDate, env str func (p Project) ArtifactPath() string { return fmt.Sprintf("%s/%s", p.OutDir, p.WasmFile) } + +func UsesAIBinding(template string) bool { + switch template { + case "ai-text", "ai-chat", "ai-vision", "ai-stt", "ai-tts", "ai-image", "ai-embeddings": + return true + default: + return false + } +} diff --git a/internal/service/project/service_test.go b/internal/service/project/service_test.go index 39f094d..86edd5c 100644 --- a/internal/service/project/service_test.go +++ b/internal/service/project/service_test.go @@ -13,48 +13,73 @@ import ( func TestInitAITemplate(t *testing.T) { t.Parallel() - filesystem := fs.New() - store := configstore.New(filesystem) - service := NewService(store, filesystem) - root := t.TempDir() - - result, err := service.Init(context.Background(), InitOptions{ - Dir: root, - Name: "ai-worker", - ModulePath: "github.com/example/ai-worker", - Template: "ai-text", - WithGit: true, - }) - if err != nil { - t.Fatalf("init project: %v", err) + tests := []struct { + name string + template string + model string + marker string + }{ + {name: "text", template: "ai-text", model: `defaultAIModel = "@cf/moonshotai/kimi-k2.5"`, marker: `messages.Call("push", user)`}, + {name: "chat", template: "ai-chat", model: `defaultAIModel = "@cf/moonshotai/kimi-k2.5"`, marker: `messages.Call("push", user)`}, + {name: "vision", template: "ai-vision", model: `defaultAIModel = "@cf/moonshotai/kimi-k2.5"`, marker: `image_url query parameter is required`}, + {name: "stt", template: "ai-stt", model: `defaultAIModel = "@cf/deepgram/nova-3"`, marker: `send audio bytes in the request body`}, + {name: "tts", template: "ai-tts", model: `defaultAIModel = "@cf/deepgram/aura-2-en"`, marker: `input.Set("speaker", speaker)`}, + {name: "image", template: "ai-image", model: `defaultAIModel = "@cf/black-forest-labs/flux-2-klein-9b"`, marker: `form.Call("append", "prompt", prompt)`}, + {name: "embeddings", template: "ai-embeddings", model: `defaultAIModel = "@cf/qwen/qwen3-embedding-0.6b"`, marker: `input.Set("text", text)`}, } - project, err := store.LoadProject(result.ProjectDir) - if err != nil { - t.Fatalf("load project: %v", err) - } - if project.Bindings.AI == nil || project.Bindings.AI.Binding != "AI" || !project.Bindings.AI.Remote { - t.Fatalf("expected project ai binding to be scaffolded, got %+v", project.Bindings.AI) - } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() - wranglerCfg, err := store.LoadWrangler(result.ProjectDir, project.WranglerConfig) - if err != nil { - t.Fatalf("load wrangler: %v", err) - } - if wranglerCfg.AI == nil || wranglerCfg.AI.Binding != "AI" || !wranglerCfg.AI.Remote { - t.Fatalf("expected wrangler ai binding to be scaffolded, got %+v", wranglerCfg.AI) - } + filesystem := fs.New() + store := configstore.New(filesystem) + service := NewService(store, filesystem) + root := t.TempDir() - workerMainPath := filepath.Join(result.ProjectDir, "cmd/worker/main.go") - workerMain, err := filesystem.ReadFile(workerMainPath) - if err != nil { - t.Fatalf("read worker main: %v", err) - } - content := string(workerMain) - if !strings.Contains(content, `defaultAIModel = "@cf/meta/llama-3.1-8b-instruct"`) { - t.Fatalf("expected ai worker template to include default model, got %s", content) - } - if !strings.Contains(content, `env.Get("AI")`) { - t.Fatalf("expected ai worker template to read env.AI") + result, err := service.Init(context.Background(), InitOptions{ + Dir: root, + Name: "ai-worker", + ModulePath: "github.com/example/ai-worker", + Template: tt.template, + WithGit: true, + }) + if err != nil { + t.Fatalf("init project: %v", err) + } + + project, err := store.LoadProject(result.ProjectDir) + if err != nil { + t.Fatalf("load project: %v", err) + } + if project.Bindings.AI == nil || project.Bindings.AI.Binding != "AI" || !project.Bindings.AI.Remote { + t.Fatalf("expected project ai binding to be scaffolded, got %+v", project.Bindings.AI) + } + + wranglerCfg, err := store.LoadWrangler(result.ProjectDir, project.WranglerConfig) + if err != nil { + t.Fatalf("load wrangler: %v", err) + } + if wranglerCfg.AI == nil || wranglerCfg.AI.Binding != "AI" || !wranglerCfg.AI.Remote { + t.Fatalf("expected wrangler ai binding to be scaffolded, got %+v", wranglerCfg.AI) + } + + workerMainPath := filepath.Join(result.ProjectDir, "cmd/worker/main.go") + workerMain, err := filesystem.ReadFile(workerMainPath) + if err != nil { + t.Fatalf("read worker main: %v", err) + } + content := string(workerMain) + if !strings.Contains(content, tt.model) { + t.Fatalf("expected ai worker template to include default model %s, got %s", tt.model, content) + } + if !strings.Contains(content, `env.Get("AI")`) { + t.Fatalf("expected ai worker template to read env.AI") + } + if !strings.Contains(content, tt.marker) { + t.Fatalf("expected ai worker template to include marker %s", tt.marker) + } + }) } } diff --git a/internal/service/project/templates.go b/internal/service/project/templates.go index 01ffd90..f9e85ae 100644 --- a/internal/service/project/templates.go +++ b/internal/service/project/templates.go @@ -23,12 +23,20 @@ func readmeTemplate(project config.Project) string { } func workerMainTemplate(project config.Project) string { - if project.Template == "ai-text" { - return aiWorkerMainTemplate() - } - message := "hello from flare-edge-cli" switch project.Template { + case "ai-text", "ai-chat": + return aiChatWorkerMainTemplate() + case "ai-vision": + return aiVisionWorkerMainTemplate() + case "ai-stt": + return aiSTTWorkerMainTemplate() + case "ai-tts": + return aiTTSWorkerMainTemplate() + case "ai-image": + return aiImageWorkerMainTemplate() + case "ai-embeddings": + return aiEmbeddingsWorkerMainTemplate() case "edge-json": message = "{\"ok\":true}" case "scheduled": @@ -54,15 +62,15 @@ func main() { })) select {} } -`, message) + `, message) } -func aiWorkerMainTemplate() string { +func aiChatWorkerMainTemplate() string { return `package main import "syscall/js" -const defaultAIModel = "@cf/meta/llama-3.1-8b-instruct" +const defaultAIModel = "@cf/moonshotai/kimi-k2.5" func main() { js.Global().Set("handleRequest", js.FuncOf(handleRequest)) @@ -95,7 +103,213 @@ func handleRequest(this js.Value, args []js.Value) any { } input := js.Global().Get("Object").New() - input.Set("prompt", prompt) + messages := js.Global().Get("Array").New() + user := js.Global().Get("Object").New() + user.Set("role", "user") + user.Set("content", prompt) + messages.Call("push", user) + input.Set("messages", messages) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + + payload := "null" + if len(args) > 0 { + payload = stringify(args[0]) + } + resolve.Invoke(jsonResponse(payload)) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + + message := "Workers AI request failed" + status := 502 + if len(args) > 0 { + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } + } + resolve.Invoke(jsonTextResponse(status, message)) + return nil + }) + + ai.Call("run", model, input).Call("then", onResolve).Call("catch", onReject) + }) +} + +func promise(fn func(resolve js.Value, reject js.Value)) js.Value { + executor := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) >= 2 { + fn(args[0], args[1]) + } + return nil + }) + defer executor.Release() + + return js.Global().Get("Promise").New(executor) +} + +func stringify(value js.Value) string { + output := js.Global().Get("JSON").Call("stringify", value) + if output.IsUndefined() || output.IsNull() { + return "null" + } + return output.String() +} + +func errorPayload(value js.Value) string { + payload := js.Global().Get("Object").New() + payload.Set("name", value.Get("name")) + payload.Set("message", value.Get("message")) + payload.Set("code", firstDefined(value, "code", "status")) + payload.Set("internalCode", firstDefined(value, "internalCode", "internal_code")) + + cause := value.Get("cause") + if !cause.IsUndefined() && !cause.IsNull() { + causePayload := js.Global().Get("Object").New() + causePayload.Set("name", cause.Get("name")) + causePayload.Set("message", cause.Get("message")) + causePayload.Set("code", firstDefined(cause, "code", "status")) + causePayload.Set("internalCode", firstDefined(cause, "internalCode", "internal_code")) + payload.Set("cause", causePayload) + } + + text := stringify(payload) + if text == "{}" || text == "null" { + return "{\"message\":\"Workers AI request failed\"}" + } + return text +} + +func errorStatus(value js.Value) int { + for _, key := range []string{"status", "statusCode", "code"} { + field := value.Get(key) + if !field.IsUndefined() && !field.IsNull() && field.Type() == js.TypeNumber { + return field.Int() + } + } + return 0 +} + +func firstDefined(value js.Value, keys ...string) js.Value { + for _, key := range keys { + field := value.Get(key) + if !field.IsUndefined() && !field.IsNull() { + return field + } + } + return js.Null() +} + +func searchParam(url js.Value, key string) string { + value := url.Get("searchParams").Call("get", key) + if value.IsUndefined() || value.IsNull() { + return "" + } + return value.String() +} + +func jsonResponse(body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "application/json") + options := js.Global().Get("Object").New() + options.Set("status", 200) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} + +func jsonTextResponse(status int, body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "application/json") + options := js.Global().Get("Object").New() + options.Set("status", status) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} + +func textResponse(status int, body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "text/plain; charset=utf-8") + options := js.Global().Get("Object").New() + options.Set("status", status) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} +` +} + +func aiVisionWorkerMainTemplate() string { + return `package main + +import "syscall/js" + +const defaultAIModel = "@cf/moonshotai/kimi-k2.5" + +func main() { + js.Global().Set("handleRequest", js.FuncOf(handleRequest)) + js.Global().Set("handleScheduled", js.FuncOf(func(this js.Value, args []js.Value) any { + return nil + })) + select {} +} + +func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + + url := js.Global().Get("URL").New(request.Get("url")) + prompt := searchParam(url, "prompt") + if prompt == "" { + prompt = "Describe this image." + } + imageURL := searchParam(url, "image_url") + if imageURL == "" { + return jsonTextResponse(400, "{\"message\":\"image_url query parameter is required\"}") + } + model := searchParam(url, "model") + if model == "" { + model = defaultAIModel + } + + content := js.Global().Get("Array").New() + + textPart := js.Global().Get("Object").New() + textPart.Set("type", "text") + textPart.Set("text", prompt) + content.Call("push", textPart) + + imagePart := js.Global().Get("Object").New() + imagePart.Set("type", "image_url") + imageURLPayload := js.Global().Get("Object").New() + imageURLPayload.Set("url", imageURL) + imagePart.Set("image_url", imageURLPayload) + content.Call("push", imagePart) + + messages := js.Global().Get("Array").New() + user := js.Global().Get("Object").New() + user.Set("role", "user") + user.Set("content", content) + messages.Call("push", user) + + input := js.Global().Get("Object").New() + input.Set("messages", messages) return promise(func(resolve, reject js.Value) { var onResolve js.Func @@ -234,6 +448,718 @@ func textResponse(status int, body string) js.Value { ` } +func aiSTTWorkerMainTemplate() string { + return `package main + +import "syscall/js" + +const defaultAIModel = "@cf/deepgram/nova-3" + +func main() { + js.Global().Set("handleRequest", js.FuncOf(handleRequest)) + js.Global().Set("handleScheduled", js.FuncOf(func(this js.Value, args []js.Value) any { + return nil + })) + select {} +} + +func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + if request.Get("body").IsUndefined() || request.Get("body").IsNull() { + return jsonTextResponse(400, "{\"message\":\"send audio bytes in the request body\"}") + } + + url := js.Global().Get("URL").New(request.Get("url")) + model := searchParam(url, "model") + if model == "" { + model = defaultAIModel + } + + contentType := request.Get("headers").Call("get", "content-type") + if contentType.IsUndefined() || contentType.IsNull() || contentType.String() == "" { + contentType = js.ValueOf("audio/mpeg") + } + + audio := js.Global().Get("Object").New() + audio.Set("body", request.Get("body")) + audio.Set("contentType", contentType) + + input := js.Global().Get("Object").New() + input.Set("audio", audio) + input.Set("detect_language", true) + + options := js.Global().Get("Object").New() + options.Set("returnRawResponse", true) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + if len(args) > 0 { + resolve.Invoke(args[0]) + return nil + } + resolve.Invoke(jsonTextResponse(502, "{\"message\":\"Workers AI returned no response\"}")) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + message := "Workers AI request failed" + status := 502 + if len(args) > 0 { + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } + } + resolve.Invoke(jsonTextResponse(status, message)) + return nil + }) + + ai.Call("run", model, input, options).Call("then", onResolve).Call("catch", onReject) + }) +} + +func promise(fn func(resolve js.Value, reject js.Value)) js.Value { + executor := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) >= 2 { + fn(args[0], args[1]) + } + return nil + }) + defer executor.Release() + + return js.Global().Get("Promise").New(executor) +} + +func stringify(value js.Value) string { + output := js.Global().Get("JSON").Call("stringify", value) + if output.IsUndefined() || output.IsNull() { + return "null" + } + return output.String() +} + +func errorPayload(value js.Value) string { + payload := js.Global().Get("Object").New() + payload.Set("name", value.Get("name")) + payload.Set("message", value.Get("message")) + payload.Set("code", firstDefined(value, "code", "status")) + payload.Set("internalCode", firstDefined(value, "internalCode", "internal_code")) + + cause := value.Get("cause") + if !cause.IsUndefined() && !cause.IsNull() { + causePayload := js.Global().Get("Object").New() + causePayload.Set("name", cause.Get("name")) + causePayload.Set("message", cause.Get("message")) + causePayload.Set("code", firstDefined(cause, "code", "status")) + causePayload.Set("internalCode", firstDefined(cause, "internalCode", "internal_code")) + payload.Set("cause", causePayload) + } + + text := stringify(payload) + if text == "{}" || text == "null" { + return "{\"message\":\"Workers AI request failed\"}" + } + return text +} + +func errorStatus(value js.Value) int { + for _, key := range []string{"status", "statusCode", "code"} { + field := value.Get(key) + if !field.IsUndefined() && !field.IsNull() && field.Type() == js.TypeNumber { + return field.Int() + } + } + return 0 +} + +func firstDefined(value js.Value, keys ...string) js.Value { + for _, key := range keys { + field := value.Get(key) + if !field.IsUndefined() && !field.IsNull() { + return field + } + } + return js.Null() +} + +func searchParam(url js.Value, key string) string { + value := url.Get("searchParams").Call("get", key) + if value.IsUndefined() || value.IsNull() { + return "" + } + return value.String() +} + +func jsonTextResponse(status int, body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "application/json") + options := js.Global().Get("Object").New() + options.Set("status", status) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} + +func textResponse(status int, body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "text/plain; charset=utf-8") + options := js.Global().Get("Object").New() + options.Set("status", status) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} +` +} + +func aiTTSWorkerMainTemplate() string { + return `package main + +import "syscall/js" + +const defaultAIModel = "@cf/deepgram/aura-2-en" + +func main() { + js.Global().Set("handleRequest", js.FuncOf(handleRequest)) + js.Global().Set("handleScheduled", js.FuncOf(func(this js.Value, args []js.Value) any { + return nil + })) + select {} +} + +func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + + url := js.Global().Get("URL").New(request.Get("url")) + text := searchParam(url, "text") + if text == "" { + text = "Hello from a Go-based Cloudflare text to speech worker." + } + model := searchParam(url, "model") + if model == "" { + model = defaultAIModel + } + speaker := searchParam(url, "speaker") + if speaker == "" { + speaker = "luna" + } + + input := js.Global().Get("Object").New() + input.Set("text", text) + input.Set("speaker", speaker) + + options := js.Global().Get("Object").New() + options.Set("returnRawResponse", true) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + if len(args) > 0 { + resolve.Invoke(args[0]) + return nil + } + resolve.Invoke(jsonTextResponse(502, "{\"message\":\"Workers AI returned no response\"}")) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + message := "Workers AI request failed" + status := 502 + if len(args) > 0 { + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } + } + resolve.Invoke(jsonTextResponse(status, message)) + return nil + }) + + ai.Call("run", model, input, options).Call("then", onResolve).Call("catch", onReject) + }) +} + +func promise(fn func(resolve js.Value, reject js.Value)) js.Value { + executor := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) >= 2 { + fn(args[0], args[1]) + } + return nil + }) + defer executor.Release() + + return js.Global().Get("Promise").New(executor) +} + +func stringify(value js.Value) string { + output := js.Global().Get("JSON").Call("stringify", value) + if output.IsUndefined() || output.IsNull() { + return "null" + } + return output.String() +} + +func errorPayload(value js.Value) string { + payload := js.Global().Get("Object").New() + payload.Set("name", value.Get("name")) + payload.Set("message", value.Get("message")) + payload.Set("code", firstDefined(value, "code", "status")) + payload.Set("internalCode", firstDefined(value, "internalCode", "internal_code")) + + cause := value.Get("cause") + if !cause.IsUndefined() && !cause.IsNull() { + causePayload := js.Global().Get("Object").New() + causePayload.Set("name", cause.Get("name")) + causePayload.Set("message", cause.Get("message")) + causePayload.Set("code", firstDefined(cause, "code", "status")) + causePayload.Set("internalCode", firstDefined(cause, "internalCode", "internal_code")) + payload.Set("cause", causePayload) + } + + text := stringify(payload) + if text == "{}" || text == "null" { + return "{\"message\":\"Workers AI request failed\"}" + } + return text +} + +func errorStatus(value js.Value) int { + for _, key := range []string{"status", "statusCode", "code"} { + field := value.Get(key) + if !field.IsUndefined() && !field.IsNull() && field.Type() == js.TypeNumber { + return field.Int() + } + } + return 0 +} + +func firstDefined(value js.Value, keys ...string) js.Value { + for _, key := range keys { + field := value.Get(key) + if !field.IsUndefined() && !field.IsNull() { + return field + } + } + return js.Null() +} + +func searchParam(url js.Value, key string) string { + value := url.Get("searchParams").Call("get", key) + if value.IsUndefined() || value.IsNull() { + return "" + } + return value.String() +} + +func jsonTextResponse(status int, body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "application/json") + options := js.Global().Get("Object").New() + options.Set("status", status) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} + +func textResponse(status int, body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "text/plain; charset=utf-8") + options := js.Global().Get("Object").New() + options.Set("status", status) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} +` +} + +func aiImageWorkerMainTemplate() string { + return `package main + +import "syscall/js" + +const defaultAIModel = "@cf/black-forest-labs/flux-2-klein-9b" + +func main() { + js.Global().Set("handleRequest", js.FuncOf(handleRequest)) + js.Global().Set("handleScheduled", js.FuncOf(func(this js.Value, args []js.Value) any { + return nil + })) + select {} +} + +func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + + url := js.Global().Get("URL").New(request.Get("url")) + prompt := searchParam(url, "prompt") + if prompt == "" { + prompt = "A dramatic cloudscape over the Alps at sunset." + } + model := searchParam(url, "model") + if model == "" { + model = defaultAIModel + } + + form := js.Global().Get("FormData").New() + form.Call("append", "prompt", prompt) + form.Call("append", "width", "1024") + form.Call("append", "height", "1024") + + formResponse := js.Global().Get("Response").New(form) + multipart := js.Global().Get("Object").New() + multipart.Set("body", formResponse.Get("body")) + multipart.Set("contentType", formResponse.Get("headers").Call("get", "content-type")) + + input := js.Global().Get("Object").New() + input.Set("multipart", multipart) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + payload := "null" + if len(args) > 0 { + payload = stringify(args[0]) + } + resolve.Invoke(jsonResponse(payload)) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + message := "Workers AI request failed" + status := 502 + if len(args) > 0 { + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } + } + resolve.Invoke(jsonTextResponse(status, message)) + return nil + }) + + ai.Call("run", model, input).Call("then", onResolve).Call("catch", onReject) + }) +} + +func promise(fn func(resolve js.Value, reject js.Value)) js.Value { + executor := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) >= 2 { + fn(args[0], args[1]) + } + return nil + }) + defer executor.Release() + + return js.Global().Get("Promise").New(executor) +} + +func stringify(value js.Value) string { + output := js.Global().Get("JSON").Call("stringify", value) + if output.IsUndefined() || output.IsNull() { + return "null" + } + return output.String() +} + +func errorPayload(value js.Value) string { + payload := js.Global().Get("Object").New() + payload.Set("name", value.Get("name")) + payload.Set("message", value.Get("message")) + payload.Set("code", firstDefined(value, "code", "status")) + payload.Set("internalCode", firstDefined(value, "internalCode", "internal_code")) + + cause := value.Get("cause") + if !cause.IsUndefined() && !cause.IsNull() { + causePayload := js.Global().Get("Object").New() + causePayload.Set("name", cause.Get("name")) + causePayload.Set("message", cause.Get("message")) + causePayload.Set("code", firstDefined(cause, "code", "status")) + causePayload.Set("internalCode", firstDefined(cause, "internalCode", "internal_code")) + payload.Set("cause", causePayload) + } + + text := stringify(payload) + if text == "{}" || text == "null" { + return "{\"message\":\"Workers AI request failed\"}" + } + return text +} + +func errorStatus(value js.Value) int { + for _, key := range []string{"status", "statusCode", "code"} { + field := value.Get(key) + if !field.IsUndefined() && !field.IsNull() && field.Type() == js.TypeNumber { + return field.Int() + } + } + return 0 +} + +func firstDefined(value js.Value, keys ...string) js.Value { + for _, key := range keys { + field := value.Get(key) + if !field.IsUndefined() && !field.IsNull() { + return field + } + } + return js.Null() +} + +func searchParam(url js.Value, key string) string { + value := url.Get("searchParams").Call("get", key) + if value.IsUndefined() || value.IsNull() { + return "" + } + return value.String() +} + +func jsonResponse(body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "application/json") + options := js.Global().Get("Object").New() + options.Set("status", 200) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} + +func jsonTextResponse(status int, body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "application/json") + options := js.Global().Get("Object").New() + options.Set("status", status) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} + +func textResponse(status int, body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "text/plain; charset=utf-8") + options := js.Global().Get("Object").New() + options.Set("status", status) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} +` +} + +func aiEmbeddingsWorkerMainTemplate() string { + return `package main + +import "syscall/js" + +const defaultAIModel = "@cf/qwen/qwen3-embedding-0.6b" + +func main() { + js.Global().Set("handleRequest", js.FuncOf(handleRequest)) + js.Global().Set("handleScheduled", js.FuncOf(func(this js.Value, args []js.Value) any { + return nil + })) + select {} +} + +func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + + url := js.Global().Get("URL").New(request.Get("url")) + text := searchParam(url, "text") + if text == "" { + text = "Cloudflare Workers AI embeddings from Go." + } + model := searchParam(url, "model") + if model == "" { + model = defaultAIModel + } + + input := js.Global().Get("Object").New() + input.Set("text", text) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + payload := "null" + if len(args) > 0 { + payload = stringify(args[0]) + } + resolve.Invoke(jsonResponse(payload)) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + message := "Workers AI request failed" + status := 502 + if len(args) > 0 { + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } + } + resolve.Invoke(jsonTextResponse(status, message)) + return nil + }) + + ai.Call("run", model, input).Call("then", onResolve).Call("catch", onReject) + }) +} + +func promise(fn func(resolve js.Value, reject js.Value)) js.Value { + executor := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) >= 2 { + fn(args[0], args[1]) + } + return nil + }) + defer executor.Release() + + return js.Global().Get("Promise").New(executor) +} + +func stringify(value js.Value) string { + output := js.Global().Get("JSON").Call("stringify", value) + if output.IsUndefined() || output.IsNull() { + return "null" + } + return output.String() +} + +func errorPayload(value js.Value) string { + payload := js.Global().Get("Object").New() + payload.Set("name", value.Get("name")) + payload.Set("message", value.Get("message")) + payload.Set("code", firstDefined(value, "code", "status")) + payload.Set("internalCode", firstDefined(value, "internalCode", "internal_code")) + + cause := value.Get("cause") + if !cause.IsUndefined() && !cause.IsNull() { + causePayload := js.Global().Get("Object").New() + causePayload.Set("name", cause.Get("name")) + causePayload.Set("message", cause.Get("message")) + causePayload.Set("code", firstDefined(cause, "code", "status")) + causePayload.Set("internalCode", firstDefined(cause, "internalCode", "internal_code")) + payload.Set("cause", causePayload) + } + + text := stringify(payload) + if text == "{}" || text == "null" { + return "{\"message\":\"Workers AI request failed\"}" + } + return text +} + +func errorStatus(value js.Value) int { + for _, key := range []string{"status", "statusCode", "code"} { + field := value.Get(key) + if !field.IsUndefined() && !field.IsNull() && field.Type() == js.TypeNumber { + return field.Int() + } + } + return 0 +} + +func firstDefined(value js.Value, keys ...string) js.Value { + for _, key := range keys { + field := value.Get(key) + if !field.IsUndefined() && !field.IsNull() { + return field + } + } + return js.Null() +} + +func searchParam(url js.Value, key string) string { + value := url.Get("searchParams").Call("get", key) + if value.IsUndefined() || value.IsNull() { + return "" + } + return value.String() +} + +func jsonResponse(body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "application/json") + options := js.Global().Get("Object").New() + options.Set("status", 200) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} + +func jsonTextResponse(status int, body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "application/json") + options := js.Global().Get("Object").New() + options.Set("status", status) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} + +func textResponse(status int, body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "text/plain; charset=utf-8") + options := js.Global().Get("Object").New() + options.Set("status", status) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} +` +} + func workerShimTemplate(wasmFile string) string { return fmt.Sprintf(`import "./wasm_exec.js"; import wasmModule from "./%s"; From 42b06cd8aa94205c109708d4c9d62b68ecf15100 Mon Sep 17 00:00:00 2001 From: paoloanzn Date: Sun, 22 Mar 2026 22:53:58 +0100 Subject: [PATCH 4/4] refactor: split ai scaffold templates --- internal/service/project/template_ai_audio.go | 143 +++ internal/service/project/template_ai_chat.go | 69 + .../service/project/template_ai_common.go | 125 ++ .../service/project/template_ai_embeddings.go | 62 + internal/service/project/template_ai_image.go | 72 ++ .../service/project/template_ai_vision.go | 88 ++ internal/service/project/templates.go | 1112 +---------------- 7 files changed, 569 insertions(+), 1102 deletions(-) create mode 100644 internal/service/project/template_ai_audio.go create mode 100644 internal/service/project/template_ai_chat.go create mode 100644 internal/service/project/template_ai_common.go create mode 100644 internal/service/project/template_ai_embeddings.go create mode 100644 internal/service/project/template_ai_image.go create mode 100644 internal/service/project/template_ai_vision.go diff --git a/internal/service/project/template_ai_audio.go b/internal/service/project/template_ai_audio.go new file mode 100644 index 0000000..98ff1ad --- /dev/null +++ b/internal/service/project/template_ai_audio.go @@ -0,0 +1,143 @@ +package project + +func aiSTTWorkerMainTemplate() string { + return aiWorkerTemplate("@cf/deepgram/nova-3", `func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + if request.Get("body").IsUndefined() || request.Get("body").IsNull() { + return jsonTextResponse(400, "{\"message\":\"send audio bytes in the request body\"}") + } + + url := js.Global().Get("URL").New(request.Get("url")) + model := searchParam(url, "model") + if model == "" { + model = defaultAIModel + } + + contentType := request.Get("headers").Call("get", "content-type") + if contentType.IsUndefined() || contentType.IsNull() || contentType.String() == "" { + contentType = js.ValueOf("audio/mpeg") + } + + audio := js.Global().Get("Object").New() + audio.Set("body", request.Get("body")) + audio.Set("contentType", contentType) + + input := js.Global().Get("Object").New() + input.Set("audio", audio) + input.Set("detect_language", true) + + options := js.Global().Get("Object").New() + options.Set("returnRawResponse", true) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + if len(args) > 0 { + resolve.Invoke(args[0]) + return nil + } + resolve.Invoke(jsonTextResponse(502, "{\"message\":\"Workers AI returned no response\"}")) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + message := "Workers AI request failed" + status := 502 + if len(args) > 0 { + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } + } + resolve.Invoke(jsonTextResponse(status, message)) + return nil + }) + + ai.Call("run", model, input, options).Call("then", onResolve).Call("catch", onReject) + }) +}`) +} + +func aiTTSWorkerMainTemplate() string { + return aiWorkerTemplate("@cf/deepgram/aura-2-en", `func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + + url := js.Global().Get("URL").New(request.Get("url")) + text := searchParam(url, "text") + if text == "" { + text = "Hello from a Go-based Cloudflare text to speech worker." + } + model := searchParam(url, "model") + if model == "" { + model = defaultAIModel + } + speaker := searchParam(url, "speaker") + if speaker == "" { + speaker = "luna" + } + + input := js.Global().Get("Object").New() + input.Set("text", text) + input.Set("speaker", speaker) + + options := js.Global().Get("Object").New() + options.Set("returnRawResponse", true) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + if len(args) > 0 { + resolve.Invoke(args[0]) + return nil + } + resolve.Invoke(jsonTextResponse(502, "{\"message\":\"Workers AI returned no response\"}")) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + message := "Workers AI request failed" + status := 502 + if len(args) > 0 { + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } + } + resolve.Invoke(jsonTextResponse(status, message)) + return nil + }) + + ai.Call("run", model, input, options).Call("then", onResolve).Call("catch", onReject) + }) +}`) +} diff --git a/internal/service/project/template_ai_chat.go b/internal/service/project/template_ai_chat.go new file mode 100644 index 0000000..250db81 --- /dev/null +++ b/internal/service/project/template_ai_chat.go @@ -0,0 +1,69 @@ +package project + +func aiChatWorkerMainTemplate() string { + return aiWorkerTemplate("@cf/moonshotai/kimi-k2.5", `func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + + url := js.Global().Get("URL").New(request.Get("url")) + prompt := searchParam(url, "prompt") + if prompt == "" { + prompt = "Say hello from a Go-based Cloudflare AI Worker." + } + model := searchParam(url, "model") + if model == "" { + model = defaultAIModel + } + + input := js.Global().Get("Object").New() + messages := js.Global().Get("Array").New() + user := js.Global().Get("Object").New() + user.Set("role", "user") + user.Set("content", prompt) + messages.Call("push", user) + input.Set("messages", messages) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + + payload := "null" + if len(args) > 0 { + payload = stringify(args[0]) + } + resolve.Invoke(jsonResponse(payload)) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + + message := "Workers AI request failed" + status := 502 + if len(args) > 0 { + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } + } + resolve.Invoke(jsonTextResponse(status, message)) + return nil + }) + + ai.Call("run", model, input).Call("then", onResolve).Call("catch", onReject) + }) +}`) +} diff --git a/internal/service/project/template_ai_common.go b/internal/service/project/template_ai_common.go new file mode 100644 index 0000000..773ebb2 --- /dev/null +++ b/internal/service/project/template_ai_common.go @@ -0,0 +1,125 @@ +package project + +import ( + "strconv" + "strings" +) + +func aiWorkerTemplate(defaultModel, handleRequest string) string { + parts := []string{ + "package main\n\n", + "import \"syscall/js\"\n\n", + "const defaultAIModel = " + strconv.Quote(defaultModel) + "\n\n", + "func main() {\n", + "\tjs.Global().Set(\"handleRequest\", js.FuncOf(handleRequest))\n", + "\tjs.Global().Set(\"handleScheduled\", js.FuncOf(func(this js.Value, args []js.Value) any {\n", + "\t\treturn nil\n", + "\t}))\n", + "\tselect {}\n", + "}\n\n", + handleRequest, + "\n\n", + aiWorkerCommonRuntime, + } + return strings.Join(parts, "") +} + +const aiWorkerCommonRuntime = `func promise(fn func(resolve js.Value, reject js.Value)) js.Value { + executor := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) >= 2 { + fn(args[0], args[1]) + } + return nil + }) + defer executor.Release() + + return js.Global().Get("Promise").New(executor) +} + +func stringify(value js.Value) string { + output := js.Global().Get("JSON").Call("stringify", value) + if output.IsUndefined() || output.IsNull() { + return "null" + } + return output.String() +} + +func errorPayload(value js.Value) string { + payload := js.Global().Get("Object").New() + payload.Set("name", value.Get("name")) + payload.Set("message", value.Get("message")) + payload.Set("code", firstDefined(value, "code", "status")) + payload.Set("internalCode", firstDefined(value, "internalCode", "internal_code")) + + cause := value.Get("cause") + if !cause.IsUndefined() && !cause.IsNull() { + causePayload := js.Global().Get("Object").New() + causePayload.Set("name", cause.Get("name")) + causePayload.Set("message", cause.Get("message")) + causePayload.Set("code", firstDefined(cause, "code", "status")) + causePayload.Set("internalCode", firstDefined(cause, "internalCode", "internal_code")) + payload.Set("cause", causePayload) + } + + text := stringify(payload) + if text == "{}" || text == "null" { + return "{\"message\":\"Workers AI request failed\"}" + } + return text +} + +func errorStatus(value js.Value) int { + for _, key := range []string{"status", "statusCode", "code"} { + field := value.Get(key) + if !field.IsUndefined() && !field.IsNull() && field.Type() == js.TypeNumber { + return field.Int() + } + } + return 0 +} + +func firstDefined(value js.Value, keys ...string) js.Value { + for _, key := range keys { + field := value.Get(key) + if !field.IsUndefined() && !field.IsNull() { + return field + } + } + return js.Null() +} + +func searchParam(url js.Value, key string) string { + value := url.Get("searchParams").Call("get", key) + if value.IsUndefined() || value.IsNull() { + return "" + } + return value.String() +} + +func jsonResponse(body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "application/json") + options := js.Global().Get("Object").New() + options.Set("status", 200) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} + +func jsonTextResponse(status int, body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "application/json") + options := js.Global().Get("Object").New() + options.Set("status", status) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} + +func textResponse(status int, body string) js.Value { + headers := js.Global().Get("Headers").New() + headers.Call("set", "content-type", "text/plain; charset=utf-8") + options := js.Global().Get("Object").New() + options.Set("status", status) + options.Set("headers", headers) + return js.Global().Get("Response").New(body, options) +} +` diff --git a/internal/service/project/template_ai_embeddings.go b/internal/service/project/template_ai_embeddings.go new file mode 100644 index 0000000..420a1f9 --- /dev/null +++ b/internal/service/project/template_ai_embeddings.go @@ -0,0 +1,62 @@ +package project + +func aiEmbeddingsWorkerMainTemplate() string { + return aiWorkerTemplate("@cf/qwen/qwen3-embedding-0.6b", `func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + + url := js.Global().Get("URL").New(request.Get("url")) + text := searchParam(url, "text") + if text == "" { + text = "Cloudflare Workers AI embeddings from Go." + } + model := searchParam(url, "model") + if model == "" { + model = defaultAIModel + } + + input := js.Global().Get("Object").New() + input.Set("text", text) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + payload := "null" + if len(args) > 0 { + payload = stringify(args[0]) + } + resolve.Invoke(jsonResponse(payload)) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + message := "Workers AI request failed" + status := 502 + if len(args) > 0 { + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } + } + resolve.Invoke(jsonTextResponse(status, message)) + return nil + }) + + ai.Call("run", model, input).Call("then", onResolve).Call("catch", onReject) + }) +}`) +} diff --git a/internal/service/project/template_ai_image.go b/internal/service/project/template_ai_image.go new file mode 100644 index 0000000..95031af --- /dev/null +++ b/internal/service/project/template_ai_image.go @@ -0,0 +1,72 @@ +package project + +func aiImageWorkerMainTemplate() string { + return aiWorkerTemplate("@cf/black-forest-labs/flux-2-klein-9b", `func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + + url := js.Global().Get("URL").New(request.Get("url")) + prompt := searchParam(url, "prompt") + if prompt == "" { + prompt = "A dramatic cloudscape over the Alps at sunset." + } + model := searchParam(url, "model") + if model == "" { + model = defaultAIModel + } + + form := js.Global().Get("FormData").New() + form.Call("append", "prompt", prompt) + form.Call("append", "width", "1024") + form.Call("append", "height", "1024") + + formResponse := js.Global().Get("Response").New(form) + multipart := js.Global().Get("Object").New() + multipart.Set("body", formResponse.Get("body")) + multipart.Set("contentType", formResponse.Get("headers").Call("get", "content-type")) + + input := js.Global().Get("Object").New() + input.Set("multipart", multipart) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + payload := "null" + if len(args) > 0 { + payload = stringify(args[0]) + } + resolve.Invoke(jsonResponse(payload)) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + message := "Workers AI request failed" + status := 502 + if len(args) > 0 { + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } + } + resolve.Invoke(jsonTextResponse(status, message)) + return nil + }) + + ai.Call("run", model, input).Call("then", onResolve).Call("catch", onReject) + }) +}`) +} diff --git a/internal/service/project/template_ai_vision.go b/internal/service/project/template_ai_vision.go new file mode 100644 index 0000000..7e337d3 --- /dev/null +++ b/internal/service/project/template_ai_vision.go @@ -0,0 +1,88 @@ +package project + +func aiVisionWorkerMainTemplate() string { + return aiWorkerTemplate("@cf/moonshotai/kimi-k2.5", `func handleRequest(this js.Value, args []js.Value) any { + if len(args) < 2 { + return textResponse(500, "missing request or env") + } + + request := args[0] + env := args[1] + ai := env.Get("AI") + if ai.IsUndefined() || ai.IsNull() { + return textResponse(500, "Workers AI binding \"AI\" is not configured") + } + + url := js.Global().Get("URL").New(request.Get("url")) + prompt := searchParam(url, "prompt") + if prompt == "" { + prompt = "Describe this image." + } + imageURL := searchParam(url, "image_url") + if imageURL == "" { + return jsonTextResponse(400, "{\"message\":\"image_url query parameter is required\"}") + } + model := searchParam(url, "model") + if model == "" { + model = defaultAIModel + } + + content := js.Global().Get("Array").New() + + textPart := js.Global().Get("Object").New() + textPart.Set("type", "text") + textPart.Set("text", prompt) + content.Call("push", textPart) + + imagePart := js.Global().Get("Object").New() + imagePart.Set("type", "image_url") + imageURLPayload := js.Global().Get("Object").New() + imageURLPayload.Set("url", imageURL) + imagePart.Set("image_url", imageURLPayload) + content.Call("push", imagePart) + + messages := js.Global().Get("Array").New() + user := js.Global().Get("Object").New() + user.Set("role", "user") + user.Set("content", content) + messages.Call("push", user) + + input := js.Global().Get("Object").New() + input.Set("messages", messages) + + return promise(func(resolve, reject js.Value) { + var onResolve js.Func + var onReject js.Func + + onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + + payload := "null" + if len(args) > 0 { + payload = stringify(args[0]) + } + resolve.Invoke(jsonResponse(payload)) + return nil + }) + + onReject = js.FuncOf(func(this js.Value, args []js.Value) any { + defer onResolve.Release() + defer onReject.Release() + + message := "Workers AI request failed" + status := 502 + if len(args) > 0 { + message = errorPayload(args[0]) + if code := errorStatus(args[0]); code > 0 { + status = code + } + } + resolve.Invoke(jsonTextResponse(status, message)) + return nil + }) + + ai.Call("run", model, input).Call("then", onResolve).Call("catch", onReject) + }) +}`) +} diff --git a/internal/service/project/templates.go b/internal/service/project/templates.go index f9e85ae..dba39c0 100644 --- a/internal/service/project/templates.go +++ b/internal/service/project/templates.go @@ -23,7 +23,6 @@ func readmeTemplate(project config.Project) string { } func workerMainTemplate(project config.Project) string { - message := "hello from flare-edge-cli" switch project.Template { case "ai-text", "ai-chat": return aiChatWorkerMainTemplate() @@ -38,17 +37,21 @@ func workerMainTemplate(project config.Project) string { case "ai-embeddings": return aiEmbeddingsWorkerMainTemplate() case "edge-json": - message = "{\"ok\":true}" + return basicWorkerMainTemplate("{\"ok\":true}") case "scheduled": - message = "scheduled handler ready" + return basicWorkerMainTemplate("scheduled handler ready") case "kv-api": - message = "kv binding ready" + return basicWorkerMainTemplate("kv binding ready") case "d1-api": - message = "d1 binding ready" + return basicWorkerMainTemplate("d1 binding ready") case "r2-api": - message = "r2 binding ready" + return basicWorkerMainTemplate("r2 binding ready") + default: + return basicWorkerMainTemplate("hello from flare-edge-cli") } +} +func basicWorkerMainTemplate(message string) string { return fmt.Sprintf(`package main import "syscall/js" @@ -62,1102 +65,7 @@ func main() { })) select {} } - `, message) -} - -func aiChatWorkerMainTemplate() string { - return `package main - -import "syscall/js" - -const defaultAIModel = "@cf/moonshotai/kimi-k2.5" - -func main() { - js.Global().Set("handleRequest", js.FuncOf(handleRequest)) - js.Global().Set("handleScheduled", js.FuncOf(func(this js.Value, args []js.Value) any { - return nil - })) - select {} -} - -func handleRequest(this js.Value, args []js.Value) any { - if len(args) < 2 { - return textResponse(500, "missing request or env") - } - - request := args[0] - env := args[1] - ai := env.Get("AI") - if ai.IsUndefined() || ai.IsNull() { - return textResponse(500, "Workers AI binding \"AI\" is not configured") - } - - url := js.Global().Get("URL").New(request.Get("url")) - prompt := searchParam(url, "prompt") - if prompt == "" { - prompt = "Say hello from a Go-based Cloudflare AI Worker." - } - model := searchParam(url, "model") - if model == "" { - model = defaultAIModel - } - - input := js.Global().Get("Object").New() - messages := js.Global().Get("Array").New() - user := js.Global().Get("Object").New() - user.Set("role", "user") - user.Set("content", prompt) - messages.Call("push", user) - input.Set("messages", messages) - - return promise(func(resolve, reject js.Value) { - var onResolve js.Func - var onReject js.Func - - onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { - defer onResolve.Release() - defer onReject.Release() - - payload := "null" - if len(args) > 0 { - payload = stringify(args[0]) - } - resolve.Invoke(jsonResponse(payload)) - return nil - }) - - onReject = js.FuncOf(func(this js.Value, args []js.Value) any { - defer onResolve.Release() - defer onReject.Release() - - message := "Workers AI request failed" - status := 502 - if len(args) > 0 { - message = errorPayload(args[0]) - if code := errorStatus(args[0]); code > 0 { - status = code - } - } - resolve.Invoke(jsonTextResponse(status, message)) - return nil - }) - - ai.Call("run", model, input).Call("then", onResolve).Call("catch", onReject) - }) -} - -func promise(fn func(resolve js.Value, reject js.Value)) js.Value { - executor := js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) >= 2 { - fn(args[0], args[1]) - } - return nil - }) - defer executor.Release() - - return js.Global().Get("Promise").New(executor) -} - -func stringify(value js.Value) string { - output := js.Global().Get("JSON").Call("stringify", value) - if output.IsUndefined() || output.IsNull() { - return "null" - } - return output.String() -} - -func errorPayload(value js.Value) string { - payload := js.Global().Get("Object").New() - payload.Set("name", value.Get("name")) - payload.Set("message", value.Get("message")) - payload.Set("code", firstDefined(value, "code", "status")) - payload.Set("internalCode", firstDefined(value, "internalCode", "internal_code")) - - cause := value.Get("cause") - if !cause.IsUndefined() && !cause.IsNull() { - causePayload := js.Global().Get("Object").New() - causePayload.Set("name", cause.Get("name")) - causePayload.Set("message", cause.Get("message")) - causePayload.Set("code", firstDefined(cause, "code", "status")) - causePayload.Set("internalCode", firstDefined(cause, "internalCode", "internal_code")) - payload.Set("cause", causePayload) - } - - text := stringify(payload) - if text == "{}" || text == "null" { - return "{\"message\":\"Workers AI request failed\"}" - } - return text -} - -func errorStatus(value js.Value) int { - for _, key := range []string{"status", "statusCode", "code"} { - field := value.Get(key) - if !field.IsUndefined() && !field.IsNull() && field.Type() == js.TypeNumber { - return field.Int() - } - } - return 0 -} - -func firstDefined(value js.Value, keys ...string) js.Value { - for _, key := range keys { - field := value.Get(key) - if !field.IsUndefined() && !field.IsNull() { - return field - } - } - return js.Null() -} - -func searchParam(url js.Value, key string) string { - value := url.Get("searchParams").Call("get", key) - if value.IsUndefined() || value.IsNull() { - return "" - } - return value.String() -} - -func jsonResponse(body string) js.Value { - headers := js.Global().Get("Headers").New() - headers.Call("set", "content-type", "application/json") - options := js.Global().Get("Object").New() - options.Set("status", 200) - options.Set("headers", headers) - return js.Global().Get("Response").New(body, options) -} - -func jsonTextResponse(status int, body string) js.Value { - headers := js.Global().Get("Headers").New() - headers.Call("set", "content-type", "application/json") - options := js.Global().Get("Object").New() - options.Set("status", status) - options.Set("headers", headers) - return js.Global().Get("Response").New(body, options) -} - -func textResponse(status int, body string) js.Value { - headers := js.Global().Get("Headers").New() - headers.Call("set", "content-type", "text/plain; charset=utf-8") - options := js.Global().Get("Object").New() - options.Set("status", status) - options.Set("headers", headers) - return js.Global().Get("Response").New(body, options) -} -` -} - -func aiVisionWorkerMainTemplate() string { - return `package main - -import "syscall/js" - -const defaultAIModel = "@cf/moonshotai/kimi-k2.5" - -func main() { - js.Global().Set("handleRequest", js.FuncOf(handleRequest)) - js.Global().Set("handleScheduled", js.FuncOf(func(this js.Value, args []js.Value) any { - return nil - })) - select {} -} - -func handleRequest(this js.Value, args []js.Value) any { - if len(args) < 2 { - return textResponse(500, "missing request or env") - } - - request := args[0] - env := args[1] - ai := env.Get("AI") - if ai.IsUndefined() || ai.IsNull() { - return textResponse(500, "Workers AI binding \"AI\" is not configured") - } - - url := js.Global().Get("URL").New(request.Get("url")) - prompt := searchParam(url, "prompt") - if prompt == "" { - prompt = "Describe this image." - } - imageURL := searchParam(url, "image_url") - if imageURL == "" { - return jsonTextResponse(400, "{\"message\":\"image_url query parameter is required\"}") - } - model := searchParam(url, "model") - if model == "" { - model = defaultAIModel - } - - content := js.Global().Get("Array").New() - - textPart := js.Global().Get("Object").New() - textPart.Set("type", "text") - textPart.Set("text", prompt) - content.Call("push", textPart) - - imagePart := js.Global().Get("Object").New() - imagePart.Set("type", "image_url") - imageURLPayload := js.Global().Get("Object").New() - imageURLPayload.Set("url", imageURL) - imagePart.Set("image_url", imageURLPayload) - content.Call("push", imagePart) - - messages := js.Global().Get("Array").New() - user := js.Global().Get("Object").New() - user.Set("role", "user") - user.Set("content", content) - messages.Call("push", user) - - input := js.Global().Get("Object").New() - input.Set("messages", messages) - - return promise(func(resolve, reject js.Value) { - var onResolve js.Func - var onReject js.Func - - onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { - defer onResolve.Release() - defer onReject.Release() - - payload := "null" - if len(args) > 0 { - payload = stringify(args[0]) - } - resolve.Invoke(jsonResponse(payload)) - return nil - }) - - onReject = js.FuncOf(func(this js.Value, args []js.Value) any { - defer onResolve.Release() - defer onReject.Release() - - message := "Workers AI request failed" - status := 502 - if len(args) > 0 { - message = errorPayload(args[0]) - if code := errorStatus(args[0]); code > 0 { - status = code - } - } - resolve.Invoke(jsonTextResponse(status, message)) - return nil - }) - - ai.Call("run", model, input).Call("then", onResolve).Call("catch", onReject) - }) -} - -func promise(fn func(resolve js.Value, reject js.Value)) js.Value { - executor := js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) >= 2 { - fn(args[0], args[1]) - } - return nil - }) - defer executor.Release() - - return js.Global().Get("Promise").New(executor) -} - -func stringify(value js.Value) string { - output := js.Global().Get("JSON").Call("stringify", value) - if output.IsUndefined() || output.IsNull() { - return "null" - } - return output.String() -} - -func errorPayload(value js.Value) string { - payload := js.Global().Get("Object").New() - payload.Set("name", value.Get("name")) - payload.Set("message", value.Get("message")) - payload.Set("code", firstDefined(value, "code", "status")) - payload.Set("internalCode", firstDefined(value, "internalCode", "internal_code")) - - cause := value.Get("cause") - if !cause.IsUndefined() && !cause.IsNull() { - causePayload := js.Global().Get("Object").New() - causePayload.Set("name", cause.Get("name")) - causePayload.Set("message", cause.Get("message")) - causePayload.Set("code", firstDefined(cause, "code", "status")) - causePayload.Set("internalCode", firstDefined(cause, "internalCode", "internal_code")) - payload.Set("cause", causePayload) - } - - text := stringify(payload) - if text == "{}" || text == "null" { - return "{\"message\":\"Workers AI request failed\"}" - } - return text -} - -func errorStatus(value js.Value) int { - for _, key := range []string{"status", "statusCode", "code"} { - field := value.Get(key) - if !field.IsUndefined() && !field.IsNull() && field.Type() == js.TypeNumber { - return field.Int() - } - } - return 0 -} - -func firstDefined(value js.Value, keys ...string) js.Value { - for _, key := range keys { - field := value.Get(key) - if !field.IsUndefined() && !field.IsNull() { - return field - } - } - return js.Null() -} - -func searchParam(url js.Value, key string) string { - value := url.Get("searchParams").Call("get", key) - if value.IsUndefined() || value.IsNull() { - return "" - } - return value.String() -} - -func jsonResponse(body string) js.Value { - headers := js.Global().Get("Headers").New() - headers.Call("set", "content-type", "application/json") - options := js.Global().Get("Object").New() - options.Set("status", 200) - options.Set("headers", headers) - return js.Global().Get("Response").New(body, options) -} - -func jsonTextResponse(status int, body string) js.Value { - headers := js.Global().Get("Headers").New() - headers.Call("set", "content-type", "application/json") - options := js.Global().Get("Object").New() - options.Set("status", status) - options.Set("headers", headers) - return js.Global().Get("Response").New(body, options) -} - -func textResponse(status int, body string) js.Value { - headers := js.Global().Get("Headers").New() - headers.Call("set", "content-type", "text/plain; charset=utf-8") - options := js.Global().Get("Object").New() - options.Set("status", status) - options.Set("headers", headers) - return js.Global().Get("Response").New(body, options) -} -` -} - -func aiSTTWorkerMainTemplate() string { - return `package main - -import "syscall/js" - -const defaultAIModel = "@cf/deepgram/nova-3" - -func main() { - js.Global().Set("handleRequest", js.FuncOf(handleRequest)) - js.Global().Set("handleScheduled", js.FuncOf(func(this js.Value, args []js.Value) any { - return nil - })) - select {} -} - -func handleRequest(this js.Value, args []js.Value) any { - if len(args) < 2 { - return textResponse(500, "missing request or env") - } - - request := args[0] - env := args[1] - ai := env.Get("AI") - if ai.IsUndefined() || ai.IsNull() { - return textResponse(500, "Workers AI binding \"AI\" is not configured") - } - if request.Get("body").IsUndefined() || request.Get("body").IsNull() { - return jsonTextResponse(400, "{\"message\":\"send audio bytes in the request body\"}") - } - - url := js.Global().Get("URL").New(request.Get("url")) - model := searchParam(url, "model") - if model == "" { - model = defaultAIModel - } - - contentType := request.Get("headers").Call("get", "content-type") - if contentType.IsUndefined() || contentType.IsNull() || contentType.String() == "" { - contentType = js.ValueOf("audio/mpeg") - } - - audio := js.Global().Get("Object").New() - audio.Set("body", request.Get("body")) - audio.Set("contentType", contentType) - - input := js.Global().Get("Object").New() - input.Set("audio", audio) - input.Set("detect_language", true) - - options := js.Global().Get("Object").New() - options.Set("returnRawResponse", true) - - return promise(func(resolve, reject js.Value) { - var onResolve js.Func - var onReject js.Func - - onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { - defer onResolve.Release() - defer onReject.Release() - if len(args) > 0 { - resolve.Invoke(args[0]) - return nil - } - resolve.Invoke(jsonTextResponse(502, "{\"message\":\"Workers AI returned no response\"}")) - return nil - }) - - onReject = js.FuncOf(func(this js.Value, args []js.Value) any { - defer onResolve.Release() - defer onReject.Release() - message := "Workers AI request failed" - status := 502 - if len(args) > 0 { - message = errorPayload(args[0]) - if code := errorStatus(args[0]); code > 0 { - status = code - } - } - resolve.Invoke(jsonTextResponse(status, message)) - return nil - }) - - ai.Call("run", model, input, options).Call("then", onResolve).Call("catch", onReject) - }) -} - -func promise(fn func(resolve js.Value, reject js.Value)) js.Value { - executor := js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) >= 2 { - fn(args[0], args[1]) - } - return nil - }) - defer executor.Release() - - return js.Global().Get("Promise").New(executor) -} - -func stringify(value js.Value) string { - output := js.Global().Get("JSON").Call("stringify", value) - if output.IsUndefined() || output.IsNull() { - return "null" - } - return output.String() -} - -func errorPayload(value js.Value) string { - payload := js.Global().Get("Object").New() - payload.Set("name", value.Get("name")) - payload.Set("message", value.Get("message")) - payload.Set("code", firstDefined(value, "code", "status")) - payload.Set("internalCode", firstDefined(value, "internalCode", "internal_code")) - - cause := value.Get("cause") - if !cause.IsUndefined() && !cause.IsNull() { - causePayload := js.Global().Get("Object").New() - causePayload.Set("name", cause.Get("name")) - causePayload.Set("message", cause.Get("message")) - causePayload.Set("code", firstDefined(cause, "code", "status")) - causePayload.Set("internalCode", firstDefined(cause, "internalCode", "internal_code")) - payload.Set("cause", causePayload) - } - - text := stringify(payload) - if text == "{}" || text == "null" { - return "{\"message\":\"Workers AI request failed\"}" - } - return text -} - -func errorStatus(value js.Value) int { - for _, key := range []string{"status", "statusCode", "code"} { - field := value.Get(key) - if !field.IsUndefined() && !field.IsNull() && field.Type() == js.TypeNumber { - return field.Int() - } - } - return 0 -} - -func firstDefined(value js.Value, keys ...string) js.Value { - for _, key := range keys { - field := value.Get(key) - if !field.IsUndefined() && !field.IsNull() { - return field - } - } - return js.Null() -} - -func searchParam(url js.Value, key string) string { - value := url.Get("searchParams").Call("get", key) - if value.IsUndefined() || value.IsNull() { - return "" - } - return value.String() -} - -func jsonTextResponse(status int, body string) js.Value { - headers := js.Global().Get("Headers").New() - headers.Call("set", "content-type", "application/json") - options := js.Global().Get("Object").New() - options.Set("status", status) - options.Set("headers", headers) - return js.Global().Get("Response").New(body, options) -} - -func textResponse(status int, body string) js.Value { - headers := js.Global().Get("Headers").New() - headers.Call("set", "content-type", "text/plain; charset=utf-8") - options := js.Global().Get("Object").New() - options.Set("status", status) - options.Set("headers", headers) - return js.Global().Get("Response").New(body, options) -} -` -} - -func aiTTSWorkerMainTemplate() string { - return `package main - -import "syscall/js" - -const defaultAIModel = "@cf/deepgram/aura-2-en" - -func main() { - js.Global().Set("handleRequest", js.FuncOf(handleRequest)) - js.Global().Set("handleScheduled", js.FuncOf(func(this js.Value, args []js.Value) any { - return nil - })) - select {} -} - -func handleRequest(this js.Value, args []js.Value) any { - if len(args) < 2 { - return textResponse(500, "missing request or env") - } - - request := args[0] - env := args[1] - ai := env.Get("AI") - if ai.IsUndefined() || ai.IsNull() { - return textResponse(500, "Workers AI binding \"AI\" is not configured") - } - - url := js.Global().Get("URL").New(request.Get("url")) - text := searchParam(url, "text") - if text == "" { - text = "Hello from a Go-based Cloudflare text to speech worker." - } - model := searchParam(url, "model") - if model == "" { - model = defaultAIModel - } - speaker := searchParam(url, "speaker") - if speaker == "" { - speaker = "luna" - } - - input := js.Global().Get("Object").New() - input.Set("text", text) - input.Set("speaker", speaker) - - options := js.Global().Get("Object").New() - options.Set("returnRawResponse", true) - - return promise(func(resolve, reject js.Value) { - var onResolve js.Func - var onReject js.Func - - onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { - defer onResolve.Release() - defer onReject.Release() - if len(args) > 0 { - resolve.Invoke(args[0]) - return nil - } - resolve.Invoke(jsonTextResponse(502, "{\"message\":\"Workers AI returned no response\"}")) - return nil - }) - - onReject = js.FuncOf(func(this js.Value, args []js.Value) any { - defer onResolve.Release() - defer onReject.Release() - message := "Workers AI request failed" - status := 502 - if len(args) > 0 { - message = errorPayload(args[0]) - if code := errorStatus(args[0]); code > 0 { - status = code - } - } - resolve.Invoke(jsonTextResponse(status, message)) - return nil - }) - - ai.Call("run", model, input, options).Call("then", onResolve).Call("catch", onReject) - }) -} - -func promise(fn func(resolve js.Value, reject js.Value)) js.Value { - executor := js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) >= 2 { - fn(args[0], args[1]) - } - return nil - }) - defer executor.Release() - - return js.Global().Get("Promise").New(executor) -} - -func stringify(value js.Value) string { - output := js.Global().Get("JSON").Call("stringify", value) - if output.IsUndefined() || output.IsNull() { - return "null" - } - return output.String() -} - -func errorPayload(value js.Value) string { - payload := js.Global().Get("Object").New() - payload.Set("name", value.Get("name")) - payload.Set("message", value.Get("message")) - payload.Set("code", firstDefined(value, "code", "status")) - payload.Set("internalCode", firstDefined(value, "internalCode", "internal_code")) - - cause := value.Get("cause") - if !cause.IsUndefined() && !cause.IsNull() { - causePayload := js.Global().Get("Object").New() - causePayload.Set("name", cause.Get("name")) - causePayload.Set("message", cause.Get("message")) - causePayload.Set("code", firstDefined(cause, "code", "status")) - causePayload.Set("internalCode", firstDefined(cause, "internalCode", "internal_code")) - payload.Set("cause", causePayload) - } - - text := stringify(payload) - if text == "{}" || text == "null" { - return "{\"message\":\"Workers AI request failed\"}" - } - return text -} - -func errorStatus(value js.Value) int { - for _, key := range []string{"status", "statusCode", "code"} { - field := value.Get(key) - if !field.IsUndefined() && !field.IsNull() && field.Type() == js.TypeNumber { - return field.Int() - } - } - return 0 -} - -func firstDefined(value js.Value, keys ...string) js.Value { - for _, key := range keys { - field := value.Get(key) - if !field.IsUndefined() && !field.IsNull() { - return field - } - } - return js.Null() -} - -func searchParam(url js.Value, key string) string { - value := url.Get("searchParams").Call("get", key) - if value.IsUndefined() || value.IsNull() { - return "" - } - return value.String() -} - -func jsonTextResponse(status int, body string) js.Value { - headers := js.Global().Get("Headers").New() - headers.Call("set", "content-type", "application/json") - options := js.Global().Get("Object").New() - options.Set("status", status) - options.Set("headers", headers) - return js.Global().Get("Response").New(body, options) -} - -func textResponse(status int, body string) js.Value { - headers := js.Global().Get("Headers").New() - headers.Call("set", "content-type", "text/plain; charset=utf-8") - options := js.Global().Get("Object").New() - options.Set("status", status) - options.Set("headers", headers) - return js.Global().Get("Response").New(body, options) -} -` -} - -func aiImageWorkerMainTemplate() string { - return `package main - -import "syscall/js" - -const defaultAIModel = "@cf/black-forest-labs/flux-2-klein-9b" - -func main() { - js.Global().Set("handleRequest", js.FuncOf(handleRequest)) - js.Global().Set("handleScheduled", js.FuncOf(func(this js.Value, args []js.Value) any { - return nil - })) - select {} -} - -func handleRequest(this js.Value, args []js.Value) any { - if len(args) < 2 { - return textResponse(500, "missing request or env") - } - - request := args[0] - env := args[1] - ai := env.Get("AI") - if ai.IsUndefined() || ai.IsNull() { - return textResponse(500, "Workers AI binding \"AI\" is not configured") - } - - url := js.Global().Get("URL").New(request.Get("url")) - prompt := searchParam(url, "prompt") - if prompt == "" { - prompt = "A dramatic cloudscape over the Alps at sunset." - } - model := searchParam(url, "model") - if model == "" { - model = defaultAIModel - } - - form := js.Global().Get("FormData").New() - form.Call("append", "prompt", prompt) - form.Call("append", "width", "1024") - form.Call("append", "height", "1024") - - formResponse := js.Global().Get("Response").New(form) - multipart := js.Global().Get("Object").New() - multipart.Set("body", formResponse.Get("body")) - multipart.Set("contentType", formResponse.Get("headers").Call("get", "content-type")) - - input := js.Global().Get("Object").New() - input.Set("multipart", multipart) - - return promise(func(resolve, reject js.Value) { - var onResolve js.Func - var onReject js.Func - - onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { - defer onResolve.Release() - defer onReject.Release() - payload := "null" - if len(args) > 0 { - payload = stringify(args[0]) - } - resolve.Invoke(jsonResponse(payload)) - return nil - }) - - onReject = js.FuncOf(func(this js.Value, args []js.Value) any { - defer onResolve.Release() - defer onReject.Release() - message := "Workers AI request failed" - status := 502 - if len(args) > 0 { - message = errorPayload(args[0]) - if code := errorStatus(args[0]); code > 0 { - status = code - } - } - resolve.Invoke(jsonTextResponse(status, message)) - return nil - }) - - ai.Call("run", model, input).Call("then", onResolve).Call("catch", onReject) - }) -} - -func promise(fn func(resolve js.Value, reject js.Value)) js.Value { - executor := js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) >= 2 { - fn(args[0], args[1]) - } - return nil - }) - defer executor.Release() - - return js.Global().Get("Promise").New(executor) -} - -func stringify(value js.Value) string { - output := js.Global().Get("JSON").Call("stringify", value) - if output.IsUndefined() || output.IsNull() { - return "null" - } - return output.String() -} - -func errorPayload(value js.Value) string { - payload := js.Global().Get("Object").New() - payload.Set("name", value.Get("name")) - payload.Set("message", value.Get("message")) - payload.Set("code", firstDefined(value, "code", "status")) - payload.Set("internalCode", firstDefined(value, "internalCode", "internal_code")) - - cause := value.Get("cause") - if !cause.IsUndefined() && !cause.IsNull() { - causePayload := js.Global().Get("Object").New() - causePayload.Set("name", cause.Get("name")) - causePayload.Set("message", cause.Get("message")) - causePayload.Set("code", firstDefined(cause, "code", "status")) - causePayload.Set("internalCode", firstDefined(cause, "internalCode", "internal_code")) - payload.Set("cause", causePayload) - } - - text := stringify(payload) - if text == "{}" || text == "null" { - return "{\"message\":\"Workers AI request failed\"}" - } - return text -} - -func errorStatus(value js.Value) int { - for _, key := range []string{"status", "statusCode", "code"} { - field := value.Get(key) - if !field.IsUndefined() && !field.IsNull() && field.Type() == js.TypeNumber { - return field.Int() - } - } - return 0 -} - -func firstDefined(value js.Value, keys ...string) js.Value { - for _, key := range keys { - field := value.Get(key) - if !field.IsUndefined() && !field.IsNull() { - return field - } - } - return js.Null() -} - -func searchParam(url js.Value, key string) string { - value := url.Get("searchParams").Call("get", key) - if value.IsUndefined() || value.IsNull() { - return "" - } - return value.String() -} - -func jsonResponse(body string) js.Value { - headers := js.Global().Get("Headers").New() - headers.Call("set", "content-type", "application/json") - options := js.Global().Get("Object").New() - options.Set("status", 200) - options.Set("headers", headers) - return js.Global().Get("Response").New(body, options) -} - -func jsonTextResponse(status int, body string) js.Value { - headers := js.Global().Get("Headers").New() - headers.Call("set", "content-type", "application/json") - options := js.Global().Get("Object").New() - options.Set("status", status) - options.Set("headers", headers) - return js.Global().Get("Response").New(body, options) -} - -func textResponse(status int, body string) js.Value { - headers := js.Global().Get("Headers").New() - headers.Call("set", "content-type", "text/plain; charset=utf-8") - options := js.Global().Get("Object").New() - options.Set("status", status) - options.Set("headers", headers) - return js.Global().Get("Response").New(body, options) -} -` -} - -func aiEmbeddingsWorkerMainTemplate() string { - return `package main - -import "syscall/js" - -const defaultAIModel = "@cf/qwen/qwen3-embedding-0.6b" - -func main() { - js.Global().Set("handleRequest", js.FuncOf(handleRequest)) - js.Global().Set("handleScheduled", js.FuncOf(func(this js.Value, args []js.Value) any { - return nil - })) - select {} -} - -func handleRequest(this js.Value, args []js.Value) any { - if len(args) < 2 { - return textResponse(500, "missing request or env") - } - - request := args[0] - env := args[1] - ai := env.Get("AI") - if ai.IsUndefined() || ai.IsNull() { - return textResponse(500, "Workers AI binding \"AI\" is not configured") - } - - url := js.Global().Get("URL").New(request.Get("url")) - text := searchParam(url, "text") - if text == "" { - text = "Cloudflare Workers AI embeddings from Go." - } - model := searchParam(url, "model") - if model == "" { - model = defaultAIModel - } - - input := js.Global().Get("Object").New() - input.Set("text", text) - - return promise(func(resolve, reject js.Value) { - var onResolve js.Func - var onReject js.Func - - onResolve = js.FuncOf(func(this js.Value, args []js.Value) any { - defer onResolve.Release() - defer onReject.Release() - payload := "null" - if len(args) > 0 { - payload = stringify(args[0]) - } - resolve.Invoke(jsonResponse(payload)) - return nil - }) - - onReject = js.FuncOf(func(this js.Value, args []js.Value) any { - defer onResolve.Release() - defer onReject.Release() - message := "Workers AI request failed" - status := 502 - if len(args) > 0 { - message = errorPayload(args[0]) - if code := errorStatus(args[0]); code > 0 { - status = code - } - } - resolve.Invoke(jsonTextResponse(status, message)) - return nil - }) - - ai.Call("run", model, input).Call("then", onResolve).Call("catch", onReject) - }) -} - -func promise(fn func(resolve js.Value, reject js.Value)) js.Value { - executor := js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) >= 2 { - fn(args[0], args[1]) - } - return nil - }) - defer executor.Release() - - return js.Global().Get("Promise").New(executor) -} - -func stringify(value js.Value) string { - output := js.Global().Get("JSON").Call("stringify", value) - if output.IsUndefined() || output.IsNull() { - return "null" - } - return output.String() -} - -func errorPayload(value js.Value) string { - payload := js.Global().Get("Object").New() - payload.Set("name", value.Get("name")) - payload.Set("message", value.Get("message")) - payload.Set("code", firstDefined(value, "code", "status")) - payload.Set("internalCode", firstDefined(value, "internalCode", "internal_code")) - - cause := value.Get("cause") - if !cause.IsUndefined() && !cause.IsNull() { - causePayload := js.Global().Get("Object").New() - causePayload.Set("name", cause.Get("name")) - causePayload.Set("message", cause.Get("message")) - causePayload.Set("code", firstDefined(cause, "code", "status")) - causePayload.Set("internalCode", firstDefined(cause, "internalCode", "internal_code")) - payload.Set("cause", causePayload) - } - - text := stringify(payload) - if text == "{}" || text == "null" { - return "{\"message\":\"Workers AI request failed\"}" - } - return text -} - -func errorStatus(value js.Value) int { - for _, key := range []string{"status", "statusCode", "code"} { - field := value.Get(key) - if !field.IsUndefined() && !field.IsNull() && field.Type() == js.TypeNumber { - return field.Int() - } - } - return 0 -} - -func firstDefined(value js.Value, keys ...string) js.Value { - for _, key := range keys { - field := value.Get(key) - if !field.IsUndefined() && !field.IsNull() { - return field - } - } - return js.Null() -} - -func searchParam(url js.Value, key string) string { - value := url.Get("searchParams").Call("get", key) - if value.IsUndefined() || value.IsNull() { - return "" - } - return value.String() -} - -func jsonResponse(body string) js.Value { - headers := js.Global().Get("Headers").New() - headers.Call("set", "content-type", "application/json") - options := js.Global().Get("Object").New() - options.Set("status", 200) - options.Set("headers", headers) - return js.Global().Get("Response").New(body, options) -} - -func jsonTextResponse(status int, body string) js.Value { - headers := js.Global().Get("Headers").New() - headers.Call("set", "content-type", "application/json") - options := js.Global().Get("Object").New() - options.Set("status", status) - options.Set("headers", headers) - return js.Global().Get("Response").New(body, options) -} - -func textResponse(status int, body string) js.Value { - headers := js.Global().Get("Headers").New() - headers.Call("set", "content-type", "text/plain; charset=utf-8") - options := js.Global().Get("Object").New() - options.Set("status", status) - options.Set("headers", headers) - return js.Global().Get("Response").New(body, options) -} -` +`, message) } func workerShimTemplate(wasmFile string) string {