diff --git a/.gitignore b/.gitignore
index 5472f1f..96e4c1f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,3 +43,7 @@ tmp/
/rdev-api
/claudebox-sidecar
/sdlc
+/render-skeleton
+
+# Rendered example monorepo (regenerated from templates)
+examples/full-monorepo/
diff --git a/CLAUDE.md b/CLAUDE.md
index a32aa4e..a930e1a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -37,6 +37,7 @@ When discussing code: "add to **platform**" = edit rdev; "add to **skeleton**" =
| **Cookbook tree system (commands)** | [services/cookbook-trees.md](.claude/guides/services/cookbook-trees.md) |
| **Slackpath reference architectures** | [services/cookbook-trees.md](.claude/guides/services/cookbook-trees.md#slackpath-trees-reference-architectures) |
| **Write cookbook trees** | [cookbook-trees/SKILL.md](.claude/skills/cookbook-trees/SKILL.md) |
+| **Build/maintain skeleton packages** | [skeleton-craftsman/SKILL.md](.claude/skills/skeleton-craftsman/SKILL.md) |
| **Build orchestration** | [services/build-orchestration.md](.claude/guides/services/build-orchestration.md) |
| **Build event streaming** | [services/build-streaming.md](.claude/guides/services/build-streaming.md) |
| **Resource provisioning plan** | [services/resource-provisioning-plan.md](.claude/guides/services/resource-provisioning-plan.md) |
@@ -55,10 +56,13 @@ When discussing code: "add to **platform**" = edit rdev; "add to **skeleton**" =
| **Woodpecker CI v3 pipelines** | [ops/woodpecker-v3.md](.claude/guides/ops/woodpecker-v3.md) |
| **Traefik v3 ingress & middleware** | [ops/traefik-v3.md](.claude/guides/ops/traefik-v3.md) |
| **Zot container registry** | [ops/zot-registry.md](.claude/guides/ops/zot-registry.md) |
+| **cert-manager / TLS certificates** | [ops/cert-manager.md](.claude/guides/ops/cert-manager.md) |
| **Structured logging** | `internal/logging/` - field constants, context propagation, redaction |
## Critical Rules
+- **Frustration = systemic fix:** When the user says they're tired of repeating something, stop what you're doing and find or create a systemic fix in `.claude/**/*` or `CLAUDE.md` — don't just apologize and do the same thing again.
+- **AI credentials are provisioned:** rdev injects `LAOZHANG_API_KEY` and `GEMINI_API_KEY` as env vars into every deployed component (`component_deploy.go:fetchProjectCredentials`). Skeleton code reads them with `os.Getenv()`. Never treat AI packages as needing external setup.
- **Root cause fixes:** When diagnosing failures in generated projects, NEVER patch the project directly. Find the systemic root cause in: (1) **platform** - rdev handlers/services that create resources, (2) **skeleton** - templates that ship in generated projects, or (3) **cookbook** - test scripts with wrong assumptions. Fix the source, not the symptom. Every project-specific fix is technical debt that will recur.
- **LLM vs rdev:** LLMs generate code; rdev executes deterministic operations (git, lint, deploy). Never rely on LLMs for runbook tasks.
- **Pod git ops:** Git operations run inside pods via `PodGitOperations` (kubectl exec), never locally.
diff --git a/cmd/rdev-api/config.go b/cmd/rdev-api/config.go
index d0ecae6..c6f371b 100644
--- a/cmd/rdev-api/config.go
+++ b/cmd/rdev-api/config.go
@@ -34,6 +34,11 @@ type Config struct {
// Internal API token for service-to-service callbacks
InternalToken string
+ // Citadel logging integration
+ CitadelURL string // e.g., "https://citadel-staging.orchard9.ai"
+ CitadelAPIKey string // API key for Citadel (starts with ck_live_ or ck_dev_)
+ CitadelPlatformTenantID string // Tenant ID for the rdev-platform environment
+
// Infrastructure adapters (threesix.ai) - fallback values if not in credential store
GiteaURL string
GiteaToken string
@@ -107,6 +112,11 @@ func loadConfig() Config {
// Internal API token for service-to-service callbacks (e.g., SDLC callbacks)
InternalToken: os.Getenv("INTERNAL_TOKEN"),
+ // Citadel logging integration
+ CitadelURL: os.Getenv("CITADEL_URL"), // e.g., "https://citadel-staging.orchard9.ai"
+ CitadelAPIKey: os.Getenv("CITADEL_API_KEY"), // API key for Citadel
+ CitadelPlatformTenantID: os.Getenv("CITADEL_PLATFORM_TENANT_ID"), // rdev-platform tenant ID
+
// Infrastructure adapters (fallback if not in credential store)
GiteaURL: envutil.GetEnv("GITEA_URL", "https://git.threesix.ai"),
GiteaToken: os.Getenv("GITEA_TOKEN"),
diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go
index cfbb264..6f4a37c 100644
--- a/cmd/rdev-api/main.go
+++ b/cmd/rdev-api/main.go
@@ -7,6 +7,7 @@ import (
"strings"
"time"
+ citadeladapter "github.com/orchard9/rdev/internal/adapter/citadel"
"github.com/orchard9/rdev/internal/adapter/cloudflare"
"github.com/orchard9/rdev/internal/adapter/cockroach"
"github.com/orchard9/rdev/internal/adapter/codeagent"
@@ -96,6 +97,16 @@ func main() {
// Load infrastructure config from credential store (falls back to env vars)
infraCfg := loadInfraConfig(context.Background(), credentialStore, cfg, logger)
+ // Initialize Citadel client (optional - for log environment provisioning and audit shipping)
+ var citadelClient *citadeladapter.Client
+ if cfg.CitadelURL != "" && cfg.CitadelAPIKey != "" {
+ citadelClient = citadeladapter.NewClient(citadeladapter.Config{
+ URL: cfg.CitadelURL,
+ APIKey: cfg.CitadelAPIKey,
+ }, logger)
+ logger.Info("citadel client initialized", "url", cfg.CitadelURL)
+ }
+
// Create adapters (dependency injection)
namespace := envutil.GetEnv("K8S_NAMESPACE", "rdev")
@@ -113,7 +124,16 @@ func main() {
}
}
- auditLogger := postgres.NewAuditLogger(database.DB)
+ var auditLogger port.AuditLogger
+ var auditShipper *citadeladapter.AuditShipper
+ pgAuditLogger := postgres.NewAuditLogger(database.DB)
+ if citadelClient != nil && cfg.CitadelPlatformTenantID != "" {
+ auditShipper = citadeladapter.NewAuditShipper(pgAuditLogger, citadelClient, cfg.CitadelPlatformTenantID, logger)
+ auditLogger = auditShipper
+ logger.Info("audit logger wrapped with citadel shipper", "tenant_id", cfg.CitadelPlatformTenantID)
+ } else {
+ auditLogger = pgAuditLogger
+ }
rateLimiter := postgres.NewRateLimiter(database.DB)
stopRateLimitCleanup := rateLimiter.StartCleanupWorker(context.Background(), 5*time.Minute)
commandQueue := postgres.NewCommandQueueRepository(database.DB)
@@ -459,6 +479,9 @@ func main() {
if registryClient != nil {
projectInfraService = projectInfraService.WithRegistryProvider(registryClient)
}
+ if citadelClient != nil {
+ projectInfraService = projectInfraService.WithCitadelClient(citadelClient)
+ }
// Create domain service adapter for infrastructure handler
domainServiceAdapter := handlers.NewDomainServiceAdapter(projectInfraService)
@@ -761,6 +784,9 @@ func main() {
}
queueProcessor.Stop()
webhookDispatcher.Stop()
+ if auditShipper != nil {
+ auditShipper.Close()
+ }
projectRepo.StopWatching()
stopRateLimitCleanup()
closeProvisioner(dbProvisioner, "database", logger)
diff --git a/cmd/render-skeleton/main.go b/cmd/render-skeleton/main.go
new file mode 100644
index 0000000..cc3a084
--- /dev/null
+++ b/cmd/render-skeleton/main.go
@@ -0,0 +1,241 @@
+// Package main provides a CLI tool for rendering skeleton templates locally.
+//
+// This is used for testing templates without needing Gitea/rdev infrastructure.
+// The tool renders the monorepo skeleton plus all component types to a local directory.
+//
+// Usage:
+//
+// go run ./cmd/render-skeleton -output ./examples/full-monorepo
+package main
+
+import (
+ "flag"
+ "fmt"
+ "maps"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/orchard9/rdev/internal/adapter/templates"
+)
+
+// Component defines a component to render.
+type Component struct {
+ Type string
+ Name string
+ Port string
+ DestDir string // "services", "workers", "apps", or "cli"
+}
+
+// defaultComponents are the components to render in a full example project.
+var defaultComponents = []Component{
+ {Type: "service", Name: "example-api", Port: "8001", DestDir: "services"},
+ {Type: "worker", Name: "example-worker", Port: "", DestDir: "workers"},
+ {Type: "app-astro", Name: "example-astro", Port: "4321", DestDir: "apps"},
+ {Type: "app-react", Name: "example-react", Port: "5173", DestDir: "apps"},
+ {Type: "app-nextjs", Name: "example-nextjs", Port: "3000", DestDir: "apps"},
+ {Type: "cli", Name: "example-cli", Port: "", DestDir: "cli"},
+}
+
+func main() {
+ outputDir := flag.String("output", "", "Output directory for rendered skeleton")
+ projectName := flag.String("project", "test-project", "Project name")
+ goModule := flag.String("module", "git.threesix.ai/threesix/test-project", "Go module path")
+ domain := flag.String("domain", "test.threesix.ai", "Domain for the project")
+ flag.Parse()
+
+ if *outputDir == "" {
+ fmt.Fprintln(os.Stderr, "Usage: render-skeleton -output
[-project ] [-module ] [-domain ]")
+ fmt.Fprintln(os.Stderr, "")
+ fmt.Fprintln(os.Stderr, "Example:")
+ fmt.Fprintln(os.Stderr, " go run ./cmd/render-skeleton -output ./examples/full-monorepo")
+ os.Exit(1)
+ }
+
+ // Base variables for the project
+ vars := map[string]string{
+ "PROJECT_NAME": *projectName,
+ "GO_MODULE": *goModule,
+ "DOMAIN": *domain,
+ "DESCRIPTION": "Test project for skeleton verification",
+ "GIT_URL": "https://" + strings.TrimPrefix(*goModule, "git."),
+ }
+
+ fmt.Printf("Rendering skeleton to %s\n", *outputDir)
+ fmt.Printf(" PROJECT_NAME: %s\n", vars["PROJECT_NAME"])
+ fmt.Printf(" GO_MODULE: %s\n", vars["GO_MODULE"])
+ fmt.Printf(" DOMAIN: %s\n", vars["DOMAIN"])
+ fmt.Println()
+
+ // Clean output directory if it exists (but preserve .git if present)
+ if err := cleanOutputDir(*outputDir); err != nil {
+ fmt.Fprintf(os.Stderr, "Error cleaning output directory: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Render skeleton
+ fmt.Println("Rendering skeleton...")
+ if err := templates.RenderSkeletonToDir(*outputDir, vars); err != nil {
+ fmt.Fprintf(os.Stderr, "Error rendering skeleton: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Render all components
+ for _, c := range defaultComponents {
+ fmt.Printf("Rendering component: %s (%s)\n", c.Name, c.Type)
+
+ // Component-specific variables
+ componentVars := copyVars(vars)
+ componentVars["COMPONENT_NAME"] = c.Name
+ componentVars["PORT"] = c.Port
+
+ // For frontend apps, inject the primary service name/port for API proxy
+ if strings.HasPrefix(c.Type, "app-") {
+ if svc := findFirstService(defaultComponents); svc != nil {
+ componentVars["SERVICE_NAME"] = svc.Name
+ componentVars["SERVICE_PORT"] = svc.Port
+ }
+ }
+
+ // Determine destination path
+ destPath := filepath.Join(c.DestDir, c.Name)
+
+ if err := templates.RenderComponentToDir(*outputDir, c.Type, destPath, componentVars); err != nil {
+ fmt.Fprintf(os.Stderr, "Error rendering component %s: %v\n", c.Name, err)
+ os.Exit(1)
+ }
+ }
+
+ // Update monorepo files
+ fmt.Println("\nUpdating monorepo files...")
+ if err := updateGoWork(*outputDir, defaultComponents); err != nil {
+ fmt.Fprintf(os.Stderr, "Error updating go.work: %v\n", err)
+ os.Exit(1)
+ }
+ if err := updateProcfile(*outputDir, defaultComponents); err != nil {
+ fmt.Fprintf(os.Stderr, "Error updating Procfile: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Remove .gitkeep files from directories that now have content
+ if err := removeGitkeeps(*outputDir); err != nil {
+ fmt.Fprintf(os.Stderr, "Error removing .gitkeep files: %v\n", err)
+ os.Exit(1)
+ }
+
+ fmt.Println("\nDone!")
+ fmt.Printf("Rendered %d components to %s\n", len(defaultComponents), *outputDir)
+ fmt.Println("\nNext steps:")
+ fmt.Println(" cd " + *outputDir)
+ fmt.Println(" go work sync")
+ fmt.Println(" go build ./...")
+ fmt.Println(" pnpm install")
+ fmt.Println(" pnpm -r typecheck")
+}
+
+// cleanOutputDir removes existing content but preserves .git directory.
+func cleanOutputDir(outputDir string) error {
+ entries, err := os.ReadDir(outputDir)
+ if os.IsNotExist(err) {
+ return os.MkdirAll(outputDir, 0755)
+ }
+ if err != nil {
+ return err
+ }
+
+ for _, entry := range entries {
+ if entry.Name() == ".git" {
+ continue // preserve .git
+ }
+ path := filepath.Join(outputDir, entry.Name())
+ if err := os.RemoveAll(path); err != nil {
+ return fmt.Errorf("failed to remove %s: %w", path, err)
+ }
+ }
+ return nil
+}
+
+// copyVars creates a copy of a variables map.
+func copyVars(vars map[string]string) map[string]string {
+ return maps.Clone(vars)
+}
+
+// updateGoWork adds component modules to go.work.
+func updateGoWork(outputDir string, components []Component) error {
+ var sb strings.Builder
+ sb.WriteString("go 1.25\n\n")
+ sb.WriteString("use ./pkg\n")
+
+ for _, c := range components {
+ // Only Go components get added to go.work
+ switch c.Type {
+ case "service", "worker", "cli":
+ fmt.Fprintf(&sb, "use ./%s/%s\n", c.DestDir, c.Name)
+ }
+ }
+
+ return os.WriteFile(filepath.Join(outputDir, "go.work"), []byte(sb.String()), 0644)
+}
+
+// updateProcfile adds component processes to Procfile.
+func updateProcfile(outputDir string, components []Component) error {
+ var lines []string
+ lines = append(lines, "# Local development processes")
+ lines = append(lines, "")
+
+ for _, c := range components {
+ var cmd string
+ switch c.Type {
+ case "service":
+ cmd = fmt.Sprintf("%s: cd %s/%s && make dev", c.Name, c.DestDir, c.Name)
+ case "worker":
+ cmd = fmt.Sprintf("%s: cd %s/%s && make dev", c.Name, c.DestDir, c.Name)
+ case "cli":
+ // CLIs don't run as processes
+ continue
+ case "app-astro", "app-react":
+ cmd = fmt.Sprintf("%s: cd %s/%s && pnpm dev", c.Name, c.DestDir, c.Name)
+ case "app-nextjs":
+ cmd = fmt.Sprintf("%s: cd %s/%s && pnpm dev", c.Name, c.DestDir, c.Name)
+ default:
+ continue
+ }
+ lines = append(lines, cmd)
+ }
+ lines = append(lines, "")
+
+ content := strings.Join(lines, "\n")
+ return os.WriteFile(filepath.Join(outputDir, "Procfile"), []byte(content), 0644)
+}
+
+// findFirstService returns the first service component, or nil if none.
+func findFirstService(components []Component) *Component {
+ for i := range components {
+ if components[i].Type == "service" {
+ return &components[i]
+ }
+ }
+ return nil
+}
+
+// removeGitkeeps removes .gitkeep files from directories that have other content.
+func removeGitkeeps(outputDir string) error {
+ keepDirs := []string{"services", "workers", "apps", "cli", "packages"}
+
+ for _, dir := range keepDirs {
+ gitkeepPath := filepath.Join(outputDir, dir, ".gitkeep")
+ dirPath := filepath.Join(outputDir, dir)
+
+ entries, err := os.ReadDir(dirPath)
+ if err != nil {
+ continue // directory might not exist
+ }
+
+ // If there's more than just .gitkeep, remove it
+ if len(entries) > 1 {
+ _ = os.Remove(gitkeepPath) // Ignore error - file may not exist
+ }
+ }
+
+ return nil
+}
diff --git a/cookbooks/scripts/common.sh b/cookbooks/scripts/common.sh
index a061a58..fc7e17e 100755
--- a/cookbooks/scripts/common.sh
+++ b/cookbooks/scripts/common.sh
@@ -95,7 +95,7 @@ api_call() {
# Returns: 0 on success, 1 on failure, 2 on timeout
wait_for_build() {
local task_id="$1"
- local max_attempts="${2:-60}" # 5 minutes default (5s * 60)
+ local max_attempts="${2:-120}" # 10 minutes default (5s * 120)
local poll_interval="${3:-5}"
local attempt=0
@@ -155,7 +155,7 @@ wait_for_build() {
# the pipeline has already failed.
wait_for_pipeline() {
local project_id="$1"
- local max_attempts="${2:-60}" # 5 minutes default
+ local max_attempts="${2:-120}" # 10 minutes default
local poll_interval="${3:-5}"
local attempt=0
local tracked_pipeline="" # Track specific pipeline once found
diff --git a/cookbooks/scripts/tree-runner.sh b/cookbooks/scripts/tree-runner.sh
index 5b1f015..9cf7651 100755
--- a/cookbooks/scripts/tree-runner.sh
+++ b/cookbooks/scripts/tree-runner.sh
@@ -120,7 +120,7 @@ execute_wait_pipeline_step() {
local project_id max_attempts poll_interval
project_id=$(echo "$step_json" | jq -r '.project_id')
- max_attempts=$(echo "$step_json" | jq -r '.max_attempts // 60')
+ max_attempts=$(echo "$step_json" | jq -r '.max_attempts // 120')
poll_interval=$(echo "$step_json" | jq -r '.poll_interval // 5')
wait_for_pipeline "$project_id" "$max_attempts" "$poll_interval"
diff --git a/cookbooks/trees/aeries-1-genesis.yaml b/cookbooks/trees/aeries-1-genesis.yaml
index a37f7cf..5e1a21e 100644
--- a/cookbooks/trees/aeries-1-genesis.yaml
+++ b/cookbooks/trees/aeries-1-genesis.yaml
@@ -77,7 +77,7 @@ steps:
depends_on: [spec-feature]
action: wait_build
build_id: "{{ .outputs.spec-feature.build_id }}"
- max_attempts: 60
+ max_attempts: 120
poll_interval: 5
implement-backend:
diff --git a/cookbooks/trees/composable-app.yaml b/cookbooks/trees/composable-app.yaml
index 612a2c3..1bb6efd 100644
--- a/cookbooks/trees/composable-app.yaml
+++ b/cookbooks/trees/composable-app.yaml
@@ -62,7 +62,7 @@ steps:
depends_on: [verify-components]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
- max_attempts: 60
+ max_attempts: 120
poll_interval: 5
on_error: continue
diff --git a/cookbooks/trees/evolving-app.yaml b/cookbooks/trees/evolving-app.yaml
index 11a943e..8b445b0 100644
--- a/cookbooks/trees/evolving-app.yaml
+++ b/cookbooks/trees/evolving-app.yaml
@@ -39,7 +39,7 @@ steps:
depends_on: [add-service]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
- max_attempts: 60
+ max_attempts: 120
# --- Phase 2: Evolve (Add Feature) ---
create-feature:
@@ -71,7 +71,7 @@ steps:
depends_on: [generate-spec]
action: wait_build
build_id: "{{ .outputs.generate-spec.build_id }}"
- max_attempts: 60
+ max_attempts: 120
poll_interval: 5
check-artifact:
diff --git a/cookbooks/trees/foundary-refine.yaml b/cookbooks/trees/foundary-refine.yaml
index 83ab504..3602182 100644
--- a/cookbooks/trees/foundary-refine.yaml
+++ b/cookbooks/trees/foundary-refine.yaml
@@ -136,7 +136,7 @@ steps:
depends_on: [wait-deploy-2]
action: wait_site
domain: "{{ .vars.domain }}"
- max_attempts: 60
+ max_attempts: 120
verify-complete:
description: "Print success summary"
diff --git a/cookbooks/trees/foundary.yaml b/cookbooks/trees/foundary.yaml
index 609c549..3bc65cc 100644
--- a/cookbooks/trees/foundary.yaml
+++ b/cookbooks/trees/foundary.yaml
@@ -86,7 +86,7 @@ steps:
depends_on: [wait-components]
action: wait_site
domain: "{{ .outputs.create-project.domain }}"
- max_attempts: 60
+ max_attempts: 120
on_error: continue
# ============================================================
diff --git a/cookbooks/trees/full-stack-feature.yaml b/cookbooks/trees/full-stack-feature.yaml
index 752873a..b2183e9 100644
--- a/cookbooks/trees/full-stack-feature.yaml
+++ b/cookbooks/trees/full-stack-feature.yaml
@@ -39,7 +39,7 @@ steps:
depends_on: [add-service]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
- max_attempts: 60
+ max_attempts: 120
# --- Phase 2: SDLC Process (Spec & Design) ---
create-feature:
@@ -71,7 +71,7 @@ steps:
depends_on: [generate-spec]
action: wait_build
build_id: "{{ .outputs.generate-spec.build_id }}"
- max_attempts: 60
+ max_attempts: 120
poll_interval: 5
approve-spec:
@@ -101,7 +101,7 @@ steps:
depends_on: [generate-design]
action: wait_build
build_id: "{{ .outputs.generate-design.build_id }}"
- max_attempts: 60
+ max_attempts: 120
poll_interval: 5
approve-design:
@@ -152,7 +152,7 @@ steps:
depends_on: [wait-implementation]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
- max_attempts: 60
+ max_attempts: 120
# --- Phase 4: Verification ---
verify-crud:
diff --git a/cookbooks/trees/genkit-test.yaml b/cookbooks/trees/genkit-test.yaml
new file mode 100644
index 0000000..ef50f63
--- /dev/null
+++ b/cookbooks/trees/genkit-test.yaml
@@ -0,0 +1,208 @@
+name: genkit-test
+description: "Validate AI generation packages with a chat UI and API backend"
+version: 1
+
+vars:
+ project_name: ""
+ service_name: "ai-gateway"
+ app_name: "chat-ui"
+ feature_slug: "ai-chat"
+
+steps:
+ # --- Infrastructure ---
+ create-project:
+ action: api
+ method: POST
+ endpoint: /project
+ body:
+ name: "{{ .vars.project_name }}"
+ description: "Genkit validation: AI chat with text and image generation"
+ outputs:
+ - project_id: .data.name
+ - domain: .data.domain
+
+ add-service:
+ description: Add AI gateway service
+ depends_on: [create-project]
+ action: api
+ method: POST
+ endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
+ body:
+ type: service
+ name: "{{ .vars.service_name }}"
+
+ add-ui:
+ description: Add React chat UI
+ depends_on: [create-project]
+ action: api
+ method: POST
+ endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
+ body:
+ type: app-react
+ name: "{{ .vars.app_name }}"
+
+ wait-init:
+ depends_on: [add-service, add-ui]
+ action: wait_pipeline
+ project_id: "{{ .outputs.create-project.project_id }}"
+ max_attempts: 120
+ poll_interval: 5
+
+ # --- SDLC: Build AI Endpoints ---
+ create-feature:
+ depends_on: [wait-init]
+ action: api
+ method: POST
+ endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features"
+ body:
+ slug: "{{ .vars.feature_slug }}"
+ title: "AI Generation Endpoints"
+
+ implement-ai:
+ description: "Agent implements text and image generation endpoints"
+ depends_on: [create-feature]
+ action: api
+ method: POST
+ endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds"
+ body:
+ prompt: |
+ /implement-feature {{ .vars.feature_slug }} --requirements '
+ Use pkg/textgen and pkg/mediagen to create AI generation endpoints.
+
+ Environment variables available: LAOZHANG_API_KEY, GEMINI_API_KEY
+
+ Endpoints to implement:
+
+ 1. GET /health/providers - Check AI provider connectivity
+ - Initialize textgen.Manager with ProductionConfig
+ - Return provider names and health status
+
+ 2. POST /chat - Text generation
+ Request: {"message": "string", "system_prompt": "string (optional)"}
+ - Use textgen.Manager.GenerateText()
+ - Return: {"response": "string", "provider": "string"}
+
+ 3. POST /generate-image - Image generation
+ Request: {"prompt": "string"}
+ - Use mediagen.Manager.GenerateImage()
+ - Return: {"image_base64": "string", "provider": "string"}
+
+ Use ProductionConfig for both managers (LaoZhang primary, Gemini fallback).
+ Handle errors gracefully with proper HTTP status codes.
+ '
+ auto_commit: true
+ auto_push: true
+ git_clone_url: "https://git.threesix.ai/jordan/{{ .outputs.create-project.project_id }}.git"
+ outputs:
+ - build_id: .data.task_id
+
+ wait-build:
+ description: Wait for agent code generation
+ depends_on: [implement-ai]
+ action: wait_build
+ build_id: "{{ .outputs.implement-ai.build_id }}"
+ max_attempts: 120
+ poll_interval: 5
+
+ wait-deploy:
+ depends_on: [wait-build]
+ action: wait_pipeline
+ project_id: "{{ .outputs.create-project.project_id }}"
+ max_attempts: 120
+ poll_interval: 5
+
+ # --- Verification ---
+ verify-service-health:
+ description: "Verify the AI service is running"
+ depends_on: [wait-deploy]
+ action: shell
+ command: |
+ DOMAIN="{{ .outputs.create-project.domain }}"
+ SERVICE_NAME="{{ .vars.service_name }}"
+
+ HEALTH=$(curl -s "https://$DOMAIN/api/$SERVICE_NAME/health" | jq -r '.data.status // .status // empty')
+ if [ "$HEALTH" == "healthy" ] || [ "$HEALTH" == "ok" ]; then
+ echo "Service healthy"
+ exit 0
+ else
+ echo "Service not healthy: $HEALTH"
+ curl -s "https://$DOMAIN/api/$SERVICE_NAME/health" | jq .
+ exit 1
+ fi
+
+ verify-provider-health:
+ description: "Verify AI providers are accessible"
+ depends_on: [verify-service-health]
+ on_error: continue
+ action: shell
+ command: |
+ DOMAIN="{{ .outputs.create-project.domain }}"
+ SERVICE_NAME="{{ .vars.service_name }}"
+
+ echo "Checking provider health..."
+ RESP=$(curl -s "https://$DOMAIN/api/$SERVICE_NAME/health/providers")
+ echo "$RESP" | jq .
+
+ # Check if we got any provider info (success even if some providers are down)
+ if echo "$RESP" | jq -e '.data // .providers // .' > /dev/null 2>&1; then
+ echo "Provider health endpoint working"
+ exit 0
+ else
+ echo "Provider health check failed"
+ exit 1
+ fi
+
+ verify-text-generation:
+ description: "Test text generation endpoint"
+ depends_on: [verify-provider-health]
+ on_error: continue
+ action: shell
+ command: |
+ DOMAIN="{{ .outputs.create-project.domain }}"
+ SERVICE_NAME="{{ .vars.service_name }}"
+
+ echo "Testing text generation..."
+ RESP=$(curl -s -X POST "https://$DOMAIN/api/$SERVICE_NAME/chat" \
+ -H "Content-Type: application/json" \
+ -d '{"message": "What is 2+2? Reply with just the number."}')
+ echo "$RESP" | jq .
+
+ # Check if we got a response
+ RESPONSE=$(echo "$RESP" | jq -r '.response // .data.response // .text // empty')
+ if [ -n "$RESPONSE" ]; then
+ echo "Text generation working: $RESPONSE"
+ exit 0
+ else
+ echo "Text generation failed"
+ exit 1
+ fi
+
+ verify-image-generation:
+ description: "Test image generation endpoint"
+ depends_on: [verify-text-generation]
+ on_error: continue
+ action: shell
+ command: |
+ DOMAIN="{{ .outputs.create-project.domain }}"
+ SERVICE_NAME="{{ .vars.service_name }}"
+
+ echo "Testing image generation..."
+ RESP=$(curl -s -X POST "https://$DOMAIN/api/$SERVICE_NAME/generate-image" \
+ -H "Content-Type: application/json" \
+ -d '{"prompt": "a simple red circle on white background"}')
+
+ # Check if we got base64 image data
+ IMAGE=$(echo "$RESP" | jq -r '.image_base64 // .data.image_base64 // .image // empty')
+ if [ -n "$IMAGE" ] && [ ${#IMAGE} -gt 100 ]; then
+ echo "Image generation working (got ${#IMAGE} chars of base64)"
+ exit 0
+ else
+ echo "Image generation failed or returned empty"
+ echo "$RESP" | jq .
+ exit 1
+ fi
+
+teardown:
+ - action: api
+ method: DELETE
+ endpoint: "/project/{{ .outputs.create-project.project_id }}"
diff --git a/cookbooks/trees/landing-page.yaml b/cookbooks/trees/landing-page.yaml
index 11d7521..48b32d8 100644
--- a/cookbooks/trees/landing-page.yaml
+++ b/cookbooks/trees/landing-page.yaml
@@ -38,7 +38,7 @@ steps:
depends_on: [add-component]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
- max_attempts: 60
+ max_attempts: 120
poll_interval: 5
on_error: continue
diff --git a/deployments/k8s/base/citadel-agent/configmap.yaml b/deployments/k8s/base/citadel-agent/configmap.yaml
new file mode 100644
index 0000000..56aded1
--- /dev/null
+++ b/deployments/k8s/base/citadel-agent/configmap.yaml
@@ -0,0 +1,27 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: citadel-agent-config
+ namespace: observability
+ labels:
+ app.kubernetes.io/name: citadel-agent
+ app.kubernetes.io/part-of: citadel
+data:
+ # Agent ships logs via HTTP to partner-hosted Citadel
+ CITADEL_HTTP: "true"
+ CITADEL_HTTP_URL: "https://citadel-staging.orchard9.ai"
+
+ # Log routing: agent reads these k8s labels to determine target environment
+ # citadel.io/environment → target Citadel tenant ID
+ # citadel.io/service → service name tag
+ CITADEL_ROUTE_LABEL_ENVIRONMENT: "citadel.io/environment"
+ CITADEL_ROUTE_LABEL_SERVICE: "citadel.io/service"
+
+ # Namespaces to collect logs from
+ # rdev namespace = platform components (rdev-api, rdev-worker, claudebox)
+ # projects namespace = generated project pods
+ CITADEL_NAMESPACES: "rdev,projects,observability"
+
+ # Default environment for pods without citadel.io/environment label
+ # Platform pods (rdev-api, rdev-worker) route here
+ CITADEL_DEFAULT_ENVIRONMENT: "rdev-platform"
diff --git a/deployments/k8s/base/citadel-agent/daemonset.yaml b/deployments/k8s/base/citadel-agent/daemonset.yaml
new file mode 100644
index 0000000..98cb4de
--- /dev/null
+++ b/deployments/k8s/base/citadel-agent/daemonset.yaml
@@ -0,0 +1,92 @@
+apiVersion: apps/v1
+kind: DaemonSet
+metadata:
+ name: citadel-agent
+ namespace: observability
+ labels:
+ app.kubernetes.io/name: citadel-agent
+ app.kubernetes.io/part-of: citadel
+spec:
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: citadel-agent
+ updateStrategy:
+ type: RollingUpdate
+ rollingUpdate:
+ maxUnavailable: 1
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: citadel-agent
+ app.kubernetes.io/part-of: citadel
+ # Label this pod so it routes its OWN logs to rdev-platform
+ citadel.io/environment: rdev-platform
+ citadel.io/service: citadel-agent
+ spec:
+ serviceAccountName: citadel-agent
+ tolerations:
+ # Run on all nodes including control plane
+ - operator: Exists
+ containers:
+ - name: agent
+ image: gcr.io/orchard9/citadel-agent:latest
+ resources:
+ requests:
+ cpu: 50m
+ memory: 64Mi
+ limits:
+ cpu: 200m
+ memory: 256Mi
+ envFrom:
+ - configMapRef:
+ name: citadel-agent-config
+ env:
+ - name: CITADEL_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: citadel-agent
+ key: api-key
+ - name: CITADEL_TENANT_ID
+ valueFrom:
+ secretKeyRef:
+ name: citadel-agent
+ key: tenant-id
+ - name: NODE_NAME
+ valueFrom:
+ fieldRef:
+ fieldPath: spec.nodeName
+ volumeMounts:
+ # Container log files on the node
+ - name: varlog
+ mountPath: /var/log
+ readOnly: true
+ # Container runtime data (for resolving container IDs to pod metadata)
+ - name: containers
+ mountPath: /var/lib/docker/containers
+ readOnly: true
+ # Persistent state (checkpoint offsets survive agent restarts)
+ - name: agent-state
+ mountPath: /var/lib/citadel-agent
+ livenessProbe:
+ httpGet:
+ path: /healthz
+ port: 9090
+ initialDelaySeconds: 10
+ periodSeconds: 30
+ readinessProbe:
+ httpGet:
+ path: /readyz
+ port: 9090
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ volumes:
+ - name: varlog
+ hostPath:
+ path: /var/log
+ - name: containers
+ hostPath:
+ path: /var/lib/docker/containers
+ - name: agent-state
+ hostPath:
+ path: /var/lib/citadel-agent
+ type: DirectoryOrCreate
diff --git a/deployments/k8s/base/citadel-agent/kustomization.yaml b/deployments/k8s/base/citadel-agent/kustomization.yaml
new file mode 100644
index 0000000..cfa5db1
--- /dev/null
+++ b/deployments/k8s/base/citadel-agent/kustomization.yaml
@@ -0,0 +1,13 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+namespace: observability
+
+resources:
+ - namespace.yaml
+ - serviceaccount.yaml
+ - daemonset.yaml
+ - configmap.yaml
+ # NOTE: secret.yaml contains real keys and is gitignored.
+ # Copy from secret.yaml.example and fill in real values before deploying.
+ - secret.yaml
diff --git a/deployments/k8s/base/citadel-agent/namespace.yaml b/deployments/k8s/base/citadel-agent/namespace.yaml
new file mode 100644
index 0000000..6ddaa87
--- /dev/null
+++ b/deployments/k8s/base/citadel-agent/namespace.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: observability
+ labels:
+ app.kubernetes.io/part-of: citadel
diff --git a/deployments/k8s/base/citadel-agent/secret.yaml.example b/deployments/k8s/base/citadel-agent/secret.yaml.example
new file mode 100644
index 0000000..551d5de
--- /dev/null
+++ b/deployments/k8s/base/citadel-agent/secret.yaml.example
@@ -0,0 +1,11 @@
+apiVersion: v1
+kind: Secret
+metadata:
+ name: citadel-agent
+ namespace: observability
+type: Opaque
+stringData:
+ # API key for Citadel (get from: citadel api-key create --name "rdev-agent" --environment live)
+ api-key: "ck_live_REPLACE_ME"
+ # Tenant ID for rdev-platform environment (get from: citadel env create rdev-platform)
+ tenant-id: "REPLACE_ME"
diff --git a/deployments/k8s/base/citadel-agent/serviceaccount.yaml b/deployments/k8s/base/citadel-agent/serviceaccount.yaml
new file mode 100644
index 0000000..ae23cd9
--- /dev/null
+++ b/deployments/k8s/base/citadel-agent/serviceaccount.yaml
@@ -0,0 +1,37 @@
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: citadel-agent
+ namespace: observability
+ labels:
+ app.kubernetes.io/name: citadel-agent
+ app.kubernetes.io/part-of: citadel
+---
+# ClusterRole to read pod metadata for log enrichment
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: citadel-agent
+ labels:
+ app.kubernetes.io/name: citadel-agent
+ app.kubernetes.io/part-of: citadel
+rules:
+ - apiGroups: [""]
+ resources: ["pods", "namespaces"]
+ verbs: ["get", "list", "watch"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ name: citadel-agent
+ labels:
+ app.kubernetes.io/name: citadel-agent
+ app.kubernetes.io/part-of: citadel
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: ClusterRole
+ name: citadel-agent
+subjects:
+ - kind: ServiceAccount
+ name: citadel-agent
+ namespace: observability
diff --git a/deployments/k8s/base/claudebox.yaml b/deployments/k8s/base/claudebox.yaml
index 3ea9102..ca29bbe 100644
--- a/deployments/k8s/base/claudebox.yaml
+++ b/deployments/k8s/base/claudebox.yaml
@@ -19,6 +19,9 @@ spec:
app.kubernetes.io/name: claudebox
app.kubernetes.io/part-of: rdev
rdev.orchard9.ai/role: worker
+ # Citadel agent routes these logs to the rdev-platform environment
+ citadel.io/environment: rdev-platform
+ citadel.io/service: claudebox
spec:
containers:
- name: claudebox
diff --git a/deployments/k8s/base/kustomization.yaml b/deployments/k8s/base/kustomization.yaml
index 3ea9597..00fd58e 100644
--- a/deployments/k8s/base/kustomization.yaml
+++ b/deployments/k8s/base/kustomization.yaml
@@ -36,3 +36,6 @@ resources:
# Wildcard TLS for session preview URLs
- preview-cert.yaml
+ # Citadel log agent (ships container logs to partner-hosted Citadel)
+ - citadel-agent/
+
diff --git a/deployments/k8s/base/rdev-api.yaml b/deployments/k8s/base/rdev-api.yaml
index 83a3d72..f91da38 100644
--- a/deployments/k8s/base/rdev-api.yaml
+++ b/deployments/k8s/base/rdev-api.yaml
@@ -20,6 +20,9 @@ spec:
app: rdev-api
app.kubernetes.io/name: rdev-api
app.kubernetes.io/part-of: rdev
+ # Citadel agent routes these logs to the rdev-platform environment
+ citadel.io/environment: rdev-platform
+ citadel.io/service: rdev-api
spec:
serviceAccountName: rdev-api
containers:
diff --git a/docs/citadel-integration.md b/docs/citadel-integration.md
new file mode 100644
index 0000000..aab218b
--- /dev/null
+++ b/docs/citadel-integration.md
@@ -0,0 +1,186 @@
+# Citadel Integration Plan
+
+rdev integrates with a **partner-hosted Citadel** instance at `citadel-staging.orchard9.ai` for centralized log aggregation, querying, and alerting across both the platform and generated projects.
+
+## Environment Architecture
+
+```
+Organization: orchard9 (on citadel-staging.orchard9.ai)
+│
+├── Environment: "rdev-platform"
+│ ├── rdev-api logs (stdout via agent)
+│ ├── rdev-worker logs (stdout via agent)
+│ ├── claudebox logs (stdout via agent)
+│ └── audit events (shipped from AuditLogger)
+│
+├── Environment: "" (auto-created per project)
+│ └── Project pod logs (stdout via agent, routed by k8s labels)
+│
+├── Tenant Group: "platform" → rdev-platform
+└── Tenant Group: "projects" → all project-* environments
+```
+
+Each project gets its own Citadel environment, matching rdev's isolation model (each project gets its own DB, Redis, DNS, registry namespace).
+
+## Integration Points
+
+### 1. Agent DaemonSet (Log Collection)
+
+**What:** A `citadel-agent` DaemonSet on every k3s node collects container stdout/stderr and ships to Citadel via HTTPS.
+
+**Why:** rdev already outputs structured JSON slog to stdout. Zero code changes needed.
+
+**Routing:** Agent reads k8s labels to route logs to the correct Citadel environment:
+- `citadel.io/environment` label → determines target environment
+- `citadel.io/service` label → tags the service name in Citadel
+
+**Manifests:** `deployments/k8s/base/citadel-agent/`
+
+### 2. Citadel Client Adapter (API Integration)
+
+**What:** Go HTTP client for Citadel's API, following rdev's hexagonal architecture (port interface + adapter).
+
+**Why:** Needed for auto-provisioning environments and shipping audit logs.
+
+**Files:**
+- `internal/port/citadel.go` — Port interface
+- `internal/adapter/citadel/client.go` — HTTP client implementation
+- `internal/adapter/citadel/audit_shipper.go` — Audit log shipping
+
+### 3. Project Provisioning Step
+
+**What:** When `ProjectInfraService.CreateProject()` runs, a new step creates a Citadel environment for the project.
+
+**Why:** Each project needs its own isolated log environment. Auto-provisioning eliminates manual setup.
+
+**Where in the flow:**
+```
+CreateProject()
+ 1. Generate slug
+ 2. Create project in DB
+ 3. Create git repo
+ 4. Create DNS
+ 5. Activate CI
+ 6. Seed template
+ 7. Provision DB + cache
+ 8. *** Create Citadel environment *** ← NEW
+ 9. Create initial deployment
+ 10. Trigger CI build
+```
+
+**Rollback:** On project deletion, the Citadel environment is deleted too.
+
+### 4. Skeleton Template Labels
+
+**What:** Add `citadel.io/*` labels to the skeleton's k8s deployment templates.
+
+**Why:** The agent uses these labels to route project logs to the correct Citadel environment.
+
+**Template vars:** `{{CITADEL_TENANT_ID}}` injected during provisioning.
+
+### 5. Audit Log Shipping
+
+**What:** Hook into rdev's existing `AuditLogger` to also ship audit events to Citadel.
+
+**Why:** Unified search across application logs and security events.
+
+**Pattern:** Wrap the existing PostgreSQL `AuditLogger` with a multi-writer that sends to both PostgreSQL and Citadel.
+
+## Configuration
+
+### Environment Variables (rdev-api)
+
+| Variable | Description | Example |
+|----------|-------------|---------|
+| `CITADEL_URL` | Partner Citadel instance URL | `https://citadel-staging.orchard9.ai` |
+| `CITADEL_API_KEY` | API key for environment management | `ck_live_...` |
+| `CITADEL_TENANT_ID` | Platform environment tenant ID | `uuid` |
+| `CITADEL_ENABLED` | Enable/disable Citadel integration | `true` |
+
+### Secrets (k8s)
+
+```yaml
+# deployments/k8s/base/citadel-agent/secret.yaml
+apiVersion: v1
+kind: Secret
+metadata:
+ name: citadel-agent
+ namespace: observability
+type: Opaque
+stringData:
+ api-key: "ck_live_..."
+ tenant-id: "..."
+```
+
+## Queries (What You Get)
+
+### Platform Operations
+
+```bash
+# All errors across platform
+citadel query "level:error" --tenant rdev-platform --last 1h
+
+# Track a project lifecycle
+citadel query "project_id:my-project" --tenant rdev-platform --last 24h
+
+# Build failures
+citadel query "component:build level:error" --tenant rdev-platform
+
+# Saga failures
+citadel query "component:saga result:failure" --tenant rdev-platform
+
+# Slow API requests
+citadel query "duration_ms:>5000 component:http" --tenant rdev-platform
+
+# Audit trail
+citadel query "action:project.create" --tenant rdev-platform --last 7d
+```
+
+### Generated Projects
+
+```bash
+# Query a specific project's logs
+citadel query "level:error" --tenant --last 1h
+
+# All project errors at once
+citadel query "level:error" --group projects --last 1h
+
+# Correlate platform + project
+citadel query "project_id:my-project" --last 24h # org-wide
+```
+
+## Implementation Order
+
+| Phase | Task | Effort |
+|-------|------|--------|
+| 1 | Write Citadel client adapter (`internal/adapter/citadel/`) | 2h |
+| 1 | Write port interface (`internal/port/citadel.go`) | 30m |
+| 2 | Deploy agent DaemonSet (`deployments/k8s/base/citadel-agent/`) | 1h |
+| 2 | Add citadel labels to existing rdev-api/worker manifests | 15m |
+| 3 | Add Citadel env creation to `ProjectInfraService.CreateProject()` | 2h |
+| 3 | Add Citadel env deletion to `ProjectInfraService.DeleteProject()` | 30m |
+| 3 | Add migration for `citadel_tenant_id` column on projects table | 15m |
+| 4 | Add `citadel.io/*` labels to skeleton k8s templates | 30m |
+| 4 | Add `CITADEL_TENANT_ID` to template variables | 15m |
+| 5 | Implement audit log shipper | 2h |
+| 5 | Wire into main.go startup | 30m |
+
+## Files Created/Modified
+
+### New Files
+- `internal/port/citadel.go`
+- `internal/adapter/citadel/client.go`
+- `internal/adapter/citadel/audit_shipper.go`
+- `deployments/k8s/base/citadel-agent/kustomization.yaml`
+- `deployments/k8s/base/citadel-agent/daemonset.yaml`
+- `deployments/k8s/base/citadel-agent/serviceaccount.yaml`
+- `deployments/k8s/base/citadel-agent/configmap.yaml`
+- `internal/db/migrations/024_citadel_tenant_id.sql`
+
+### Modified Files
+- `deployments/k8s/base/kustomization.yaml` — include citadel-agent
+- `deployments/k8s/base/rdev-api.yaml` — add citadel labels + env vars
+- `internal/service/project_infra.go` — add CitadelClient field
+- `internal/service/project_infra_crud.go` — add provisioning + cleanup steps
+- `internal/adapter/templates/templates/skeleton/` — k8s template labels
+- `cmd/rdev-api/main.go` — wire Citadel adapter
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..544042c
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,45 @@
+# Examples
+
+This directory contains example projects that are rendered from rdev's skeleton templates.
+
+## full-monorepo
+
+A fully rendered monorepo skeleton with all component types:
+
+| Component | Type | Purpose |
+|-----------|------|---------|
+| `services/example-api` | service | Go REST API |
+| `workers/example-worker` | worker | Go background worker |
+| `apps/example-astro` | app-astro | Astro landing page |
+| `apps/example-react` | app-react | React SPA |
+| `apps/example-nextjs` | app-nextjs | Next.js dashboard |
+| `cli/example-cli` | cli | Go CLI tool |
+
+### Purpose
+
+1. **Template testing**: Ensures templates render correctly and compile
+2. **IDE debugging**: Step through generated code with full syntax highlighting
+3. **Documentation**: Shows what rendered projects look like
+
+### Regenerating
+
+To regenerate after template changes:
+
+```bash
+./scripts/verify-skeleton.sh --update
+```
+
+### Verification
+
+To verify templates are in sync:
+
+```bash
+./scripts/verify-skeleton.sh # Full verification (Go + TypeScript)
+./scripts/verify-skeleton.sh --quick # Skip TypeScript
+```
+
+## How This Works
+
+1. `cmd/render-skeleton/main.go` - CLI tool that renders templates using the actual `templates.Provider`
+2. `internal/adapter/templates/provider.go` - Has `RenderSkeletonToDir` and `RenderComponentToDir` functions
+3. `scripts/verify-skeleton.sh` - CI script that checks sync and attempts builds
diff --git a/go.mod b/go.mod
index 351ecb0..3708a73 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/orchard9/rdev
go 1.25.0
require (
+ cloud.google.com/go/storage v1.59.2
code.gitea.io/sdk/gitea v0.22.1
github.com/bdpiprava/scalar-go v0.13.0
github.com/go-chi/chi/v5 v5.1.0
@@ -18,6 +19,7 @@ require (
go.opentelemetry.io/otel/sdk v1.39.0
go.opentelemetry.io/otel/trace v1.39.0
go.woodpecker-ci.org/woodpecker/v3 v3.13.0
+ google.golang.org/api v0.265.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.35.0
k8s.io/apimachinery v0.35.0
@@ -32,7 +34,6 @@ require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.5.3 // indirect
cloud.google.com/go/monitoring v1.24.3 // indirect
- cloud.google.com/go/storage v1.59.2 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
@@ -95,7 +96,6 @@ require (
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
- google.golang.org/api v0.265.0 // indirect
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
diff --git a/go.sum b/go.sum
index 69915f3..7cc2630 100644
--- a/go.sum
+++ b/go.sum
@@ -10,10 +10,16 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
+cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
+cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
+cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
+cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
cloud.google.com/go/storage v1.59.2 h1:gmOAuG1opU8YvycMNpP+DvHfT9BfzzK5Cy+arP+Nocw=
cloud.google.com/go/storage v1.59.2/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
+cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
+cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA=
code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
@@ -22,6 +28,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
@@ -52,8 +60,11 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cu
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM=
+github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs=
github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo=
github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs=
+github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
+github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -86,6 +97,8 @@ github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7O
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
+github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
@@ -179,6 +192,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNl
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
@@ -237,8 +252,6 @@ google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
diff --git a/internal/adapter/citadel/audit_shipper.go b/internal/adapter/citadel/audit_shipper.go
new file mode 100644
index 0000000..3702b4a
--- /dev/null
+++ b/internal/adapter/citadel/audit_shipper.go
@@ -0,0 +1,161 @@
+package citadel
+
+import (
+ "context"
+ "log/slog"
+ "sync"
+ "time"
+
+ "github.com/orchard9/rdev/internal/domain"
+ "github.com/orchard9/rdev/internal/port"
+)
+
+// AuditShipper wraps an existing AuditLogger and also ships audit events to Citadel.
+// It buffers events and flushes them in batches for efficiency.
+type AuditShipper struct {
+ inner port.AuditLogger
+ client port.CitadelClient
+ tenantID string // rdev-platform tenant ID
+ logger *slog.Logger
+
+ mu sync.Mutex
+ buffer []map[string]any
+ done chan struct{}
+}
+
+// NewAuditShipper wraps an existing AuditLogger with Citadel shipping.
+// tenantID is the Citadel tenant ID for the rdev-platform environment.
+func NewAuditShipper(inner port.AuditLogger, client port.CitadelClient, tenantID string, logger *slog.Logger) *AuditShipper {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ s := &AuditShipper{
+ inner: inner,
+ client: client,
+ tenantID: tenantID,
+ logger: logger.With("component", "audit_shipper"),
+ buffer: make([]map[string]any, 0, 64),
+ done: make(chan struct{}),
+ }
+ go s.flushLoop()
+ return s
+}
+
+// LogCommandStart records the start of a command and ships to Citadel.
+func (s *AuditShipper) LogCommandStart(ctx context.Context, entry *domain.AuditLogEntry) error {
+ // Always write to primary store first
+ if err := s.inner.LogCommandStart(ctx, entry); err != nil {
+ return err
+ }
+
+ // Buffer for Citadel (best-effort, don't block)
+ s.enqueue(map[string]any{
+ "message": "audit: command started",
+ "level": "info",
+ "service": "rdev-platform",
+ "event_type": "audit",
+ "audit_action": "command_start",
+ "command_id": entry.CommandID,
+ "command_type": string(entry.CommandType),
+ "project_id": entry.ProjectID,
+ "api_key_id": entry.APIKeyID,
+ "client_ip": entry.ClientIP,
+ "timestamp": entry.StartedAt.Format(time.RFC3339Nano),
+ })
+
+ return nil
+}
+
+// LogCommandEnd records the completion of a command and ships to Citadel.
+func (s *AuditShipper) LogCommandEnd(ctx context.Context, commandID string, result *domain.AuditResult) error {
+ // Always write to primary store first
+ if err := s.inner.LogCommandEnd(ctx, commandID, result); err != nil {
+ return err
+ }
+
+ // Buffer for Citadel (best-effort)
+ s.enqueue(map[string]any{
+ "message": "audit: command completed",
+ "level": auditStatusToLevel(result.Status),
+ "service": "rdev-platform",
+ "event_type": "audit",
+ "audit_action": "command_end",
+ "command_id": commandID,
+ "status": string(result.Status),
+ "exit_code": result.ExitCode,
+ "duration_ms": result.DurationMs,
+ "error_message": result.ErrorMessage,
+ "output_size_bytes": result.OutputSizeBytes,
+ "timestamp": time.Now().Format(time.RFC3339Nano),
+ })
+
+ return nil
+}
+
+// List delegates to the inner AuditLogger.
+func (s *AuditShipper) List(ctx context.Context, filters domain.AuditFilters) ([]domain.AuditLogEntry, error) {
+ return s.inner.List(ctx, filters)
+}
+
+// Get delegates to the inner AuditLogger.
+func (s *AuditShipper) Get(ctx context.Context, commandID string) (*domain.AuditLogEntry, error) {
+ return s.inner.Get(ctx, commandID)
+}
+
+// Close flushes remaining events and stops the background goroutine.
+func (s *AuditShipper) Close() {
+ close(s.done)
+ s.flush()
+}
+
+func (s *AuditShipper) enqueue(event map[string]any) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.buffer = append(s.buffer, event)
+}
+
+func (s *AuditShipper) flushLoop() {
+ ticker := time.NewTicker(5 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ s.flush()
+ case <-s.done:
+ return
+ }
+ }
+}
+
+func (s *AuditShipper) flush() {
+ s.mu.Lock()
+ if len(s.buffer) == 0 {
+ s.mu.Unlock()
+ return
+ }
+ events := s.buffer
+ s.buffer = make([]map[string]any, 0, 64)
+ s.mu.Unlock()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ if err := s.client.IngestBatch(ctx, s.tenantID, events); err != nil {
+ s.logger.Warn("failed to ship audit events to citadel",
+ "error", err,
+ "event_count", len(events),
+ )
+ }
+}
+
+func auditStatusToLevel(status domain.AuditStatus) string {
+ switch status {
+ case domain.AuditStatusError:
+ return "error"
+ case domain.AuditStatusCancelled:
+ return "warn"
+ default:
+ return "info"
+ }
+}
diff --git a/internal/adapter/citadel/client.go b/internal/adapter/citadel/client.go
new file mode 100644
index 0000000..2aa2743
--- /dev/null
+++ b/internal/adapter/citadel/client.go
@@ -0,0 +1,244 @@
+// Package citadel provides an HTTP client adapter for the Citadel logging platform.
+//
+// This adapter communicates with a partner-hosted Citadel instance
+// (e.g., citadel-staging.orchard9.ai) to manage environments and ship logs.
+package citadel
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "time"
+
+ "github.com/orchard9/rdev/internal/port"
+)
+
+// Client implements port.CitadelClient via HTTP.
+type Client struct {
+ baseURL string
+ apiKey string
+ httpClient *http.Client
+ logger *slog.Logger
+}
+
+// Ensure Client implements port.CitadelClient.
+var _ port.CitadelClient = (*Client)(nil)
+
+// Config holds configuration for the Citadel client.
+type Config struct {
+ // URL is the base URL of the Citadel instance (e.g., "https://citadel-staging.orchard9.ai").
+ URL string
+ // APIKey is the API key for authentication (starts with "ck_live_" or "ck_dev_").
+ APIKey string
+}
+
+// NewClient creates a new Citadel HTTP client.
+func NewClient(cfg Config, logger *slog.Logger) *Client {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &Client{
+ baseURL: cfg.URL,
+ apiKey: cfg.APIKey,
+ httpClient: &http.Client{
+ Timeout: 30 * time.Second,
+ },
+ logger: logger.With("component", "citadel_client"),
+ }
+}
+
+// CreateEnvironment creates a new Citadel environment.
+func (c *Client) CreateEnvironment(ctx context.Context, name string) (*port.CitadelEnvironment, error) {
+ body := map[string]string{"name": name}
+ data, err := json.Marshal(body)
+ if err != nil {
+ return nil, fmt.Errorf("marshal request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/environments", bytes.NewReader(data))
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+ c.setHeaders(req)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("create environment request failed: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode == http.StatusConflict {
+ // Environment already exists — fetch and return it
+ c.logger.Info("citadel environment already exists, fetching", "name", name)
+ return c.GetEnvironment(ctx, name)
+ }
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, c.readError(resp)
+ }
+
+ var result struct {
+ TenantID string `json:"tenant_id"`
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("decode response: %w", err)
+ }
+
+ c.logger.Info("citadel environment created", "name", name, "tenant_id", result.TenantID)
+
+ return &port.CitadelEnvironment{
+ TenantID: result.TenantID,
+ Name: result.Name,
+ }, nil
+}
+
+// DeleteEnvironment removes a Citadel environment.
+func (c *Client) DeleteEnvironment(ctx context.Context, tenantID string) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL+"/api/v1/environments/"+tenantID, nil)
+ if err != nil {
+ return fmt.Errorf("create request: %w", err)
+ }
+ c.setHeaders(req)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("delete environment request failed: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // 404 is fine — environment may already be gone
+ if resp.StatusCode == http.StatusNotFound {
+ return nil
+ }
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return c.readError(resp)
+ }
+
+ c.logger.Info("citadel environment deleted", "tenant_id", tenantID)
+ return nil
+}
+
+// GetEnvironment returns an environment by name.
+func (c *Client) GetEnvironment(ctx context.Context, name string) (*port.CitadelEnvironment, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/environments?name="+name, nil)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+ c.setHeaders(req)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("get environment request failed: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode == http.StatusNotFound {
+ return nil, nil
+ }
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, c.readError(resp)
+ }
+
+ var result struct {
+ TenantID string `json:"tenant_id"`
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("decode response: %w", err)
+ }
+
+ return &port.CitadelEnvironment{
+ TenantID: result.TenantID,
+ Name: result.Name,
+ }, nil
+}
+
+// IngestEvent sends a single log event to Citadel.
+func (c *Client) IngestEvent(ctx context.Context, tenantID string, event map[string]any) error {
+ data, err := json.Marshal(event)
+ if err != nil {
+ return fmt.Errorf("marshal event: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/ingest/event", bytes.NewReader(data))
+ if err != nil {
+ return fmt.Errorf("create request: %w", err)
+ }
+ c.setHeaders(req)
+ req.Header.Set("X-Tenant-ID", tenantID)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("ingest event request failed: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return c.readError(resp)
+ }
+
+ return nil
+}
+
+// IngestBatch sends a batch of log events to Citadel.
+func (c *Client) IngestBatch(ctx context.Context, tenantID string, events []map[string]any) error {
+ data, err := json.Marshal(events)
+ if err != nil {
+ return fmt.Errorf("marshal events: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/ingest", bytes.NewReader(data))
+ if err != nil {
+ return fmt.Errorf("create request: %w", err)
+ }
+ c.setHeaders(req)
+ req.Header.Set("X-Tenant-ID", tenantID)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("ingest batch request failed: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return c.readError(resp)
+ }
+
+ return nil
+}
+
+// Healthy returns true if the Citadel instance is reachable.
+func (c *Client) Healthy(ctx context.Context) bool {
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil)
+ if err != nil {
+ return false
+ }
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return false
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ return resp.StatusCode == http.StatusOK
+}
+
+func (c *Client) setHeaders(req *http.Request) {
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+}
+
+func (c *Client) readError(resp *http.Response) error {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
+ return fmt.Errorf("citadel API error (HTTP %d): %s", resp.StatusCode, string(body))
+}
diff --git a/internal/adapter/deployer/resources.go b/internal/adapter/deployer/resources.go
index e2aa8f3..962d701 100644
--- a/internal/adapter/deployer/resources.go
+++ b/internal/adapter/deployer/resources.go
@@ -156,6 +156,10 @@ func (d *Deployer) buildDeployment(spec domain.DeploySpec, ns string, replicas i
if spec.ComponentPath != "" {
labels["component"] = sanitizeLabelValue(spec.ComponentPath)
}
+ // Apply extra labels (e.g., citadel.io/environment for log routing)
+ for k, v := range spec.ExtraLabels {
+ labels[k] = v
+ }
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
diff --git a/internal/adapter/templates/components.go b/internal/adapter/templates/components.go
new file mode 100644
index 0000000..81e9f17
--- /dev/null
+++ b/internal/adapter/templates/components.go
@@ -0,0 +1,341 @@
+package templates
+
+import (
+ "context"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/orchard9/rdev/internal/domain"
+ "github.com/orchard9/rdev/internal/port"
+)
+
+// availableComponentTemplates lists all supported component templates.
+var availableComponentTemplates = []port.ComponentTemplateInfo{
+ {
+ Type: "service",
+ Description: "Go API service using pkg/ shared packages",
+ Stack: "go",
+ DefaultPort: 8080,
+ DestDir: "services",
+ },
+ {
+ Type: "worker",
+ Description: "Go background worker for async job processing",
+ Stack: "go",
+ DefaultPort: 0, // Workers don't expose ports
+ DestDir: "workers",
+ },
+ {
+ Type: "app-astro",
+ Description: "Astro landing page with Tailwind CSS",
+ Stack: "astro",
+ DefaultPort: 4321,
+ DestDir: "apps",
+ },
+ {
+ Type: "app-react",
+ Description: "React SPA with Vite, TypeScript, and Tailwind",
+ Stack: "react",
+ DefaultPort: 5173,
+ DestDir: "apps",
+ },
+ {
+ Type: "app-nextjs",
+ Description: "Next.js 14 dashboard with App Router and design system",
+ Stack: "nextjs",
+ DefaultPort: 3000,
+ DestDir: "apps",
+ },
+ {
+ Type: "cli",
+ Description: "Go CLI tool using Cobra",
+ Stack: "go",
+ DefaultPort: 0, // CLIs don't expose ports
+ DestDir: "cli",
+ },
+}
+
+// GetComponentTemplate returns info about a specific component template.
+func (p *Provider) GetComponentTemplate(ctx context.Context, componentType string) (*port.ComponentTemplateInfo, error) {
+ // Check for context cancellation
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ default:
+ }
+
+ for _, t := range availableComponentTemplates {
+ if t.Type == componentType {
+ result := t
+ files, err := listComponentTemplateFiles(componentType)
+ if err == nil {
+ result.Files = files
+ }
+ return &result, nil
+ }
+ }
+ return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType)
+}
+
+// ListComponentTemplates returns available component templates.
+// If componentType is empty, returns all templates; otherwise filters by type.
+func (p *Provider) ListComponentTemplates(ctx context.Context, componentType string) ([]port.ComponentTemplateInfo, error) {
+ // Check for context cancellation
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ default:
+ }
+
+ var result []port.ComponentTemplateInfo
+ for _, t := range availableComponentTemplates {
+ if componentType != "" && t.Type != componentType {
+ continue
+ }
+ info := t
+ files, err := listComponentTemplateFiles(t.Type)
+ if err == nil {
+ info.Files = files
+ }
+ result = append(result, info)
+ }
+ return result, nil
+}
+
+// GetComponentFiles returns the files for a component template with variables interpolated.
+func (p *Provider) GetComponentFiles(ctx context.Context, componentType string, destPath string, vars map[string]string) ([]port.ComponentFile, error) {
+ // Check for context cancellation
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ default:
+ }
+
+ // Validate component type exists
+ found := false
+ for _, t := range availableComponentTemplates {
+ if t.Type == componentType {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType)
+ }
+
+ templateDir := "templates/components/" + componentType
+ var files []port.ComponentFile
+
+ err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+
+ // Read file content
+ content, err := templatesFS.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("failed to read component template file %s: %w", path, err)
+ }
+
+ // Interpolate variables
+ interpolated := interpolateVars(string(content), vars)
+
+ // Calculate relative path from component template root
+ relPath, err := filepath.Rel(templateDir, path)
+ if err != nil {
+ return fmt.Errorf("failed to get relative path: %w", err)
+ }
+
+ // Strip .tmpl extension
+ relPath = strings.TrimSuffix(relPath, ".tmpl")
+
+ // Prepend destination path
+ fullPath := filepath.Join(destPath, relPath)
+
+ files = append(files, port.ComponentFile{
+ Path: fullPath,
+ Content: interpolated,
+ })
+
+ return nil
+ })
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to collect component template files: %w", err)
+ }
+
+ if len(files) == 0 {
+ return nil, fmt.Errorf("component template %s contains no files", componentType)
+ }
+
+ p.logger.Debug("prepared component files",
+ "component_type", componentType,
+ "dest_path", destPath,
+ "file_count", len(files),
+ )
+
+ return files, nil
+}
+
+// listComponentTemplateFiles returns the list of files in a component template.
+func listComponentTemplateFiles(componentType string) ([]string, error) {
+ templateDir := "templates/components/" + componentType
+ var files []string
+
+ err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ relPath, err := filepath.Rel(templateDir, path)
+ if err != nil {
+ return err
+ }
+ // Strip .tmpl extension for display
+ relPath = strings.TrimSuffix(relPath, ".tmpl")
+ files = append(files, relPath)
+ return nil
+ })
+
+ return files, err
+}
+
+// GetComponentWoodpeckerStep returns the .woodpecker.step.yml content for a component.
+// This is the CI step that should be inserted into the main .woodpecker.yml file.
+func (p *Provider) GetComponentWoodpeckerStep(ctx context.Context, componentType string, vars map[string]string) (string, error) {
+ // Check for context cancellation
+ select {
+ case <-ctx.Done():
+ return "", ctx.Err()
+ default:
+ }
+
+ stepPath := "templates/components/" + componentType + "/.woodpecker.step.yml.tmpl"
+ content, err := templatesFS.ReadFile(stepPath)
+ if err != nil {
+ return "", fmt.Errorf("failed to read woodpecker step template: %w", err)
+ }
+
+ return interpolateVars(string(content), vars), nil
+}
+
+// RenderSkeletonToDir renders the monorepo skeleton template to a local directory.
+// This is used for testing templates locally without needing Gitea.
+func RenderSkeletonToDir(outputDir string, vars map[string]string) error {
+ templateDir := "templates/skeleton"
+
+ return fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+
+ // Read file content
+ content, err := templatesFS.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("failed to read template file %s: %w", path, err)
+ }
+
+ // Interpolate variables
+ interpolated := interpolateVars(string(content), vars)
+
+ // Calculate relative path from template root
+ relPath, err := filepath.Rel(templateDir, path)
+ if err != nil {
+ return fmt.Errorf("failed to get relative path: %w", err)
+ }
+
+ // Strip .tmpl extension
+ relPath = strings.TrimSuffix(relPath, ".tmpl")
+
+ // Create output path
+ outPath := filepath.Join(outputDir, relPath)
+
+ // Create parent directories
+ if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
+ return fmt.Errorf("failed to create directory for %s: %w", outPath, err)
+ }
+
+ // Write file
+ if err := os.WriteFile(outPath, []byte(interpolated), 0644); err != nil {
+ return fmt.Errorf("failed to write file %s: %w", outPath, err)
+ }
+
+ return nil
+ })
+}
+
+// RenderComponentToDir renders a component template to a local directory.
+// The destPath is relative to outputDir (e.g., "services/my-api").
+func RenderComponentToDir(outputDir, componentType, destPath string, vars map[string]string) error {
+ // Validate component type exists
+ found := false
+ for _, t := range availableComponentTemplates {
+ if t.Type == componentType {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return fmt.Errorf("unknown component type: %s", componentType)
+ }
+
+ templateDir := "templates/components/" + componentType
+
+ return fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+
+ // Skip .woodpecker.step.yml.tmpl - it's for CI insertion, not file creation
+ if strings.HasSuffix(path, ".woodpecker.step.yml.tmpl") {
+ return nil
+ }
+
+ // Read file content
+ content, err := templatesFS.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("failed to read component template file %s: %w", path, err)
+ }
+
+ // Interpolate variables
+ interpolated := interpolateVars(string(content), vars)
+
+ // Calculate relative path from component template root
+ relPath, err := filepath.Rel(templateDir, path)
+ if err != nil {
+ return fmt.Errorf("failed to get relative path: %w", err)
+ }
+
+ // Strip .tmpl extension
+ relPath = strings.TrimSuffix(relPath, ".tmpl")
+
+ // Create output path under destPath
+ outPath := filepath.Join(outputDir, destPath, relPath)
+
+ // Create parent directories
+ if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
+ return fmt.Errorf("failed to create directory for %s: %w", outPath, err)
+ }
+
+ // Write file
+ if err := os.WriteFile(outPath, []byte(interpolated), 0644); err != nil {
+ return fmt.Errorf("failed to write file %s: %w", outPath, err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/adapter/templates/provider.go b/internal/adapter/templates/provider.go
index 93862e8..85dbc19 100644
--- a/internal/adapter/templates/provider.go
+++ b/internal/adapter/templates/provider.go
@@ -43,52 +43,6 @@ var skeletonTemplate = port.TemplateInfo{
Stack: "monorepo",
}
-// availableComponentTemplates lists all supported component templates.
-var availableComponentTemplates = []port.ComponentTemplateInfo{
- {
- Type: "service",
- Description: "Go API service using pkg/ shared packages",
- Stack: "go",
- DefaultPort: 8080,
- DestDir: "services",
- },
- {
- Type: "worker",
- Description: "Go background worker for async job processing",
- Stack: "go",
- DefaultPort: 0, // Workers don't expose ports
- DestDir: "workers",
- },
- {
- Type: "app-astro",
- Description: "Astro landing page with Tailwind CSS",
- Stack: "astro",
- DefaultPort: 4321,
- DestDir: "apps",
- },
- {
- Type: "app-react",
- Description: "React SPA with Vite, TypeScript, and Tailwind",
- Stack: "react",
- DefaultPort: 5173,
- DestDir: "apps",
- },
- {
- Type: "app-nextjs",
- Description: "Next.js 14 dashboard with App Router and design system",
- Stack: "nextjs",
- DefaultPort: 3000,
- DestDir: "apps",
- },
- {
- Type: "cli",
- Description: "Go CLI tool using Cobra",
- Stack: "go",
- DefaultPort: 0, // CLIs don't expose ports
- DestDir: "cli",
- },
-}
-
// templateNameRegex validates template names (alphanumeric, dash only).
var templateNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
@@ -323,172 +277,3 @@ func (p *Provider) GetSkeleton(ctx context.Context) (*port.TemplateInfo, error)
}
return &result, nil
}
-
-// GetComponentTemplate returns info about a specific component template.
-func (p *Provider) GetComponentTemplate(ctx context.Context, componentType string) (*port.ComponentTemplateInfo, error) {
- // Check for context cancellation
- select {
- case <-ctx.Done():
- return nil, ctx.Err()
- default:
- }
-
- for _, t := range availableComponentTemplates {
- if t.Type == componentType {
- result := t
- files, err := listComponentTemplateFiles(componentType)
- if err == nil {
- result.Files = files
- }
- return &result, nil
- }
- }
- return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType)
-}
-
-// ListComponentTemplates returns available component templates.
-// If componentType is empty, returns all templates; otherwise filters by type.
-func (p *Provider) ListComponentTemplates(ctx context.Context, componentType string) ([]port.ComponentTemplateInfo, error) {
- // Check for context cancellation
- select {
- case <-ctx.Done():
- return nil, ctx.Err()
- default:
- }
-
- var result []port.ComponentTemplateInfo
- for _, t := range availableComponentTemplates {
- if componentType != "" && t.Type != componentType {
- continue
- }
- info := t
- files, err := listComponentTemplateFiles(t.Type)
- if err == nil {
- info.Files = files
- }
- result = append(result, info)
- }
- return result, nil
-}
-
-// GetComponentFiles returns the files for a component template with variables interpolated.
-func (p *Provider) GetComponentFiles(ctx context.Context, componentType string, destPath string, vars map[string]string) ([]port.ComponentFile, error) {
- // Check for context cancellation
- select {
- case <-ctx.Done():
- return nil, ctx.Err()
- default:
- }
-
- // Validate component type exists
- found := false
- for _, t := range availableComponentTemplates {
- if t.Type == componentType {
- found = true
- break
- }
- }
- if !found {
- return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType)
- }
-
- templateDir := "templates/components/" + componentType
- var files []port.ComponentFile
-
- err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
- if err != nil {
- return err
- }
- if d.IsDir() {
- return nil
- }
-
- // Read file content
- content, err := templatesFS.ReadFile(path)
- if err != nil {
- return fmt.Errorf("failed to read component template file %s: %w", path, err)
- }
-
- // Interpolate variables
- interpolated := interpolateVars(string(content), vars)
-
- // Calculate relative path from component template root
- relPath, err := filepath.Rel(templateDir, path)
- if err != nil {
- return fmt.Errorf("failed to get relative path: %w", err)
- }
-
- // Strip .tmpl extension
- relPath = strings.TrimSuffix(relPath, ".tmpl")
-
- // Prepend destination path
- fullPath := filepath.Join(destPath, relPath)
-
- files = append(files, port.ComponentFile{
- Path: fullPath,
- Content: interpolated,
- })
-
- return nil
- })
-
- if err != nil {
- return nil, fmt.Errorf("failed to collect component template files: %w", err)
- }
-
- if len(files) == 0 {
- return nil, fmt.Errorf("component template %s contains no files", componentType)
- }
-
- p.logger.Debug("prepared component files",
- "component_type", componentType,
- "dest_path", destPath,
- "file_count", len(files),
- )
-
- return files, nil
-}
-
-// listComponentTemplateFiles returns the list of files in a component template.
-func listComponentTemplateFiles(componentType string) ([]string, error) {
- templateDir := "templates/components/" + componentType
- var files []string
-
- err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
- if err != nil {
- return err
- }
- if d.IsDir() {
- return nil
- }
- relPath, err := filepath.Rel(templateDir, path)
- if err != nil {
- return err
- }
- // Strip .tmpl extension for display
- relPath = strings.TrimSuffix(relPath, ".tmpl")
- files = append(files, relPath)
- return nil
- })
-
- return files, err
-}
-
-// GetComponentWoodpeckerStep returns the .woodpecker.step.yml content for a component.
-// This is the CI step that should be inserted into the main .woodpecker.yml file.
-func (p *Provider) GetComponentWoodpeckerStep(ctx context.Context, componentType string, vars map[string]string) (string, error) {
- // Check for context cancellation
- select {
- case <-ctx.Done():
- return "", ctx.Err()
- default:
- }
-
- stepPath := "templates/components/" + componentType + "/.woodpecker.step.yml.tmpl"
- content, err := templatesFS.ReadFile(stepPath)
- if err != nil {
- return "", fmt.Errorf("failed to read woodpecker step template: %w", err)
- }
-
- return interpolateVars(string(content), vars), nil
-}
diff --git a/internal/adapter/templates/templates/components/app-nextjs/next.config.ts.tmpl b/internal/adapter/templates/templates/components/app-nextjs/next.config.mjs.tmpl
similarity index 87%
rename from internal/adapter/templates/templates/components/app-nextjs/next.config.ts.tmpl
rename to internal/adapter/templates/templates/components/app-nextjs/next.config.mjs.tmpl
index be67f05..4ca51c0 100644
--- a/internal/adapter/templates/templates/components/app-nextjs/next.config.ts.tmpl
+++ b/internal/adapter/templates/templates/components/app-nextjs/next.config.mjs.tmpl
@@ -1,6 +1,5 @@
-import type { NextConfig } from 'next';
-
-const nextConfig: NextConfig = {
+/** @type {import('next').NextConfig} */
+const nextConfig = {
// Enable React strict mode for better development experience
reactStrictMode: true,
diff --git a/internal/adapter/templates/templates/components/app-nextjs/postcss.config.js b/internal/adapter/templates/templates/components/app-nextjs/postcss.config.cjs
similarity index 100%
rename from internal/adapter/templates/templates/components/app-nextjs/postcss.config.js
rename to internal/adapter/templates/templates/components/app-nextjs/postcss.config.cjs
diff --git a/internal/adapter/templates/templates/components/app-react/package.json.tmpl b/internal/adapter/templates/templates/components/app-react/package.json.tmpl
index 398220a..0200f2e 100644
--- a/internal/adapter/templates/templates/components/app-react/package.json.tmpl
+++ b/internal/adapter/templates/templates/components/app-react/package.json.tmpl
@@ -11,9 +11,13 @@
"format": "prettier --write src/"
},
"dependencies": {
- "@{{PROJECT_NAME}}/logger": "workspace:*",
- "@{{PROJECT_NAME}}/ui": "workspace:*",
+ "@{{PROJECT_NAME}}/ai-client": "workspace:*",
+ "@{{PROJECT_NAME}}/api-client": "workspace:*",
+ "@{{PROJECT_NAME}}/auth": "workspace:*",
"@{{PROJECT_NAME}}/layout": "workspace:*",
+ "@{{PROJECT_NAME}}/logger": "workspace:*",
+ "@{{PROJECT_NAME}}/realtime": "workspace:*",
+ "@{{PROJECT_NAME}}/ui": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1"
diff --git a/internal/adapter/templates/templates/components/app-react/postcss.config.js b/internal/adapter/templates/templates/components/app-react/postcss.config.cjs
similarity index 77%
rename from internal/adapter/templates/templates/components/app-react/postcss.config.js
rename to internal/adapter/templates/templates/components/app-react/postcss.config.cjs
index 2aa7205..12a703d 100644
--- a/internal/adapter/templates/templates/components/app-react/postcss.config.js
+++ b/internal/adapter/templates/templates/components/app-react/postcss.config.cjs
@@ -1,4 +1,4 @@
-export default {
+module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
diff --git a/internal/adapter/templates/templates/components/app-react/src/App.tsx.tmpl b/internal/adapter/templates/templates/components/app-react/src/App.tsx.tmpl
index 3acfd3b..bcf1243 100644
--- a/internal/adapter/templates/templates/components/app-react/src/App.tsx.tmpl
+++ b/internal/adapter/templates/templates/components/app-react/src/App.tsx.tmpl
@@ -1,4 +1,5 @@
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
+import { AuthProvider, useAuth, ProtectedRoute } from '@{{PROJECT_NAME}}/auth';
import { DashboardShell, Sidebar, Header, type NavItem } from '@{{PROJECT_NAME}}/layout';
import {
Button,
@@ -9,13 +10,24 @@ import {
CardContent,
Badge,
Home,
+ ImageIcon,
Users,
Settings,
BarChart3,
+ MessageSquare,
+ Sparkles,
+ Loader2,
} from '@{{PROJECT_NAME}}/ui';
+import { LoginPage } from './pages/LoginPage';
+import { ChatPage } from './pages/ChatPage';
+import { GeneratePage } from './pages/GeneratePage';
+import { MediaPage } from './pages/MediaPage';
const navItems: NavItem[] = [
{ label: 'Dashboard', href: '/', icon: Home },
+ { label: 'Chat', href: '/chat', icon: MessageSquare },
+ { label: 'Generate', href: '/generate', icon: Sparkles },
+ { label: 'Media', href: '/media', icon: ImageIcon },
{ label: 'Analytics', href: '/analytics', icon: BarChart3 },
{ label: 'Users', href: '/users', icon: Users, badge: '12' },
{ label: 'Settings', href: '/settings', icon: Settings },
@@ -23,6 +35,9 @@ const navItems: NavItem[] = [
const pageTitles: Record = {
'/': 'Dashboard',
+ '/chat': 'Chat',
+ '/generate': 'Generate',
+ '/media': 'Media',
'/analytics': 'Analytics',
'/users': 'Users',
'/settings': 'Settings',
@@ -195,6 +210,14 @@ function AnalyticsPage() {
}
function SettingsPage() {
+ const { logout } = useAuth();
+ const navigate = useNavigate();
+
+ const handleLogout = async () => {
+ await logout();
+ navigate('/login');
+ };
+
return (
@@ -218,6 +241,22 @@ function SettingsPage() {
+
+
+ Account
+ Manage your account settings.
+
+
+
+
+
Sign Out
+
Sign out of your account on this device.
+
+
Sign Out
+
+
+
+
Danger Zone
@@ -237,9 +276,21 @@ function SettingsPage() {
);
}
-function App() {
+function LoadingScreen() {
+ return (
+
+ );
+}
+
+function AppLayout() {
const location = useLocation();
const navigate = useNavigate();
+ const { user } = useAuth();
const itemsWithActive = navItems.map((item) => ({
...item,
@@ -259,7 +310,7 @@ function App() {
onNavigate={(href) => navigate(href)}
footer={
- v0.0.1
+ {user?.email || 'v0.0.1'}
}
/>
@@ -274,6 +325,9 @@ function App() {
>
} />
+ } />
+ } />
+ } />
} />
} />
} />
@@ -282,4 +336,41 @@ function App() {
);
}
+function AppRoutes() {
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ return (
+
+ } />
+ {
+ // Navigate to login, storing current location for redirect after login
+ navigate(path, { state: { from: location.pathname }, replace: true });
+ }}
+ fallback={ }
+ >
+
+
+ }
+ />
+
+ );
+}
+
+function App() {
+ // Determine API base URL from environment or current origin
+ const apiBaseUrl = import.meta.env.VITE_API_URL || '';
+
+ return (
+
+
+
+ );
+}
+
export default App;
diff --git a/internal/adapter/templates/templates/components/app-react/src/main.tsx.tmpl b/internal/adapter/templates/templates/components/app-react/src/main.tsx.tmpl
index d1a78ed..7757f92 100644
--- a/internal/adapter/templates/templates/components/app-react/src/main.tsx.tmpl
+++ b/internal/adapter/templates/templates/components/app-react/src/main.tsx.tmpl
@@ -7,7 +7,12 @@ import './lib/logger';
ReactDOM.createRoot(document.getElementById('root')!).render(
-
+
diff --git a/internal/adapter/templates/templates/components/app-react/src/pages/ChatPage.tsx.tmpl b/internal/adapter/templates/templates/components/app-react/src/pages/ChatPage.tsx.tmpl
new file mode 100644
index 0000000..d5995a2
--- /dev/null
+++ b/internal/adapter/templates/templates/components/app-react/src/pages/ChatPage.tsx.tmpl
@@ -0,0 +1,190 @@
+import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
+import { useAuth } from '@{{PROJECT_NAME}}/auth';
+import { useChat } from '@{{PROJECT_NAME}}/realtime';
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ ChatBubble,
+ ChatInput,
+ Badge,
+ ProviderBadge,
+} from '@{{PROJECT_NAME}}/ui';
+
+interface TimelineMessage {
+ id: string;
+ content: string;
+ role: 'user' | 'assistant' | 'system';
+ timestamp: Date;
+ provider?: string;
+ isStreaming?: boolean;
+}
+
+export function ChatPage() {
+ const { user, getToken } = useAuth();
+ const messagesEndRef = useRef(null);
+
+ // API base URL from environment
+ const apiBaseUrl = import.meta.env.VITE_API_URL || '';
+
+ const authHeaders = useMemo(() => {
+ const token = getToken();
+ return token ? { Authorization: `Bearer ${token}` } : undefined;
+ }, [getToken]);
+
+ const {
+ messages,
+ aiMessages,
+ streamingMessages,
+ sendMessage,
+ connectionState,
+ onlineUsers,
+ } = useChat({
+ endpoint: `${apiBaseUrl}/api/{{SERVICE_NAME}}/chat/messages`,
+ sseEndpoint: `${apiBaseUrl}/api/{{SERVICE_NAME}}/events`,
+ channel: 'channel:general',
+ userId: user?.id || 'anonymous',
+ userName: user?.name || user?.email || 'Anonymous',
+ headers: authHeaders,
+ });
+
+ // Track send errors for user feedback
+ const [sendError, setSendError] = useState(null);
+
+ // Merge user messages + AI messages into a single sorted timeline
+ const timeline = useMemo(() => {
+ const combined: TimelineMessage[] = [];
+
+ for (const msg of messages) {
+ combined.push({
+ id: msg.id,
+ content: msg.content,
+ role: msg.userId === user?.id ? 'user' : 'assistant',
+ timestamp: new Date(msg.timestamp),
+ });
+ }
+
+ for (const msg of aiMessages) {
+ combined.push({
+ id: msg.id,
+ content: msg.content,
+ role: 'assistant',
+ timestamp: new Date(msg.timestamp),
+ provider: msg.provider,
+ });
+ }
+
+ // Add in-progress streaming messages
+ for (const [, stream] of streamingMessages) {
+ combined.push({
+ id: stream.streamId,
+ content: stream.content,
+ role: 'assistant',
+ timestamp: new Date(stream.timestamp),
+ isStreaming: true,
+ });
+ }
+
+ combined.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
+ return combined;
+ }, [messages, aiMessages, streamingMessages, user?.id]);
+
+ // Handle sending a message (wraps async sendMessage for ChatInput)
+ const handleSendMessage = useCallback((content: string) => {
+ sendMessage(content).catch(() => {
+ setSendError('Failed to send message. Please try again.');
+ setTimeout(() => setSendError(null), 3000);
+ });
+ }, [sendMessage]);
+
+ // Auto-scroll to bottom when new messages arrive
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [timeline]);
+
+ const connectionBadge = () => {
+ switch (connectionState) {
+ case 'connected':
+ return Connected ;
+ case 'connecting':
+ return Connecting... ;
+ case 'disconnected':
+ return Disconnected ;
+ case 'error':
+ return Error ;
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+
+
+
+ AI Chat
+
+ Chat with AI in real-time
+
+
+
+
+ {onlineUsers.length} online
+
+ {connectionBadge()}
+
+
+
+
+ {/* Messages area */}
+
+ {timeline.length === 0 ? (
+
+
+ No messages yet. Start the conversation!
+
+
+ ) : (
+ timeline.map((msg) => (
+
+
+ {msg.provider && (
+
+ )}
+
+ ))
+ )}
+
+
+
+ {/* Input area */}
+
+ {sendError && (
+
+ {sendError}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/internal/adapter/templates/templates/components/app-react/src/pages/GeneratePage.tsx.tmpl b/internal/adapter/templates/templates/components/app-react/src/pages/GeneratePage.tsx.tmpl
new file mode 100644
index 0000000..5773c82
--- /dev/null
+++ b/internal/adapter/templates/templates/components/app-react/src/pages/GeneratePage.tsx.tmpl
@@ -0,0 +1,248 @@
+import { useState, useMemo } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAuth } from '@{{PROJECT_NAME}}/auth';
+import { useMediaGeneration } from '@{{PROJECT_NAME}}/realtime';
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ Button,
+ FormField,
+ Select,
+ SelectTrigger,
+ SelectValue,
+ SelectContent,
+ SelectItem,
+ ImageGrid,
+ VideoGrid,
+ GenerationProgress,
+ ProviderBadge,
+ Loader2,
+} from '@{{PROJECT_NAME}}/ui';
+
+type GenerateMode = 'image' | 'video';
+
+interface ImageResult {
+ images: Array<{ data: string; isUrl: boolean; seed?: number }>;
+ provider: string;
+ latencyMs: number;
+}
+
+interface VideoResult {
+ videos: Array<{ data: string; isUrl: boolean; mimeType: string }>;
+ provider: string;
+ latencyMs: number;
+}
+
+export function GeneratePage() {
+ const { user, getToken } = useAuth();
+ const navigate = useNavigate();
+ const [mode, setMode] = useState('image');
+ const [prompt, setPrompt] = useState('');
+ const [aspectRatio, setAspectRatio] = useState('1:1');
+ const [count, setCount] = useState(1);
+ const [duration, setDuration] = useState('5s');
+
+ const apiPrefix = import.meta.env.VITE_API_URL || '';
+
+ const authHeaders = useMemo(() => {
+ const token = getToken();
+ return token ? { Authorization: `Bearer ${token}` } : undefined;
+ }, [getToken]);
+
+ const imageGen = useMediaGeneration({
+ endpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/generate/image`,
+ sseEndpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/events`,
+ userId: user?.id || 'anonymous',
+ headers: authHeaders,
+ });
+
+ const videoGen = useMediaGeneration({
+ endpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/generate/video`,
+ sseEndpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/events`,
+ userId: user?.id || 'anonymous',
+ headers: authHeaders,
+ });
+
+ const gen = mode === 'image' ? imageGen : videoGen;
+ const isGenerating = gen.status === 'pending' || gen.status === 'generating';
+
+ const handleGenerate = async () => {
+ if (!prompt.trim()) return;
+ gen.reset();
+ const request = mode === 'image'
+ ? { prompt, count, aspectRatio }
+ : { prompt, aspectRatio, duration };
+ await gen.generate(request);
+ };
+
+ return (
+
+
+
+ AI Generation
+
+ Generate images and videos using AI (Gemini / LaoZhang)
+
+
+
+ {/* Mode toggle */}
+
+ setMode('image')}
+ disabled={isGenerating}
+ >
+ Images
+
+ {
+ setMode('video');
+ if (aspectRatio === '1:1') setAspectRatio('16:9');
+ }}
+ disabled={isGenerating}
+ >
+ Video
+
+
+
+ setPrompt(e.target.value)}
+ placeholder={
+ mode === 'image'
+ ? 'A serene mountain landscape at sunset...'
+ : 'A cat playing piano in a jazz club...'
+ }
+ />
+
+
+
+ Aspect Ratio
+ setAspectRatio(v)}>
+
+
+
+
+ {mode === 'image' && Square (1:1) }
+ Landscape (16:9)
+ Portrait (9:16)
+
+
+
+
+ {mode === 'image' ? (
+
+ Count
+ setCount(Number(v))}>
+
+
+
+
+ 1 image
+ 2 images
+ 4 images
+
+
+
+ ) : (
+
+ Duration
+ setDuration(v)}>
+
+
+
+
+ 5 seconds
+ 10 seconds
+
+
+
+ )}
+
+
+
+ {isGenerating && }
+ {isGenerating ? 'Generating...' : `Generate ${mode === 'image' ? 'Images' : 'Video'}`}
+
+
+
+
+ {isGenerating && (
+
+
+
+ {gen.message || 'Starting...'}
+ {gen.progress}%
+
+
+ {gen.sseState !== 'connected' && (
+
+ SSE {gen.sseState} — events may be delayed
+
+ )}
+
+
+ )}
+
+ {gen.status === 'failed' && gen.error && (
+
+
+ {gen.error}
+
+
+ )}
+
+ {gen.status === 'complete' && imageGen.result && mode === 'image' && (
+
+
+ Results
+
+ {imageGen.result.provider &&
}
+
navigate('/media')}>
+ View in Library
+
+
+
+
+ ({
+ src: img.isUrl ? img.data : `data:image/png;base64,${img.data}`,
+ alt: prompt,
+ }))}
+ columns={imageGen.result.images.length > 1 ? 2 : 1}
+ />
+
+
+ )}
+
+ {gen.status === 'complete' && videoGen.result && mode === 'video' && (
+
+
+ Results
+
+ {videoGen.result.provider &&
}
+
navigate('/media')}>
+ View in Library
+
+
+
+
+ ({
+ src: vid.isUrl ? vid.data : `data:${vid.mimeType};base64,${vid.data}`,
+ mimeType: vid.mimeType,
+ alt: prompt,
+ }))}
+ />
+
+
+ )}
+
+ );
+}
diff --git a/internal/adapter/templates/templates/components/app-react/src/pages/LoginPage.tsx.tmpl b/internal/adapter/templates/templates/components/app-react/src/pages/LoginPage.tsx.tmpl
new file mode 100644
index 0000000..f519b8a
--- /dev/null
+++ b/internal/adapter/templates/templates/components/app-react/src/pages/LoginPage.tsx.tmpl
@@ -0,0 +1,109 @@
+import { useState } from 'react';
+import { useNavigate, useLocation } from 'react-router-dom';
+import { useAuth } from '@{{PROJECT_NAME}}/auth';
+import {
+ Button,
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ CardFooter,
+ FormField,
+ useFormErrors,
+ Alert,
+ AlertDescription,
+ Loader2,
+} from '@{{PROJECT_NAME}}/ui';
+import { isApiClientError } from '@{{PROJECT_NAME}}/api-client';
+
+export function LoginPage() {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const { login, isLoading } = useAuth();
+ const { setErrors, clearErrors, getError } = useFormErrors();
+ const [generalError, setGeneralError] = useState(null);
+
+ // Get the redirect path from location state, default to dashboard
+ const from = (location.state as { from?: string })?.from || '/';
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ clearErrors();
+ setGeneralError(null);
+
+ const formData = new FormData(e.currentTarget);
+ const email = formData.get('email') as string;
+ const password = formData.get('password') as string;
+
+ try {
+ await login({ email, password });
+ navigate(from, { replace: true });
+ } catch (error) {
+ if (isApiClientError(error)) {
+ if (error.isValidationError()) {
+ setErrors(error.getFieldErrors());
+ } else {
+ setGeneralError(error.message);
+ }
+ } else {
+ setGeneralError('An unexpected error occurred. Please try again.');
+ }
+ }
+ };
+
+ return (
+
+
+
+ Welcome back
+ Sign in to your {{PROJECT_NAME}} account
+
+
+
+
+
+ );
+}
diff --git a/internal/adapter/templates/templates/components/app-react/src/pages/MediaPage.tsx.tmpl b/internal/adapter/templates/templates/components/app-react/src/pages/MediaPage.tsx.tmpl
new file mode 100644
index 0000000..93378fb
--- /dev/null
+++ b/internal/adapter/templates/templates/components/app-react/src/pages/MediaPage.tsx.tmpl
@@ -0,0 +1,129 @@
+import { useState, useEffect, useMemo, useCallback } from 'react';
+import { useAuth } from '@{{PROJECT_NAME}}/auth';
+import { useMediaUpload } from '@{{PROJECT_NAME}}/realtime';
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ MediaUploader,
+ MediaLibrary,
+ type MediaItem,
+ Badge,
+} from '@{{PROJECT_NAME}}/ui';
+
+export function MediaPage() {
+ const { getToken } = useAuth();
+ const [items, setItems] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [deleteError, setDeleteError] = useState(null);
+
+ const apiPrefix = import.meta.env.VITE_API_URL || '';
+
+ const authHeaders = useMemo(() => {
+ const token = getToken();
+ return token ? { Authorization: `Bearer ${token}` } : undefined;
+ }, [getToken]);
+
+ const mediaUpload = useMediaUpload({
+ apiPrefix,
+ serviceName: '{{SERVICE_NAME}}',
+ headers: authHeaders,
+ });
+
+ const [fetchError, setFetchError] = useState(null);
+
+ const fetchMedia = useCallback(async () => {
+ setFetchError(null);
+ try {
+ const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/media`, {
+ headers: { ...authHeaders },
+ });
+ if (!res.ok) {
+ setFetchError(`Failed to load media (${res.status})`);
+ return;
+ }
+ const data = await res.json();
+ setItems(data.items || []);
+ } catch (err) {
+ setFetchError(err instanceof Error ? err.message : 'Failed to load media');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [apiPrefix, authHeaders]);
+
+ useEffect(() => {
+ fetchMedia();
+ }, [fetchMedia]);
+
+ const handleUploadComplete = useCallback(() => {
+ fetchMedia();
+ }, [fetchMedia]);
+
+ const handleDelete = useCallback(async (path: string) => {
+ setDeleteError(null);
+ try {
+ const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/media/${path}`, {
+ method: 'DELETE',
+ headers: { ...authHeaders },
+ });
+ if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
+ setItems((prev) => prev.filter((item) => item.path !== path));
+ } catch (err) {
+ setDeleteError(err instanceof Error ? err.message : 'Delete failed');
+ }
+ }, [apiPrefix, authHeaders]);
+
+ return (
+
+
+
+ Upload Media
+
+ Upload images and videos to your media library
+
+
+
+ console.error('Upload error:', err)}
+ />
+ {mediaUpload.error && (
+ {mediaUpload.error}
+ )}
+
+
+
+
+
+
+ Media Library
+
+ Your uploaded and generated media files
+
+
+ {items.length > 0 && (
+ {items.length} files
+ )}
+
+
+ {(deleteError || fetchError) && (
+ {deleteError || fetchError}
+ )}
+ {isLoading ? (
+ Loading...
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/internal/adapter/templates/templates/components/app-react/tailwind.config.js b/internal/adapter/templates/templates/components/app-react/tailwind.config.ts
similarity index 74%
rename from internal/adapter/templates/templates/components/app-react/tailwind.config.js
rename to internal/adapter/templates/templates/components/app-react/tailwind.config.ts
index 6e1cb34..b4d1f21 100644
--- a/internal/adapter/templates/templates/components/app-react/tailwind.config.js
+++ b/internal/adapter/templates/templates/components/app-react/tailwind.config.ts
@@ -1,5 +1,6 @@
-/** @type {import('tailwindcss').Config} */
-export default {
+import type { Config } from 'tailwindcss';
+
+const config: Config = {
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
@@ -12,3 +13,5 @@ export default {
},
plugins: [],
};
+
+export default config;
diff --git a/internal/adapter/templates/templates/components/app-react/vite.config.ts.tmpl b/internal/adapter/templates/templates/components/app-react/vite.config.ts.tmpl
index a214c56..747c11a 100644
--- a/internal/adapter/templates/templates/components/app-react/vite.config.ts.tmpl
+++ b/internal/adapter/templates/templates/components/app-react/vite.config.ts.tmpl
@@ -6,6 +6,27 @@ export default defineConfig({
plugins: [react()],
server: {
port: {{PORT}},
+ proxy: {
+ // SSE events endpoint — must disable buffering for streaming
+ '/api/{{SERVICE_NAME}}/events': {
+ target: 'http://localhost:{{SERVICE_PORT}}',
+ changeOrigin: true,
+ // Disable response buffering so SSE events stream immediately
+ configure: (proxy) => {
+ proxy.on('proxyRes', (proxyRes) => {
+ // Prevent Vite from buffering SSE responses
+ if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
+ proxyRes.headers['cache-control'] = 'no-cache';
+ proxyRes.headers['x-accel-buffering'] = 'no';
+ }
+ });
+ },
+ },
+ '/api': {
+ target: 'http://localhost:{{SERVICE_PORT}}',
+ changeOrigin: true,
+ },
+ },
},
preview: {
port: {{PORT}},
diff --git a/internal/adapter/templates/templates/components/service/Makefile.tmpl b/internal/adapter/templates/templates/components/service/Makefile.tmpl
index a23d1e3..a368bf2 100644
--- a/internal/adapter/templates/templates/components/service/Makefile.tmpl
+++ b/internal/adapter/templates/templates/components/service/Makefile.tmpl
@@ -1,4 +1,4 @@
-.PHONY: build run test lint fmt docker-build clean
+.PHONY: build run dev test lint fmt docker-build clean
SERVICE := {{COMPONENT_NAME}}
BINARY := bin/$(SERVICE)
@@ -12,6 +12,10 @@ build:
run:
go run ./cmd/server
+# Run the service in development mode (alias for run)
+dev:
+ go run ./cmd/server
+
# Run tests
test:
go test -v ./...
diff --git a/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl b/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl
index 6f23b9b..3c0447b 100644
--- a/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl
+++ b/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl
@@ -2,14 +2,30 @@
package main
import (
+ "context"
"flag"
"fmt"
"os"
+ "time"
+
+ "github.com/redis/go-redis/v9"
"{{GO_MODULE}}/pkg/app"
+ "{{GO_MODULE}}/pkg/database"
+ "{{GO_MODULE}}/pkg/gemini"
+ "{{GO_MODULE}}/pkg/laozhang"
"{{GO_MODULE}}/pkg/logging"
+ "{{GO_MODULE}}/pkg/mediagen"
+ mediagenAdapters "{{GO_MODULE}}/pkg/mediagen/adapters"
+ "{{GO_MODULE}}/pkg/generation"
+ "{{GO_MODULE}}/pkg/queue"
+ "{{GO_MODULE}}/pkg/realtime"
+ "{{GO_MODULE}}/pkg/storage"
+ "{{GO_MODULE}}/pkg/textgen"
+ textgenAdapters "{{GO_MODULE}}/pkg/textgen/adapters"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/memory"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api"
+ "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/config"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service"
)
@@ -30,21 +46,247 @@ func main() {
os.Exit(0)
}
+ // Load config
+ cfg := config.Load()
+
// Create logger
logger := logging.Default()
+ ctx := context.Background()
+
+ // Create SSE hub for async event delivery (generation progress, chat, etc.)
+ sseHub := realtime.NewSSEHub(logger.Logger)
+
+ // Initialize storage backend (before queue, since standalone queue handlers use it).
+ // GCS_BUCKET set = production (GCS). Otherwise = dev (in-memory).
+ port := fmt.Sprintf("%d", {{PORT}})
+ var mediaStore storage.Store
+ if bucket := os.Getenv("GCS_BUCKET"); bucket != "" {
+ gcsStore, err := storage.NewGCSStore(bucket, os.Getenv("GCS_SERVICE_ACCOUNT_JSON"), logger.Logger)
+ if err != nil {
+ logger.Error("failed to create GCS store", "error", err)
+ os.Exit(1)
+ }
+ defer func() { _ = gcsStore.Close() }()
+ mediaStore = gcsStore
+ logger.Info("storage initialized (GCS)", "bucket", bucket)
+ } else {
+ memStore := storage.NewMemoryStore("http://localhost:" + port + "/storage")
+ mediaStore = memStore
+ logger.Info("storage initialized (in-memory dev mode)")
+ }
+
+ // Select queue backend based on DATABASE_URL availability.
+ // With DATABASE_URL: DB queue + separate worker process (production)
+ // Without DATABASE_URL: in-memory queue + in-process handlers (development)
+ var jobQueue queue.Producer
+ if cfg.Database.URL != "" {
+ jobQueue = setupDBQueue(ctx, cfg, sseHub, logger)
+ } else {
+ logger.Info("DATABASE_URL not set — running in standalone mode (in-memory queue + in-process AI)")
+ jobQueue = setupStandaloneQueue(ctx, mediaStore, sseHub, logger)
+ }
+
// Create adapters (repositories)
exampleRepo := memory.NewExampleRepository()
+ userRepo := memory.NewUserRepository()
// Create services (business logic)
exampleService := service.NewExampleService(exampleRepo, logger)
+ authService := service.NewAuthService(userRepo, cfg.JWTSecret, logger)
// Create application
application := app.New("{{COMPONENT_NAME}}", app.WithDefaultPort({{PORT}}))
+ // Mount in-memory storage HTTP handler for dev mode
+ if memStore, ok := mediaStore.(*storage.MemoryStore); ok {
+ application.Router().Handle("/storage/*", memStore)
+ }
+
// Register routes with dependency injection
- api.RegisterRoutes(application, exampleService)
+ api.RegisterRoutes(application, &api.Dependencies{
+ ExampleService: exampleService,
+ AuthService: authService,
+ Queue: jobQueue,
+ SSEHub: sseHub,
+ Store: mediaStore,
+ })
// Start server
application.Run()
}
+
+// setupDBQueue initializes the production queue backend with database + optional Redis.
+func setupDBQueue(ctx context.Context, cfg *config.Config, sseHub *realtime.SSEHub, logger *logging.Logger) queue.Producer {
+ pool, err := database.Connect(ctx, cfg.Database.URL, database.Options{
+ MaxOpenConns: cfg.Database.MaxOpenConns,
+ MaxIdleConns: cfg.Database.MaxIdleConns,
+ ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
+ })
+ if err != nil {
+ logger.Error("failed to connect to database", "error", err)
+ os.Exit(1)
+ }
+ // Note: pool is not deferred here since it's needed for the lifetime of the process.
+ // The OS reclaims resources on exit.
+ logger.Info("connected to database")
+
+ if err := queue.RunMigrations(ctx, pool); err != nil {
+ logger.Error("failed to run queue migrations", "error", err)
+ os.Exit(1)
+ }
+ logger.Info("queue migrations complete")
+
+ jobQueue := queue.NewQueue(pool.DB, logger)
+
+ // Start Redis SSE subscriber if configured.
+ if cfg.RedisURL != "" {
+ opts, err := redis.ParseURL(cfg.RedisURL)
+ if err != nil {
+ logger.Error("failed to parse REDIS_URL", "error", err)
+ os.Exit(1)
+ }
+ redisClient := redis.NewClient(opts)
+ if err := redisClient.Ping(ctx).Err(); err != nil {
+ logger.Error("failed to connect to Redis", "error", err)
+ os.Exit(1)
+ }
+ logger.Info("connected to Redis")
+
+ go func() {
+ if err := realtime.RunSSESubscriber(ctx, redisClient, sseHub, logger.Logger); err != nil {
+ logger.Error("SSE Redis subscriber stopped", "error", err)
+ }
+ }()
+ } else {
+ logger.Warn("REDIS_URL not set — SSE events from worker will not be delivered")
+ }
+
+ return jobQueue
+}
+
+// setupStandaloneQueue initializes an in-memory queue with in-process AI handlers.
+// This mode requires no database or Redis — everything runs in a single process.
+func setupStandaloneQueue(ctx context.Context, store storage.Store, sseHub *realtime.SSEHub, logger *logging.Logger) queue.Producer {
+ memQueue := queue.NewMemoryQueue(logger.Logger)
+
+ // LocalPublisher delivers events directly to the SSE hub (no Redis needed).
+ pub := realtime.NewLocalPublisher(sseHub)
+
+ // Initialize AI providers
+ mediagenManager := initMediagen(ctx, logger)
+ textgenManager := initTextgen(ctx, logger)
+
+ // Register job handlers (same handlers the worker uses).
+ if mediagenManager != nil {
+ memQueue.RegisterHandler("generate_image", generation.ImageHandler(mediagenManager, store, pub, logger))
+ memQueue.RegisterHandler("generate_video", generation.VideoHandler(mediagenManager, store, pub, logger))
+ }
+ if textgenManager != nil {
+ memQueue.RegisterHandler("generate_text", generation.TextHandler(textgenManager, pub, logger))
+ memQueue.RegisterHandler("ai_chat_response", generation.ChatResponseHandler(textgenManager, pub, logger))
+ }
+
+ return memQueue
+}
+
+// initMediagen creates a mediagen manager from available AI provider credentials.
+func initMediagen(ctx context.Context, logger *logging.Logger) *mediagen.Manager {
+ var laozhangMediaProvider *mediagenAdapters.LaoZhangProvider
+ var geminiMediaProvider *mediagenAdapters.GeminiProvider
+
+ if apiKey := os.Getenv("LAOZHANG_API_KEY"); apiKey != "" {
+ client, err := laozhang.NewClient(laozhang.Config{
+ APIKey: apiKey,
+ VideoTimeout: 5 * time.Minute,
+ Logger: logger.Logger,
+ })
+ if err != nil {
+ logger.Warn("failed to create LaoZhang client", "error", err)
+ } else {
+ laozhangMediaProvider = mediagenAdapters.NewLaoZhangProvider(client)
+ logger.Info("LaoZhang media provider initialized")
+ }
+ }
+
+ if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
+ client, err := gemini.NewClient(ctx, gemini.Config{
+ APIKey: apiKey,
+ Logger: logger.Logger,
+ })
+ if err != nil {
+ logger.Warn("failed to create Gemini client", "error", err)
+ } else {
+ geminiMediaProvider = mediagenAdapters.NewGeminiProvider(client)
+ logger.Info("Gemini media provider initialized")
+ }
+ }
+
+ if laozhangMediaProvider == nil && geminiMediaProvider == nil {
+ logger.Warn("no media generation providers available (set LAOZHANG_API_KEY or GEMINI_API_KEY)")
+ return nil
+ }
+
+ mgCfg := mediagen.ProductionConfig(mediagen.ProviderSet{
+ LaoZhang: laozhangMediaProvider,
+ Gemini: geminiMediaProvider,
+ }, mediagen.WithLogger(logger.Logger))
+ if laozhangMediaProvider != nil {
+ mgCfg.VideoProviders = append(mgCfg.VideoProviders, laozhangMediaProvider)
+ }
+ if geminiMediaProvider != nil {
+ mgCfg.VideoProviders = append(mgCfg.VideoProviders, geminiMediaProvider)
+ }
+
+ mgr, err := mediagen.NewManager(mgCfg)
+ if err != nil {
+ logger.Warn("failed to create mediagen manager", "error", err)
+ return nil
+ }
+ logger.Info("mediagen manager initialized (image + video)")
+ return mgr
+}
+
+// initTextgen creates a textgen manager from available AI provider credentials.
+func initTextgen(ctx context.Context, logger *logging.Logger) *textgen.Manager {
+ var textProviders []textgen.TextGenerator
+
+ if apiKey := os.Getenv("LAOZHANG_API_KEY"); apiKey != "" {
+ client, err := laozhang.NewClient(laozhang.Config{
+ APIKey: apiKey,
+ Logger: logger.Logger,
+ })
+ if err != nil {
+ logger.Warn("failed to create LaoZhang text client", "error", err)
+ } else {
+ textProviders = append(textProviders, textgenAdapters.NewLaoZhangTextProvider(client, ""))
+ }
+ }
+
+ if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
+ provider, err := textgenAdapters.NewGeminiTextProvider(ctx, textgenAdapters.GeminiTextConfig{
+ APIKey: apiKey,
+ })
+ if err != nil {
+ logger.Warn("failed to create Gemini text provider", "error", err)
+ } else {
+ textProviders = append(textProviders, provider)
+ }
+ }
+
+ if len(textProviders) == 0 {
+ logger.Warn("no text generation providers available")
+ return nil
+ }
+
+ tgCfg := textgen.ProductionConfig(textgen.ProviderSet{}, textgen.WithLogger(logger.Logger))
+ tgCfg.Providers = textProviders
+
+ mgr, err := textgen.NewManager(tgCfg)
+ if err != nil {
+ logger.Warn("failed to create textgen manager", "error", err)
+ return nil
+ }
+ logger.Info("textgen manager initialized")
+ return mgr
+}
diff --git a/internal/adapter/templates/templates/components/service/internal/adapter/memory/user.go.tmpl b/internal/adapter/templates/templates/components/service/internal/adapter/memory/user.go.tmpl
new file mode 100644
index 0000000..e742ee6
--- /dev/null
+++ b/internal/adapter/templates/templates/components/service/internal/adapter/memory/user.go.tmpl
@@ -0,0 +1,92 @@
+package memory
+
+import (
+ "context"
+ "sync"
+
+ "{{GO_MODULE}}/pkg/auth"
+)
+
+// userEntry stores a user with their password for demo purposes.
+type userEntry struct {
+ user *auth.User
+ password string
+}
+
+// UserRepository is an in-memory user store for demo/testing purposes.
+// Pre-populated with demo users.
+type UserRepository struct {
+ mu sync.RWMutex
+ users map[string]*userEntry // keyed by email
+}
+
+// NewUserRepository creates a new in-memory user repository with demo users.
+func NewUserRepository() *UserRepository {
+ repo := &UserRepository{
+ users: make(map[string]*userEntry),
+ }
+
+ // Add demo users
+ repo.users["test@example.com"] = &userEntry{
+ user: &auth.User{
+ ID: "usr_test_001",
+ Email: "test@example.com",
+ Roles: []string{"user"},
+ Metadata: map[string]any{
+ "name": "Test User",
+ },
+ },
+ password: "password123",
+ }
+
+ repo.users["admin@example.com"] = &userEntry{
+ user: &auth.User{
+ ID: "usr_admin_001",
+ Email: "admin@example.com",
+ Roles: []string{"admin", "user"},
+ Metadata: map[string]any{
+ "name": "Admin User",
+ },
+ },
+ password: "admin123",
+ }
+
+ return repo
+}
+
+// FindByEmail returns a user by email address.
+func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*auth.User, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ entry, ok := r.users[email]
+ if !ok {
+ return nil, nil
+ }
+ return entry.user, nil
+}
+
+// FindByID returns a user by ID.
+func (r *UserRepository) FindByID(ctx context.Context, id string) (*auth.User, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ for _, entry := range r.users {
+ if entry.user.ID == id {
+ return entry.user, nil
+ }
+ }
+ return nil, nil
+}
+
+// ValidatePassword checks if the password matches for a user.
+func (r *UserRepository) ValidatePassword(ctx context.Context, user *auth.User, password string) bool {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ entry, ok := r.users[user.Email]
+ if !ok {
+ return false
+ }
+ return entry.password == password
+}
diff --git a/internal/adapter/templates/templates/components/service/internal/api/handlers/auth.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/handlers/auth.go.tmpl
new file mode 100644
index 0000000..2aeca32
--- /dev/null
+++ b/internal/adapter/templates/templates/components/service/internal/api/handlers/auth.go.tmpl
@@ -0,0 +1,127 @@
+package handlers
+
+import (
+ "errors"
+ "net/http"
+
+ "{{GO_MODULE}}/pkg/app"
+ "{{GO_MODULE}}/pkg/auth"
+ "{{GO_MODULE}}/pkg/httperror"
+ "{{GO_MODULE}}/pkg/httpresponse"
+ "{{GO_MODULE}}/pkg/logging"
+ "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service"
+)
+
+// Auth handles authentication HTTP requests.
+type Auth struct {
+ svc *service.AuthService
+ logger *logging.Logger
+}
+
+// NewAuth creates a new Auth handler with injected dependencies.
+func NewAuth(svc *service.AuthService, logger *logging.Logger) *Auth {
+ return &Auth{
+ svc: svc,
+ logger: logger.WithComponent("AuthHandler"),
+ }
+}
+
+// LoginRequest is the request body for login.
+type LoginRequest struct {
+ Email string `json:"email" validate:"required,email"`
+ Password string `json:"password" validate:"required,min=1"`
+}
+
+// LoginResponse is the response for successful login.
+type LoginResponse struct {
+ Token string `json:"token"`
+ User UserResponse `json:"user"`
+}
+
+// UserResponse is the user data returned in auth responses.
+type UserResponse struct {
+ ID string `json:"id"`
+ Email string `json:"email"`
+ Name string `json:"name,omitempty"`
+ Roles []string `json:"roles,omitempty"`
+}
+
+// toUserResponse converts an auth.User to UserResponse.
+func toUserResponse(u *auth.User) UserResponse {
+ name := ""
+ if u.Metadata != nil {
+ if n, ok := u.Metadata["name"].(string); ok {
+ name = n
+ }
+ }
+ return UserResponse{
+ ID: u.ID,
+ Email: u.Email,
+ Name: name,
+ Roles: u.Roles,
+ }
+}
+
+// Login authenticates a user and returns a JWT token.
+//
+// POST /api/{service}/auth/login
+func (h *Auth) Login(w http.ResponseWriter, r *http.Request) error {
+ var req LoginRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ output, err := h.svc.Login(r.Context(), service.LoginInput{
+ Email: req.Email,
+ Password: req.Password,
+ })
+ if err != nil {
+ if errors.Is(err, service.ErrInvalidCredentials) {
+ return httperror.Unauthorized("invalid email or password")
+ }
+ return err
+ }
+
+ httpresponse.OK(w, r, LoginResponse{
+ Token: output.Token,
+ User: toUserResponse(output.User),
+ })
+ return nil
+}
+
+// Me returns the current authenticated user.
+//
+// GET /api/{service}/auth/me
+func (h *Auth) Me(w http.ResponseWriter, r *http.Request) error {
+ user, err := auth.GetUserOrError(r.Context())
+ if err != nil {
+ return httperror.Unauthorized("not authenticated")
+ }
+
+ // Optionally refresh user data from repository
+ freshUser, err := h.svc.GetCurrentUser(r.Context(), user.ID)
+ if err != nil {
+ if errors.Is(err, service.ErrUserNotFound) {
+ return httperror.Unauthorized("user not found")
+ }
+ return err
+ }
+
+ httpresponse.OK(w, r, toUserResponse(freshUser))
+ return nil
+}
+
+// Logout handles user logout.
+// This is a stateless operation since we use JWTs.
+//
+// POST /api/{service}/auth/logout
+func (h *Auth) Logout(w http.ResponseWriter, r *http.Request) error {
+ // With JWT-based auth, logout is handled client-side by discarding the token.
+ // This endpoint exists for API completeness and could be extended to:
+ // - Add the token to a blacklist
+ // - Clear server-side sessions if using hybrid auth
+ // - Log the logout event
+
+ httpresponse.NoContent(w)
+ return nil
+}
diff --git a/internal/adapter/templates/templates/components/service/internal/api/handlers/chat.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/handlers/chat.go.tmpl
new file mode 100644
index 0000000..0e6f4d3
--- /dev/null
+++ b/internal/adapter/templates/templates/components/service/internal/api/handlers/chat.go.tmpl
@@ -0,0 +1,94 @@
+package handlers
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/google/uuid"
+
+ "{{GO_MODULE}}/pkg/app"
+ "{{GO_MODULE}}/pkg/auth"
+ "{{GO_MODULE}}/pkg/httpresponse"
+ "{{GO_MODULE}}/pkg/logging"
+ "{{GO_MODULE}}/pkg/queue"
+ "{{GO_MODULE}}/pkg/realtime"
+)
+
+// Chat handles HTTP requests for chat messaging with AI responses.
+// User messages are broadcast immediately via SSE.
+// AI responses are enqueued and processed by the worker with streaming chunks.
+type Chat struct {
+ queue queue.Producer
+ sseHub *realtime.SSEHub
+ logger *logging.Logger
+}
+
+// NewChat creates a new Chat handler.
+func NewChat(q queue.Producer, hub *realtime.SSEHub, logger *logging.Logger) *Chat {
+ return &Chat{
+ queue: q,
+ sseHub: hub,
+ logger: logger.WithComponent("ChatHandler"),
+ }
+}
+
+// SendMessageRequest is the request body for sending a chat message.
+type SendMessageRequest struct {
+ Content string `json:"content" validate:"required,min=1,max=5000"`
+}
+
+// SendMessage broadcasts a chat message to a channel via SSE
+// and enqueues an AI response job for the worker.
+func (h *Chat) SendMessage(w http.ResponseWriter, r *http.Request) error {
+ var req SendMessageRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ // Get user info
+ userID := "anonymous"
+ userName := "Anonymous"
+ if user := auth.GetUser(r.Context()); user != nil {
+ userID = user.ID
+ if name, ok := user.Metadata["name"].(string); ok && name != "" {
+ userName = name
+ } else if user.Email != "" {
+ userName = user.Email
+ }
+ }
+
+ msgID := uuid.New().String()
+ now := time.Now().UTC()
+
+ // Broadcast user message to channel:general immediately (synchronous — users
+ // see their own messages instantly without waiting for the queue)
+ h.sseHub.SendToChannel("channel:general", &realtime.SSEEvent{
+ Type: "chat",
+ Timestamp: now,
+ JobID: msgID,
+ Message: req.Content,
+ Result: map[string]any{
+ "id": msgID,
+ "content": req.Content,
+ "userId": userID,
+ "userName": userName,
+ "timestamp": now.Format(time.RFC3339),
+ },
+ })
+
+ // Enqueue AI response job — worker streams chunks via Redis → SSE
+ if _, err := h.queue.Enqueue(r.Context(), "ai_chat_response", map[string]any{
+ "content": req.Content,
+ "userID": userID,
+ "channel": "channel:general",
+ }); err != nil {
+ h.logger.Error("failed to enqueue AI chat response", "error", err)
+ // Don't fail the request — user message was already delivered
+ }
+
+ httpresponse.OK(w, r, map[string]string{
+ "id": msgID,
+ "status": "sent",
+ })
+ return nil
+}
diff --git a/internal/adapter/templates/templates/components/service/internal/api/handlers/generate.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/handlers/generate.go.tmpl
new file mode 100644
index 0000000..351dd19
--- /dev/null
+++ b/internal/adapter/templates/templates/components/service/internal/api/handlers/generate.go.tmpl
@@ -0,0 +1,188 @@
+package handlers
+
+import (
+ "net/http"
+
+ "{{GO_MODULE}}/pkg/app"
+ "{{GO_MODULE}}/pkg/auth"
+ "{{GO_MODULE}}/pkg/httperror"
+ "{{GO_MODULE}}/pkg/httpresponse"
+ "{{GO_MODULE}}/pkg/logging"
+ "{{GO_MODULE}}/pkg/queue"
+ "{{GO_MODULE}}/pkg/realtime"
+)
+
+// Generate handles HTTP requests for AI generation endpoints.
+// All generation is async: validate request, enqueue job, return 202 with job ID.
+// The worker processes jobs and sends results via Redis → SSE.
+type Generate struct {
+ queue queue.Producer
+ sseHub *realtime.SSEHub
+ logger *logging.Logger
+}
+
+// NewGenerate creates a new Generate handler with injected dependencies.
+func NewGenerate(q queue.Producer, hub *realtime.SSEHub, logger *logging.Logger) *Generate {
+ return &Generate{
+ queue: q,
+ sseHub: hub,
+ logger: logger.WithComponent("GenerateHandler"),
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Image generation (async - returns job ID, results via SSE)
+// ---------------------------------------------------------------------------
+
+// GenerateImageRequest is the request body for image generation.
+type GenerateImageRequest struct {
+ Prompt string `json:"prompt" validate:"required,min=1,max=2000"`
+ Count int `json:"count"`
+ AspectRatio string `json:"aspectRatio"`
+}
+
+// GenerateAccepted is the immediate HTTP response with the job ID.
+type GenerateAccepted struct {
+ JobID string `json:"jobId"`
+}
+
+// GenerateImage queues an image generation job.
+// Returns immediately with job ID. Results come via SSE events:
+// - generation_started: Job accepted
+// - generation_progress: Progress updates
+// - generation_complete: Images available
+// - generation_failed: Error occurred
+//
+// Client should subscribe to SSE channel `user:` before calling.
+func (h *Generate) GenerateImage(w http.ResponseWriter, r *http.Request) error {
+ var req GenerateImageRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ // Set defaults
+ if req.Count == 0 {
+ req.Count = 1
+ }
+ if req.Count > 4 {
+ req.Count = 4
+ }
+
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+
+ jobID, err := h.queue.Enqueue(r.Context(), "generate_image", map[string]any{
+ "prompt": req.Prompt,
+ "count": req.Count,
+ "aspectRatio": req.AspectRatio,
+ "userID": user.ID,
+ })
+ if err != nil {
+ h.logger.Error("failed to enqueue image job", "error", err)
+ return httperror.Internal("failed to queue image generation")
+ }
+
+ h.logger.Info("image generation queued", "jobId", jobID, "userID", user.ID)
+
+ httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// Video generation (async - takes 2-5 minutes)
+// ---------------------------------------------------------------------------
+
+// GenerateVideoRequest is the request body for video generation.
+type GenerateVideoRequest struct {
+ Prompt string `json:"prompt" validate:"required,min=1,max=2000"`
+ AspectRatio string `json:"aspectRatio"`
+ Duration string `json:"duration"`
+}
+
+// GenerateVideo queues a video generation job.
+// Returns immediately with job ID. Results come via SSE events.
+func (h *Generate) GenerateVideo(w http.ResponseWriter, r *http.Request) error {
+ var req GenerateVideoRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ // Validate video aspect ratio (Veo only supports 16:9 and 9:16)
+ if req.AspectRatio != "" && req.AspectRatio != "16:9" && req.AspectRatio != "9:16" {
+ return httperror.BadRequest("video only supports 16:9 and 9:16 aspect ratios")
+ }
+
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+
+ jobID, err := h.queue.Enqueue(r.Context(), "generate_video", map[string]any{
+ "prompt": req.Prompt,
+ "aspectRatio": req.AspectRatio,
+ "duration": req.Duration,
+ "userID": user.ID,
+ })
+ if err != nil {
+ h.logger.Error("failed to enqueue video job", "error", err)
+ return httperror.Internal("failed to queue video generation")
+ }
+
+ h.logger.Info("video generation queued", "jobId", jobID, "userID", user.ID)
+
+ httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// Text generation (async - returns job ID, results via SSE with streaming chunks)
+// ---------------------------------------------------------------------------
+
+// GenerateTextRequest is the request body for text generation.
+type GenerateTextRequest struct {
+ Prompt string `json:"prompt" validate:"required,min=1,max=5000"`
+ SystemPrompt string `json:"systemPrompt"`
+ MaxTokens int `json:"maxTokens"`
+}
+
+// GenerateText queues a text generation job.
+// Returns immediately with job ID. Chunks come via SSE as ai_chat_chunk events.
+func (h *Generate) GenerateText(w http.ResponseWriter, r *http.Request) error {
+ var req GenerateTextRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+
+ jobID, err := h.queue.Enqueue(r.Context(), "generate_text", map[string]any{
+ "prompt": req.Prompt,
+ "systemPrompt": req.SystemPrompt,
+ "maxTokens": req.MaxTokens,
+ "userID": user.ID,
+ })
+ if err != nil {
+ h.logger.Error("failed to enqueue text job", "error", err)
+ return httperror.Internal("failed to queue text generation")
+ }
+
+ h.logger.Info("text generation queued", "jobId", jobID, "userID", user.ID)
+
+ httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// SSE Events endpoint
+// ---------------------------------------------------------------------------
+
+// Events returns the SSE handler for event subscriptions.
+// Mount at /api/events.
+func (h *Generate) Events() http.Handler {
+ return realtime.NewSSEHandler(h.sseHub, h.logger.Logger)
+}
diff --git a/internal/adapter/templates/templates/components/service/internal/api/handlers/media.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/handlers/media.go.tmpl
new file mode 100644
index 0000000..9398f10
--- /dev/null
+++ b/internal/adapter/templates/templates/components/service/internal/api/handlers/media.go.tmpl
@@ -0,0 +1,161 @@
+package handlers
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "{{GO_MODULE}}/pkg/app"
+ "{{GO_MODULE}}/pkg/auth"
+ "{{GO_MODULE}}/pkg/httperror"
+ "{{GO_MODULE}}/pkg/httpresponse"
+ "{{GO_MODULE}}/pkg/logging"
+ "{{GO_MODULE}}/pkg/storage"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/google/uuid"
+)
+
+// Media handles media upload and library operations.
+type Media struct {
+ store storage.Store
+ logger *logging.Logger
+}
+
+// NewMedia creates a new media handler.
+func NewMedia(store storage.Store, logger *logging.Logger) *Media {
+ return &Media{store: store, logger: logger.WithComponent("MediaHandler")}
+}
+
+// Routes returns the media subrouter.
+func (h *Media) Routes() http.Handler {
+ r := chi.NewRouter()
+ r.Post("/upload/init", app.Wrap(h.InitUpload))
+ r.Post("/upload/complete", app.Wrap(h.CompleteUpload))
+ r.Get("/", app.Wrap(h.List))
+ r.Delete("/*", app.Wrap(h.Delete))
+ return r
+}
+
+// initUploadRequest is the request body for POST /media/upload/init.
+type initUploadRequest struct {
+ Filename string `json:"filename" validate:"required"`
+ ContentType string `json:"contentType" validate:"required"`
+}
+
+// InitUpload returns a presigned URL for direct client-to-storage upload.
+func (h *Media) InitUpload(w http.ResponseWriter, r *http.Request) error {
+ var req initUploadRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ user := auth.GetUser(r.Context())
+ userID := "anonymous"
+ if user != nil {
+ userID = user.ID
+ }
+
+ // Build object path: media/{userID}/{uuid}/{filename}
+ objectPath := fmt.Sprintf("media/%s/%s/%s", userID, uuid.New().String(), req.Filename)
+
+ presigned, err := h.store.UploadPresigned(r.Context(), objectPath, req.ContentType)
+ if err != nil {
+ h.logger.Error("failed to create presigned upload", "error", err)
+ return httperror.Internal("failed to create upload URL")
+ }
+
+ httpresponse.OK(w, r, map[string]any{
+ "uploadURL": presigned.URL,
+ "objectPath": objectPath,
+ "headers": presigned.Headers,
+ "method": presigned.Method,
+ "expires": presigned.Expires,
+ })
+ return nil
+}
+
+// completeUploadRequest is the request body for POST /media/upload/complete.
+type completeUploadRequest struct {
+ ObjectPath string `json:"objectPath" validate:"required"`
+}
+
+// CompleteUpload confirms an upload is done and returns the final URL.
+func (h *Media) CompleteUpload(w http.ResponseWriter, r *http.Request) error {
+ var req completeUploadRequest
+ if err := app.BindAndValidate(r, &req); err != nil {
+ return err
+ }
+
+ url, err := h.store.GetURL(r.Context(), req.ObjectPath)
+ if err != nil {
+ h.logger.Error("failed to get object URL", "error", err, "path", req.ObjectPath)
+ return httperror.Internal("failed to confirm upload")
+ }
+
+ httpresponse.OK(w, r, map[string]any{
+ "url": url,
+ "path": req.ObjectPath,
+ })
+ return nil
+}
+
+// List returns the user's media objects.
+func (h *Media) List(w http.ResponseWriter, r *http.Request) error {
+ user := auth.GetUser(r.Context())
+ userID := "anonymous"
+ if user != nil {
+ userID = user.ID
+ }
+
+ prefix := fmt.Sprintf("media/%s/", userID)
+
+ // Allow filtering by sub-prefix (e.g., ?prefix=images)
+ if subPrefix := r.URL.Query().Get("prefix"); subPrefix != "" {
+ prefix = fmt.Sprintf("media/%s/%s", userID, subPrefix)
+ }
+
+ objects, err := h.store.List(r.Context(), prefix)
+ if err != nil {
+ h.logger.Error("failed to list media", "error", err)
+ return httperror.Internal("failed to list media")
+ }
+
+ if objects == nil {
+ objects = []storage.MediaObject{}
+ }
+
+ httpresponse.OK(w, r, map[string]any{
+ "items": objects,
+ "count": len(objects),
+ })
+ return nil
+}
+
+// Delete removes a media object.
+// Users can only delete objects under their own media/{userID}/ prefix.
+func (h *Media) Delete(w http.ResponseWriter, r *http.Request) error {
+ // Extract path from URL (everything after /media/)
+ path := strings.TrimPrefix(r.URL.Path, "/")
+ if path == "" {
+ return httperror.BadRequest("path is required")
+ }
+
+ // Verify the path belongs to the authenticated user
+ user := auth.GetUser(r.Context())
+ if user == nil {
+ return httperror.Unauthorized("authentication required")
+ }
+ expectedPrefix := fmt.Sprintf("media/%s/", user.ID)
+ if !strings.HasPrefix(path, expectedPrefix) {
+ return httperror.Forbidden("cannot delete another user's media")
+ }
+
+ if err := h.store.Delete(r.Context(), path); err != nil {
+ h.logger.Error("failed to delete media", "error", err, "path", path)
+ return httperror.Internal("failed to delete media")
+ }
+
+ httpresponse.OK(w, r, map[string]any{"deleted": path})
+ return nil
+}
diff --git a/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl
index 996611c..9f1db6e 100644
--- a/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl
+++ b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl
@@ -4,6 +4,9 @@ package api
import (
"{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/auth"
+ "{{GO_MODULE}}/pkg/queue"
+ "{{GO_MODULE}}/pkg/realtime"
+ "{{GO_MODULE}}/pkg/storage"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api/handlers"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/config"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service"
@@ -14,23 +17,52 @@ import (
// This allows the monorepo to expose multiple services under a single domain:
// - https://domain/api/{{COMPONENT_NAME}}/health
// - https://domain/api/{{COMPONENT_NAME}}/examples
-func RegisterRoutes(application *app.App, exampleService *service.ExampleService) {
+// - https://domain/api/{{COMPONENT_NAME}}/events?channel=user:123 (SSE)
+func RegisterRoutes(application *app.App, deps *Dependencies) {
logger := application.Logger()
cfg := config.Load()
// Initialize handlers with injected services
healthHandler := handlers.NewHealth(logger)
- exampleHandler := handlers.NewExample(exampleService, logger)
+ exampleHandler := handlers.NewExample(deps.ExampleService, logger)
+ authHandler := handlers.NewAuth(deps.AuthService, logger)
+ generateHandler := handlers.NewGenerate(deps.Queue, deps.SSEHub, logger)
+ chatHandler := handlers.NewChat(deps.Queue, deps.SSEHub, logger)
+ mediaHandler := handlers.NewMedia(deps.Store, logger)
// Build and mount OpenAPI spec
spec := NewServiceSpec()
application.EnableDocs(spec)
+ // JWT validator for protected routes
+ jwtValidator := auth.NewJWTValidator(auth.JWTConfig{
+ Secret: []byte(cfg.JWTSecret),
+ Issuer: "{{PROJECT_NAME}}",
+ })
+
// Register API routes under /api/{service-name} to match ingress path routing.
// The ingress routes /api/{{COMPONENT_NAME}}/* to this service.
application.Route("/api/{{COMPONENT_NAME}}", func(r app.Router) {
r.Get("/health", healthHandler.Check)
+ // ----- Auth routes -----
+ // Public auth routes
+ r.Post("/auth/login", app.Wrap(authHandler.Login))
+ r.Post("/auth/logout", app.Wrap(authHandler.Logout))
+
+ // Protected auth routes
+ r.Group(func(r app.Router) {
+ r.Use(auth.Middleware(auth.MiddlewareConfig{
+ Validator: jwtValidator,
+ }))
+ r.Get("/auth/me", app.Wrap(authHandler.Me))
+ })
+
+ // ----- SSE Events -----
+ // Server-Sent Events for async job updates (generation progress, etc.)
+ r.Mount("/events", generateHandler.Events())
+
+ // ----- Example routes -----
// Public routes (no auth required)
r.Get("/examples", app.Wrap(exampleHandler.List))
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
@@ -39,10 +71,7 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
r.Group(func(r app.Router) {
if cfg.AuthEnabled {
r.Use(auth.Middleware(auth.MiddlewareConfig{
- Validator: auth.NewJWTValidator(auth.JWTConfig{
- Secret: []byte(cfg.JWTSecret),
- Issuer: "{{PROJECT_NAME}}",
- }),
+ Validator: jwtValidator,
}))
}
@@ -50,5 +79,34 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update))
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
})
+
+ // ----- Chat + Generate + Media routes (auth required) -----
+ // Auth is required because SSE events are delivered to user: channels.
+ // Without a real user identity, events go to user:anonymous and never reach the client.
+ r.Group(func(r app.Router) {
+ r.Use(auth.Middleware(auth.MiddlewareConfig{
+ Validator: jwtValidator,
+ }))
+
+ // Chat messaging
+ r.Post("/chat/messages", app.Wrap(chatHandler.SendMessage))
+
+ // Media generation (all queue-based, returns 202)
+ r.Post("/generate/image", app.Wrap(generateHandler.GenerateImage))
+ r.Post("/generate/video", app.Wrap(generateHandler.GenerateVideo))
+ r.Post("/generate/text", app.Wrap(generateHandler.GenerateText))
+
+ // Media library (upload, list, delete)
+ r.Mount("/media", mediaHandler.Routes())
+ })
})
}
+
+// Dependencies holds all service dependencies for route registration.
+type Dependencies struct {
+ ExampleService *service.ExampleService
+ AuthService *service.AuthService
+ Queue queue.Producer
+ SSEHub *realtime.SSEHub
+ Store storage.Store
+}
diff --git a/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl b/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl
index 0152c87..f96dfe6 100644
--- a/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl
+++ b/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl
@@ -18,6 +18,9 @@ type Config struct {
// Auth
AuthEnabled bool
JWTSecret string
+
+ // Redis for cross-process SSE event delivery
+ RedisURL string
}
// Load reads configuration from environment variables.
@@ -30,5 +33,6 @@ func Load() *Config {
AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"),
JWTSecret: os.Getenv("JWT_SECRET"),
+ RedisURL: os.Getenv("REDIS_URL"),
}
}
diff --git a/internal/adapter/templates/templates/components/service/internal/port/user.go.tmpl b/internal/adapter/templates/templates/components/service/internal/port/user.go.tmpl
new file mode 100644
index 0000000..2b1fc68
--- /dev/null
+++ b/internal/adapter/templates/templates/components/service/internal/port/user.go.tmpl
@@ -0,0 +1,23 @@
+package port
+
+import (
+ "context"
+
+ "{{GO_MODULE}}/pkg/auth"
+)
+
+// UserRepository defines the interface for user lookup operations.
+// Used by AuthService for authentication.
+type UserRepository interface {
+ // FindByEmail returns a user by email address.
+ // Returns nil if not found (no error).
+ FindByEmail(ctx context.Context, email string) (*auth.User, error)
+
+ // FindByID returns a user by ID.
+ // Returns nil if not found (no error).
+ FindByID(ctx context.Context, id string) (*auth.User, error)
+
+ // ValidatePassword checks if the password matches for a user.
+ // Returns true if valid, false otherwise.
+ ValidatePassword(ctx context.Context, user *auth.User, password string) bool
+}
diff --git a/internal/adapter/templates/templates/components/service/internal/service/auth.go.tmpl b/internal/adapter/templates/templates/components/service/internal/service/auth.go.tmpl
new file mode 100644
index 0000000..7e42177
--- /dev/null
+++ b/internal/adapter/templates/templates/components/service/internal/service/auth.go.tmpl
@@ -0,0 +1,97 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "{{GO_MODULE}}/pkg/auth"
+ "{{GO_MODULE}}/pkg/logging"
+ "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port"
+)
+
+// Auth errors.
+var (
+ ErrInvalidCredentials = errors.New("invalid email or password")
+ ErrUserNotFound = errors.New("user not found")
+)
+
+// AuthService handles authentication logic.
+type AuthService struct {
+ userRepo port.UserRepository
+ jwtSecret []byte
+ issuer string
+ logger *logging.Logger
+}
+
+// NewAuthService creates a new auth service.
+func NewAuthService(userRepo port.UserRepository, jwtSecret string, logger *logging.Logger) *AuthService {
+ return &AuthService{
+ userRepo: userRepo,
+ jwtSecret: []byte(jwtSecret),
+ issuer: "{{PROJECT_NAME}}",
+ logger: logger.WithService("AuthService"),
+ }
+}
+
+// LoginInput contains the data needed to log in.
+type LoginInput struct {
+ Email string
+ Password string
+}
+
+// LoginOutput contains the login result.
+type LoginOutput struct {
+ Token string
+ User *auth.User
+}
+
+// Login authenticates a user and returns a JWT token.
+func (s *AuthService) Login(ctx context.Context, input LoginInput) (*LoginOutput, error) {
+ // Find user by email
+ user, err := s.userRepo.FindByEmail(ctx, input.Email)
+ if err != nil {
+ return nil, err
+ }
+ if user == nil {
+ s.logger.Warn("login attempt for unknown email", "email", input.Email)
+ return nil, ErrInvalidCredentials
+ }
+
+ // Validate password
+ if !s.userRepo.ValidatePassword(ctx, user, input.Password) {
+ s.logger.Warn("invalid password attempt", "email", input.Email)
+ return nil, ErrInvalidCredentials
+ }
+
+ // Generate JWT token
+ token, err := auth.GenerateTokenWithIssuer(
+ s.jwtSecret,
+ user,
+ 24*time.Hour, // 24 hour expiration
+ s.issuer,
+ s.issuer, // audience = issuer for simplicity
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ s.logger.Info("user logged in", "user_id", user.ID, "email", user.Email)
+
+ return &LoginOutput{
+ Token: token,
+ User: user,
+ }, nil
+}
+
+// GetCurrentUser returns the user for the given ID.
+func (s *AuthService) GetCurrentUser(ctx context.Context, userID string) (*auth.User, error) {
+ user, err := s.userRepo.FindByID(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ if user == nil {
+ return nil, ErrUserNotFound
+ }
+ return user, nil
+}
diff --git a/internal/adapter/templates/templates/components/worker/Makefile.tmpl b/internal/adapter/templates/templates/components/worker/Makefile.tmpl
index fb8befd..d236b7d 100644
--- a/internal/adapter/templates/templates/components/worker/Makefile.tmpl
+++ b/internal/adapter/templates/templates/components/worker/Makefile.tmpl
@@ -1,4 +1,4 @@
-.PHONY: build run test lint fmt docker-build clean
+.PHONY: build run dev test lint fmt docker-build clean
WORKER := {{COMPONENT_NAME}}
BINARY := bin/$(WORKER)
@@ -12,6 +12,10 @@ build:
run:
go run ./cmd/worker
+# Run the worker in development mode (alias for run)
+dev:
+ go run ./cmd/worker
+
# Run tests
test:
go test -v ./...
diff --git a/internal/adapter/templates/templates/components/worker/cmd/worker/main.go.tmpl b/internal/adapter/templates/templates/components/worker/cmd/worker/main.go.tmpl
index 50dbb3a..acaa962 100644
--- a/internal/adapter/templates/templates/components/worker/cmd/worker/main.go.tmpl
+++ b/internal/adapter/templates/templates/components/worker/cmd/worker/main.go.tmpl
@@ -3,22 +3,28 @@ package main
import (
"context"
- "embed"
"os"
"os/signal"
"syscall"
"time"
+ "github.com/redis/go-redis/v9"
+
"{{GO_MODULE}}/pkg/database"
+ "{{GO_MODULE}}/pkg/gemini"
+ "{{GO_MODULE}}/pkg/laozhang"
"{{GO_MODULE}}/pkg/logging"
+ "{{GO_MODULE}}/pkg/mediagen"
+ mediagenAdapters "{{GO_MODULE}}/pkg/mediagen/adapters"
"{{GO_MODULE}}/pkg/queue"
+ "{{GO_MODULE}}/pkg/realtime"
+ "{{GO_MODULE}}/pkg/storage"
+ "{{GO_MODULE}}/pkg/textgen"
+ textgenAdapters "{{GO_MODULE}}/pkg/textgen/adapters"
"{{GO_MODULE}}/workers/{{COMPONENT_NAME}}/internal/config"
"{{GO_MODULE}}/workers/{{COMPONENT_NAME}}/internal/handlers"
)
-//go:embed migrations/*.sql
-var migrationsFS embed.FS
-
func main() {
// Initialize logger first (with defaults) so we can log config errors
logger := logging.New(logging.Config{
@@ -60,24 +66,156 @@ func main() {
defer pool.Close()
logger.Info("connected to database", "url", pool.URL)
- // Run migrations
- database.MustRunMigrations(ctx, pool, migrationsFS, "migrations")
- logger.Info("migrations complete")
+ // Run queue migrations (idempotent — safe for both service and worker)
+ if err := queue.RunMigrations(ctx, pool); err != nil {
+ logger.Error("failed to run queue migrations", "error", err)
+ os.Exit(1)
+ }
+ logger.Info("queue migrations complete")
// Initialize queue
- jobQueue := queue.NewPostgresQueue(pool.DB, logger)
+ jobQueue := queue.NewQueue(pool.DB, logger)
+
+ // Initialize Redis for SSE event publishing
+ if cfg.RedisURL == "" {
+ logger.Error("REDIS_URL is required for worker to publish SSE events")
+ os.Exit(1)
+ }
+ redisOpts, err := redis.ParseURL(cfg.RedisURL)
+ if err != nil {
+ logger.Error("failed to parse REDIS_URL", "error", err)
+ os.Exit(1)
+ }
+ redisClient := redis.NewClient(redisOpts)
+ if err := redisClient.Ping(ctx).Err(); err != nil {
+ logger.Error("failed to connect to Redis", "error", err)
+ os.Exit(1)
+ }
+ logger.Info("connected to Redis")
+
+ ssePub := realtime.NewSSEPublisher(redisClient, logger.Logger)
+
+ // Initialize AI providers
+ // LaoZhang client (primary provider — pay-per-use, OpenAI-compatible)
+ var laozhangClient *laozhang.Client
+ if apiKey := os.Getenv("LAOZHANG_API_KEY"); apiKey != "" {
+ laozhangClient, err = laozhang.NewClient(laozhang.Config{
+ APIKey: apiKey,
+ VideoTimeout: 5 * time.Minute,
+ Logger: logger.Logger,
+ })
+ if err != nil {
+ logger.Warn("failed to create LaoZhang client", "error", err)
+ } else {
+ logger.Info("LaoZhang client initialized")
+ }
+ }
+
+ // Gemini client for media generation
+ var geminiClient *gemini.Client
+ if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
+ geminiClient, err = gemini.NewClient(ctx, gemini.Config{
+ APIKey: apiKey,
+ Logger: logger.Logger,
+ })
+ if err != nil {
+ logger.Warn("failed to create Gemini client", "error", err)
+ } else {
+ logger.Info("Gemini client initialized")
+ }
+ }
+
+ // Create mediagen manager (image + video)
+ var mediagenManager *mediagen.Manager
+ {
+ var laozhangMediaProvider *mediagenAdapters.LaoZhangProvider
+ var geminiMediaProvider *mediagenAdapters.GeminiProvider
+ if laozhangClient != nil {
+ laozhangMediaProvider = mediagenAdapters.NewLaoZhangProvider(laozhangClient)
+ }
+ if geminiClient != nil {
+ geminiMediaProvider = mediagenAdapters.NewGeminiProvider(geminiClient)
+ }
+
+ if geminiMediaProvider != nil || laozhangMediaProvider != nil {
+ mgCfg := mediagen.ProductionConfig(mediagen.ProviderSet{
+ LaoZhang: laozhangMediaProvider,
+ Gemini: geminiMediaProvider,
+ }, mediagen.WithLogger(logger.Logger))
+ if laozhangMediaProvider != nil {
+ mgCfg.VideoProviders = append(mgCfg.VideoProviders, laozhangMediaProvider)
+ }
+ if geminiMediaProvider != nil {
+ mgCfg.VideoProviders = append(mgCfg.VideoProviders, geminiMediaProvider)
+ }
+ mediagenManager, err = mediagen.NewManager(mgCfg)
+ if err != nil {
+ logger.Warn("failed to create mediagen manager", "error", err)
+ } else {
+ logger.Info("mediagen manager initialized (image + video)")
+ }
+ }
+ }
+
+ // Create textgen manager (text + streaming)
+ var textgenManager *textgen.Manager
+ {
+ var textProviders []textgen.TextGenerator
+ if laozhangClient != nil {
+ textProviders = append(textProviders, textgenAdapters.NewLaoZhangTextProvider(laozhangClient, ""))
+ }
+ if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
+ geminiTextProvider, err := textgenAdapters.NewGeminiTextProvider(ctx, textgenAdapters.GeminiTextConfig{
+ APIKey: apiKey,
+ })
+ if err != nil {
+ logger.Warn("failed to create gemini text provider", "error", err)
+ } else {
+ textProviders = append(textProviders, geminiTextProvider)
+ }
+ }
+ if len(textProviders) > 0 {
+ textgenCfg := textgen.ProductionConfig(textgen.ProviderSet{}, textgen.WithLogger(logger.Logger))
+ textgenCfg.Providers = textProviders
+ textgenManager, err = textgen.NewManager(textgenCfg)
+ if err != nil {
+ logger.Warn("failed to create textgen manager", "error", err)
+ } else {
+ logger.Info("textgen manager initialized")
+ }
+ }
+ }
// Initialize and start handler
handler := handlers.New(logger, jobQueue, handlers.Config{
- PollInterval: cfg.Worker.PollInterval,
- StaleJobTimeout: cfg.Worker.StaleJobTimeout,
- JobTimeout: cfg.Worker.JobTimeout,
+ PollInterval: cfg.Worker.PollInterval,
+ StaleJobTimeout: cfg.Worker.StaleJobTimeout,
+ JobTimeout: cfg.Worker.JobTimeout,
})
+ // Initialize storage backend for persisting generated media.
+ // GCS_BUCKET is injected by the platform; if absent, store is nil (media not persisted).
+ var mediaStore storage.Store
+ if bucket := os.Getenv("GCS_BUCKET"); bucket != "" {
+ gcsStore, err := storage.NewGCSStore(bucket, os.Getenv("GCS_SERVICE_ACCOUNT_JSON"), logger.Logger)
+ if err != nil {
+ logger.Warn("failed to create GCS store, generated media will not be persisted", "error", err)
+ } else {
+ defer func() { _ = gcsStore.Close() }()
+ mediaStore = gcsStore
+ logger.Info("storage initialized (GCS)", "bucket", bucket)
+ }
+ }
+
// Register job handlers
- // TODO: Register your job handlers here
- // handler.RegisterHandler("send_email", emailHandler)
- // handler.RegisterHandler("process_image", imageHandler)
+ if mediagenManager != nil {
+ handler.RegisterHandler("generate_image", handlers.ImageHandler(mediagenManager, mediaStore, ssePub, logger))
+ handler.RegisterHandler("generate_video", handlers.VideoHandler(mediagenManager, mediaStore, ssePub, logger))
+ }
+ if textgenManager != nil {
+ handler.RegisterHandler("generate_text", handlers.TextHandler(textgenManager, ssePub, logger))
+ handler.RegisterHandler("ai_chat_response", handlers.ChatResponseHandler(textgenManager, ssePub, logger))
+ }
// Setup signal handling
sigCh := make(chan os.Signal, 1)
@@ -98,7 +236,6 @@ func main() {
cancel()
// Give in-flight jobs time to complete (grace period)
- // This allows handlers to notice context cancellation and finish cleanly.
const shutdownGracePeriod = 5 * time.Second
time.Sleep(shutdownGracePeriod)
@@ -106,7 +243,7 @@ func main() {
}
// runStaleJobRecovery periodically requeues jobs that have been running too long.
-func runStaleJobRecovery(ctx context.Context, q *queue.PostgresQueue, timeout time.Duration, logger *logging.Logger) {
+func runStaleJobRecovery(ctx context.Context, q *queue.DBQueue, timeout time.Duration, logger *logging.Logger) {
const staleCheckInterval = time.Minute
ticker := time.NewTicker(staleCheckInterval)
defer ticker.Stop()
diff --git a/internal/adapter/templates/templates/components/worker/internal/config/config.go.tmpl b/internal/adapter/templates/templates/components/worker/internal/config/config.go.tmpl
index c2173aa..0450cb5 100644
--- a/internal/adapter/templates/templates/components/worker/internal/config/config.go.tmpl
+++ b/internal/adapter/templates/templates/components/worker/internal/config/config.go.tmpl
@@ -2,6 +2,7 @@
package config
import (
+ "os"
"time"
"github.com/spf13/viper"
@@ -15,6 +16,9 @@ type Config struct {
Database config.DatabaseConfig
Logging config.LoggingConfig
Worker WorkerConfig
+
+ // Redis for publishing SSE events to the service
+ RedisURL string
}
// WorkerConfig holds worker-specific settings.
@@ -62,5 +66,6 @@ func Load() (*Config, error) {
StaleJobTimeout: viper.GetDuration("WORKER_STALE_JOB_TIMEOUT"),
JobTimeout: viper.GetDuration("WORKER_JOB_TIMEOUT"),
},
+ RedisURL: os.Getenv("REDIS_URL"),
}, nil
}
diff --git a/internal/adapter/templates/templates/components/worker/internal/handlers/generate.go.tmpl b/internal/adapter/templates/templates/components/worker/internal/handlers/generate.go.tmpl
new file mode 100644
index 0000000..ab69430
--- /dev/null
+++ b/internal/adapter/templates/templates/components/worker/internal/handlers/generate.go.tmpl
@@ -0,0 +1,33 @@
+// Package handlers re-exports generation job handlers from the shared package.
+// The worker registers these handlers to process queue jobs.
+package handlers
+
+import (
+ "{{GO_MODULE}}/pkg/generation"
+ "{{GO_MODULE}}/pkg/logging"
+ "{{GO_MODULE}}/pkg/mediagen"
+ "{{GO_MODULE}}/pkg/queue"
+ "{{GO_MODULE}}/pkg/realtime"
+ "{{GO_MODULE}}/pkg/storage"
+ "{{GO_MODULE}}/pkg/textgen"
+)
+
+// ImageHandler returns a queue.Handler that processes image generation jobs.
+func ImageHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler {
+ return generation.ImageHandler(mg, store, pub, logger)
+}
+
+// VideoHandler returns a queue.Handler that processes video generation jobs.
+func VideoHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler {
+ return generation.VideoHandler(mg, store, pub, logger)
+}
+
+// TextHandler returns a queue.Handler that processes text generation jobs with streaming.
+func TextHandler(tg *textgen.Manager, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler {
+ return generation.TextHandler(tg, pub, logger)
+}
+
+// ChatResponseHandler returns a queue.Handler that generates AI chat responses.
+func ChatResponseHandler(tg *textgen.Manager, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler {
+ return generation.ChatResponseHandler(tg, pub, logger)
+}
diff --git a/internal/adapter/templates/templates/skeleton/.claude/agents/realtime-specialist.md b/internal/adapter/templates/templates/skeleton/.claude/agents/realtime-specialist.md
index 50279d0..38960dd 100644
--- a/internal/adapter/templates/templates/skeleton/.claude/agents/realtime-specialist.md
+++ b/internal/adapter/templates/templates/skeleton/.claude/agents/realtime-specialist.md
@@ -1,40 +1,42 @@
---
name: realtime-specialist
-description: WebSocket and real-time communication patterns for {{PROJECT_NAME}} - connection management, room-based broadcasting, Redis pub/sub scaling
+description: SSE and real-time communication patterns for {{PROJECT_NAME}} - HTTP2 POST for input, SSE for output, Redis pub/sub for scaling
color: cyan
---
# Realtime Specialist
-You design and implement real-time communication features for {{PROJECT_NAME}} using pkg/realtime. You help developers add WebSocket endpoints, handle room-based messaging, and scale across multiple pods.
+You design and implement real-time communication features for {{PROJECT_NAME}} using HTTP2 + SSE. You help developers add event streams, handle channel-based messaging, and scale across multiple pods.
+
+## Critical Rules
+
+- **NO WEBSOCKETS. EVER.** All real-time communication uses HTTP2 + SSE.
+- **User → Server:** HTTP2 POST/PUT/DELETE. Standard REST endpoints.
+- **Server → User:** SSE only. One-way event stream.
+- **Event flow:** `server → redis → redis listeners → SSE hub → user`
## When to Use
-- Adding WebSocket endpoints to a service
-- Implementing chat or notification features
-- Broadcasting messages to connected clients
+- Adding SSE endpoints to a service
+- Implementing chat, notifications, or progress features
+- Broadcasting events to connected clients
- Scaling real-time features across multiple pods
- Handling client reconnection and presence
## Architecture Overview
```
- ┌─────────────────────────────────────┐
- │ Redis Pub/Sub │
- └─────────────┬───────────┬───────────┘
- │ │
- ┌───────────────────────┼───────────┼───────────────────────┐
- │ │ │ │
- ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
- │ Pod A │ │ Pod B │ │ Pod C │
- │ │ │ │ │ │
- │ ┌───────┐ │ │ ┌───────┐ │ │ ┌───────┐ │
- │ │ Hub │ │ │ │ Hub │ │ │ │ Hub │ │
- │ └───┬───┘ │ │ └───┬───┘ │ │ └───┬───┘ │
- │ │ │ │ │ │ │ │ │
- │ ┌───▼───┐ │ │ ┌───▼───┐ │ │ ┌───▼───┐ │
- │ │Clients│ │ │ │Clients│ │ │ │Clients│ │
- └─────────┘ └─────────┘ └─────────┘
+┌─────────────┐ HTTP2 POST ┌─────────────┐ publish ┌─────────────┐
+│ Browser │ ───────────────▶│ API │ ────────────▶│ Redis │
+│ │ │ Handler │ │ Pub/Sub │
+│ │ └─────────────┘ └──────┬──────┘
+│ │ │
+│ │ subscribe
+│ │ │
+│ │ ┌──────▼──────┐
+│ │ SSE stream ┌─────────────┐ notify │ Redis │
+│ │ ◀───────────────│ SSE Hub │ ◀────────────│ Listener │
+└─────────────┘ └─────────────┘ └─────────────┘
```
## Quick Start
@@ -45,15 +47,14 @@ You design and implement real-time communication features for {{PROJECT_NAME}} u
func main() {
logger := logging.NewDevelopment()
- // Create hub
- hub := realtime.NewHub(logger)
- go hub.Run(ctx)
+ // Create SSE hub
+ sseHub := realtime.NewSSEHub(logger)
- // Create handler (no Redis needed for single pod)
- wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{})
+ // Create handler
+ sseHandler := realtime.NewSSEHandler(sseHub, logger)
// Mount on router
- r.Mount("/ws", wsHandler.Routes())
+ r.Mount("/api/events", sseHandler.Routes())
}
```
@@ -63,160 +64,212 @@ func main() {
func main() {
logger := logging.NewProduction()
- // Create hub
- hub := realtime.NewHub(logger)
- go hub.Run(ctx)
+ // Create SSE hub
+ sseHub := realtime.NewSSEHub(logger)
// Create Redis broadcaster for cross-pod messaging
redisClient := redis.NewClient(&redis.Options{Addr: os.Getenv("REDIS_URL")})
- broadcaster := realtime.NewRedisBroadcaster(redisClient, hub, logger)
+ broadcaster := realtime.NewRedisBroadcaster(redisClient, sseHub, logger)
go broadcaster.Run(ctx)
// Create handler with broadcaster
- wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{
- Broadcaster: broadcaster,
- })
+ sseHandler := realtime.NewSSEHandler(sseHub, logger)
- r.Mount("/ws", wsHandler.Routes())
+ r.Mount("/api/events", sseHandler.Routes())
}
```
-## Message Protocol
+## Channel Types
-Messages use JSON format:
+| Pattern | Use For | Example |
+|---------|---------|---------|
+| `user:` | Private events for one user | `user:u_abc123` |
+| `channel:` | Shared events for a room/topic | `channel:general` |
+
+## Event Structure
+
+Every event MUST follow this structure:
```json
{
- "id": "uuid",
"type": "chat",
- "room": "general",
- "from": "client-id",
- "data": { "text": "Hello world" },
- "timestamp": "2024-01-15T10:30:00Z"
+ "timestamp": "2024-01-15T10:30:00Z",
+ "userId": "u_abc123",
+ "content": "Hello world"
}
```
-### Message Types
-
-| Type | Description |
-|------|-------------|
-| `chat` | User-generated chat message |
-| `presence` | User online/offline/away status |
-| `notification` | System notification to user |
-| `system` | Broadcast from server |
-| `error` | Error response to client |
-| `ping` / `pong` | Application-level keepalive |
-
## Patterns
-### Room-Based Chat
+### Chat Room
-```go
-wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{
- OnConnect: func(conn realtime.Connection) {
- // Notify room of new member
- msg, _ := realtime.SystemMessage("presence", realtime.PresenceData{
- Status: realtime.PresenceOnline,
- UserID: conn.UserID(),
- })
- hub.Broadcast(msg)
- },
- OnDisconnect: func(conn realtime.Connection) {
- msg, _ := realtime.SystemMessage("presence", realtime.PresenceData{
- Status: realtime.PresenceOffline,
- UserID: conn.UserID(),
- })
- hub.Broadcast(msg)
- },
-})
+**Client sends message (HTTP POST):**
-// Connect: ws://host/ws/room-name
+```typescript
+// POST /api/chat/messages
+await fetch('/api/chat/messages', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ channel: 'general',
+ content: 'Hello world',
+ }),
+});
```
-### Message Filtering
+**Server handles POST, publishes to Redis:**
```go
-wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{
- OnMessage: func(conn realtime.Connection, msg *realtime.Message) *realtime.Message {
- // Filter profanity
- if containsProfanity(msg.Data) {
- return nil // Suppress message
- }
-
- // Add server metadata
- msg.From = conn.UserID() // Use user ID instead of connection ID
-
- return msg
- },
-})
-```
-
-### Authenticated Connections
-
-```go
-wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{
- AuthRequired: true, // Requires valid JWT
-})
-
-// Client connects with token:
-// ws://host/ws?token=
-// OR
-// ws://host/ws with Authorization header
-```
-
-### Sending from HTTP Handlers
-
-```go
-// Broadcast to a room from REST endpoint
-func (h *ChatHandler) PostMessage(w http.ResponseWriter, r *http.Request) {
+func (h *ChatHandler) PostMessage(w http.ResponseWriter, r *http.Request) error {
var req struct {
- Room string `json:"room"`
- Text string `json:"text"`
+ Channel string `json:"channel"`
+ Content string `json:"content"`
+ }
+ if err := app.Bind(r, &req); err != nil {
+ return err
}
- // ... decode request ...
- msg := &realtime.Message{
- Type: realtime.MessageTypeChat,
- Room: req.Room,
- Data: json.RawMessage(`{"text":"` + req.Text + `"}`),
+ user := auth.GetUser(r.Context())
+
+ // Publish to Redis (reaches all pods)
+ h.broadcaster.Publish(r.Context(), &realtime.Event{
+ Type: "chat",
+ Channel: req.Channel,
+ UserID: user.ID,
+ UserName: user.Name,
+ Content: req.Content,
Timestamp: time.Now().UTC(),
- }
+ })
- // Publish via broadcaster (reaches all pods)
- if h.broadcaster != nil {
- h.broadcaster.Publish(r.Context(), msg)
- } else {
- h.hub.Broadcast(msg)
+ return httpresponse.NoContent(w, r)
+}
+```
+
+**Client receives via SSE:**
+
+```typescript
+useEventChannel(`channel:general`, {
+ onEvent: (event) => {
+ if (event.type === 'chat') {
+ addMessage(event);
}
+ }
+});
+```
+
+### Async Job Progress
+
+**Client initiates job (HTTP POST):**
+
+```typescript
+const { jobId } = await api.post('/generate/video', {
+ prompt: 'A cat playing piano',
+ aspectRatio: '16:9',
+});
+```
+
+**Client listens for progress (SSE):**
+
+```typescript
+useEventChannel(`user:${userId}`, {
+ onEvent: (event) => {
+ if (event.jobId !== jobId) return;
+ switch (event.type) {
+ case 'generation_progress':
+ setProgress(event.progress);
+ break;
+ case 'generation_complete':
+ setResult(event.result);
+ break;
+ }
+ }
+});
+```
+
+**Worker sends progress events:**
+
+```go
+func (w *Worker) ProcessJob(ctx context.Context, job *domain.Job) error {
+ // Send progress
+ w.hub.SendToUser(job.UserID, &realtime.Event{
+ Type: "generation_progress",
+ JobID: job.ID,
+ Progress: 50,
+ Message: "Processing...",
+ })
+
+ // ... do work ...
+
+ // Send complete
+ w.hub.SendToUser(job.UserID, &realtime.Event{
+ Type: "generation_complete",
+ JobID: job.ID,
+ Result: result,
+ })
+ return nil
+}
+```
+
+### Presence
+
+**Client connects, server broadcasts presence:**
+
+```go
+// In SSE connection handler
+func (h *SSEHandler) onConnect(userID string, channel string) {
+ h.broadcaster.Publish(ctx, &realtime.Event{
+ Type: "presence",
+ Channel: channel,
+ UserID: userID,
+ Status: "online",
+ })
+}
+
+func (h *SSEHandler) onDisconnect(userID string, channel string) {
+ h.broadcaster.Publish(ctx, &realtime.Event{
+ Type: "presence",
+ Channel: channel,
+ UserID: userID,
+ Status: "offline",
+ })
}
```
## Client Reconnection
-Clients should implement reconnection with exponential backoff:
+SSE clients should implement reconnection with exponential backoff:
-```javascript
-class RealtimeClient {
- connect() {
- this.ws = new WebSocket(`${this.url}?last_id=${this.lastMessageId}`);
- this.ws.onclose = () => this.scheduleReconnect();
- this.ws.onmessage = (e) => {
- const msg = JSON.parse(e.data);
- this.lastMessageId = msg.id;
- this.onMessage(msg);
- };
- }
+```typescript
+function useEventChannel(channel: string, config: Config) {
+ const [retries, setRetries] = useState(0);
- scheduleReconnect() {
- const delay = Math.min(1000 * Math.pow(2, this.retries), 30000);
- setTimeout(() => this.connect(), delay);
- this.retries++;
- }
+ const connect = useCallback(() => {
+ const eventSource = new EventSource(`/api/events?channel=${channel}`);
+
+ eventSource.onopen = () => setRetries(0);
+
+ eventSource.onerror = () => {
+ eventSource.close();
+ const delay = Math.min(1000 * Math.pow(2, retries), 30000);
+ setTimeout(connect, delay);
+ setRetries(r => r + 1);
+ };
+
+ eventSource.onmessage = (e) => {
+ config.onEvent(JSON.parse(e.data));
+ };
+ }, [channel, retries]);
}
```
## Scaling Considerations
+### Redis Channel Strategy
+
+- One channel per room: `realtime:channel:{channelId}`
+- One channel per user: `realtime:user:{userId}`
+- Pattern subscription: `realtime:*`
+
### Connection Limits
Set reasonable limits per pod:
@@ -224,26 +277,20 @@ Set reasonable limits per pod:
```go
const maxConnectionsPerPod = 10000
-func (h *Handler) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
+func (h *SSEHandler) HandleSSE(w http.ResponseWriter, r *http.Request) {
if h.hub.ConnectionCount() >= maxConnectionsPerPod {
http.Error(w, "server at capacity", http.StatusServiceUnavailable)
return
}
- // ... continue upgrade ...
+ // ... continue ...
}
```
-### Redis Channel Strategy
-
-- One channel per room: `realtime:room:{roomId}`
-- Global channel for broadcasts: `realtime:global`
-- Pattern subscription: `realtime:room:*`
-
### Memory Considerations
-Each connection uses ~10KB for buffers. Plan accordingly:
-- 10,000 connections ≈ 100MB
-- 100,000 connections ≈ 1GB
+Each SSE connection uses ~5KB for buffers. Plan accordingly:
+- 10,000 connections ≈ 50MB
+- 100,000 connections ≈ 500MB
## Monitoring
@@ -251,50 +298,26 @@ Track these metrics:
| Metric | Description |
|--------|-------------|
-| `realtime_connections_total` | Total active connections |
-| `realtime_rooms_total` | Number of active rooms |
-| `realtime_messages_sent` | Messages sent per second |
-| `realtime_messages_received` | Messages received per second |
-| `realtime_redis_publish_errors` | Failed Redis publishes |
-
-## Error Handling
-
-### Client Errors
-
-```go
-OnMessage: func(conn realtime.Connection, msg *realtime.Message) *realtime.Message {
- if err := validate(msg); err != nil {
- errMsg, _ := realtime.SystemMessage(realtime.MessageTypeError, map[string]string{
- "error": err.Error(),
- })
- conn.Send(errMsg)
- return nil // Don't broadcast invalid message
- }
- return msg
-}
-```
-
-### Redis Failures
-
-RedisBroadcaster degrades gracefully:
-- If publish fails, message still broadcasts locally
-- Subscriber reconnects automatically on disconnect
-- Log warnings for monitoring
+| `sse_connections_total` | Total active SSE connections |
+| `sse_channels_total` | Number of active channels |
+| `sse_events_sent` | Events sent per second |
+| `redis_publish_errors` | Failed Redis publishes |
## Do
-1. ALWAYS use room-based broadcasting for multi-tenant apps
-2. SET connection limits per pod
-3. IMPLEMENT client reconnection with backoff
-4. USE Redis for multi-pod deployments
-5. AUTHENTICATE WebSocket connections in production
-6. MONITOR connection count and message rates
+1. USE HTTP POST for all client→server messages
+2. USE SSE for all server→client events
+3. USE Redis pub/sub for multi-pod deployments
+4. SET connection limits per pod
+5. IMPLEMENT client reconnection with backoff
+6. AUTHENTICATE SSE connections in production
+7. DOCUMENT all channels in `docs/channels.md`
## Do Not
-1. STORE large payloads in messages (send IDs, fetch data separately)
-2. BROADCAST without rate limiting
-3. RELY on message ordering (out-of-order is possible)
-4. SKIP ping/pong (connections will time out)
-5. USE synchronous operations in message handlers (blocks hub)
+1. USE WebSocket for anything — SSE only
+2. STORE large payloads in events (send IDs, fetch data separately)
+3. BROADCAST without rate limiting
+4. SIMULATE progress with fake timers
+5. SKIP ping/pong (connections will time out)
6. TRUST client-provided user IDs (extract from auth token)
diff --git a/internal/adapter/templates/templates/skeleton/.claude/guides/events.md b/internal/adapter/templates/templates/skeleton/.claude/guides/events.md
new file mode 100644
index 0000000..ed7b40e
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/.claude/guides/events.md
@@ -0,0 +1,164 @@
+# Event Channels
+
+## Critical Rules
+
+- **NO WEBSOCKETS. EVER.** All real-time communication uses HTTP2 + SSE.
+- **User → Server:** HTTP2 POST/PUT/DELETE. Standard REST endpoints.
+- **Server → User:** SSE only. One-way event stream.
+- **Event flow:** `POST → Service (enqueue) → Queue → Worker (generate) → Redis pub/sub → Service SSE subscriber → SSE Hub → User`
+- **This applies to EVERYTHING:** Chat, notifications, progress updates, generation — all SSE.
+- **Channel format is non-negotiable:** `user:` or `channel:`. No exceptions.
+- **All events are JSON** with `type` as the first field.
+- **Document every channel** in this file before using it.
+- **Service is thin:** Validates, enqueues, returns 202. No AI work in the service.
+- **Worker does all AI work:** Initializes providers, processes jobs, publishes events via Redis.
+
+## Architecture
+
+```
+┌─────────────┐ POST /generate/* ┌─────────────┐ enqueue ┌─────────────┐
+│ Browser │ ──────────────────────▶│ Service │ ────────────▶│ CRDB │
+│ │ { jobId } (202) │ (thin) │ │ Queue │
+│ │ ◀──────────────────────│ │ └──────┬──────┘
+│ │ │ │ │
+│ │ │ │ dequeue│
+│ │ │ │ ▼
+│ │ │ │ ┌──────────────┐
+│ │ │ │ │ Worker │
+│ │ │ │ │ (AI work) │
+│ │ │ │ └──────┬───────┘
+│ │ │ │ │
+│ │ │ │ publish│(SSE events)
+│ │ │ │ ▼
+│ │ │ │ ┌──────────────┐
+│ │ SSE stream │ SSE Hub │ subscribe │ Redis │
+│ │ ◀──────────────────────│ ◀──────────│──────────────│ Pub/Sub │
+└─────────────┘ └─────────────┘ └──────────────┘
+```
+
+**Generation flow:**
+1. User sends `POST /api/generate/image` with prompt
+2. Service validates, enqueues job in CRDB, returns `202 {jobId}`
+3. Worker dequeues job, calls AI provider (LaoZhang/Gemini)
+4. Worker publishes progress/result SSE events to Redis
+5. Service's SSE subscriber receives events from Redis
+6. SSE Hub delivers to the user's connected SSE stream
+
+**Chat flow:**
+1. User sends `POST /api/chat/messages`
+2. Service broadcasts user message to `channel:general` via SSE Hub (immediate)
+3. Service enqueues `ai_chat_response` job
+4. Worker dequeues, streams AI response tokens via Redis pub/sub
+5. Service's SSE subscriber delivers `ai_chat_chunk` events to channel
+
+## Channel Types
+
+| Pattern | Use For | Example |
+|---------|---------|---------|
+| `user:` | Private events for one user | `user:u_abc123` |
+| `channel:` | Shared events for a room/topic | `channel:general` |
+
+## Event Structure
+
+Every event MUST follow this structure:
+
+```typescript
+interface Event {
+ type: string; // REQUIRED: event type identifier
+ timestamp: string; // REQUIRED: ISO 8601
+ jobId?: string; // Job correlation ID
+ progress?: number; // 0-100 percentage
+ message?: string; // Human-readable status
+ result?: any; // Payload (type-specific)
+ error?: string; // Error message
+}
+```
+
+## Standard Event Types
+
+### User Channel Events
+
+#### Media Generation Events
+
+| Event | Trigger | Payload |
+|-------|---------|---------|
+| `generation_started` | Worker picks up job | `{ jobId, message }` |
+| `generation_progress` | Progress update | `{ jobId, progress, message }` |
+| `generation_complete` | Generation done | `{ jobId, progress: 100, result }` |
+| `generation_failed` | Error occurred | `{ jobId, error }` |
+
+#### Text Generation Events (streaming)
+
+| Event | Trigger | Payload |
+|-------|---------|---------|
+| `ai_chat_chunk` | Token generated | `{ jobId, result: { streamId, text, done, provider? } }` |
+
+#### Media Upload Events
+
+| Event | Trigger | Payload |
+|-------|---------|---------|
+| `upload_started` | Upload job begins | `{ jobId }` |
+| `upload_progress` | Chunk uploaded | `{ jobId, progress, bytesUploaded }` |
+| `upload_complete` | Processing done | `{ jobId, result: { original, optimized, thumbnail } }` |
+| `upload_failed` | Error occurred | `{ jobId, error }` |
+
+### Room Channel Events
+
+| Event | Trigger | Payload |
+|-------|---------|---------|
+| `chat` | User sends message | `{ result: { id, content, userId, userName, timestamp } }` |
+| `ai_chat_chunk` | AI streaming chunk | `{ result: { streamId, text, done, provider? } }` |
+| `ai_chat` | AI response complete | `{ result: { id, content, provider, timestamp } }` |
+| `presence` | User joins/leaves | `{ status, userId, userName }` |
+| `typing` | User typing indicator | `{ userId, isTyping }` |
+
+## Implementation Pattern
+
+### Backend (Go) — Service Handler
+
+```go
+// Enqueue generation job (service is thin — no AI work)
+jobID, err := h.queue.Enqueue(r.Context(), "generate_image", map[string]any{
+ "prompt": req.Prompt,
+ "userID": userID,
+})
+httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
+```
+
+### Backend (Go) — Worker Job Handler
+
+```go
+// Worker publishes events via Redis SSE publisher
+pub.SendToUser(userID, &realtime.SSEEvent{
+ Type: realtime.EventGenerationComplete,
+ JobID: jobID,
+ Progress: 100,
+ Result: result,
+})
+```
+
+### Frontend (TypeScript)
+
+```typescript
+// Subscribe to user channel for generation events
+const { status, progress, result } = useMediaGeneration({
+ endpoint: '/api/generate/image',
+ userId: currentUser.id,
+});
+
+// Subscribe to room channel for chat events
+const { messages, aiMessages, streamingMessages } = useChat({
+ endpoint: '/api/chat/messages',
+ channel: 'channel:general',
+ userId: currentUser.id,
+});
+```
+
+## Active Channels
+
+
+
+| Channel | Events | Purpose |
+|---------|--------|---------|
+| `user:` | `generation_*`, `ai_chat_chunk` | Async job results, text streaming |
+| `channel:` | `chat`, `ai_chat_chunk`, `presence` | Real-time chat |
diff --git a/internal/adapter/templates/templates/skeleton/.claude/guides/media.md b/internal/adapter/templates/templates/skeleton/.claude/guides/media.md
new file mode 100644
index 0000000..6d582ee
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/.claude/guides/media.md
@@ -0,0 +1,200 @@
+# Media Pipeline
+
+## Critical Rules
+
+- **ALL media operations are async jobs.** Upload, process, generate - everything goes through the job queue.
+- **NEVER wait synchronously.** POST returns a job ID immediately. Results come via SSE.
+- **NEVER simulate progress.** Real progress comes from real events. Fake progress is a lie.
+- **Storage is opaque.** Backend returns URLs. Frontend never constructs storage paths.
+- **GCS in production, MemoryStore in dev.** When `GCS_BUCKET` env var is set, storage uses GCS. Otherwise, an in-memory store serves files at `/storage/`.
+
+## Architecture Overview
+
+```
+┌─────────────┐ POST /generate/* ┌──────────────┐ enqueue ┌──────────────┐
+│ Frontend │ ─────────────────────▶│ Service │ ───────────▶│ CRDB │
+│ │ { jobId } (202) │ (thin) │ │ Queue │
+│ │ ◀─────────────────────│ │ └──────┬───────┘
+│ │ │ │ │
+│ │ │ │ dequeue│
+│ │ │ │ ▼
+│ │ │ │ ┌──────────────┐
+│ │ │ │ │ Worker │
+│ │ │ │ │ (AI work) │
+│ │ │ │ └──────┬───────┘
+│ │ │ │ │
+│ │ SSE stream │ SSE Hub │ Redis sub │ persist to
+│ │ ◀─────────────────────│ ◀──────────│─────────────│◀── storage
+└─────────────┘ └──────────────┘ │ (GCS)
+ └─────┘
+```
+
+## Storage
+
+### Backend: `pkg/storage/`
+
+The storage package provides a `Store` interface with two implementations:
+
+| Implementation | When | Env Vars |
+|---|---|---|
+| `GCSStore` | `GCS_BUCKET` is set (production, deployed) | `GCS_BUCKET`, `GCS_SERVICE_ACCOUNT_JSON` |
+| `MemoryStore` | No `GCS_BUCKET` (local dev, standalone) | None |
+
+```go
+type Store interface {
+ Upload(ctx context.Context, path string, data []byte, contentType string) (string, error)
+ UploadPresigned(ctx context.Context, path string, contentType string) (*PresignedUpload, error)
+ GetURL(ctx context.Context, path string) (string, error)
+ Delete(ctx context.Context, path string) error
+ List(ctx context.Context, prefix string) ([]MediaObject, error)
+}
+```
+
+### Initialization (service main.go)
+
+Storage is initialized early in `main()` — before the queue, since standalone queue handlers need it:
+
+```go
+var mediaStore storage.Store
+if bucket := os.Getenv("GCS_BUCKET"); bucket != "" {
+ mediaStore, _ = storage.NewGCSStore(bucket, os.Getenv("GCS_SERVICE_ACCOUNT_JSON"), logger)
+} else {
+ memStore := storage.NewMemoryStore("http://localhost:" + port + "/storage")
+ mediaStore = memStore
+ // Mount memStore.ServeHTTP at /storage/* for dev mode
+}
+```
+
+### Object Path Convention
+
+All media is stored under `media/{userID}/`:
+- Generated images: `media/{userID}/images/{jobID}_{index}.png`
+- Generated videos: `media/{userID}/videos/{jobID}_{index}.mp4`
+- Uploads: `media/{userID}/{uuid}/{filename}`
+
+### Generation Auto-Persist
+
+Image and video generation handlers accept a `storage.Store`. When non-nil, generated results are automatically persisted and SSE events contain permanent URLs instead of temporary provider URLs or base64.
+
+```go
+generation.ImageHandler(mediagenManager, store, pub, logger)
+generation.VideoHandler(mediagenManager, store, pub, logger)
+```
+
+## Upload Flow (Presigned URL)
+
+```
+Frontend Backend Storage (GCS/Memory)
+ │ │ │
+ │ POST /media/upload/init │ │
+ │ {filename, contentType} │ │
+ │──────────────────────────▶│ │
+ │ │ UploadPresigned() │
+ │ │─────────────────────────────▶│
+ │ {uploadURL, objectPath} │ │
+ │◀──────────────────────────│ │
+ │ │ │
+ │ PUT uploadURL (file body) │ │
+ │─────────────────────────────────────────────────────────▶│
+ │ 200 OK │ │
+ │◀─────────────────────────────────────────────────────────│
+ │ │ │
+ │ POST /media/upload/complete│ │
+ │ {objectPath} │ │
+ │──────────────────────────▶│ GetURL() │
+ │ │─────────────────────────────▶│
+ │ {url, path} │ │
+ │◀──────────────────────────│ │
+```
+
+### Frontend Hook
+
+```typescript
+import { useMediaUpload } from '@project/realtime';
+
+const { upload, isUploading, progress, error, reset } = useMediaUpload({
+ apiPrefix: '',
+ serviceName: 'example-api',
+ headers: { Authorization: `Bearer ${token}` },
+});
+
+// Upload a file
+const result = await upload(file); // { url, path }
+```
+
+## Media Library
+
+### Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `POST` | `/media/upload/init` | Get presigned upload URL |
+| `POST` | `/media/upload/complete` | Confirm upload, get final URL |
+| `GET` | `/media` | List user's media (optional `?prefix=images`) |
+| `DELETE` | `/media/{path...}` | Delete a media object |
+
+All endpoints require authentication.
+
+### Frontend Components
+
+```tsx
+import { MediaUploader, MediaLibrary } from '@project/ui';
+
+// Upload drop zone
+ refetchMedia()}
+/>
+
+// Media grid with preview
+ deleteMedia(path)}
+/>
+```
+
+## Event Reference
+
+| Event | When | Payload |
+|-------|------|---------|
+| `generation_started` | Generation begins | `{ jobId }` |
+| `generation_progress` | Progress update | `{ jobId, progress, message }` |
+| `generation_complete` | Generation done (URLs are persistent) | `{ jobId, result }` |
+| `generation_failed` | Error occurred | `{ jobId, error }` |
+| `upload_started` | Upload job begins | `{ jobId }` |
+| `upload_progress` | Chunk uploaded | `{ jobId, progress }` |
+| `upload_complete` | Upload done | `{ jobId, result: { url, path } }` |
+| `upload_failed` | Error occurred | `{ jobId, error }` |
+
+## Common Mistakes
+
+### DON'T: Construct storage URLs
+
+```typescript
+// WRONG
+const url = `/storage/uploads/${userId}/${fileId}.jpg`;
+```
+
+### DO: Use URLs from backend
+
+```typescript
+// CORRECT
+const url = media.url; // Backend provides complete URL
+```
+
+### DON'T: Proxy uploads through the backend
+
+```typescript
+// WRONG - wastes backend bandwidth
+await fetch('/api/upload', { body: file });
+```
+
+### DO: Upload directly to storage via presigned URL
+
+```typescript
+// CORRECT - frontend uploads directly to GCS
+const { uploadURL } = await initUpload(file);
+await fetch(uploadURL, { method: 'PUT', body: file });
+```
diff --git a/internal/adapter/templates/templates/skeleton/.gitignore b/internal/adapter/templates/templates/skeleton/.gitignore
index 02efa94..49f0256 100644
--- a/internal/adapter/templates/templates/skeleton/.gitignore
+++ b/internal/adapter/templates/templates/skeleton/.gitignore
@@ -17,8 +17,9 @@ coverage.html
# Dependency directories
vendor/
-# Go workspace file (local only)
+# Go workspace files (local only)
go.work.sum
+*.go.sum
# IDE
.idea/
@@ -34,6 +35,7 @@ go.work.sum
# Node
node_modules/
.npm/
+pnpm-lock.yaml
# Shared packages
packages/*/node_modules/
diff --git a/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl b/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl
index f7b550e..50d363a 100644
--- a/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl
+++ b/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl
@@ -10,6 +10,8 @@
| **Build a feature** | [feature-development.md](.claude/guides/feature-development.md) |
| **Backend API patterns** | [backend/api-patterns.md](.claude/guides/backend/api-patterns.md) |
| **Frontend design system** | [frontend/design-system.md](.claude/guides/frontend/design-system.md) |
+| **Event channels** | [events.md](.claude/guides/events.md) |
+| **Media pipeline** | [media.md](.claude/guides/media.md) |
| **Deploy** | [ops/deploying.md](.claude/guides/ops/deploying.md) |
## Quick Reference
@@ -36,6 +38,13 @@
- **OpenAPI first:** Document endpoints in `spec.go` using `openapi.*` helpers. Mount with `application.EnableDocs(spec)`.
- **CSS variables:** All UI components use CSS custom properties (`var(--background)`, `var(--accent)`, etc.). Never hardcode colors.
- **Monorepo imports:** Go packages from `{{GO_MODULE}}/pkg/*`, TypeScript from `@{{PROJECT_NAME}}/*`.
+- **NO WEBSOCKETS. EVER.** All real-time communication uses HTTP2 + SSE. User → server is HTTP2 POST. Server → user is SSE. This includes chat, notifications, progress, everything.
+- **Event flow:** `POST → Service (enqueue) → Queue → Worker (generate) → Redis pub/sub → Service SSE subscriber → User`. Service is thin, worker does AI work.
+- **Channel naming:** `user:` = events for a specific user. `channel:` = events for a topic/room/resource. Document all channels in `./docs/channels.md`.
+- **Media uploads:** POST returns job ID immediately. Progress and result come via SSE events. Never wait synchronously.
+- **Media generation:** Same pattern - POST queues job, returns ID, results via SSE. Video takes 2-5 min; never block HTTP. Text generation streams `ai_chat_chunk` events token-by-token.
+- **Media storage:** Backend returns complete URLs. Never construct storage paths in frontend. Variants (thumbnail, optimized) auto-generated.
+- **No fake progress:** Never simulate progress with timers. Real progress comes from real events.
## Architecture
diff --git a/internal/adapter/templates/templates/skeleton/packages/ai-client/package.json.tmpl b/internal/adapter/templates/templates/skeleton/packages/ai-client/package.json.tmpl
new file mode 100644
index 0000000..c2090ac
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ai-client/package.json.tmpl
@@ -0,0 +1,22 @@
+{
+ "name": "@{{PROJECT_NAME}}/ai-client",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts"
+ }
+ },
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "build": "tsc",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
+ },
+ "devDependencies": {
+ "typescript": "^5.5.3"
+ }
+}
diff --git a/internal/adapter/templates/templates/skeleton/packages/ai-client/src/index.ts b/internal/adapter/templates/templates/skeleton/packages/ai-client/src/index.ts
new file mode 100644
index 0000000..a3aa708
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ai-client/src/index.ts
@@ -0,0 +1,44 @@
+// Types
+export type {
+ TokenUsage,
+ AIResponseMeta,
+ AIError,
+ ErrorCode,
+} from './types';
+
+export { createAIError, ErrorCodes } from './types';
+
+// Text generation
+export type {
+ MessageRole,
+ TextGenMessage,
+ TextGenRequest,
+ TextGenResponse,
+ TextGenChunk,
+ TextGenOptions,
+} from './textgen';
+
+export { streamText, generateText } from './textgen';
+
+// Image generation
+export type {
+ ImageSize,
+ AspectRatio,
+ ImageGenRequest,
+ GeneratedImage,
+ ImageGenResponse,
+ GenerationProgress,
+ MediaGenOptions,
+} from './mediagen';
+
+export { generateImage, generateImageWithProgress } from './mediagen';
+
+// Video generation
+export type {
+ VideoDuration,
+ VideoGenRequest,
+ GeneratedVideo,
+ VideoGenResponse,
+} from './mediagen';
+
+export { generateVideo, generateVideoWithProgress } from './mediagen';
diff --git a/internal/adapter/templates/templates/skeleton/packages/ai-client/src/mediagen.ts b/internal/adapter/templates/templates/skeleton/packages/ai-client/src/mediagen.ts
new file mode 100644
index 0000000..40ffc3e
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ai-client/src/mediagen.ts
@@ -0,0 +1,384 @@
+/**
+ * Media generation types and utilities.
+ *
+ * IMPORTANT: For generation, use @{{PROJECT_NAME}}/realtime's useMediaGeneration hook.
+ * That hook implements the correct async pattern:
+ * 1. POST returns job ID immediately
+ * 2. Progress and results come via SSE events
+ * 3. Never blocks waiting for generation
+ *
+ * @example
+ * ```tsx
+ * import { useMediaGeneration } from '@{{PROJECT_NAME}}/realtime';
+ *
+ * const { status, progress, result, generate } = useMediaGeneration({
+ * endpoint: '/api/generate/video',
+ * userId: user.id,
+ * });
+ *
+ * await generate({ prompt: 'A cat playing piano' });
+ * // Results arrive via SSE - no blocking
+ * ```
+ *
+ * The synchronous functions in this file are DEPRECATED.
+ * Only use them for simple image generation where blocking is acceptable.
+ */
+
+import { AIResponseMeta, createAIError, ErrorCodes } from './types';
+
+/** Image size presets. */
+export type ImageSize = '256x256' | '512x512' | '1024x1024' | '1024x1792' | '1792x1024';
+
+/** Aspect ratio presets. */
+export type AspectRatio = '1:1' | '16:9' | '9:16' | '4:3' | '3:4';
+
+/** Video duration presets. */
+export type VideoDuration = '5s' | '10s';
+
+// ---------------------------------------------------------------------------
+// Image types
+// ---------------------------------------------------------------------------
+
+/** Request for image generation. */
+export interface ImageGenRequest {
+ prompt: string;
+ model?: string;
+ size?: ImageSize;
+ aspectRatio?: AspectRatio;
+ count?: number;
+ negativePrompt?: string;
+ style?: string;
+}
+
+/** A single generated image. */
+export interface GeneratedImage {
+ /** Base64-encoded image data or URL. */
+ data: string;
+ /** Whether data is a URL (true) or base64 (false). */
+ isUrl: boolean;
+ /** Provider that generated this image. */
+ provider: string;
+ /** Generation seed if available. */
+ seed?: number;
+ /** Revised prompt if the provider modified it. */
+ revisedPrompt?: string;
+}
+
+/** Response from image generation. */
+export interface ImageGenResponse extends AIResponseMeta {
+ images: GeneratedImage[];
+}
+
+// ---------------------------------------------------------------------------
+// Video types
+// ---------------------------------------------------------------------------
+
+/** Request for video generation. */
+export interface VideoGenRequest {
+ prompt: string;
+ model?: string;
+ aspectRatio?: AspectRatio;
+ duration?: VideoDuration;
+}
+
+/** A single generated video. */
+export interface GeneratedVideo {
+ /** Base64-encoded video data or URL. */
+ data: string;
+ /** Whether data is a URL (true) or base64 (false). */
+ isUrl: boolean;
+ /** MIME type (e.g. "video/mp4"). */
+ mimeType: string;
+ /** Provider that generated this video. */
+ provider: string;
+ /** Generation seed if available. */
+ seed?: number;
+}
+
+/** Response from video generation. */
+export interface VideoGenResponse extends AIResponseMeta {
+ videos: GeneratedVideo[];
+}
+
+// ---------------------------------------------------------------------------
+// Shared types
+// ---------------------------------------------------------------------------
+
+/** Progress update during generation. */
+export interface GenerationProgress {
+ percent: number;
+ stage: string;
+}
+
+/** Options for API calls. */
+export interface MediaGenOptions {
+ timeout?: number;
+ headers?: Record;
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers
+// ---------------------------------------------------------------------------
+
+/** Parse image array from backend response. */
+function parseImages(data: Record): GeneratedImage[] {
+ const images = data.images as Record[] | undefined;
+ if (!images) return [];
+ return images.map((img) => ({
+ data: (img.data as string) || (img.url as string) || '',
+ isUrl: !!img.isUrl || !!img.url,
+ provider: (img.provider as string) || (data.provider as string) || '',
+ seed: img.seed as number | undefined,
+ revisedPrompt: img.revised_prompt as string | undefined,
+ }));
+}
+
+/** Parse video array from backend response. */
+function parseVideos(data: Record): GeneratedVideo[] {
+ const videos = data.videos as Record[] | undefined;
+ if (!videos) return [];
+ return videos.map((v) => ({
+ data: (v.data as string) || (v.url as string) || '',
+ isUrl: !!v.isUrl || !!v.url,
+ mimeType: (v.mimeType as string) || 'video/mp4',
+ provider: (v.provider as string) || (data.provider as string) || '',
+ seed: v.seed as number | undefined,
+ }));
+}
+
+/** Handle HTTP errors from generation endpoints. */
+function handleHttpError(status: number, errorData: Record): never {
+ throw createAIError(
+ (errorData.message as string) || `HTTP ${status}`,
+ status === 429 ? ErrorCodes.RATE_LIMITED : ErrorCodes.PROVIDER_ERROR,
+ undefined,
+ status === 429 || status >= 500,
+ );
+}
+
+/**
+ * Simulate progress ticks during a synchronous fetch.
+ * Returns a cleanup function.
+ */
+function simulateProgress(
+ onProgress: (p: GenerationProgress) => void,
+ stage: string,
+ maxPercent: number,
+ intervalMs: number,
+): () => void {
+ let current = 10;
+ const id = setInterval(() => {
+ if (current < maxPercent) {
+ current += Math.random() * 8 + 2;
+ current = Math.min(current, maxPercent);
+ onProgress({ percent: Math.round(current), stage });
+ }
+ }, intervalMs);
+ return () => clearInterval(id);
+}
+
+// ---------------------------------------------------------------------------
+// Image generation
+// ---------------------------------------------------------------------------
+
+/**
+ * Generate images from a text prompt.
+ *
+ * @param endpoint - The image generation API endpoint URL
+ * @param request - The generation request
+ * @param options - Additional options
+ * @returns Promise resolving to generated images
+ *
+ * @example
+ * ```ts
+ * const response = await generateImage('/api/my-api/generate/image', {
+ * prompt: 'A sunset over mountains',
+ * count: 2,
+ * });
+ * ```
+ */
+export async function generateImage(
+ endpoint: string,
+ request: ImageGenRequest,
+ options: MediaGenOptions = {},
+): Promise {
+ const startTime = Date.now();
+
+ const controller = new AbortController();
+ const timeoutId = options.timeout
+ ? setTimeout(() => controller.abort(), options.timeout)
+ : undefined;
+
+ try {
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...options.headers },
+ body: JSON.stringify(request),
+ signal: controller.signal,
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ handleHttpError(response.status, errorData);
+ }
+
+ const data = await response.json();
+
+ return {
+ images: parseImages(data),
+ provider: (data.provider as string) || '',
+ model: data.model as string | undefined,
+ latencyMs: Date.now() - startTime,
+ };
+ } catch (error) {
+ if (error instanceof Error && error.name === 'AbortError') {
+ throw createAIError('Request timed out', ErrorCodes.TIMEOUT, undefined, true);
+ }
+ throw error;
+ } finally {
+ if (timeoutId) clearTimeout(timeoutId);
+ }
+}
+
+/**
+ * Generate images with progress feedback.
+ *
+ * The backend generates images synchronously. Progress is simulated
+ * to give the user visual feedback while waiting.
+ *
+ * @param endpoint - The image generation API endpoint URL
+ * @param request - The generation request
+ * @param onProgress - Callback for progress updates
+ * @param options - Additional options
+ * @returns Promise resolving to generated images
+ *
+ * @example
+ * ```ts
+ * const response = await generateImageWithProgress(
+ * '/api/my-api/generate/image',
+ * { prompt: 'A detailed fantasy landscape', count: 4 },
+ * (progress) => setProgress(progress.percent),
+ * );
+ * ```
+ */
+export async function generateImageWithProgress(
+ endpoint: string,
+ request: ImageGenRequest,
+ onProgress: (progress: GenerationProgress) => void,
+ options: MediaGenOptions = {},
+): Promise {
+ onProgress({ percent: 0, stage: 'Starting generation...' });
+
+ const stopSimulation = simulateProgress(onProgress, 'Generating images...', 85, 800);
+
+ try {
+ const result = await generateImage(endpoint, request, options);
+ onProgress({ percent: 100, stage: 'Complete' });
+ return result;
+ } catch (error) {
+ onProgress({ percent: 0, stage: 'Failed' });
+ throw error;
+ } finally {
+ stopSimulation();
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Video generation
+// ---------------------------------------------------------------------------
+
+/**
+ * @deprecated Use useMediaGeneration from @{{PROJECT_NAME}}/realtime instead.
+ * Video generation takes 2-5 minutes. Blocking HTTP requests is wrong.
+ *
+ * Correct pattern:
+ * ```tsx
+ * import { useMediaGeneration } from '@{{PROJECT_NAME}}/realtime';
+ * const { generate, progress, result } = useMediaGeneration({
+ * endpoint: '/api/generate/video',
+ * userId: user.id,
+ * });
+ * await generate({ prompt: '...' }); // Returns immediately
+ * // Result arrives via SSE
+ * ```
+ */
+export async function generateVideo(
+ endpoint: string,
+ request: VideoGenRequest,
+ options: MediaGenOptions = {},
+): Promise {
+ const startTime = Date.now();
+
+ const controller = new AbortController();
+ // Video generation can take minutes; default timeout is 5 min
+ const timeout = options.timeout || 300_000;
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
+
+ try {
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...options.headers },
+ body: JSON.stringify(request),
+ signal: controller.signal,
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ handleHttpError(response.status, errorData);
+ }
+
+ const data = await response.json();
+
+ return {
+ videos: parseVideos(data),
+ provider: (data.provider as string) || '',
+ model: data.model as string | undefined,
+ latencyMs: Date.now() - startTime,
+ };
+ } catch (error) {
+ if (error instanceof Error && error.name === 'AbortError') {
+ throw createAIError('Video generation timed out', ErrorCodes.TIMEOUT, undefined, true);
+ }
+ throw error;
+ } finally {
+ clearTimeout(timeoutId);
+ }
+}
+
+/**
+ * @deprecated Use useMediaGeneration from @{{PROJECT_NAME}}/realtime instead.
+ * This function uses FAKE simulated progress. Real progress comes from SSE events.
+ *
+ * Correct pattern:
+ * ```tsx
+ * import { useMediaGeneration } from '@{{PROJECT_NAME}}/realtime';
+ * const { generate, progress, result } = useMediaGeneration({
+ * endpoint: '/api/generate/video',
+ * userId: user.id,
+ * });
+ * await generate({ prompt: '...' });
+ * // progress updates automatically from real SSE events
+ * ```
+ */
+export async function generateVideoWithProgress(
+ endpoint: string,
+ request: VideoGenRequest,
+ onProgress: (progress: GenerationProgress) => void,
+ options: MediaGenOptions = {},
+): Promise {
+ onProgress({ percent: 0, stage: 'Starting video generation...' });
+
+ // Video takes longer; simulate progress more slowly
+ const stopSimulation = simulateProgress(onProgress, 'Rendering video...', 80, 2000);
+
+ try {
+ const result = await generateVideo(endpoint, request, options);
+ onProgress({ percent: 100, stage: 'Complete' });
+ return result;
+ } catch (error) {
+ onProgress({ percent: 0, stage: 'Failed' });
+ throw error;
+ } finally {
+ stopSimulation();
+ }
+}
diff --git a/internal/adapter/templates/templates/skeleton/packages/ai-client/src/textgen.ts b/internal/adapter/templates/templates/skeleton/packages/ai-client/src/textgen.ts
new file mode 100644
index 0000000..447c287
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ai-client/src/textgen.ts
@@ -0,0 +1,226 @@
+/**
+ * Text generation client for calling textgen API endpoints.
+ */
+
+import { TokenUsage, AIResponseMeta, createAIError, ErrorCodes } from './types';
+
+/** Role for chat messages. */
+export type MessageRole = 'system' | 'user' | 'assistant';
+
+/** A single message in a conversation. */
+export interface TextGenMessage {
+ role: MessageRole;
+ content: string;
+}
+
+/** Request for text generation. */
+export interface TextGenRequest {
+ messages: TextGenMessage[];
+ model?: string;
+ maxTokens?: number;
+ temperature?: number;
+ stream?: boolean;
+}
+
+/** Response from text generation. */
+export interface TextGenResponse extends AIResponseMeta {
+ text: string;
+ usage?: TokenUsage;
+ finishReason?: 'stop' | 'length' | 'content_filter';
+}
+
+/** Chunk received during streaming. */
+export interface TextGenChunk {
+ text: string;
+ done: boolean;
+}
+
+/** Options for API calls. */
+export interface TextGenOptions {
+ timeout?: number;
+ headers?: Record;
+}
+
+/**
+ * Stream text generation with Server-Sent Events.
+ *
+ * @param endpoint - The textgen API endpoint URL
+ * @param request - The generation request
+ * @param onChunk - Callback for each text chunk received
+ * @param onDone - Callback when generation completes
+ * @param onError - Callback for errors
+ * @param options - Additional options
+ * @returns Abort function to cancel the stream
+ *
+ * @example
+ * ```ts
+ * const abort = streamText(
+ * '/api/textgen',
+ * { messages: [{ role: 'user', content: 'Hello!' }] },
+ * (chunk) => console.log(chunk),
+ * (response) => console.log('Done:', response),
+ * (error) => console.error(error)
+ * );
+ *
+ * // Cancel if needed
+ * abort();
+ * ```
+ */
+export function streamText(
+ endpoint: string,
+ request: TextGenRequest,
+ onChunk: (chunk: string) => void,
+ onDone: (response: TextGenResponse) => void,
+ onError: (error: Error) => void,
+ options: TextGenOptions = {}
+): () => void {
+ const controller = new AbortController();
+ const startTime = Date.now();
+
+ const streamRequest = { ...request, stream: true };
+
+ fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
+ body: JSON.stringify(streamRequest),
+ signal: controller.signal,
+ })
+ .then(async (response) => {
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw createAIError(
+ errorData.message || `HTTP ${response.status}`,
+ response.status === 429 ? ErrorCodes.RATE_LIMITED : ErrorCodes.PROVIDER_ERROR,
+ undefined,
+ response.status === 429 || response.status >= 500
+ );
+ }
+
+ const reader = response.body?.getReader();
+ if (!reader) {
+ throw createAIError('No response body', ErrorCodes.NETWORK_ERROR);
+ }
+
+ const decoder = new TextDecoder();
+ let fullText = '';
+ let provider = '';
+ let usage: TokenUsage | undefined;
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ const chunk = decoder.decode(value, { stream: true });
+ const lines = chunk.split('\n');
+
+ for (const line of lines) {
+ if (line.startsWith('data: ')) {
+ const data = line.slice(6);
+ if (data === '[DONE]') continue;
+
+ try {
+ const parsed = JSON.parse(data);
+ if (parsed.text) {
+ fullText += parsed.text;
+ onChunk(parsed.text);
+ }
+ if (parsed.provider) provider = parsed.provider;
+ if (parsed.usage) usage = parsed.usage;
+ } catch {
+ // Skip invalid JSON lines
+ }
+ }
+ }
+ }
+
+ onDone({
+ text: fullText,
+ provider,
+ usage,
+ latencyMs: Date.now() - startTime,
+ });
+ })
+ .catch((error) => {
+ if (error.name === 'AbortError') return;
+ onError(error);
+ });
+
+ return () => controller.abort();
+}
+
+/**
+ * Generate text (non-streaming).
+ *
+ * @param endpoint - The textgen API endpoint URL
+ * @param request - The generation request
+ * @param options - Additional options
+ * @returns Promise resolving to the generation response
+ *
+ * @example
+ * ```ts
+ * const response = await generateText('/api/textgen', {
+ * messages: [
+ * { role: 'system', content: 'You are a helpful assistant.' },
+ * { role: 'user', content: 'What is 2 + 2?' }
+ * ],
+ * temperature: 0.7,
+ * });
+ * console.log(response.text);
+ * ```
+ */
+export async function generateText(
+ endpoint: string,
+ request: TextGenRequest,
+ options: TextGenOptions = {}
+): Promise {
+ const startTime = Date.now();
+ const nonStreamRequest = { ...request, stream: false };
+
+ const controller = new AbortController();
+ const timeoutId = options.timeout
+ ? setTimeout(() => controller.abort(), options.timeout)
+ : undefined;
+
+ try {
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
+ body: JSON.stringify(nonStreamRequest),
+ signal: controller.signal,
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw createAIError(
+ errorData.message || `HTTP ${response.status}`,
+ response.status === 429 ? ErrorCodes.RATE_LIMITED : ErrorCodes.PROVIDER_ERROR,
+ undefined,
+ response.status === 429 || response.status >= 500
+ );
+ }
+
+ const data = await response.json();
+
+ return {
+ text: data.text || '',
+ provider: data.provider || '',
+ model: data.model,
+ usage: data.usage,
+ finishReason: data.finish_reason,
+ latencyMs: Date.now() - startTime,
+ };
+ } catch (error) {
+ if (error instanceof Error && error.name === 'AbortError') {
+ throw createAIError('Request timed out', ErrorCodes.TIMEOUT, undefined, true);
+ }
+ throw error;
+ } finally {
+ if (timeoutId) clearTimeout(timeoutId);
+ }
+}
diff --git a/internal/adapter/templates/templates/skeleton/packages/ai-client/src/types.ts b/internal/adapter/templates/templates/skeleton/packages/ai-client/src/types.ts
new file mode 100644
index 0000000..71f84e3
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ai-client/src/types.ts
@@ -0,0 +1,51 @@
+/**
+ * Shared types for AI client operations.
+ */
+
+/** Token usage information from AI providers. */
+export interface TokenUsage {
+ promptTokens: number;
+ completionTokens: number;
+ totalTokens?: number;
+}
+
+/** Base response metadata from any AI operation. */
+export interface AIResponseMeta {
+ provider: string;
+ model?: string;
+ latencyMs: number;
+}
+
+/** Error from AI operations. */
+export interface AIError extends Error {
+ code: string;
+ provider?: string;
+ retryable: boolean;
+}
+
+/** Create a typed AI error. */
+export function createAIError(
+ message: string,
+ code: string,
+ provider?: string,
+ retryable = false
+): AIError {
+ const error = new Error(message) as AIError;
+ error.code = code;
+ error.provider = provider;
+ error.retryable = retryable;
+ return error;
+}
+
+/** Common error codes. */
+export const ErrorCodes = {
+ NETWORK_ERROR: 'NETWORK_ERROR',
+ TIMEOUT: 'TIMEOUT',
+ RATE_LIMITED: 'RATE_LIMITED',
+ INVALID_REQUEST: 'INVALID_REQUEST',
+ PROVIDER_ERROR: 'PROVIDER_ERROR',
+ QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
+ CONTENT_FILTERED: 'CONTENT_FILTERED',
+} as const;
+
+export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
diff --git a/internal/adapter/templates/templates/skeleton/packages/ai-client/tsconfig.json b/internal/adapter/templates/templates/skeleton/packages/ai-client/tsconfig.json
new file mode 100644
index 0000000..2523b9a
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ai-client/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/internal/adapter/templates/templates/skeleton/packages/api-client/package.json.tmpl b/internal/adapter/templates/templates/skeleton/packages/api-client/package.json.tmpl
index 843427e..0088a9b 100644
--- a/internal/adapter/templates/templates/skeleton/packages/api-client/package.json.tmpl
+++ b/internal/adapter/templates/templates/skeleton/packages/api-client/package.json.tmpl
@@ -5,6 +5,12 @@
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts"
+ }
+ },
"scripts": {
"generate": "../scripts/generate-client.sh",
"typecheck": "tsc --noEmit",
diff --git a/internal/adapter/templates/templates/skeleton/packages/api-client/src/client.ts b/internal/adapter/templates/templates/skeleton/packages/api-client/src/client.ts
index dedf866..33ca524 100644
--- a/internal/adapter/templates/templates/skeleton/packages/api-client/src/client.ts
+++ b/internal/adapter/templates/templates/skeleton/packages/api-client/src/client.ts
@@ -1,3 +1,6 @@
+import type { ApiError } from './types';
+import { ApiClientError } from './errors';
+
/**
* API Client Configuration
*/
@@ -6,7 +9,85 @@ export interface ClientConfig {
apiKey?: string;
bearerToken?: string;
headers?: Record;
- onError?: (error: Error) => void;
+ /** Called on any error (after parsing) */
+ onError?: (error: ApiClientError) => void;
+ /** Called specifically on auth errors (401) - useful for triggering logout */
+ onAuthError?: (error: ApiClientError) => void;
+}
+
+/**
+ * Parse error response body into ApiError structure.
+ */
+async function parseErrorResponse(response: Response): Promise {
+ try {
+ const body = await response.json();
+ // Handle wrapped response format { error: { ... } }
+ if (body.error && typeof body.error === 'object') {
+ return {
+ status: response.status,
+ code: body.error.code || 'UNKNOWN_ERROR',
+ message: body.error.message || response.statusText,
+ details: body.error.details,
+ };
+ }
+ // Handle direct error format { code, message, details }
+ if (body.code && body.message) {
+ return {
+ status: response.status,
+ code: body.code,
+ message: body.message,
+ details: body.details,
+ };
+ }
+ // Handle simple message format { message: "..." }
+ if (body.message) {
+ return {
+ status: response.status,
+ code: codeFromStatus(response.status),
+ message: body.message,
+ details: body.details,
+ };
+ }
+ // Fallback
+ return {
+ status: response.status,
+ code: codeFromStatus(response.status),
+ message: response.statusText || 'An error occurred',
+ };
+ } catch {
+ // Failed to parse JSON
+ return {
+ status: response.status,
+ code: codeFromStatus(response.status),
+ message: response.statusText || 'An error occurred',
+ };
+ }
+}
+
+/**
+ * Map HTTP status to error code.
+ */
+function codeFromStatus(status: number): string {
+ switch (status) {
+ case 400:
+ return 'BAD_REQUEST';
+ case 401:
+ return 'UNAUTHORIZED';
+ case 403:
+ return 'FORBIDDEN';
+ case 404:
+ return 'NOT_FOUND';
+ case 409:
+ return 'CONFLICT';
+ case 422:
+ return 'UNPROCESSABLE_ENTITY';
+ case 429:
+ return 'TOO_MANY_REQUESTS';
+ case 503:
+ return 'SERVICE_UNAVAILABLE';
+ default:
+ return status >= 500 ? 'INTERNAL_ERROR' : 'UNKNOWN_ERROR';
+ }
}
/**
@@ -20,9 +101,20 @@ export interface ClientConfig {
*
* const users = await client.get('/users');
* const newUser = await client.post('/users', { name: 'John' });
+ *
+ * @example
+ * // Handling validation errors
+ * try {
+ * await client.post('/users', { email: 'invalid' });
+ * } catch (error) {
+ * if (isApiClientError(error) && error.isValidationError()) {
+ * const fieldErrors = error.getFieldErrors();
+ * console.log(fieldErrors.email); // "email must be a valid email address"
+ * }
+ * }
*/
export function createClient(config: ClientConfig) {
- const { baseUrl, apiKey, bearerToken, headers = {}, onError } = config;
+ const { baseUrl, apiKey, bearerToken, headers = {}, onError, onAuthError } = config;
async function request(
method: string,
@@ -65,10 +157,19 @@ export function createClient(config: ClientConfig) {
});
if (!response.ok) {
- const error = new Error(`API error: ${response.status}`);
+ const apiError = await parseErrorResponse(response);
+ const error = new ApiClientError(apiError, response);
+
+ // Call auth error handler for 401s
+ if (error.isAuthError() && onAuthError) {
+ onAuthError(error);
+ }
+
+ // Call general error handler
if (onError) {
onError(error);
}
+
throw error;
}
@@ -77,19 +178,22 @@ export function createClient(config: ClientConfig) {
return undefined as T;
}
- return response.json();
+ const json = await response.json();
+
+ // Handle wrapped response format { data: { ... } }
+ if (json.data !== undefined) {
+ return json.data as T;
+ }
+
+ return json as T;
}
return {
get: (path: string, params?: Record) =>
request('GET', path, { params }),
- post: (path: string, body?: unknown) =>
- request('POST', path, { body }),
- put: (path: string, body?: unknown) =>
- request('PUT', path, { body }),
- patch: (path: string, body?: unknown) =>
- request('PATCH', path, { body }),
- delete: (path: string) =>
- request('DELETE', path),
+ post: (path: string, body?: unknown) => request('POST', path, { body }),
+ put: (path: string, body?: unknown) => request('PUT', path, { body }),
+ patch: (path: string, body?: unknown) => request('PATCH', path, { body }),
+ delete: (path: string) => request('DELETE', path),
};
}
diff --git a/internal/adapter/templates/templates/skeleton/packages/api-client/src/errors.ts b/internal/adapter/templates/templates/skeleton/packages/api-client/src/errors.ts
new file mode 100644
index 0000000..5a38892
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/api-client/src/errors.ts
@@ -0,0 +1,114 @@
+import type { ApiError, ValidationDetail, ErrorCodeType } from './types';
+
+/**
+ * API client error with typed error information.
+ * Provides convenient methods for accessing validation errors.
+ */
+export class ApiClientError extends Error {
+ /** HTTP status code */
+ readonly status: number;
+ /** Machine-readable error code */
+ readonly code: string;
+ /** Optional validation details or other error details */
+ readonly details?: ValidationDetail[] | Record;
+ /** The original response */
+ readonly response?: Response;
+
+ constructor(apiError: ApiError, response?: Response) {
+ super(apiError.message);
+ this.name = 'ApiClientError';
+ this.status = apiError.status;
+ this.code = apiError.code;
+ this.details = apiError.details;
+ this.response = response;
+
+ // Maintains proper stack trace in V8 environments
+ if ('captureStackTrace' in Error && typeof Error.captureStackTrace === 'function') {
+ Error.captureStackTrace(this, ApiClientError);
+ }
+ }
+
+ /**
+ * Check if this is a validation error.
+ */
+ isValidationError(): boolean {
+ return this.code === 'VALIDATION_ERROR' || this.code === 'BAD_REQUEST';
+ }
+
+ /**
+ * Check if this is an authentication error.
+ */
+ isAuthError(): boolean {
+ return this.status === 401 || this.code === 'UNAUTHORIZED';
+ }
+
+ /**
+ * Check if this is a permission error.
+ */
+ isForbiddenError(): boolean {
+ return this.status === 403 || this.code === 'FORBIDDEN';
+ }
+
+ /**
+ * Check if this is a not found error.
+ */
+ isNotFoundError(): boolean {
+ return this.status === 404 || this.code === 'NOT_FOUND';
+ }
+
+ /**
+ * Check if the error has a specific code.
+ */
+ hasCode(code: ErrorCodeType | string): boolean {
+ return this.code === code;
+ }
+
+ /**
+ * Get validation errors as an array.
+ * Returns empty array if no validation details are present.
+ */
+ getValidationErrors(): ValidationDetail[] {
+ if (!this.details) return [];
+ if (Array.isArray(this.details)) return this.details;
+ return [];
+ }
+
+ /**
+ * Get validation errors mapped by field name.
+ * Useful for displaying errors next to form fields.
+ *
+ * @example
+ * const errors = error.getFieldErrors();
+ * // { email: 'email is required', password: 'password must be at least 8 characters' }
+ */
+ getFieldErrors(): Record {
+ const errors: Record = {};
+ for (const detail of this.getValidationErrors()) {
+ errors[detail.field] = detail.message;
+ }
+ return errors;
+ }
+
+ /**
+ * Get the error message for a specific field.
+ * Returns undefined if no error for that field.
+ */
+ getFieldError(field: string): string | undefined {
+ const errors = this.getValidationErrors();
+ return errors.find((e) => e.field === field)?.message;
+ }
+
+ /**
+ * Check if a specific field has an error.
+ */
+ hasFieldError(field: string): boolean {
+ return this.getFieldError(field) !== undefined;
+ }
+}
+
+/**
+ * Type guard for ApiClientError.
+ */
+export function isApiClientError(error: unknown): error is ApiClientError {
+ return error instanceof ApiClientError;
+}
diff --git a/internal/adapter/templates/templates/skeleton/packages/api-client/src/index.ts b/internal/adapter/templates/templates/skeleton/packages/api-client/src/index.ts
index 560ecd2..032c097 100644
--- a/internal/adapter/templates/templates/skeleton/packages/api-client/src/index.ts
+++ b/internal/adapter/templates/templates/skeleton/packages/api-client/src/index.ts
@@ -1,3 +1,5 @@
export * from './client';
+export * from './types';
+export * from './errors';
// Note: schema.d.ts is generated by running `pnpm generate`
// export type { paths, components, operations } from './schema';
diff --git a/internal/adapter/templates/templates/skeleton/packages/api-client/src/types.ts b/internal/adapter/templates/templates/skeleton/packages/api-client/src/types.ts
new file mode 100644
index 0000000..cf36115
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/api-client/src/types.ts
@@ -0,0 +1,67 @@
+/**
+ * Validation error detail from the API.
+ * Matches the Go httpvalidation.ValidationDetail struct.
+ */
+export interface ValidationDetail {
+ /** The field name that failed validation */
+ field: string;
+ /** Human-readable error message */
+ message: string;
+}
+
+/**
+ * Standard API error response structure.
+ * Matches the Go httperror.HTTPError struct.
+ */
+export interface ApiError {
+ /** HTTP status code */
+ status: number;
+ /** Machine-readable error code (e.g., "VALIDATION_ERROR", "NOT_FOUND") */
+ code: string;
+ /** Human-readable error message */
+ message: string;
+ /** Optional additional details (validation errors, etc.) */
+ details?: ValidationDetail[] | Record;
+}
+
+/**
+ * Standard API response wrapper.
+ */
+export interface ApiResponse {
+ data?: T;
+ error?: ApiError;
+}
+
+/**
+ * Common error codes returned by the API.
+ */
+export const ErrorCode = {
+ BAD_REQUEST: 'BAD_REQUEST',
+ VALIDATION_ERROR: 'VALIDATION_ERROR',
+ UNAUTHORIZED: 'UNAUTHORIZED',
+ FORBIDDEN: 'FORBIDDEN',
+ NOT_FOUND: 'NOT_FOUND',
+ CONFLICT: 'CONFLICT',
+ UNPROCESSABLE_ENTITY: 'UNPROCESSABLE_ENTITY',
+ TOO_MANY_REQUESTS: 'TOO_MANY_REQUESTS',
+ INTERNAL_ERROR: 'INTERNAL_ERROR',
+ SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
+} as const;
+
+export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode];
+
+/**
+ * Auth-related types for login/user responses.
+ */
+export interface AuthUser {
+ id: string;
+ email: string;
+ name?: string;
+ roles?: string[];
+ scopes?: string[];
+}
+
+export interface LoginResponse {
+ token: string;
+ user: AuthUser;
+}
diff --git a/internal/adapter/templates/templates/skeleton/packages/auth/package.json.tmpl b/internal/adapter/templates/templates/skeleton/packages/auth/package.json.tmpl
index a0ff0e5..17e6854 100644
--- a/internal/adapter/templates/templates/skeleton/packages/auth/package.json.tmpl
+++ b/internal/adapter/templates/templates/skeleton/packages/auth/package.json.tmpl
@@ -5,6 +5,12 @@
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts"
+ }
+ },
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc",
diff --git a/internal/adapter/templates/templates/skeleton/packages/auth/src/AuthProvider.tsx b/internal/adapter/templates/templates/skeleton/packages/auth/src/AuthProvider.tsx
index 878dbeb..c8c8c55 100644
--- a/internal/adapter/templates/templates/skeleton/packages/auth/src/AuthProvider.tsx
+++ b/internal/adapter/templates/templates/skeleton/packages/auth/src/AuthProvider.tsx
@@ -34,8 +34,6 @@ export interface AuthProviderProps {
loginUrl?: string;
/** API endpoint for logout */
logoutUrl?: string;
- /** API endpoint for fetching current user */
- userUrl?: string;
/** Custom login handler */
onLogin?: (credentials: LoginCredentials) => Promise<{ token: string; user: User }>;
/** Custom logout handler */
@@ -68,7 +66,6 @@ export function AuthProvider({
children,
loginUrl = '/api/auth/login',
logoutUrl = '/api/auth/logout',
- userUrl = '/api/auth/me',
onLogin,
onLogout,
storage = 'localStorage',
@@ -140,8 +137,9 @@ export function AuthProvider({
});
if (!response.ok) {
- const error = await response.json().catch(() => ({}));
- throw new Error(error.message || 'Login failed');
+ const errBody = await response.json().catch(() => ({}));
+ const errMsg = errBody.error?.message || errBody.message || 'Login failed';
+ throw new Error(errMsg);
}
const data = await response.json();
diff --git a/internal/adapter/templates/templates/skeleton/packages/layout/package.json.tmpl b/internal/adapter/templates/templates/skeleton/packages/layout/package.json.tmpl
index 080e1fe..ae4a482 100644
--- a/internal/adapter/templates/templates/skeleton/packages/layout/package.json.tmpl
+++ b/internal/adapter/templates/templates/skeleton/packages/layout/package.json.tmpl
@@ -5,6 +5,12 @@
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts"
+ }
+ },
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc",
diff --git a/internal/adapter/templates/templates/skeleton/packages/logger/package.json b/internal/adapter/templates/templates/skeleton/packages/logger/package.json.tmpl
similarity index 73%
rename from internal/adapter/templates/templates/skeleton/packages/logger/package.json
rename to internal/adapter/templates/templates/skeleton/packages/logger/package.json.tmpl
index 7088514..3f1a1dd 100644
--- a/internal/adapter/templates/templates/skeleton/packages/logger/package.json
+++ b/internal/adapter/templates/templates/skeleton/packages/logger/package.json.tmpl
@@ -5,6 +5,12 @@
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts"
+ }
+ },
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc"
diff --git a/internal/adapter/templates/templates/skeleton/packages/realtime/package.json.tmpl b/internal/adapter/templates/templates/skeleton/packages/realtime/package.json.tmpl
new file mode 100644
index 0000000..1af4165
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/realtime/package.json.tmpl
@@ -0,0 +1,28 @@
+{
+ "name": "@{{PROJECT_NAME}}/realtime",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts"
+ }
+ },
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "build": "tsc",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "typescript": "^5.5.3"
+ }
+}
diff --git a/internal/adapter/templates/templates/skeleton/packages/realtime/src/index.ts b/internal/adapter/templates/templates/skeleton/packages/realtime/src/index.ts
new file mode 100644
index 0000000..f539f60
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/realtime/src/index.ts
@@ -0,0 +1,5 @@
+export * from './types';
+export { useEventChannel, type ChannelEvent, type SSEState, type UseEventChannelConfig } from './useEventChannel';
+export { useMediaGeneration, type GenerationStatus, type UseMediaGenerationConfig, type UseMediaGenerationResult } from './useMediaGeneration';
+export { useChat, type UseChatConfig, type UseChatResult, type ChatMessage } from './useChat';
+export { useMediaUpload, type UploadProgress, type UploadResult, type UseMediaUploadConfig, type UseMediaUploadResult } from './useMediaUpload';
diff --git a/internal/adapter/templates/templates/skeleton/packages/realtime/src/types.ts b/internal/adapter/templates/templates/skeleton/packages/realtime/src/types.ts
new file mode 100644
index 0000000..3b68f16
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/realtime/src/types.ts
@@ -0,0 +1,79 @@
+/**
+ * SSE event types matching Go realtime constants.
+ */
+export type EventType =
+ | 'chat'
+ | 'ai_chat'
+ | 'ai_chat_chunk'
+ | 'presence'
+ | 'notification'
+ | 'system'
+ | 'error'
+ | 'generation_started'
+ | 'generation_progress'
+ | 'generation_complete'
+ | 'generation_failed'
+ | 'upload_started'
+ | 'upload_progress'
+ | 'upload_complete'
+ | 'upload_failed';
+
+/**
+ * Chat message data payload.
+ */
+export interface ChatData {
+ content: string;
+ userId: string;
+ userName?: string;
+}
+
+/**
+ * AI response data payload.
+ */
+export interface AIResponseData {
+ content: string;
+ provider: string;
+}
+
+/**
+ * AI chunk data payload (for streaming).
+ */
+export interface AIChunkData {
+ streamId: string;
+ text: string;
+ done: boolean;
+ provider?: string;
+}
+
+/**
+ * Presence message data payload.
+ */
+export interface PresenceData {
+ userId: string;
+ userName?: string;
+ status: PresenceStatus;
+}
+
+/**
+ * Presence status values.
+ */
+export type PresenceStatus = 'online' | 'offline' | 'away';
+
+/**
+ * Generation result payload.
+ */
+export interface GenerationResult {
+ url: string;
+ provider: string;
+ latencyMs: number;
+}
+
+/**
+ * Upload result payload.
+ */
+export interface UploadResult {
+ id: string;
+ original: string;
+ optimized: string;
+ thumbnail: string;
+}
diff --git a/internal/adapter/templates/templates/skeleton/packages/realtime/src/useChat.ts b/internal/adapter/templates/templates/skeleton/packages/realtime/src/useChat.ts
new file mode 100644
index 0000000..44c41a8
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/realtime/src/useChat.ts
@@ -0,0 +1,296 @@
+'use client';
+
+import { useState, useCallback, useRef } from 'react';
+import { useEventChannel, type ChannelEvent } from './useEventChannel';
+
+/**
+ * Chat message from a user.
+ */
+export interface ChatMessage {
+ id: string;
+ content: string;
+ userId: string;
+ userName?: string;
+ timestamp: string;
+}
+
+/**
+ * AI response message.
+ */
+export interface AIMessage {
+ id: string;
+ content: string;
+ provider: string;
+ timestamp: string;
+}
+
+/**
+ * In-progress AI streaming message.
+ */
+export interface AIStreamingMessage {
+ streamId: string;
+ content: string;
+ isStreaming: boolean;
+ timestamp: string;
+}
+
+/**
+ * Online user in the room.
+ */
+export interface OnlineUser {
+ id: string;
+ name?: string;
+ status: 'online' | 'away' | 'offline';
+}
+
+/**
+ * Configuration for the chat hook.
+ */
+export interface UseChatConfig {
+ /** API endpoint for sending messages (e.g., '/api/chat/messages') */
+ endpoint: string;
+ /** SSE endpoint for events (default: '/api/events') */
+ sseEndpoint?: string;
+ /** Channel to subscribe to (e.g., 'channel:room123') */
+ channel: string;
+ /** Current user ID */
+ userId: string;
+ /** Current user display name */
+ userName?: string;
+ /** Additional headers for POST requests */
+ headers?: Record;
+}
+
+/**
+ * Result from the chat hook.
+ */
+export interface UseChatResult {
+ /** List of chat messages */
+ messages: ChatMessage[];
+ /** List of AI responses */
+ aiMessages: AIMessage[];
+ /** Map of in-progress AI streams */
+ streamingMessages: Map;
+ /** List of online users */
+ onlineUsers: OnlineUser[];
+ /** SSE connection state */
+ connectionState: 'connecting' | 'connected' | 'disconnected' | 'error';
+ /** Send a chat message */
+ sendMessage: (content: string) => Promise;
+ /** Clear all messages */
+ clearMessages: () => void;
+}
+
+/** Maximum message IDs to track for deduplication */
+const MAX_SEEN_MESSAGES = 500;
+
+/**
+ * Chat hook using HTTP2 + SSE pattern.
+ *
+ * - Send messages: HTTP POST to endpoint
+ * - Receive messages: SSE subscription to channel
+ *
+ * @example
+ * ```tsx
+ * const {
+ * messages,
+ * aiMessages,
+ * sendMessage,
+ * connectionState,
+ * } = useChat({
+ * endpoint: '/api/chat/messages',
+ * channel: 'channel:room123',
+ * userId: currentUser.id,
+ * userName: currentUser.name,
+ * });
+ *
+ * // Send a message (HTTP POST)
+ * await sendMessage('Hello, world!');
+ *
+ * // Messages arrive via SSE automatically
+ * return (
+ *
+ * {messages.map(msg => (
+ *
+ * ))}
+ *
+ * );
+ * ```
+ */
+export function useChat(config: UseChatConfig): UseChatResult {
+ const {
+ endpoint,
+ sseEndpoint = '/api/events',
+ channel,
+ userId,
+ userName,
+ headers,
+ } = config;
+
+ const [messages, setMessages] = useState([]);
+ const [aiMessages, setAIMessages] = useState([]);
+ const [streamingMessages, setStreamingMessages] = useState>(new Map());
+ const [onlineUsers, setOnlineUsers] = useState([]);
+
+ const seenMessageIds = useRef(new Set());
+
+ // Handle incoming SSE events
+ const handleEvent = useCallback((event: ChannelEvent) => {
+ const eventId = event.id as string | undefined;
+
+ switch (event.type) {
+ case 'chat': {
+ // Deduplicate
+ if (eventId && seenMessageIds.current.has(eventId)) return;
+ if (eventId) {
+ seenMessageIds.current.add(eventId);
+ if (seenMessageIds.current.size > MAX_SEEN_MESSAGES) {
+ const arr = Array.from(seenMessageIds.current);
+ seenMessageIds.current = new Set(arr.slice(-MAX_SEEN_MESSAGES));
+ }
+ }
+
+ // Chat data may be at top level or nested in event.result (SSEEvent envelope)
+ const payload = (event.result as Record) || event;
+ const msg: ChatMessage = {
+ id: (payload.id as string) || eventId || crypto.randomUUID(),
+ content: (payload.content as string) || (event.message as string) || '',
+ userId: (payload.userId as string) || 'unknown',
+ userName: payload.userName as string | undefined,
+ timestamp: (payload.timestamp as string) || event.timestamp || new Date().toISOString(),
+ };
+ setMessages((prev) => [...prev, msg]);
+ break;
+ }
+
+ case 'ai_chat_chunk': {
+ // Chunk data may be at top level or nested in event.result (SSEEvent envelope)
+ const chunkPayload = (event.result as Record) || event;
+ const streamId = (chunkPayload.streamId as string) || (event.streamId as string);
+ const text = (chunkPayload.text as string) || (event.text as string) || '';
+ const done = (chunkPayload.done as boolean) || (event.done as boolean) || false;
+
+ setStreamingMessages((prev) => {
+ const updated = new Map(prev);
+ const existing = updated.get(streamId);
+
+ if (done) {
+ // Move completed stream to aiMessages
+ const finalContent = (existing?.content || '') + text;
+ if (finalContent) {
+ const provider = (chunkPayload.provider as string) || '';
+ setAIMessages((prevAI) => [...prevAI, {
+ id: streamId || crypto.randomUUID(),
+ content: finalContent,
+ provider,
+ timestamp: existing?.timestamp || new Date().toISOString(),
+ }]);
+ }
+ updated.delete(streamId);
+ } else {
+ updated.set(streamId, {
+ streamId,
+ content: (existing?.content || '') + text,
+ isStreaming: true,
+ timestamp: existing?.timestamp || new Date().toISOString(),
+ });
+ }
+ return updated;
+ });
+ break;
+ }
+
+ case 'ai_chat': {
+ // AI chat data may be at top level or nested in event.result (SSEEvent envelope)
+ const aiPayload = (event.result as Record) || event;
+ const aiId = (aiPayload.id as string) || event.jobId || eventId || crypto.randomUUID();
+
+ // Deduplicate
+ if (seenMessageIds.current.has(aiId)) return;
+ seenMessageIds.current.add(aiId);
+ if (seenMessageIds.current.size > MAX_SEEN_MESSAGES) {
+ const arr = Array.from(seenMessageIds.current);
+ seenMessageIds.current = new Set(arr.slice(-MAX_SEEN_MESSAGES));
+ }
+
+ const aiMsg: AIMessage = {
+ id: aiId,
+ content: (aiPayload.content as string) || (event.message as string) || '',
+ provider: (aiPayload.provider as string) || '',
+ timestamp: (aiPayload.timestamp as string) || event.timestamp || new Date().toISOString(),
+ };
+ setAIMessages((prev) => [...prev, aiMsg]);
+ break;
+ }
+
+ case 'presence': {
+ const presenceUserId = event.userId as string;
+ const status = event.status as 'online' | 'away' | 'offline';
+ const name = event.userName as string | undefined;
+
+ setOnlineUsers((prev) => {
+ if (status === 'offline') {
+ return prev.filter((u) => u.id !== presenceUserId);
+ }
+ const existing = prev.find((u) => u.id === presenceUserId);
+ if (existing) {
+ return prev.map((u) =>
+ u.id === presenceUserId ? { ...u, status, name } : u
+ );
+ }
+ return [...prev, { id: presenceUserId, name, status }];
+ });
+ break;
+ }
+ }
+ }, []);
+
+ // Subscribe to SSE channel
+ const { state: sseState } = useEventChannel({
+ endpoint: sseEndpoint,
+ channel,
+ onEvent: handleEvent,
+ });
+
+ // Send message via HTTP POST
+ const sendMessage = useCallback(
+ async (content: string) => {
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...headers,
+ },
+ body: JSON.stringify({
+ content,
+ userId,
+ userName,
+ channel,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}));
+ throw new Error((error as { message?: string }).message || `HTTP ${response.status}`);
+ }
+ },
+ [endpoint, headers, userId, userName, channel]
+ );
+
+ const clearMessages = useCallback(() => {
+ setMessages([]);
+ setAIMessages([]);
+ setStreamingMessages(new Map());
+ seenMessageIds.current.clear();
+ }, []);
+
+ return {
+ messages,
+ aiMessages,
+ streamingMessages,
+ onlineUsers,
+ connectionState: sseState,
+ sendMessage,
+ clearMessages,
+ };
+}
diff --git a/internal/adapter/templates/templates/skeleton/packages/realtime/src/useEventChannel.ts b/internal/adapter/templates/templates/skeleton/packages/realtime/src/useEventChannel.ts
new file mode 100644
index 0000000..d707b96
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/realtime/src/useEventChannel.ts
@@ -0,0 +1,164 @@
+'use client';
+
+import { useEffect, useRef, useCallback, useState } from 'react';
+
+/**
+ * Event from SSE channel.
+ */
+export interface ChannelEvent {
+ type: string;
+ timestamp: string;
+ jobId?: string;
+ progress?: number;
+ message?: string;
+ result?: unknown;
+ error?: string;
+ [key: string]: unknown;
+}
+
+/**
+ * Connection state for SSE.
+ */
+export type SSEState = 'connecting' | 'connected' | 'disconnected' | 'error';
+
+/**
+ * Configuration for useEventChannel hook.
+ */
+export interface UseEventChannelConfig {
+ /** SSE endpoint URL (e.g., '/api/events') */
+ endpoint: string;
+ /** Channel to subscribe to (e.g., 'user:123') */
+ channel: string;
+ /** Handler for incoming events */
+ onEvent?: (event: ChannelEvent) => void;
+ /** Handler for connection state changes */
+ onStateChange?: (state: SSEState) => void;
+ /** Auto-reconnect on disconnect (default: true) */
+ autoReconnect?: boolean;
+ /** Reconnect delay in ms (default: 3000) */
+ reconnectDelay?: number;
+}
+
+/**
+ * Hook for subscribing to SSE event channels.
+ *
+ * Connects to an SSE endpoint and receives events for a specific channel.
+ * Automatically reconnects on disconnect.
+ *
+ * @example
+ * ```tsx
+ * const { state } = useEventChannel({
+ * endpoint: '/api/events',
+ * channel: `user:${userId}`,
+ * onEvent: (event) => {
+ * switch (event.type) {
+ * case 'generation_complete':
+ * setResult(event.result);
+ * break;
+ * case 'generation_progress':
+ * setProgress(event.progress);
+ * break;
+ * }
+ * },
+ * });
+ * ```
+ */
+export function useEventChannel(config: UseEventChannelConfig) {
+ const {
+ endpoint,
+ channel,
+ onEvent,
+ onStateChange,
+ autoReconnect = true,
+ reconnectDelay = 3000,
+ } = config;
+
+ const [state, setState] = useState('disconnected');
+ const eventSourceRef = useRef(null);
+ const reconnectTimeoutRef = useRef>();
+
+ // Refs for callbacks to avoid stale closures
+ const onEventRef = useRef(onEvent);
+ const onStateChangeRef = useRef(onStateChange);
+
+ useEffect(() => {
+ onEventRef.current = onEvent;
+ onStateChangeRef.current = onStateChange;
+ });
+
+ const updateState = useCallback((newState: SSEState) => {
+ setState(newState);
+ onStateChangeRef.current?.(newState);
+ }, []);
+
+ const connect = useCallback(() => {
+ // Close existing connection
+ if (eventSourceRef.current) {
+ eventSourceRef.current.close();
+ }
+
+ // Build URL with channel parameter
+ const url = new URL(endpoint, window.location.origin);
+ url.searchParams.set('channel', channel);
+
+ updateState('connecting');
+
+ const eventSource = new EventSource(url.toString());
+ eventSourceRef.current = eventSource;
+
+ eventSource.onopen = () => {
+ updateState('connected');
+ };
+
+ eventSource.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data) as ChannelEvent;
+ onEventRef.current?.(data);
+ } catch {
+ console.error('[SSE] Failed to parse event:', event.data);
+ }
+ };
+
+ eventSource.onerror = () => {
+ updateState('error');
+ eventSource.close();
+ eventSourceRef.current = null;
+
+ // Reconnect if enabled
+ if (autoReconnect) {
+ reconnectTimeoutRef.current = setTimeout(() => {
+ connect();
+ }, reconnectDelay);
+ }
+ };
+ }, [endpoint, channel, autoReconnect, reconnectDelay, updateState]);
+
+ const disconnect = useCallback(() => {
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current);
+ }
+ if (eventSourceRef.current) {
+ eventSourceRef.current.close();
+ eventSourceRef.current = null;
+ }
+ updateState('disconnected');
+ }, [updateState]);
+
+ // Connect on mount, disconnect on unmount
+ useEffect(() => {
+ connect();
+
+ return () => {
+ disconnect();
+ };
+ }, [connect, disconnect]);
+
+ return {
+ /** Current connection state */
+ state,
+ /** Manually reconnect */
+ reconnect: connect,
+ /** Manually disconnect */
+ disconnect,
+ };
+}
diff --git a/internal/adapter/templates/templates/skeleton/packages/realtime/src/useMediaGeneration.ts b/internal/adapter/templates/templates/skeleton/packages/realtime/src/useMediaGeneration.ts
new file mode 100644
index 0000000..08d3401
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/realtime/src/useMediaGeneration.ts
@@ -0,0 +1,241 @@
+'use client';
+
+import { useState, useCallback, useRef } from 'react';
+import { useEventChannel, type ChannelEvent, type SSEState } from './useEventChannel';
+
+/**
+ * Generation status.
+ */
+export type GenerationStatus = 'idle' | 'pending' | 'generating' | 'complete' | 'failed';
+
+/**
+ * Configuration for media generation hook.
+ */
+export interface UseMediaGenerationConfig {
+ /** API endpoint for starting the generation job */
+ endpoint: string;
+ /** SSE endpoint for events (default: '/api/events') */
+ sseEndpoint?: string;
+ /** User ID for subscribing to user channel */
+ userId: string;
+ /** Additional headers for the POST request */
+ headers?: Record;
+}
+
+/**
+ * Result from the media generation hook.
+ */
+export interface UseMediaGenerationResult {
+ /** Current generation status */
+ status: GenerationStatus;
+ /** SSE connection state */
+ sseState: SSEState;
+ /** Progress percentage (0-100) */
+ progress: number;
+ /** Current stage message */
+ message: string;
+ /** Generated result (available when status is 'complete') */
+ result: T | null;
+ /** Error message (available when status is 'failed') */
+ error: string | null;
+ /** Job ID of the current generation */
+ jobId: string | null;
+ /** Start a new generation */
+ generate: (request: Record) => Promise;
+ /** Reset state for a new generation */
+ reset: () => void;
+}
+
+/**
+ * Hook for async media generation with real-time progress via SSE events.
+ *
+ * This hook implements the correct async pattern:
+ * 1. POST to endpoint returns job ID immediately
+ * 2. Progress and results come via SSE events
+ * 3. Never blocks waiting for generation
+ *
+ * @example
+ * ```tsx
+ * const {
+ * status,
+ * progress,
+ * message,
+ * result,
+ * error,
+ * generate,
+ * } = useMediaGeneration({
+ * endpoint: '/api/generate/video',
+ * userId: currentUser.id,
+ * });
+ *
+ * // Start generation
+ * await generate({ prompt: 'A cat playing piano', aspectRatio: '16:9' });
+ *
+ * // UI automatically updates as SSE events arrive
+ * return (
+ *
+ *
Status: {status}
+ *
Progress: {progress}%
+ *
{message}
+ * {result &&
}
+ * {error &&
{error}
}
+ *
+ * );
+ * ```
+ */
+export function useMediaGeneration(
+ config: UseMediaGenerationConfig
+): UseMediaGenerationResult {
+ const {
+ endpoint,
+ sseEndpoint = '/api/events',
+ userId,
+ headers,
+ } = config;
+
+ const [status, setStatus] = useState('idle');
+ const [progress, setProgress] = useState(0);
+ const [message, setMessage] = useState('');
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState(null);
+ const [jobId, setJobId] = useState(null);
+
+ const jobIdRef = useRef(null);
+
+ // Handle incoming SSE events
+ const handleEvent = useCallback((event: ChannelEvent) => {
+ // Only handle events for our job
+ if (!jobIdRef.current || event.jobId !== jobIdRef.current) return;
+
+ switch (event.type) {
+ case 'generation_started':
+ setStatus('generating');
+ setProgress(0);
+ setMessage('Starting generation...');
+ break;
+
+ case 'generation_progress':
+ setProgress(event.progress ?? 0);
+ setMessage(event.message ?? 'Generating...');
+ break;
+
+ case 'generation_complete':
+ setStatus('complete');
+ setProgress(100);
+ setMessage('Complete');
+ setResult(event.result as T);
+ break;
+
+ case 'generation_failed':
+ setStatus('failed');
+ setError(event.error ?? 'Generation failed');
+ break;
+
+ case 'upload_started':
+ setStatus('generating');
+ setProgress(0);
+ setMessage('Starting upload...');
+ break;
+
+ case 'upload_progress':
+ setProgress(event.progress ?? 0);
+ setMessage('Uploading...');
+ break;
+
+ case 'upload_complete':
+ setStatus('complete');
+ setProgress(100);
+ setMessage('Complete');
+ setResult(event.result as T);
+ break;
+
+ case 'upload_failed':
+ setStatus('failed');
+ setError(event.error ?? 'Upload failed');
+ break;
+ }
+ }, []);
+
+ // Subscribe to SSE events for user channel
+ const { state: sseState } = useEventChannel({
+ endpoint: sseEndpoint,
+ channel: `user:${userId}`,
+ onEvent: handleEvent,
+ });
+
+ const generate = useCallback(
+ async (request: Record) => {
+ // Reset state
+ setStatus('pending');
+ setProgress(0);
+ setMessage('Submitting job...');
+ setResult(null);
+ setError(null);
+
+ try {
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...headers,
+ },
+ body: JSON.stringify(request),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(
+ (errorData as { message?: string }).message || `HTTP ${response.status}`
+ );
+ }
+
+ // Handle both envelope-wrapped ({ data: { jobId } }) and direct ({ jobId }) responses
+ const json = (await response.json()) as
+ | { data: { jobId: string }; meta?: unknown }
+ | { jobId: string };
+ const resolvedJobId =
+ 'data' in json && json.data?.jobId
+ ? json.data.jobId
+ : (json as { jobId: string }).jobId;
+
+ if (!resolvedJobId) {
+ throw new Error('No job ID returned from server');
+ }
+
+ // Store job ID and transition to generating immediately.
+ // The 202 response confirms the job was accepted — don't wait for
+ // the generation_started SSE event to leave the 'pending' state.
+ jobIdRef.current = resolvedJobId;
+ setJobId(resolvedJobId);
+ setStatus('generating');
+ setMessage('Generation in progress...');
+ } catch (err) {
+ setStatus('failed');
+ setError(err instanceof Error ? err.message : 'Failed to start generation');
+ }
+ },
+ [endpoint, headers]
+ );
+
+ const reset = useCallback(() => {
+ jobIdRef.current = null;
+ setJobId(null);
+ setStatus('idle');
+ setProgress(0);
+ setMessage('');
+ setResult(null);
+ setError(null);
+ }, []);
+
+ return {
+ status,
+ sseState,
+ progress,
+ message,
+ result,
+ error,
+ jobId,
+ generate,
+ reset,
+ };
+}
diff --git a/internal/adapter/templates/templates/skeleton/packages/realtime/src/useMediaUpload.ts b/internal/adapter/templates/templates/skeleton/packages/realtime/src/useMediaUpload.ts
new file mode 100644
index 0000000..53d63bf
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/realtime/src/useMediaUpload.ts
@@ -0,0 +1,141 @@
+import { useState, useCallback } from 'react';
+
+export interface UploadProgress {
+ loaded: number;
+ total: number;
+ percent: number;
+}
+
+export interface UploadResult {
+ url: string;
+ path: string;
+}
+
+export interface UseMediaUploadConfig {
+ /** Base URL for the API (e.g., "" or "http://localhost:8001") */
+ apiPrefix?: string;
+ /** Service name for API path (e.g., "example-api") */
+ serviceName: string;
+ /** Auth headers to include in API calls */
+ headers?: Record;
+ /** Called on progress updates during the upload to GCS */
+ onProgress?: (progress: UploadProgress) => void;
+}
+
+export interface UseMediaUploadResult {
+ /** Upload a file. Returns the final URL on success. */
+ upload: (file: File) => Promise;
+ /** Whether an upload is in progress */
+ isUploading: boolean;
+ /** Current upload progress (0-100) */
+ progress: number;
+ /** Error message if upload failed */
+ error: string | null;
+ /** Reset state */
+ reset: () => void;
+}
+
+export function useMediaUpload(config: UseMediaUploadConfig): UseMediaUploadResult {
+ const [isUploading, setIsUploading] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [error, setError] = useState(null);
+
+ const reset = useCallback(() => {
+ setIsUploading(false);
+ setProgress(0);
+ setError(null);
+ }, []);
+
+ const upload = useCallback(async (file: File): Promise => {
+ setIsUploading(true);
+ setProgress(0);
+ setError(null);
+
+ try {
+ // 1. Get presigned upload URL from backend
+ const initRes = await fetch(
+ `${config.apiPrefix || ''}/api/${config.serviceName}/media/upload/init`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...config.headers,
+ },
+ body: JSON.stringify({
+ filename: file.name,
+ contentType: file.type,
+ }),
+ }
+ );
+
+ if (!initRes.ok) {
+ throw new Error(`Upload init failed: ${initRes.status}`);
+ }
+
+ const { uploadURL, objectPath, headers: uploadHeaders } = await initRes.json();
+
+ // 2. Upload directly to storage using XHR (for progress tracking)
+ await new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open('PUT', uploadURL);
+ xhr.timeout = 5 * 60 * 1000; // 5 minutes for large uploads
+
+ // Set headers from presigned response
+ if (uploadHeaders) {
+ Object.entries(uploadHeaders).forEach(([key, value]) => {
+ xhr.setRequestHeader(key, value as string);
+ });
+ }
+
+ xhr.upload.onprogress = (e) => {
+ if (e.lengthComputable) {
+ const pct = Math.round((e.loaded / e.total) * 100);
+ setProgress(pct);
+ config.onProgress?.({ loaded: e.loaded, total: e.total, percent: pct });
+ }
+ };
+
+ xhr.onload = () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ resolve();
+ } else {
+ reject(new Error(`Upload failed: ${xhr.status}`));
+ }
+ };
+
+ xhr.onerror = () => reject(new Error('Upload failed: network error'));
+ xhr.ontimeout = () => reject(new Error('Upload timed out'));
+ xhr.send(file);
+ });
+
+ // 3. Confirm upload with backend
+ const completeRes = await fetch(
+ `${config.apiPrefix || ''}/api/${config.serviceName}/media/upload/complete`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...config.headers,
+ },
+ body: JSON.stringify({ objectPath }),
+ }
+ );
+
+ if (!completeRes.ok) {
+ throw new Error(`Upload complete failed: ${completeRes.status}`);
+ }
+
+ const result = await completeRes.json();
+ setProgress(100);
+ setIsUploading(false);
+ return { url: result.url, path: result.path };
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : 'Upload failed';
+ setError(msg);
+ setIsUploading(false);
+ throw err;
+ }
+ }, [config]);
+
+ return { upload, isUploading, progress, error, reset };
+}
diff --git a/internal/adapter/templates/templates/skeleton/packages/realtime/tsconfig.json b/internal/adapter/templates/templates/skeleton/packages/realtime/tsconfig.json
new file mode 100644
index 0000000..a4c834a
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/realtime/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ChatBubble.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ChatBubble.tsx
new file mode 100644
index 0000000..c28113f
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ChatBubble.tsx
@@ -0,0 +1,75 @@
+import * as React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { cn } from '../utils/cn';
+
+const chatBubbleVariants = cva(
+ 'relative max-w-[85%] rounded-2xl px-4 py-3 text-sm leading-relaxed',
+ {
+ variants: {
+ role: {
+ user: 'ml-auto bg-[var(--accent)] text-[var(--accent-foreground)] rounded-br-sm',
+ assistant: 'mr-auto bg-[var(--surface-200)] text-[var(--text-primary)] rounded-bl-sm',
+ system: 'mx-auto bg-[var(--surface-100)] text-[var(--text-secondary)] text-xs italic max-w-[90%]',
+ },
+ },
+ defaultVariants: {
+ role: 'assistant',
+ },
+ }
+);
+
+export interface ChatBubbleProps
+ extends Omit, 'role'>,
+ VariantProps {
+ content: string;
+ timestamp?: Date;
+ avatar?: string;
+ isStreaming?: boolean;
+}
+
+const ChatBubble = React.forwardRef(
+ ({ className, role, content, timestamp, avatar, isStreaming, ...props }, ref) => {
+ const isUser = role === 'user';
+
+ return (
+
+ {avatar && role !== 'system' && (
+
+
+
+ )}
+
+
+
+ {content}
+ {isStreaming && (
+
+ )}
+
+
+ {timestamp && (
+
+ {timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+
+ )}
+
+
+ );
+ }
+);
+ChatBubble.displayName = 'ChatBubble';
+
+export { ChatBubble, chatBubbleVariants };
diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ChatInput.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ChatInput.tsx
new file mode 100644
index 0000000..256bea9
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ChatInput.tsx
@@ -0,0 +1,118 @@
+import * as React from 'react';
+import { cn } from '../utils/cn';
+import { Send } from 'lucide-react';
+
+export interface ChatInputProps {
+ placeholder?: string;
+ maxLength?: number;
+ disabled?: boolean;
+ /** Called when the user submits a message. Return false to indicate failure. */
+ onSubmit: (text: string) => boolean | void;
+ /** Called when send fails (onSubmit returns false). */
+ onSendError?: (text: string) => void;
+ className?: string;
+}
+
+const ChatInput = React.forwardRef(
+ ({ placeholder = 'Type a message...', maxLength = 4000, disabled, onSubmit, onSendError, className }, ref) => {
+ const [value, setValue] = React.useState('');
+ const textareaRef = React.useRef(null);
+
+ const adjustHeight = React.useCallback(() => {
+ const textarea = textareaRef.current;
+ if (textarea) {
+ textarea.style.height = 'auto';
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
+ }
+ }, []);
+
+ React.useEffect(() => {
+ adjustHeight();
+ }, [value, adjustHeight]);
+
+ const handleSubmit = React.useCallback(() => {
+ const trimmed = value.trim();
+ if (trimmed && !disabled) {
+ const result = onSubmit(trimmed);
+ if (result === false) {
+ // Send failed - notify via callback
+ onSendError?.(trimmed);
+ return;
+ }
+ setValue('');
+ if (textareaRef.current) {
+ textareaRef.current.style.height = 'auto';
+ }
+ }
+ }, [value, disabled, onSubmit, onSendError]);
+
+ const handleKeyDown = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ },
+ [handleSubmit]
+ );
+
+ const charCount = value.length;
+ const isNearLimit = charCount > maxLength * 0.9;
+ const isOverLimit = charCount > maxLength;
+
+ return (
+
+ );
+ }
+);
+ChatInput.displayName = 'ChatInput';
+
+export { ChatInput };
diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ErrorMessage.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ErrorMessage.tsx
new file mode 100644
index 0000000..7804974
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ErrorMessage.tsx
@@ -0,0 +1,43 @@
+import * as React from 'react';
+import { cn } from '../utils/cn';
+import { AlertCircle } from 'lucide-react';
+
+export interface ErrorMessageProps extends React.HTMLAttributes {
+ /** The error message to display */
+ message?: string;
+ /** Show an icon alongside the message */
+ showIcon?: boolean;
+}
+
+/**
+ * ErrorMessage displays a field-level error message.
+ *
+ * @example
+ *
+ *
+ * @example
+ *
+ */
+const ErrorMessage = React.forwardRef(
+ ({ className, message, showIcon = false, ...props }, ref) => {
+ if (!message) return null;
+
+ return (
+
+ {showIcon && }
+ {message}
+
+ );
+ }
+);
+ErrorMessage.displayName = 'ErrorMessage';
+
+export { ErrorMessage };
diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/FormField.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/FormField.tsx
new file mode 100644
index 0000000..a64a246
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/FormField.tsx
@@ -0,0 +1,93 @@
+import * as React from 'react';
+import { cn } from '../utils/cn';
+import { Label } from './Label';
+import { Input, type InputProps } from './Input';
+import { ErrorMessage } from './ErrorMessage';
+
+export interface FormFieldProps extends Omit {
+ /** Field label text */
+ label: string;
+ /** Field name (used for id and error lookup) */
+ name: string;
+ /** Error message for this field */
+ error?: string;
+ /** Additional description text */
+ description?: string;
+ /** Whether the field is required */
+ required?: boolean;
+ /** Container className */
+ containerClassName?: string;
+}
+
+/**
+ * FormField is a compound component that combines Label, Input, and ErrorMessage.
+ *
+ * @example
+ *
+ *
+ * @example
+ *
+ */
+const FormField = React.forwardRef(
+ (
+ {
+ label,
+ name,
+ error,
+ description,
+ required,
+ containerClassName,
+ className,
+ ...inputProps
+ },
+ ref
+ ) => {
+ const id = `field-${name}`;
+ const descriptionId = description ? `${id}-description` : undefined;
+ const errorId = error ? `${id}-error` : undefined;
+
+ return (
+
+
+ {label}
+ {required && * }
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+
+
+ );
+ }
+);
+FormField.displayName = 'FormField';
+
+export { FormField };
diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/GenerationProgress.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/GenerationProgress.tsx
new file mode 100644
index 0000000..f944519
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/GenerationProgress.tsx
@@ -0,0 +1,45 @@
+import * as React from 'react';
+import { cn } from '../utils/cn';
+
+export interface GenerationProgressProps extends React.HTMLAttributes {
+ /** Optional: 0-100. When provided, shows determinate progress. Otherwise indeterminate. */
+ percent?: number;
+}
+
+const GenerationProgress = React.forwardRef(
+ ({ percent, className, ...props }, ref) => {
+ const isDeterminate = percent != null && percent > 0;
+ const clampedPercent = isDeterminate ? Math.max(0, Math.min(100, percent)) : 0;
+
+ return (
+
+ {isDeterminate ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+ }
+);
+GenerationProgress.displayName = 'GenerationProgress';
+
+export { GenerationProgress };
diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ImageGrid.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ImageGrid.tsx
new file mode 100644
index 0000000..bc0fd35
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ImageGrid.tsx
@@ -0,0 +1,90 @@
+import * as React from 'react';
+import { cn } from '../utils/cn';
+
+export interface ImageGridImage {
+ src: string;
+ alt?: string;
+}
+
+export interface ImageGridProps extends React.HTMLAttributes {
+ images: ImageGridImage[];
+ columns?: 1 | 2 | 3 | 4;
+ onImageClick?: (index: number) => void;
+ loading?: boolean;
+}
+
+const columnClasses = {
+ 1: 'grid-cols-1',
+ 2: 'grid-cols-2',
+ 3: 'grid-cols-3',
+ 4: 'grid-cols-4',
+} as const;
+
+const ImageGrid = React.forwardRef(
+ ({ images, columns = 2, onImageClick, loading, className, ...props }, ref) => {
+ const [loadedImages, setLoadedImages] = React.useState>(new Set());
+
+ const handleImageLoad = React.useCallback((index: number) => {
+ setLoadedImages((prev) => new Set(prev).add(index));
+ }, []);
+
+ if (loading) {
+ return (
+
+ {Array.from({ length: columns * 2 }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (images.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {images.map((image, index) => (
+
onImageClick?.(index)}
+ disabled={!onImageClick}
+ className={cn(
+ 'relative aspect-square rounded-lg overflow-hidden bg-[var(--surface-200)]',
+ 'focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2 focus:ring-offset-[var(--background)]',
+ onImageClick && 'cursor-pointer hover:opacity-90 transition-opacity'
+ )}
+ >
+ {!loadedImages.has(index) && (
+
+ )}
+ handleImageLoad(index)}
+ />
+
+ ))}
+
+ );
+ }
+);
+ImageGrid.displayName = 'ImageGrid';
+
+export { ImageGrid };
diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/MediaLibrary.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/MediaLibrary.tsx
new file mode 100644
index 0000000..361cd34
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/MediaLibrary.tsx
@@ -0,0 +1,144 @@
+import { useState } from 'react';
+import { Trash2, Image, Video, ExternalLink } from 'lucide-react';
+
+export interface MediaItem {
+ path: string;
+ url: string;
+ contentType: string;
+ size: number;
+ createdAt: string;
+}
+
+export interface MediaLibraryProps {
+ /** Media items to display */
+ items: MediaItem[];
+ /** Called when a media item is deleted */
+ onDelete?: (path: string) => void;
+ /** Called when a media item is selected */
+ onSelect?: (item: MediaItem) => void;
+ /** Whether delete operations are in progress */
+ isDeleting?: boolean;
+ /** Show empty state */
+ emptyMessage?: string;
+}
+
+function formatSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function isImage(contentType: string): boolean {
+ return contentType.startsWith('image/');
+}
+
+function isVideo(contentType: string): boolean {
+ return contentType.startsWith('video/');
+}
+
+export function MediaLibrary({
+ items,
+ onDelete,
+ onSelect,
+ isDeleting = false,
+ emptyMessage = 'No media files yet. Upload or generate some!',
+}: MediaLibraryProps) {
+ const [selectedPath, setSelectedPath] = useState(null);
+
+ if (items.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {items.map((item) => (
+
{
+ setSelectedPath(item.path);
+ onSelect?.(item);
+ }}
+ >
+ {/* Preview */}
+
+ {isImage(item.contentType) ? (
+
+ ) : isVideo(item.contentType) ? (
+
(e.target as HTMLVideoElement).play()}
+ onMouseOut={(e) => { const v = e.target as HTMLVideoElement; v.pause(); v.currentTime = 0; }}
+ />
+ ) : (
+
+
+
+ )}
+
+
+ {/* Info bar */}
+
+
+ {item.path.split('/').pop()}
+
+
+ {formatSize(item.size)}
+
+
+
+ {/* Overlay actions */}
+
+
+ {/* Type badge */}
+
+ {isVideo(item.contentType) ? (
+
+ Video
+
+ ) : null}
+
+
+ ))}
+
+ );
+}
diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/MediaUploader.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/MediaUploader.tsx
new file mode 100644
index 0000000..1116ffd
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/MediaUploader.tsx
@@ -0,0 +1,143 @@
+import { useCallback, useState, useRef } from 'react';
+import { Upload, Loader2, Check } from 'lucide-react';
+
+export interface MediaUploaderProps {
+ /** Called when upload completes successfully */
+ onUploadComplete?: (result: { url: string; path: string }) => void;
+ /** Called on error */
+ onError?: (error: string) => void;
+ /** Upload function from useMediaUpload hook */
+ upload: (file: File) => Promise<{ url: string; path: string }>;
+ /** Whether an upload is currently in progress */
+ isUploading?: boolean;
+ /** Upload progress percentage (0-100) */
+ progress?: number;
+ /** Accepted file types (e.g., "image/*,video/*") */
+ accept?: string;
+ /** Max file size in MB */
+ maxSizeMB?: number;
+}
+
+export function MediaUploader({
+ onUploadComplete,
+ onError,
+ upload,
+ isUploading = false,
+ progress = 0,
+ accept = 'image/*,video/*',
+ maxSizeMB = 50,
+}: MediaUploaderProps) {
+ const [isDragOver, setIsDragOver] = useState(false);
+ const [lastResult, setLastResult] = useState<{ url: string; path: string } | null>(null);
+ const fileInputRef = useRef(null);
+
+ const handleFile = useCallback(async (file: File) => {
+ // Validate file type (accept prop only restricts file picker, not drag-and-drop)
+ if (accept) {
+ const acceptedTypes = accept.split(',').map((t) => t.trim());
+ const isAccepted = acceptedTypes.some((type) => {
+ if (type.endsWith('/*')) return file.type.startsWith(type.replace('/*', '/'));
+ return file.type === type;
+ });
+ if (!isAccepted) {
+ onError?.(`File type "${file.type}" is not accepted. Allowed: ${accept}`);
+ return;
+ }
+ }
+
+ if (file.size > maxSizeMB * 1024 * 1024) {
+ onError?.(`File too large. Maximum size is ${maxSizeMB}MB.`);
+ return;
+ }
+
+ try {
+ const result = await upload(file);
+ setLastResult(result);
+ onUploadComplete?.(result);
+ } catch (err) {
+ onError?.(err instanceof Error ? err.message : 'Upload failed');
+ }
+ }, [upload, accept, maxSizeMB, onUploadComplete, onError]);
+
+ const handleDrop = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragOver(false);
+ const file = e.dataTransfer.files[0];
+ if (file) handleFile(file);
+ }, [handleFile]);
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragOver(true);
+ }, []);
+
+ const handleDragLeave = useCallback(() => {
+ setIsDragOver(false);
+ }, []);
+
+ const handleClick = () => fileInputRef.current?.click();
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) handleFile(file);
+ if (fileInputRef.current) fileInputRef.current.value = '';
+ };
+
+ return (
+
+
+
+ {isUploading ? (
+
+
+
Uploading... {progress}%
+
+
+ ) : lastResult ? (
+
+
+
Upload complete
+
{ e.stopPropagation(); setLastResult(null); }}
+ className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] underline"
+ >
+ Upload another
+
+
+ ) : (
+
+
+
+ Drop a file here or click to browse
+
+
+ Max {maxSizeMB}MB
+
+
+ )}
+
+ );
+}
diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ProviderBadge.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ProviderBadge.tsx
new file mode 100644
index 0000000..220b17c
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ProviderBadge.tsx
@@ -0,0 +1,70 @@
+import * as React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { cn } from '../utils/cn';
+
+const providerBadgeVariants = cva(
+ 'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium',
+ {
+ variants: {
+ provider: {
+ gemini: 'bg-blue-500/15 text-blue-400 border border-blue-500/30',
+ laozhang: 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30',
+ default: 'bg-[var(--surface-200)] text-[var(--text-secondary)] border border-[var(--border)]',
+ },
+ },
+ defaultVariants: {
+ provider: 'default',
+ },
+ }
+);
+
+type ProviderVariant = 'gemini' | 'laozhang' | 'default';
+
+export interface ProviderBadgeProps
+ extends React.HTMLAttributes,
+ Omit, 'provider'> {
+ provider: string;
+ latency?: number;
+}
+
+const providerIcons: Record = {
+ gemini: '✦',
+ laozhang: '◆',
+};
+
+const providerLabels: Record = {
+ gemini: 'Gemini',
+ laozhang: 'LaoZhang',
+};
+
+function getProviderVariant(provider: string): ProviderVariant {
+ const normalized = provider.toLowerCase();
+ if (normalized.includes('gemini')) return 'gemini';
+ if (normalized.includes('laozhang')) return 'laozhang';
+ return 'default';
+}
+
+const ProviderBadge = React.forwardRef(
+ ({ provider, latency, className, ...props }, ref) => {
+ const variant = getProviderVariant(provider);
+ const icon = providerIcons[variant] || '●';
+ const label = providerLabels[variant] || provider;
+
+ return (
+
+ {icon}
+ {label}
+ {latency !== undefined && (
+ {latency}ms
+ )}
+
+ );
+ }
+);
+ProviderBadge.displayName = 'ProviderBadge';
+
+export { ProviderBadge, providerBadgeVariants };
diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/StreamingText.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/StreamingText.tsx
new file mode 100644
index 0000000..476cf45
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/StreamingText.tsx
@@ -0,0 +1,35 @@
+import * as React from 'react';
+import { cn } from '../utils/cn';
+
+export interface StreamingTextProps extends React.HTMLAttributes {
+ text: string;
+ isStreaming: boolean;
+}
+
+const StreamingText = React.forwardRef(
+ ({ text, isStreaming, className, ...props }, ref) => {
+ return (
+
+ {text}
+ {isStreaming && (
+
+ )}
+
+
+ );
+ }
+);
+StreamingText.displayName = 'StreamingText';
+
+export { StreamingText };
diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/VideoPlayer.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/VideoPlayer.tsx
new file mode 100644
index 0000000..39ad144
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/VideoPlayer.tsx
@@ -0,0 +1,103 @@
+'use client';
+
+import { useRef, useState } from 'react';
+import { cn } from '../utils/cn';
+
+export interface VideoPlayerProps {
+ /** Video source URL or base64 data URI. */
+ src: string;
+ /** MIME type for the source. */
+ mimeType?: string;
+ /** Alt text / accessible label. */
+ alt?: string;
+ /** Additional class names for the container. */
+ className?: string;
+ /** Aspect ratio CSS class (default: aspect-video). */
+ aspectRatio?: string;
+}
+
+/**
+ * VideoPlayer renders an HTML5 video with controls, loading state,
+ * and error handling. Supports both URL and base64 data URIs.
+ */
+export function VideoPlayer({
+ src,
+ mimeType = 'video/mp4',
+ alt = 'Generated video',
+ className,
+ aspectRatio = 'aspect-video',
+}: VideoPlayerProps) {
+ const videoRef = useRef(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ return (
+
+ {isLoading && !error && (
+
+ )}
+
+ {error && (
+
+ )}
+
+
setIsLoading(false)}
+ onError={() => {
+ setIsLoading(false);
+ setError('Failed to load video');
+ }}
+ >
+
+
+
+ );
+}
+
+export interface VideoGridProps {
+ /** Videos to display. */
+ videos: Array<{ src: string; mimeType?: string; alt?: string }>;
+ /** Additional class names. */
+ className?: string;
+}
+
+/**
+ * VideoGrid displays one or more videos in a responsive grid.
+ */
+export function VideoGrid({ videos, className }: VideoGridProps) {
+ if (videos.length === 0) return null;
+
+ return (
+
+ {videos.map((video, i) => (
+
+ ))}
+
+ );
+}
diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/hooks/useFormErrors.ts b/internal/adapter/templates/templates/skeleton/packages/ui/src/hooks/useFormErrors.ts
new file mode 100644
index 0000000..8f9acff
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/hooks/useFormErrors.ts
@@ -0,0 +1,103 @@
+'use client';
+
+import { useState, useCallback } from 'react';
+
+export interface FieldErrors {
+ [field: string]: string;
+}
+
+export interface UseFormErrorsReturn {
+ /** Current field errors */
+ errors: FieldErrors;
+ /** Set error for a specific field */
+ setFieldError: (field: string, message: string) => void;
+ /** Clear error for a specific field */
+ clearFieldError: (field: string) => void;
+ /** Set multiple field errors at once */
+ setErrors: (errors: FieldErrors) => void;
+ /** Clear all errors */
+ clearErrors: () => void;
+ /** Check if a field has an error */
+ hasError: (field: string) => boolean;
+ /** Get error message for a field */
+ getError: (field: string) => string | undefined;
+ /** Check if any errors exist */
+ hasAnyErrors: boolean;
+}
+
+/**
+ * Hook for managing form field errors.
+ * Works well with ApiClientError.getFieldErrors().
+ *
+ * @example
+ * const { errors, setErrors, clearErrors, hasError, getError } = useFormErrors();
+ *
+ * const handleSubmit = async (data) => {
+ * clearErrors();
+ * try {
+ * await api.post('/users', data);
+ * } catch (error) {
+ * if (isApiClientError(error) && error.isValidationError()) {
+ * setErrors(error.getFieldErrors());
+ * }
+ * }
+ * };
+ *
+ * return (
+ *
+ * );
+ */
+export function useFormErrors(initialErrors: FieldErrors = {}): UseFormErrorsReturn {
+ const [errors, setErrorsState] = useState(initialErrors);
+
+ const setFieldError = useCallback((field: string, message: string) => {
+ setErrorsState((prev) => ({ ...prev, [field]: message }));
+ }, []);
+
+ const clearFieldError = useCallback((field: string) => {
+ setErrorsState((prev) => {
+ const next = { ...prev };
+ delete next[field];
+ return next;
+ });
+ }, []);
+
+ const setErrors = useCallback((newErrors: FieldErrors) => {
+ setErrorsState(newErrors);
+ }, []);
+
+ const clearErrors = useCallback(() => {
+ setErrorsState({});
+ }, []);
+
+ const hasError = useCallback(
+ (field: string) => {
+ return !!errors[field];
+ },
+ [errors]
+ );
+
+ const getError = useCallback(
+ (field: string) => {
+ return errors[field];
+ },
+ [errors]
+ );
+
+ const hasAnyErrors = Object.keys(errors).length > 0;
+
+ return {
+ errors,
+ setFieldError,
+ clearFieldError,
+ setErrors,
+ clearErrors,
+ hasError,
+ getError,
+ hasAnyErrors,
+ };
+}
diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/index.ts b/internal/adapter/templates/templates/skeleton/packages/ui/src/index.ts
index 693319c..9cb9900 100644
--- a/internal/adapter/templates/templates/skeleton/packages/ui/src/index.ts
+++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/index.ts
@@ -16,6 +16,26 @@ export { Textarea, type TextareaProps } from './components/Textarea';
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuGroup, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup } from './components/DropdownMenu';
export { Sheet, SheetPortal, SheetOverlay, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription } from './components/Sheet';
+// Form Components
+export { ErrorMessage, type ErrorMessageProps } from './components/ErrorMessage';
+export { FormField, type FormFieldProps } from './components/FormField';
+
+// Hooks
+export { useFormErrors, type FieldErrors, type UseFormErrorsReturn } from './hooks/useFormErrors';
+
+// AI Components
+export { ChatBubble, type ChatBubbleProps } from './components/ChatBubble';
+export { ChatInput, type ChatInputProps } from './components/ChatInput';
+export { StreamingText, type StreamingTextProps } from './components/StreamingText';
+export { GenerationProgress, type GenerationProgressProps } from './components/GenerationProgress';
+export { ImageGrid, type ImageGridProps, type ImageGridImage } from './components/ImageGrid';
+export { ProviderBadge, type ProviderBadgeProps } from './components/ProviderBadge';
+export { VideoPlayer, VideoGrid, type VideoPlayerProps, type VideoGridProps } from './components/VideoPlayer';
+
+// Media Components
+export { MediaUploader, type MediaUploaderProps } from './components/MediaUploader';
+export { MediaLibrary, type MediaLibraryProps, type MediaItem } from './components/MediaLibrary';
+
// Icons (re-export commonly used ones)
export {
AlertCircle,
@@ -34,17 +54,22 @@ export {
EyeOff,
Filter,
Home,
+ Image as ImageIcon,
Loader2,
Menu,
+ MessageSquare,
MoreHorizontal,
MoreVertical,
Plus,
RefreshCw,
Search,
+ Send,
Settings,
+ Sparkles,
Trash2,
Upload,
User,
Users,
+ Video,
X,
} from 'lucide-react';
diff --git a/internal/adapter/templates/templates/skeleton/pkg/README.md b/internal/adapter/templates/templates/skeleton/pkg/README.md
index ecab83d..feb5f5b 100644
--- a/internal/adapter/templates/templates/skeleton/pkg/README.md
+++ b/internal/adapter/templates/templates/skeleton/pkg/README.md
@@ -8,13 +8,19 @@ This directory contains shared Go packages used across all components in the mon
|---------|-------------|
| `app` | Service bootstrapper with Wrap pattern, Bind helpers, health probes |
| `config` | Viper-based configuration loading from environment variables |
+| `gemini` | Google Gemini API client (text + image generation) |
| `httpcontext` | Type-safe context key helpers for request-scoped data |
| `httpclient` | Resilient HTTP client with automatic retries and exponential backoff |
| `httperror` | Typed HTTP errors with sentinel error matching |
| `httpresponse` | Standard response envelope pattern for API responses |
| `httpvalidation` | Struct validation wrapper around go-playground/validator |
+| `laozhang` | LaoZhang API client (text + image generation, pay-per-use) |
| `logging` | slog-based structured logging with context integration |
+| `mediagen` | Unified media generation with provider fallback routing |
| `middleware` | HTTP middleware: CORS, recovery, request ID, request logging |
+| `routing` | Provider fallback, circuit breaker, cooldown management |
+| `synap` | Synap cognitive memory database client |
+| `textgen` | Unified text generation with provider fallback routing |
## Quick Start
@@ -395,6 +401,195 @@ r.Use(middleware.CORS(middleware.CORSConfig{
}))
```
+## AI Generation Packages
+
+The following packages provide unified AI generation capabilities with automatic provider fallback.
+
+### pkg/routing
+
+Core fallback and cooldown logic used by mediagen and textgen.
+
+```go
+import "{{GO_MODULE}}/pkg/routing"
+
+// Strategies
+routing.StrategyPrimaryOnly // Only try first provider
+routing.StrategyFallback // Try providers in order until success
+routing.StrategyRoundRobin // Rotate between providers
+
+// Execute with fallback
+result, err := routing.Execute(ctx, providers, config, func(ctx context.Context, p routing.Provider) (*Response, error) {
+ return p.Generate(ctx, req)
+})
+
+// The LAST provider is the "terminus" and is ALWAYS tried regardless of cooldown
+```
+
+### pkg/gemini
+
+Google Gemini API client for text and image generation.
+
+```go
+import "{{GO_MODULE}}/pkg/gemini"
+
+// Create client
+client, err := gemini.NewClient(ctx, os.Getenv("GEMINI_API_KEY"))
+
+// Text generation
+resp, err := client.Chat(ctx, []gemini.Message{
+ {Role: "user", Content: "Hello!"},
+})
+
+// Image generation (Imagen)
+images, err := client.GenerateImages(ctx, gemini.ImageRequest{
+ Prompt: "a sunset over mountains",
+ Count: 1,
+})
+```
+
+### pkg/laozhang
+
+LaoZhang API client (pay-per-use, reliable terminus provider).
+
+```go
+import "{{GO_MODULE}}/pkg/laozhang"
+
+// Create client
+client := laozhang.NewClient(os.Getenv("LAOZHANG_API_KEY"))
+
+// Text generation
+resp, err := client.ChatCompletion(ctx, laozhang.ChatCompletionRequest{
+ Model: "gemini-3-flash-preview",
+ Messages: []laozhang.ChatMessage{
+ {Role: "user", Content: "Hello!"},
+ },
+})
+
+// Image generation
+images, err := client.GenerateImage(ctx, laozhang.ImageRequest{
+ Prompt: "a sunset over mountains",
+})
+```
+
+### pkg/mediagen
+
+Unified media generation manager with provider routing.
+
+```go
+import (
+ "{{GO_MODULE}}/pkg/mediagen"
+ "{{GO_MODULE}}/pkg/mediagen/adapters"
+ "{{GO_MODULE}}/pkg/gemini"
+ "{{GO_MODULE}}/pkg/laozhang"
+)
+
+// Create providers
+geminiClient, _ := gemini.NewClient(ctx, os.Getenv("GEMINI_API_KEY"))
+laozhangClient := laozhang.NewClient(os.Getenv("LAOZHANG_API_KEY"))
+
+// Create manager with production config
+// Ordering: LaoZhang (primary) -> Gemini (terminus)
+cfg := mediagen.ProductionConfig(mediagen.ProviderSet{
+ LaoZhang: adapters.NewLaoZhangProvider(laozhangClient),
+ Gemini: adapters.NewGeminiProvider(geminiClient),
+})
+
+manager, err := mediagen.NewManager(cfg)
+
+// Generate image (auto-fallback between providers)
+resp, err := manager.GenerateImage(ctx, mediagen.ImageRequest{
+ Prompt: "a sunset over mountains",
+})
+```
+
+### pkg/textgen
+
+Unified text generation manager with provider routing.
+
+```go
+import (
+ "{{GO_MODULE}}/pkg/textgen"
+ "{{GO_MODULE}}/pkg/textgen/adapters"
+ "{{GO_MODULE}}/pkg/gemini"
+ "{{GO_MODULE}}/pkg/laozhang"
+)
+
+// Create providers
+geminiClient, _ := gemini.NewClient(ctx, os.Getenv("GEMINI_API_KEY"))
+laozhangClient := laozhang.NewClient(os.Getenv("LAOZHANG_API_KEY"))
+
+// Create manager with production config
+// Ordering: LaoZhang (primary) -> Gemini (terminus)
+cfg := textgen.ProductionConfig(textgen.ProviderSet{
+ LaoZhang: adapters.NewLaoZhangTextProvider(laozhangClient, ""),
+ Gemini: adapters.NewGeminiTextProvider(ctx, adapters.GeminiTextConfig{Client: geminiClient}),
+})
+
+manager, err := textgen.NewManager(cfg)
+
+// Generate text (auto-fallback between providers)
+resp, err := manager.GenerateText(ctx, textgen.TextRequest{
+ SystemPrompt: "You are a helpful assistant.",
+ Prompt: "What is the capital of France?",
+})
+```
+
+### pkg/synap
+
+Client for Synap cognitive memory database.
+
+```go
+import "{{GO_MODULE}}/pkg/synap"
+
+// Create client
+client := synap.NewClient(synap.Config{
+ BaseURL: os.Getenv("SYNAP_URL"),
+ APIKey: os.Getenv("SYNAP_API_KEY"),
+ Space: "conversation_" + userID,
+})
+
+// Store episode (memory)
+err := client.StoreEpisode(ctx, synap.Episode{
+ What: "User asked about capital of France",
+ When: time.Now(),
+ Where: "chat",
+ Who: "user123",
+ Why: "geography question",
+ How: "direct question",
+})
+
+// Recall memories
+memories, err := client.Recall(ctx, synap.RecallRequest{
+ Query: "France geography",
+ Limit: 10,
+})
+
+// Build chat context with memories
+context, err := client.BuildChatContext(ctx, synap.ChatContextRequest{
+ RecentMessages: recentMessages,
+ Query: currentQuery,
+ MemoryLimit: 5,
+})
+```
+
+### Environment Variables
+
+The AI packages require these environment variables:
+
+```bash
+# Gemini
+GEMINI_API_KEY=xxx
+
+# LaoZhang
+LAOZHANG_API_KEY=xxx
+LAOZHANG_BASE_URL=https://api.laozhang.ai # Optional, has default
+
+# Synap (memory)
+SYNAP_URL=http://synap.synap.svc.cluster.local:7432
+SYNAP_API_KEY=xxx
+SYNAP_DEFAULT_SPACE=conversation_{uuid} # Optional
+```
+
## Guidelines
- **Import Path**: Use `{{GO_MODULE}}/pkg/` for imports
diff --git a/internal/adapter/templates/templates/skeleton/pkg/auth/middleware.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/auth/middleware.go.tmpl
index e206160..57ca2c0 100644
--- a/internal/adapter/templates/templates/skeleton/pkg/auth/middleware.go.tmpl
+++ b/internal/adapter/templates/templates/skeleton/pkg/auth/middleware.go.tmpl
@@ -72,6 +72,12 @@ func Middleware(cfg MiddlewareConfig) func(http.Handler) http.Handler {
// Validate token
user, err := cfg.Validator.Validate(r.Context(), token)
if err != nil {
+ if cfg.Optional {
+ // Token invalid/expired but auth is optional — continue without user context.
+ // The handler falls back to anonymous behavior via auth.GetUser() == nil.
+ next.ServeHTTP(w, r)
+ return
+ }
httpresponse.Unauthorized(w, r, "invalid credentials")
return
}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/gemini/client.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/gemini/client.go.tmpl
new file mode 100644
index 0000000..1fb7b27
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/gemini/client.go.tmpl
@@ -0,0 +1,198 @@
+// Package gemini provides a Go client for Google's Generative AI APIs.
+//
+// This package wraps the official google.golang.org/genai SDK to provide
+// a simplified interface for image generation (Imagen) and video generation (Veo).
+//
+// Basic usage:
+//
+// client, err := gemini.NewClient(gemini.Config{
+// APIKey: os.Getenv("GEMINI_API_KEY"),
+// })
+// if err != nil {
+// log.Fatal(err)
+// }
+// defer client.Close()
+//
+// // Generate an image
+// resp, err := client.GenerateImage(ctx, gemini.ImageRequest{
+// Prompt: "A serene Japanese garden",
+// })
+//
+// The client handles authentication and provides methods for both image and
+// video generation with configurable models and parameters.
+package gemini
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "google.golang.org/genai"
+)
+
+const (
+ defaultTimeout = 120 * time.Second
+ defaultVideoPollDelay = 10 * time.Second
+ defaultVideoMaxWait = 5 * time.Minute
+
+ // Retry configuration defaults
+ defaultMaxRetries = 3
+ defaultInitialDelay = 100 * time.Millisecond
+ defaultMaxDelay = 2 * time.Second
+)
+
+// Config holds configuration options for the Gemini client
+type Config struct {
+ APIKey string // Required: API key for authentication
+ Timeout time.Duration // Optional: defaults to 120s
+ VideoPollDelay time.Duration // Optional: delay between video status polls, defaults to 10s
+ VideoMaxWait time.Duration // Optional: max time to wait for video generation, defaults to 5m
+ Logger *slog.Logger // Optional: defaults to slog.Default()
+
+ // Retry configuration for transient errors (5xx, timeouts)
+ MaxRetries int // Optional: max retry attempts, defaults to 3 (0 disables retry)
+ InitialDelay time.Duration // Optional: initial delay between retries, defaults to 100ms
+ MaxDelay time.Duration // Optional: max delay between retries, defaults to 2s
+}
+
+// Client is the Gemini API client.
+// Supports automatic retry with exponential backoff for transient errors.
+type Client struct {
+ genaiClient *genai.Client
+ config *Config
+ logger *slog.Logger
+ maxRetries int
+ initialDelay time.Duration
+ maxDelay time.Duration
+}
+
+// NewClient creates a new Gemini API client with automatic retry for transient errors.
+func NewClient(ctx context.Context, config Config) (*Client, error) {
+ if config.APIKey == "" {
+ return nil, fmt.Errorf("%w: API key is required", ErrInvalidConfig)
+ }
+
+ if config.Timeout == 0 {
+ config.Timeout = defaultTimeout
+ }
+
+ if config.VideoPollDelay == 0 {
+ config.VideoPollDelay = defaultVideoPollDelay
+ }
+
+ if config.VideoMaxWait == 0 {
+ config.VideoMaxWait = defaultVideoMaxWait
+ }
+
+ if config.Logger == nil {
+ config.Logger = slog.Default()
+ }
+
+ // Set retry defaults
+ maxRetries := config.MaxRetries
+ if maxRetries == 0 {
+ maxRetries = defaultMaxRetries
+ }
+
+ initialDelay := config.InitialDelay
+ if initialDelay == 0 {
+ initialDelay = defaultInitialDelay
+ }
+
+ maxDelay := config.MaxDelay
+ if maxDelay == 0 {
+ maxDelay = defaultMaxDelay
+ }
+
+ // Create genai client with API key
+ genaiClient, err := genai.NewClient(ctx, &genai.ClientConfig{
+ APIKey: config.APIKey,
+ Backend: genai.BackendGeminiAPI,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("create genai client: %w", err)
+ }
+
+ return &Client{
+ genaiClient: genaiClient,
+ config: &config,
+ logger: config.Logger,
+ maxRetries: maxRetries,
+ initialDelay: initialDelay,
+ maxDelay: maxDelay,
+ }, nil
+}
+
+// Close closes the underlying genai client
+func (c *Client) Close() error {
+ // The genai client doesn't have a Close method, but we keep this for future compatibility
+ return nil
+}
+
+// retryWithBackoff executes fn with exponential backoff for transient errors.
+// Returns the result of fn if successful, or the last error if all retries fail.
+func (c *Client) retryWithBackoff(ctx context.Context, operation string, fn func() error) error {
+ var lastErr error
+
+ for attempt := 0; attempt <= c.maxRetries; attempt++ {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
+ lastErr = fn()
+ if lastErr == nil {
+ return nil
+ }
+
+ // Only retry retryable errors
+ if !IsRetryableError(lastErr) {
+ return lastErr
+ }
+
+ // Don't retry on last attempt
+ if attempt == c.maxRetries {
+ break
+ }
+
+ // Calculate backoff delay
+ delay := c.initialDelay * time.Duration(1< c.maxDelay {
+ delay = c.maxDelay
+ }
+
+ c.logger.Warn("retrying after transient error",
+ "operation", operation,
+ "attempt", attempt+1,
+ "max_retries", c.maxRetries,
+ "delay", delay,
+ "error", lastErr,
+ )
+
+ // Wait for backoff or context cancellation
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(delay):
+ }
+ }
+
+ return lastErr
+}
+
+// Health checks if the client can communicate with the Gemini API
+// by listing available models
+func (c *Client) Health(ctx context.Context) error {
+ iter, err := c.genaiClient.Models.List(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("health check failed: %w", err)
+ }
+
+ // Try to get at least one model to verify connectivity
+ _, err = iter.Next(ctx)
+ if err != nil {
+ return fmt.Errorf("health check failed: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/gemini/errors.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/gemini/errors.go.tmpl
new file mode 100644
index 0000000..24eba6b
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/gemini/errors.go.tmpl
@@ -0,0 +1,131 @@
+package gemini
+
+import (
+ "errors"
+ "strings"
+)
+
+// Sentinel errors for common error types
+var (
+ // ErrInvalidConfig indicates configuration validation failed
+ ErrInvalidConfig = errors.New("invalid configuration")
+
+ // ErrRateLimit indicates rate limit exceeded
+ ErrRateLimit = errors.New("rate limit exceeded")
+
+ // ErrServerError indicates server-side error
+ ErrServerError = errors.New("server error")
+
+ // ErrInvalidRequest indicates client error
+ ErrInvalidRequest = errors.New("invalid request")
+
+ // ErrTimeout indicates request timeout
+ ErrTimeout = errors.New("request timeout")
+
+ // ErrUnauthorized indicates authentication failed
+ ErrUnauthorized = errors.New("unauthorized")
+
+ // ErrQuotaExceeded indicates quota has been exceeded
+ ErrQuotaExceeded = errors.New("quota exceeded")
+
+ // ErrContentBlocked indicates content was blocked by safety filters
+ ErrContentBlocked = errors.New("content blocked by safety filters")
+)
+
+// classifyError attempts to classify an error from the Gemini API
+// into one of our sentinel errors
+func classifyError(err error) error {
+ if err == nil {
+ return nil
+ }
+
+ errStr := strings.ToLower(err.Error())
+
+ // Check for rate limiting
+ if strings.Contains(errStr, "rate limit") ||
+ strings.Contains(errStr, "resource exhausted") ||
+ strings.Contains(errStr, "too many requests") {
+ return errors.Join(ErrRateLimit, err)
+ }
+
+ // Check for quota exceeded
+ if strings.Contains(errStr, "quota") ||
+ strings.Contains(errStr, "billing") {
+ return errors.Join(ErrQuotaExceeded, err)
+ }
+
+ // Check for authentication errors
+ if strings.Contains(errStr, "unauthorized") ||
+ strings.Contains(errStr, "unauthenticated") ||
+ strings.Contains(errStr, "invalid api key") ||
+ strings.Contains(errStr, "permission denied") {
+ return errors.Join(ErrUnauthorized, err)
+ }
+
+ // Check for content blocked
+ if strings.Contains(errStr, "blocked") ||
+ strings.Contains(errStr, "safety") ||
+ strings.Contains(errStr, "harmful") {
+ return errors.Join(ErrContentBlocked, err)
+ }
+
+ // Check for timeout
+ if strings.Contains(errStr, "timeout") ||
+ strings.Contains(errStr, "deadline exceeded") {
+ return errors.Join(ErrTimeout, err)
+ }
+
+ // Check for server errors
+ if strings.Contains(errStr, "internal") ||
+ strings.Contains(errStr, "unavailable") ||
+ strings.Contains(errStr, "server error") {
+ return errors.Join(ErrServerError, err)
+ }
+
+ // Check for invalid request
+ if strings.Contains(errStr, "invalid") ||
+ strings.Contains(errStr, "bad request") ||
+ strings.Contains(errStr, "malformed") {
+ return errors.Join(ErrInvalidRequest, err)
+ }
+
+ // Return original error if no classification matches
+ return err
+}
+
+// IsRateLimitError checks if the error is a rate limit error
+func IsRateLimitError(err error) bool {
+ return errors.Is(err, ErrRateLimit)
+}
+
+// IsQuotaExceededError checks if the error is a quota exceeded error
+func IsQuotaExceededError(err error) bool {
+ return errors.Is(err, ErrQuotaExceeded)
+}
+
+// IsUnauthorizedError checks if the error is an unauthorized error
+func IsUnauthorizedError(err error) bool {
+ return errors.Is(err, ErrUnauthorized)
+}
+
+// IsContentBlockedError checks if the error is a content blocked error
+func IsContentBlockedError(err error) bool {
+ return errors.Is(err, ErrContentBlocked)
+}
+
+// IsTimeoutError checks if the error is a timeout error
+func IsTimeoutError(err error) bool {
+ return errors.Is(err, ErrTimeout)
+}
+
+// IsServerError checks if the error is a server error
+func IsServerError(err error) bool {
+ return errors.Is(err, ErrServerError)
+}
+
+// IsRetryableError checks if the error should trigger a retry
+func IsRetryableError(err error) bool {
+ return errors.Is(err, ErrRateLimit) ||
+ errors.Is(err, ErrServerError) ||
+ errors.Is(err, ErrTimeout)
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/gemini/image.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/gemini/image.go.tmpl
new file mode 100644
index 0000000..4d16d2c
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/gemini/image.go.tmpl
@@ -0,0 +1,181 @@
+package gemini
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/binary"
+ "fmt"
+
+ "google.golang.org/genai"
+)
+
+const (
+ // Gemini API seed range constraints
+ minSeed = 1
+ maxSeed = 999999
+)
+
+const (
+ // Gemini native image models (Nano Banana Pro)
+ ModelGemini3ProImage = "gemini-3-pro-image-preview"
+
+ defaultImageModel = ModelGemini3ProImage
+)
+
+// ImageRequest represents an image generation request
+type ImageRequest struct {
+ Model string // Model to use (default: "gemini-3-pro-image-preview")
+ Prompt string // Required: text description of the desired image
+ AspectRatio string // Optional: aspect ratio (e.g., "16:9", "1:1", "9:16")
+ Size string // Optional: image size - "1K", "2K", "4K" (Gemini 3 Pro only)
+
+ // Reference image for identity consistency
+ ReferenceImage []byte // Optional: reference image bytes
+ ReferenceMime string // Optional: MIME type ("image/png", "image/webp")
+
+ // Determinism controls
+ Seed *int32 // Optional: seed for reproducible results (nil = random)
+}
+
+// ImageResponse represents an image generation response
+type ImageResponse struct {
+ Images []ImageData // List of generated images
+ Text string // Optional text response from model
+ Seed *int32 // Seed used for generation
+}
+
+// ImageData represents a single generated image
+type ImageData struct {
+ Data []byte // Raw image bytes
+ MimeType string // MIME type of the image
+}
+
+// GenerateImage generates images using Gemini native image generation (Nano Banana Pro)
+func (c *Client) GenerateImage(ctx context.Context, req ImageRequest) (*ImageResponse, error) {
+ // Validate required fields
+ if req.Prompt == "" {
+ return nil, fmt.Errorf("%w: prompt is required", ErrInvalidConfig)
+ }
+
+ // Set defaults
+ if req.Model == "" {
+ req.Model = defaultImageModel
+ }
+
+ // Generate or validate provided seed
+ var seed int32
+ if req.Seed != nil {
+ seed = *req.Seed
+ // Validate seed is within Gemini's accepted range
+ if seed < minSeed || seed > maxSeed {
+ return nil, fmt.Errorf("%w: seed must be between %d and %d (got %d)",
+ ErrInvalidConfig, minSeed, maxSeed, seed)
+ }
+ } else {
+ // Generate cryptographically random seed
+ var seedBytes [4]byte
+ if _, err := rand.Read(seedBytes[:]); err != nil {
+ return nil, fmt.Errorf("generate random seed: %w", err)
+ }
+ seed = int32(binary.LittleEndian.Uint32(seedBytes[:])%maxSeed) + minSeed
+ }
+
+ c.logger.Debug("generating image",
+ "model", req.Model,
+ "prompt_length", len(req.Prompt),
+ "seed", seed,
+ "has_reference", len(req.ReferenceImage) > 0,
+ )
+
+ // Build generation config
+ config := &genai.GenerateContentConfig{
+ ResponseModalities: []string{"image", "text"},
+ Seed: &seed,
+ }
+
+ // Apply image-specific config (aspect ratio, size)
+ if req.AspectRatio != "" || req.Size != "" {
+ config.ImageConfig = &genai.ImageConfig{}
+ if req.AspectRatio != "" {
+ config.ImageConfig.AspectRatio = req.AspectRatio
+ }
+ if req.Size != "" {
+ config.ImageConfig.ImageSize = req.Size
+ }
+ }
+
+ // Build content parts based on whether reference image is provided
+ var parts []*genai.Part
+ if len(req.ReferenceImage) > 0 {
+ // Determine MIME type
+ mime := req.ReferenceMime
+ if mime == "" {
+ mime = "image/png" // default
+ }
+
+ // Multipart content: reference image + text prompt
+ parts = []*genai.Part{
+ {InlineData: &genai.Blob{MIMEType: mime, Data: req.ReferenceImage}},
+ {Text: req.Prompt},
+ }
+ } else {
+ // Text-only content
+ parts = []*genai.Part{
+ {Text: req.Prompt},
+ }
+ }
+
+ content := []*genai.Content{{Parts: parts}}
+
+ // Call the API with retry for transient errors
+ var response *genai.GenerateContentResponse
+ err := c.retryWithBackoff(ctx, "GenerateImage", func() error {
+ var apiErr error
+ response, apiErr = c.genaiClient.Models.GenerateContent(ctx, req.Model, content, config)
+ if apiErr != nil {
+ return classifyError(apiErr)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ // Convert response
+ imageResp := &ImageResponse{
+ Images: make([]ImageData, 0),
+ Seed: &seed,
+ }
+
+ // Extract images and text from response
+ if response != nil && len(response.Candidates) > 0 {
+ candidate := response.Candidates[0]
+ if candidate.Content != nil {
+ for _, part := range candidate.Content.Parts {
+ if part.Text != "" {
+ imageResp.Text = part.Text
+ }
+ if part.InlineData != nil && len(part.InlineData.Data) > 0 {
+ mimeType := "image/png"
+ if part.InlineData.MIMEType != "" {
+ mimeType = part.InlineData.MIMEType
+ }
+ imageResp.Images = append(imageResp.Images, ImageData{
+ Data: part.InlineData.Data,
+ MimeType: mimeType,
+ })
+ }
+ }
+ }
+ }
+
+ if len(imageResp.Images) == 0 {
+ return nil, fmt.Errorf("no images generated")
+ }
+
+ c.logger.Debug("image generation complete",
+ "images_count", len(imageResp.Images),
+ )
+
+ return imageResp, nil
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/gemini/video.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/gemini/video.go.tmpl
new file mode 100644
index 0000000..0134e72
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/gemini/video.go.tmpl
@@ -0,0 +1,187 @@
+package gemini
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "google.golang.org/genai"
+)
+
+const (
+ // Veo models
+ ModelVeo31 = "veo-3.1-generate-preview"
+ ModelVeo2 = "veo-2.0-generate-001"
+
+ defaultVideoModel = ModelVeo31
+)
+
+// VideoRequest represents a video generation request
+type VideoRequest struct {
+ Model string // Model to use (default: "veo-3.1-generate-preview")
+ Prompt string // Required: text description of the desired video
+ Image []byte // Optional: reference image for image-to-video
+ ImageMimeType string // Optional: MIME type of the reference image (default: "image/png")
+ AspectRatio string // Optional: aspect ratio (e.g., "16:9", "9:16")
+ Duration string // Optional: video duration (e.g., "5s", "10s")
+}
+
+// VideoResponse represents a video generation response
+type VideoResponse struct {
+ Video VideoData // Generated video
+}
+
+// VideoData represents a single generated video
+type VideoData struct {
+ Data []byte // Raw video bytes
+ MimeType string // MIME type of the video
+ URI string // URI if available (for downloading)
+}
+
+// GenerateVideo generates a video using the Veo model
+// Note: Video generation is asynchronous and this method polls until completion
+func (c *Client) GenerateVideo(ctx context.Context, req VideoRequest) (*VideoResponse, error) {
+ // Validate required fields
+ if req.Prompt == "" {
+ return nil, fmt.Errorf("%w: prompt is required", ErrInvalidConfig)
+ }
+
+ // Set defaults
+ if req.Model == "" {
+ req.Model = defaultVideoModel
+ }
+
+ c.logger.Debug("starting video generation",
+ "model", req.Model,
+ "prompt_length", len(req.Prompt),
+ "has_image", len(req.Image) > 0,
+ )
+
+ // Build configuration
+ var config *genai.GenerateVideosConfig
+ if req.AspectRatio != "" || req.Duration != "" {
+ config = &genai.GenerateVideosConfig{}
+ if req.AspectRatio != "" {
+ config.AspectRatio = req.AspectRatio
+ }
+ // Duration would be set here if the SDK supports it
+ }
+
+ // Prepare image input if provided
+ var image *genai.Image
+ if len(req.Image) > 0 {
+ mimeType := req.ImageMimeType
+ if mimeType == "" {
+ mimeType = "image/png"
+ }
+ image = &genai.Image{
+ ImageBytes: req.Image,
+ MIMEType: mimeType,
+ }
+ }
+
+ // Start video generation (async operation) with retry
+ var operation *genai.GenerateVideosOperation
+ err := c.retryWithBackoff(ctx, "GenerateVideo", func() error {
+ var apiErr error
+ operation, apiErr = c.genaiClient.Models.GenerateVideos(ctx, req.Model, req.Prompt, image, config)
+ if apiErr != nil {
+ return classifyError(apiErr)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ c.logger.Debug("video generation started, polling for completion",
+ "operation_name", operation.Name,
+ )
+
+ // Poll for completion
+ startTime := time.Now()
+ for !operation.Done {
+ // Check timeout
+ if time.Since(startTime) > c.config.VideoMaxWait {
+ return nil, fmt.Errorf("%w: video generation timed out after %v", ErrTimeout, c.config.VideoMaxWait)
+ }
+
+ c.logger.Debug("waiting for video generation",
+ "elapsed", time.Since(startTime).Round(time.Second),
+ )
+
+ // Wait before polling again
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-time.After(c.config.VideoPollDelay):
+ }
+
+ // Get updated operation status with retry
+ err = c.retryWithBackoff(ctx, "GetVideosOperation", func() error {
+ var apiErr error
+ operation, apiErr = c.genaiClient.Operations.GetVideosOperation(ctx, operation, nil)
+ if apiErr != nil {
+ return classifyError(apiErr)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ c.logger.Debug("video generation complete",
+ "elapsed", time.Since(startTime).Round(time.Second),
+ )
+
+ // Check for errors in the operation
+ if operation.Error != nil {
+ msg, _ := operation.Error["message"].(string)
+ code, _ := operation.Error["code"].(float64)
+ return nil, fmt.Errorf("video generation failed: %s (code: %.0f)", msg, code)
+ }
+ if operation.Response == nil {
+ return nil, fmt.Errorf("no video generated (nil response)")
+ }
+ if len(operation.Response.GeneratedVideos) == 0 {
+ // Log the full response for debugging
+ c.logger.Error("video generation returned empty",
+ "response", fmt.Sprintf("%+v", operation.Response),
+ )
+ return nil, fmt.Errorf("no video generated (empty GeneratedVideos array)")
+ }
+
+ // Get the generated video
+ generatedVideo := operation.Response.GeneratedVideos[0]
+ if generatedVideo.Video == nil {
+ return nil, fmt.Errorf("video data is empty")
+ }
+
+ videoResp := &VideoResponse{
+ Video: VideoData{
+ URI: generatedVideo.Video.URI,
+ },
+ }
+
+ // Download the video if URI is provided
+ if generatedVideo.Video.URI != "" {
+ c.logger.Debug("downloading video",
+ "uri", generatedVideo.Video.URI,
+ )
+
+ downloadResp, err := c.genaiClient.Files.Download(ctx, generatedVideo.Video, nil)
+ if err != nil {
+ // Return URI even if download fails
+ c.logger.Warn("failed to download video, returning URI only",
+ "error", err,
+ )
+ return videoResp, nil
+ }
+
+ videoResp.Video.Data = downloadResp
+ videoResp.Video.MimeType = "video/mp4"
+ }
+
+ return videoResp, nil
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/generation/handlers.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/generation/handlers.go.tmpl
new file mode 100644
index 0000000..c1440c5
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/generation/handlers.go.tmpl
@@ -0,0 +1,346 @@
+// Package generation provides queue job handlers for AI generation tasks.
+// Used by both the worker (production) and service standalone mode (development).
+package generation
+
+import (
+ "context"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "time"
+
+ "{{GO_MODULE}}/pkg/logging"
+ "{{GO_MODULE}}/pkg/mediagen"
+ "{{GO_MODULE}}/pkg/queue"
+ "{{GO_MODULE}}/pkg/realtime"
+ "{{GO_MODULE}}/pkg/storage"
+ "{{GO_MODULE}}/pkg/textgen"
+)
+
+// httpClient is used for downloading video content from provider URLs before persisting to storage.
+var httpClient = &http.Client{Timeout: 2 * time.Minute}
+
+// sendUserEvent sends an SSE event and logs delivery failures at warn level.
+// SSE delivery can fail if the user disconnected; this is non-fatal for the job.
+func sendUserEvent(pub realtime.EventPublisher, userID string, event *realtime.SSEEvent) {
+ if err := pub.SendToUser(userID, event); err != nil {
+ slog.Warn("failed to send SSE event", "error", err, "type", event.Type, "job_id", event.JobID)
+ }
+}
+
+// GeneratedImage represents a single generated image in SSE response payloads.
+type GeneratedImage struct {
+ Data string `json:"data"`
+ IsURL bool `json:"isUrl"`
+ Seed *int32 `json:"seed,omitempty"`
+}
+
+// GenerateImageResponse is the SSE result payload for completed image generation.
+type GenerateImageResponse struct {
+ Images []GeneratedImage `json:"images"`
+ Provider string `json:"provider"`
+ LatencyMs int64 `json:"latencyMs"`
+}
+
+// ImageHandler returns a queue.Handler that processes image generation jobs.
+// If store is non-nil, generated images are persisted and URLs are returned instead of base64.
+func ImageHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler {
+ return func(ctx context.Context, job *queue.Job) error {
+ userID, _ := job.Payload["userID"].(string)
+ if userID == "" {
+ return fmt.Errorf("missing userID in job payload")
+ }
+
+ prompt, _ := job.Payload["prompt"].(string)
+ count := 1
+ if c, ok := job.Payload["count"].(float64); ok && c > 0 {
+ count = int(c)
+ }
+ aspectRatio, _ := job.Payload["aspectRatio"].(string)
+
+ sendUserEvent(pub, userID, &realtime.SSEEvent{
+ Type: realtime.EventGenerationStarted,
+ JobID: job.ID,
+ Message: "Starting image generation...",
+ })
+
+ sendUserEvent(pub, userID, &realtime.SSEEvent{
+ Type: realtime.EventGenerationProgress,
+ JobID: job.ID,
+ Progress: 30,
+ Message: "Generating image...",
+ })
+
+ start := time.Now()
+ resp, err := mg.GenerateImage(ctx, mediagen.ImageRequest{
+ Prompt: prompt,
+ Count: count,
+ AspectRatio: aspectRatio,
+ })
+ elapsed := time.Since(start)
+
+ if err != nil {
+ logger.Error("image generation failed", "error", err, "job_id", job.ID)
+ sendUserEvent(pub, userID, &realtime.SSEEvent{
+ Type: realtime.EventGenerationFailed,
+ JobID: job.ID,
+ Error: "Image generation failed: " + err.Error(),
+ })
+ return err
+ }
+
+ images := make([]GeneratedImage, len(resp.Images))
+ for i, img := range resp.Images {
+ // Try to persist to storage if available
+ if store != nil && img.Data != nil {
+ storagePath := fmt.Sprintf("media/%s/images/%s_%d.png", userID, job.ID, i)
+ url, uploadErr := store.Upload(ctx, storagePath, img.Data, "image/png")
+ if uploadErr != nil {
+ logger.Warn("failed to persist image to storage", "error", uploadErr, "job_id", job.ID)
+ } else {
+ images[i] = GeneratedImage{Data: url, IsURL: true, Seed: resp.Seed}
+ continue
+ }
+ }
+ // Fallback: return URL or base64
+ if img.URL != "" {
+ images[i] = GeneratedImage{Data: img.URL, IsURL: true, Seed: resp.Seed}
+ } else {
+ images[i] = GeneratedImage{Data: base64.StdEncoding.EncodeToString(img.Data), IsURL: false, Seed: resp.Seed}
+ }
+ }
+
+ sendUserEvent(pub, userID, &realtime.SSEEvent{
+ Type: realtime.EventGenerationComplete,
+ JobID: job.ID,
+ Progress: 100,
+ Message: "Complete",
+ Result: GenerateImageResponse{
+ Images: images,
+ Provider: resp.Provider,
+ LatencyMs: elapsed.Milliseconds(),
+ },
+ })
+
+ logger.Info("image generation complete",
+ "job_id", job.ID, "provider", resp.Provider,
+ "images", len(resp.Images), "elapsed", elapsed)
+ return nil
+ }
+}
+
+// VideoHandler returns a queue.Handler that processes video generation jobs.
+// If store is non-nil, generated videos are persisted and URLs are returned.
+func VideoHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler {
+ return func(ctx context.Context, job *queue.Job) error {
+ userID, _ := job.Payload["userID"].(string)
+ if userID == "" {
+ return fmt.Errorf("missing userID in job payload")
+ }
+
+ prompt, _ := job.Payload["prompt"].(string)
+ aspectRatio, _ := job.Payload["aspectRatio"].(string)
+
+ sendUserEvent(pub, userID, &realtime.SSEEvent{
+ Type: realtime.EventGenerationStarted,
+ JobID: job.ID,
+ Message: "Starting video generation...",
+ })
+
+ sendUserEvent(pub, userID, &realtime.SSEEvent{
+ Type: realtime.EventGenerationProgress,
+ JobID: job.ID,
+ Progress: 10,
+ Message: "Initializing video generation (this takes 2-5 minutes)...",
+ })
+
+ start := time.Now()
+ resp, err := mg.GenerateVideo(ctx, mediagen.VideoRequest{
+ Prompt: prompt,
+ AspectRatio: aspectRatio,
+ })
+ elapsed := time.Since(start)
+
+ if err != nil {
+ logger.Error("video generation failed", "error", err, "job_id", job.ID)
+ sendUserEvent(pub, userID, &realtime.SSEEvent{
+ Type: realtime.EventGenerationFailed,
+ JobID: job.ID,
+ Error: "Video generation failed: " + err.Error(),
+ })
+ return err
+ }
+
+ // Build videos array matching frontend VideoResult shape:
+ // { videos: [{ data, isUrl, mimeType }], provider, latencyMs }
+ videos := make([]map[string]any, 0, len(resp.Videos))
+ for i, vid := range resp.Videos {
+ videoURL := vid.URL
+
+ // Persist to storage: download from provider URL, then upload to GCS.
+ if store != nil && vid.URL != "" {
+ storagePath := fmt.Sprintf("media/%s/videos/%s_%d.mp4", userID, job.ID, i)
+ videoData, downloadErr := downloadURL(ctx, vid.URL)
+ if downloadErr != nil {
+ logger.Warn("failed to download video from provider", "error", downloadErr, "job_id", job.ID)
+ } else {
+ persistedURL, uploadErr := store.Upload(ctx, storagePath, videoData, "video/mp4")
+ if uploadErr != nil {
+ logger.Warn("failed to persist video to storage", "error", uploadErr, "job_id", job.ID)
+ } else {
+ videoURL = persistedURL
+ }
+ }
+ }
+
+ videos = append(videos, map[string]any{
+ "data": videoURL,
+ "isUrl": true,
+ "mimeType": "video/mp4",
+ })
+ }
+
+ sendUserEvent(pub, userID, &realtime.SSEEvent{
+ Type: realtime.EventGenerationComplete,
+ JobID: job.ID,
+ Progress: 100,
+ Message: "Complete",
+ Result: map[string]any{
+ "videos": videos,
+ "provider": resp.Provider,
+ "latencyMs": elapsed.Milliseconds(),
+ },
+ })
+
+ logger.Info("video generation complete",
+ "job_id", job.ID, "provider", resp.Provider, "elapsed", elapsed)
+ return nil
+ }
+}
+
+// TextHandler returns a queue.Handler that processes text generation jobs with streaming.
+func TextHandler(tg *textgen.Manager, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler {
+ return func(ctx context.Context, job *queue.Job) error {
+ userID, _ := job.Payload["userID"].(string)
+ if userID == "" {
+ return fmt.Errorf("missing userID in job payload")
+ }
+
+ prompt, _ := job.Payload["prompt"].(string)
+ systemPrompt, _ := job.Payload["systemPrompt"].(string)
+ maxTokens := 0
+ if mt, ok := job.Payload["maxTokens"].(float64); ok {
+ maxTokens = int(mt)
+ }
+
+ sendUserEvent(pub, userID, &realtime.SSEEvent{
+ Type: realtime.EventGenerationStarted,
+ JobID: job.ID,
+ Message: "Starting text generation...",
+ })
+
+ streamID := job.ID
+
+ err := tg.GenerateStream(ctx, textgen.TextRequest{
+ Prompt: prompt,
+ SystemPrompt: systemPrompt,
+ MaxTokens: maxTokens,
+ Temperature: 0.7,
+ }, func(chunk textgen.StreamChunk) {
+ sendUserEvent(pub, userID, &realtime.SSEEvent{
+ Type: realtime.MessageTypeAIChatChunk,
+ JobID: job.ID,
+ Result: realtime.AIChunkData{
+ StreamID: streamID,
+ Text: chunk.Text,
+ Done: chunk.Done,
+ Provider: chunk.Provider,
+ },
+ })
+ })
+
+ if err != nil {
+ logger.Error("text generation failed", "error", err, "job_id", job.ID)
+ sendUserEvent(pub, userID, &realtime.SSEEvent{
+ Type: realtime.EventGenerationFailed,
+ JobID: job.ID,
+ Error: "Text generation failed: " + err.Error(),
+ })
+ return err
+ }
+
+ sendUserEvent(pub, userID, &realtime.SSEEvent{
+ Type: realtime.EventGenerationComplete,
+ JobID: job.ID,
+ Progress: 100,
+ Message: "Complete",
+ })
+
+ logger.Info("text generation complete", "job_id", job.ID)
+ return nil
+ }
+}
+
+// ChatResponseHandler returns a queue.Handler that generates AI chat responses
+// and streams them to a channel (e.g., channel:general) for all participants.
+func ChatResponseHandler(tg *textgen.Manager, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler {
+ return func(ctx context.Context, job *queue.Job) error {
+ content, _ := job.Payload["content"].(string)
+ channel, _ := job.Payload["channel"].(string)
+ if channel == "" {
+ channel = "channel:general"
+ }
+
+ streamID := job.ID
+
+ err := tg.GenerateStream(ctx, textgen.TextRequest{
+ Prompt: content,
+ SystemPrompt: "You are a helpful AI assistant in a chat room. Keep responses concise, friendly, and under 200 words.",
+ MaxTokens: 300,
+ Temperature: 0.7,
+ }, func(chunk textgen.StreamChunk) {
+ pub.SendToChannel(channel, &realtime.SSEEvent{
+ Type: realtime.MessageTypeAIChatChunk,
+ JobID: job.ID,
+ Result: realtime.AIChunkData{
+ StreamID: streamID,
+ Text: chunk.Text,
+ Done: chunk.Done,
+ Provider: chunk.Provider,
+ },
+ })
+ })
+
+ if err != nil {
+ logger.Error("AI chat response failed", "error", err, "job_id", job.ID)
+ return err
+ }
+
+ logger.Info("AI chat response complete", "job_id", job.ID)
+ return nil
+ }
+}
+
+// downloadURL fetches content from a URL and returns the bytes.
+// Used to download provider-hosted videos before persisting to storage.
+func downloadURL(ctx context.Context, url string) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("download: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("download: status %d", resp.StatusCode)
+ }
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read body: %w", err)
+ }
+ return data, nil
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/go.mod.tmpl b/internal/adapter/templates/templates/skeleton/pkg/go.mod.tmpl
index 16c5b59..1b23303 100644
--- a/internal/adapter/templates/templates/skeleton/pkg/go.mod.tmpl
+++ b/internal/adapter/templates/templates/skeleton/pkg/go.mod.tmpl
@@ -3,6 +3,7 @@ module {{GO_MODULE}}/pkg
go 1.25
require (
+ cloud.google.com/go/storage v1.43.0
github.com/bdpiprava/scalar-go v0.13.0
github.com/go-chi/chi/v5 v5.2.0
github.com/go-chi/cors v1.2.1
@@ -14,6 +15,8 @@ require (
github.com/lib/pq v1.10.9
github.com/redis/go-redis/v9 v9.7.0
github.com/spf13/viper v1.19.0
+ google.golang.org/api v0.192.0
+ google.golang.org/genai v1.46.0
)
require (
diff --git a/internal/adapter/templates/templates/skeleton/pkg/laozhang/client.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/laozhang/client.go.tmpl
new file mode 100644
index 0000000..70a0610
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/laozhang/client.go.tmpl
@@ -0,0 +1,440 @@
+// Package laozhang provides a Go client for the LaoZhang API gateway.
+//
+// LaoZhang is an OpenAI-compatible API gateway that provides access to various
+// AI models including chat completion, image generation (Nano Banana Pro), and
+// video generation (Veo 3.1).
+//
+// Basic usage:
+//
+// client, err := laozhang.NewClient(laozhang.Config{
+// APIKey: os.Getenv("LAOZHANG_API_KEY"),
+// })
+// if err != nil {
+// log.Fatal(err)
+// }
+//
+// // Generate an image
+// resp, err := client.GenerateImage(ctx, laozhang.ImageRequest{
+// Prompt: "A serene Japanese garden",
+// })
+//
+// The client automatically handles retries for server errors (5xx) and rate
+// limits (429) with exponential backoff. Use the sentinel errors (ErrRateLimit,
+// ErrServerError, etc.) with errors.Is() for programmatic error handling.
+package laozhang
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "{{GO_MODULE}}/pkg/httpclient"
+)
+
+const (
+ defaultBaseURL = "https://api.laozhang.ai/v1"
+ defaultTimeout = 60 * time.Second
+ defaultVideoTimeout = 5 * time.Minute
+ defaultMaxRetries = 3
+)
+
+// Config holds configuration options for the LaoZhang client
+type Config struct {
+ APIKey string // Required: API key for authentication
+ BaseURL string // Optional: defaults to https://api.laozhang.ai/v1
+ Timeout time.Duration // Optional: defaults to 60s
+ VideoTimeout time.Duration // Optional: defaults to 5m (video generation takes 2-5 minutes)
+ MaxRetries int // Optional: defaults to 3
+ Logger *slog.Logger // Optional: defaults to slog.Default()
+}
+
+// Client is the LaoZhang API client
+type Client struct {
+ httpClient *httpclient.Client // Standard timeout (60s) for text/image
+ videoHTTPClient *httpclient.Client // Long timeout (5m) for video generation
+ config *Config
+ logger *slog.Logger
+}
+
+// NewClient creates a new LaoZhang API client
+func NewClient(config Config) (*Client, error) {
+ if config.APIKey == "" {
+ return nil, fmt.Errorf("%w: API key is required", ErrInvalidConfig)
+ }
+
+ if config.BaseURL == "" {
+ config.BaseURL = defaultBaseURL
+ }
+
+ if config.Timeout == 0 {
+ config.Timeout = defaultTimeout
+ }
+
+ if config.VideoTimeout == 0 {
+ config.VideoTimeout = defaultVideoTimeout
+ }
+
+ if config.MaxRetries == 0 {
+ config.MaxRetries = defaultMaxRetries
+ }
+
+ if config.Logger == nil {
+ config.Logger = slog.Default()
+ }
+
+ // Validate base URL
+ if _, err := url.Parse(config.BaseURL); err != nil {
+ return nil, fmt.Errorf("%w: invalid base URL: %v", ErrInvalidConfig, err)
+ }
+
+ return &Client{
+ httpClient: httpclient.New(httpclient.Config{
+ Timeout: config.Timeout,
+ MaxRetries: config.MaxRetries,
+ Logger: config.Logger,
+ }),
+ videoHTTPClient: httpclient.New(httpclient.Config{
+ Timeout: config.VideoTimeout,
+ MaxRetries: config.MaxRetries,
+ Logger: config.Logger,
+ }),
+ config: &config,
+ logger: config.Logger,
+ }, nil
+}
+
+// Health checks the health of the LaoZhang API
+func (c *Client) Health(ctx context.Context) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.config.BaseURL+"/models", nil)
+ if err != nil {
+ return fmt.Errorf("create health check request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("health check request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ baseErr := classifyHTTPError(resp.StatusCode)
+ return NewAPIError(resp.StatusCode, string(body), "", "", baseErr)
+ }
+
+ return nil
+}
+
+// ChatCompletion sends a chat completion request to the LaoZhang API
+func (c *Client) ChatCompletion(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) {
+ if req.Model == "" {
+ return nil, fmt.Errorf("%w: model is required", ErrInvalidConfig)
+ }
+ if len(req.Messages) == 0 {
+ return nil, fmt.Errorf("%w: messages are required", ErrInvalidConfig)
+ }
+
+ respBody, err := c.doRequest(ctx, http.MethodPost, "/chat/completions", req)
+ if err != nil {
+ return nil, err
+ }
+
+ var chatResp ChatCompletionResponse
+ if err := json.Unmarshal(respBody, &chatResp); err != nil {
+ return nil, fmt.Errorf("unmarshal response: %w", err)
+ }
+
+ return &chatResp, nil
+}
+
+// ChatCompletionStream sends a streaming chat completion request.
+// Returns chunks via the onChunk callback as tokens arrive from the API.
+// The request is sent with stream=true and the response body is read as SSE.
+func (c *Client) ChatCompletionStream(ctx context.Context, req ChatCompletionRequest, onChunk func(StreamChunk)) error {
+ if req.Model == "" {
+ return fmt.Errorf("%w: model is required", ErrInvalidConfig)
+ }
+ if len(req.Messages) == 0 {
+ return fmt.Errorf("%w: messages are required", ErrInvalidConfig)
+ }
+
+ req.Stream = true
+
+ jsonBody, err := json.Marshal(req)
+ if err != nil {
+ return fmt.Errorf("marshal request: %w", err)
+ }
+
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.config.BaseURL+"/chat/completions", bytes.NewReader(jsonBody))
+ if err != nil {
+ return fmt.Errorf("create request: %w", err)
+ }
+ httpReq.Header.Set("Authorization", "Bearer "+c.config.APIKey)
+ httpReq.Header.Set("Content-Type", "application/json")
+
+ // Use a raw http.Client with timeout for streaming (httpClient.Do may buffer)
+ rawClient := &http.Client{Timeout: c.config.Timeout}
+ resp, err := rawClient.Do(httpReq)
+ if err != nil {
+ return fmt.Errorf("stream request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ baseErr := classifyHTTPError(resp.StatusCode)
+ return NewAPIError(resp.StatusCode, string(body), "", "", baseErr)
+ }
+
+ // Read SSE stream line by line
+ scanner := bufio.NewScanner(resp.Body)
+ for scanner.Scan() {
+ line := scanner.Text()
+
+ // Skip empty lines and comments
+ if line == "" || line[0] == ':' {
+ continue
+ }
+
+ // Parse SSE data lines
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+ data := strings.TrimPrefix(line, "data: ")
+
+ // [DONE] marks the end of the stream
+ if data == "[DONE]" {
+ onChunk(StreamChunk{Done: true})
+ return nil
+ }
+
+ var chunk streamResponse
+ if err := json.Unmarshal([]byte(data), &chunk); err != nil {
+ c.logger.Debug("skipping unparseable stream chunk", "error", err)
+ continue
+ }
+
+ if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
+ onChunk(StreamChunk{Text: chunk.Choices[0].Delta.Content})
+ }
+ }
+
+ if err := scanner.Err(); err != nil {
+ return fmt.Errorf("read stream: %w", err)
+ }
+
+ // If we reach here without [DONE], send a final chunk
+ onChunk(StreamChunk{Done: true})
+ return nil
+}
+
+// StreamChunk represents a chunk from a streaming response.
+type StreamChunk struct {
+ Text string
+ Done bool
+}
+
+// streamResponse is the JSON structure for streaming chat completion chunks.
+type streamResponse struct {
+ Choices []streamChoice `json:"choices"`
+}
+
+type streamChoice struct {
+ Delta struct {
+ Content string `json:"content"`
+ } `json:"delta"`
+ FinishReason *string `json:"finish_reason"`
+}
+
+// doRequest is a helper method for making HTTP requests
+func (c *Client) doRequest(ctx context.Context, method, path string, bodyData interface{}) ([]byte, error) {
+ var reqBody io.Reader
+ if bodyData != nil {
+ jsonBody, err := json.Marshal(bodyData)
+ if err != nil {
+ return nil, fmt.Errorf("marshal request: %w", err)
+ }
+ reqBody = bytes.NewReader(jsonBody)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, c.config.BaseURL+path, reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
+ if bodyData != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response body: %w", err)
+ }
+
+ // Success response
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ return respBody, nil
+ }
+
+ // Parse error response
+ var errResp ErrorResponse
+ baseErr := classifyHTTPError(resp.StatusCode)
+
+ if err := json.Unmarshal(respBody, &errResp); err != nil {
+ // Failed to parse error response
+ return nil, NewAPIError(
+ resp.StatusCode,
+ string(respBody),
+ "",
+ "",
+ baseErr,
+ )
+ }
+
+ // Successfully parsed error response
+ return nil, NewAPIError(
+ resp.StatusCode,
+ errResp.Error.Message,
+ errResp.Error.Type,
+ errResp.Error.Code,
+ baseErr,
+ )
+}
+
+// doRequestVideo is like doRequest but uses the video HTTP client with a longer timeout.
+// Video generation (Veo) takes 2-5 minutes, exceeding the standard 60s client timeout.
+func (c *Client) doRequestVideo(ctx context.Context, method, path string, bodyData interface{}) ([]byte, error) {
+ var reqBody io.Reader
+ if bodyData != nil {
+ jsonBody, err := json.Marshal(bodyData)
+ if err != nil {
+ return nil, fmt.Errorf("marshal request: %w", err)
+ }
+ reqBody = bytes.NewReader(jsonBody)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, c.config.BaseURL+path, reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
+ if bodyData != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ resp, err := c.videoHTTPClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response body: %w", err)
+ }
+
+ // Success response
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ return respBody, nil
+ }
+
+ // Parse error response
+ var errResp ErrorResponse
+ baseErr := classifyHTTPError(resp.StatusCode)
+
+ if err := json.Unmarshal(respBody, &errResp); err != nil {
+ return nil, NewAPIError(
+ resp.StatusCode,
+ string(respBody),
+ "",
+ "",
+ baseErr,
+ )
+ }
+
+ return nil, NewAPIError(
+ resp.StatusCode,
+ errResp.Error.Message,
+ errResp.Error.Type,
+ errResp.Error.Code,
+ baseErr,
+ )
+}
+
+// geminiBaseURL returns the base URL for Gemini API endpoints (without /v1 suffix)
+func (c *Client) geminiBaseURL() string {
+ // Strip /v1 suffix if present to get the root URL for Gemini endpoints
+ baseURL := c.config.BaseURL
+ if len(baseURL) > 3 && baseURL[len(baseURL)-3:] == "/v1" {
+ return baseURL[:len(baseURL)-3]
+ }
+ return baseURL
+}
+
+// doRequestGemini is similar to doRequest but uses the Gemini base URL format
+func (c *Client) doRequestGemini(ctx context.Context, method, path string, bodyData interface{}) ([]byte, error) {
+ var reqBody io.Reader
+ if bodyData != nil {
+ jsonBody, err := json.Marshal(bodyData)
+ if err != nil {
+ return nil, fmt.Errorf("marshal request: %w", err)
+ }
+ reqBody = bytes.NewReader(jsonBody)
+ }
+
+ // Use Gemini base URL (without /v1)
+ fullURL := c.geminiBaseURL() + path
+ req, err := http.NewRequestWithContext(ctx, method, fullURL, reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
+ if bodyData != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response body: %w", err)
+ }
+
+ // Success response
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ return respBody, nil
+ }
+
+ // Parse error response (Gemini format may differ)
+ baseErr := classifyHTTPError(resp.StatusCode)
+ return nil, NewAPIError(
+ resp.StatusCode,
+ string(respBody),
+ "",
+ "",
+ baseErr,
+ )
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/laozhang/errors.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/laozhang/errors.go.tmpl
new file mode 100644
index 0000000..042f9a4
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/laozhang/errors.go.tmpl
@@ -0,0 +1,199 @@
+package laozhang
+
+import (
+ "errors"
+ "fmt"
+)
+
+// Sentinel errors for common error types
+var (
+ // ErrInvalidConfig indicates configuration validation failed
+ ErrInvalidConfig = errors.New("invalid configuration")
+
+ // ErrRateLimit indicates rate limit exceeded (HTTP 429)
+ ErrRateLimit = errors.New("rate limit exceeded")
+
+ // ErrServerError indicates server-side error (HTTP 5xx)
+ ErrServerError = errors.New("server error")
+
+ // ErrInvalidRequest indicates client error (HTTP 4xx except 429, 401)
+ ErrInvalidRequest = errors.New("invalid request")
+
+ // ErrTimeout indicates request timeout
+ ErrTimeout = errors.New("request timeout")
+
+ // ErrUnauthorized indicates authentication failed (HTTP 401)
+ ErrUnauthorized = errors.New("unauthorized")
+)
+
+// APIError represents an error returned by the LaoZhang API
+type APIError struct {
+ StatusCode int // HTTP status code
+ Message string // Human-readable error message
+ Type string // Error type from API response
+ Code string // Error code from API response
+ err error // Underlying error for wrapping
+ Details map[string]string // Additional error details
+}
+
+// Error implements the error interface
+func (e *APIError) Error() string {
+ if e.Code != "" {
+ return fmt.Sprintf("laozhang api error (status %d): [%s] %s", e.StatusCode, e.Code, e.Message)
+ }
+ return fmt.Sprintf("laozhang api error (status %d): %s", e.StatusCode, e.Message)
+}
+
+// Unwrap implements the errors.Unwrap interface for error chains
+func (e *APIError) Unwrap() error {
+ return e.err
+}
+
+// NewAPIError creates a new APIError with the given parameters
+func NewAPIError(statusCode int, message, errorType, code string, underlying error) *APIError {
+ return &APIError{
+ StatusCode: statusCode,
+ Message: message,
+ Type: errorType,
+ Code: code,
+ err: underlying,
+ Details: make(map[string]string),
+ }
+}
+
+// WithDetails adds additional details to the error
+func (e *APIError) WithDetails(key, value string) *APIError {
+ if e.Details == nil {
+ e.Details = make(map[string]string)
+ }
+ e.Details[key] = value
+ return e
+}
+
+// IsRateLimitError checks if the error is a rate limit error
+func IsRateLimitError(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ // Check for sentinel error
+ if errors.Is(err, ErrRateLimit) {
+ return true
+ }
+
+ // Check for APIError with 429 status
+ var apiErr *APIError
+ if errors.As(err, &apiErr) {
+ return apiErr.StatusCode == 429
+ }
+
+ return false
+}
+
+// IsServerError checks if the error is a server error (5xx)
+func IsServerError(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ // Check for sentinel error
+ if errors.Is(err, ErrServerError) {
+ return true
+ }
+
+ // Check for APIError with 5xx status
+ var apiErr *APIError
+ if errors.As(err, &apiErr) {
+ return apiErr.StatusCode >= 500 && apiErr.StatusCode < 600
+ }
+
+ return false
+}
+
+// IsRetryableError checks if the error should trigger a retry
+// Retryable errors include: rate limits (429), server errors (5xx), and timeouts
+func IsRetryableError(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ // Check sentinel errors
+ if errors.Is(err, ErrRateLimit) || errors.Is(err, ErrServerError) || errors.Is(err, ErrTimeout) {
+ return true
+ }
+
+ // Check APIError status codes
+ var apiErr *APIError
+ if errors.As(err, &apiErr) {
+ // Retry on rate limits and server errors
+ return apiErr.StatusCode == 429 || (apiErr.StatusCode >= 500 && apiErr.StatusCode < 600)
+ }
+
+ return false
+}
+
+// IsUnauthorizedError checks if the error is an unauthorized error (401)
+func IsUnauthorizedError(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ // Check for sentinel error
+ if errors.Is(err, ErrUnauthorized) {
+ return true
+ }
+
+ // Check for APIError with 401 status
+ var apiErr *APIError
+ if errors.As(err, &apiErr) {
+ return apiErr.StatusCode == 401
+ }
+
+ return false
+}
+
+// IsTimeoutError checks if the error is a timeout error
+func IsTimeoutError(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ return errors.Is(err, ErrTimeout)
+}
+
+// IsInvalidRequestError checks if the error is a client error (4xx except 429, 401)
+func IsInvalidRequestError(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ // Check for sentinel error
+ if errors.Is(err, ErrInvalidRequest) {
+ return true
+ }
+
+ // Check for APIError with 4xx status (excluding 429 and 401)
+ var apiErr *APIError
+ if errors.As(err, &apiErr) {
+ return apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 &&
+ apiErr.StatusCode != 429 && apiErr.StatusCode != 401
+ }
+
+ return false
+}
+
+// classifyHTTPError classifies an HTTP status code into a domain error type
+func classifyHTTPError(statusCode int) error {
+ switch {
+ case statusCode == 401:
+ return ErrUnauthorized
+ case statusCode == 429:
+ return ErrRateLimit
+ case statusCode >= 400 && statusCode < 500:
+ return ErrInvalidRequest
+ case statusCode >= 500 && statusCode < 600:
+ return ErrServerError
+ default:
+ return nil
+ }
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/laozhang/image.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/laozhang/image.go.tmpl
new file mode 100644
index 0000000..d91dbfc
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/laozhang/image.go.tmpl
@@ -0,0 +1,203 @@
+package laozhang
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "time"
+)
+
+const (
+ defaultImageModel = "gemini-3-pro-image-preview"
+ defaultImageSize = "1K"
+ defaultImageCount = 1
+)
+
+// ImageRequest represents an image generation request
+type ImageRequest struct {
+ Model string `json:"model"` // Model to use for generation (default: "gemini-3-pro-image-preview")
+ Prompt string `json:"prompt"` // Required: text description of the desired image
+ Size string `json:"size,omitempty"` // Image size: "1K", "2K", or "4K" (default: "1K")
+ AspectRatio string `json:"aspect_ratio,omitempty"` // Aspect ratio: "9:16", "16:9", "1:1", "4:3", "3:4" (default: "1:1")
+ N int `json:"n,omitempty"` // Number of images to generate (default: 1)
+
+ // Determinism control (support varies by model)
+ Seed *int32 `json:"seed,omitempty"` // Optional: seed for reproducible results (nil = random)
+
+ // Reference images for identity/style consistency (up to 5 person images supported)
+ // Each ReferenceImage should have Data (raw bytes) and MimeType set
+ ReferenceImages []ReferenceImage `json:"-"` // Not serialized directly, added to parts
+}
+
+// ReferenceImage represents a reference image for image-to-image generation
+type ReferenceImage struct {
+ Data []byte // Raw image bytes
+ MimeType string // e.g., "image/png", "image/jpeg"
+}
+
+// ImageResponse represents an image generation response
+type ImageResponse struct {
+ Created int64 `json:"created"` // Unix timestamp of when the image was created
+ Data []ImageData `json:"data"` // List of generated images
+}
+
+// ImageData represents a single generated image
+type ImageData struct {
+ URL string `json:"url,omitempty"` // Image URL (if available)
+ B64JSON string `json:"b64_json,omitempty"` // Base64-encoded image data
+}
+
+// Gemini native API request/response types (internal)
+
+type geminiRequest struct {
+ Contents []geminiContent `json:"contents"`
+ GenerationConfig geminiGenConfig `json:"generationConfig"`
+}
+
+type geminiContent struct {
+ Parts []geminiPart `json:"parts"`
+}
+
+type geminiPart struct {
+ Text string `json:"text,omitempty"`
+ InlineData *geminiInlineData `json:"inlineData,omitempty"`
+}
+
+type geminiInlineData struct {
+ MimeType string `json:"mimeType"`
+ Data string `json:"data"` // base64 encoded
+}
+
+type geminiGenConfig struct {
+ ResponseModalities []string `json:"responseModalities"`
+ ImageConfig *geminiImageConfig `json:"imageConfig,omitempty"`
+}
+
+type geminiImageConfig struct {
+ AspectRatio string `json:"aspectRatio,omitempty"`
+ ImageSize string `json:"imageSize,omitempty"`
+}
+
+type geminiResponse struct {
+ Candidates []geminiCandidate `json:"candidates"`
+}
+
+type geminiCandidate struct {
+ Content geminiContent `json:"content"`
+}
+
+// GenerateImage generates images based on the provided prompt using the Gemini native API
+func (c *Client) GenerateImage(ctx context.Context, req ImageRequest) (*ImageResponse, error) {
+ // Validate required fields
+ if req.Prompt == "" {
+ return nil, fmt.Errorf("%w: prompt is required", ErrInvalidConfig)
+ }
+
+ // Set defaults
+ if req.Model == "" {
+ req.Model = defaultImageModel
+ }
+ if req.Size == "" {
+ req.Size = defaultImageSize
+ }
+ if req.N == 0 {
+ req.N = defaultImageCount
+ }
+
+ // Validate size
+ validSizes := map[string]bool{"1K": true, "2K": true, "4K": true}
+ if !validSizes[req.Size] {
+ return nil, fmt.Errorf("%w: invalid size %s (must be 1K, 2K, or 4K)", ErrInvalidConfig, req.Size)
+ }
+
+ // Build parts array: text prompt first, then reference images
+ parts := []geminiPart{
+ {Text: req.Prompt},
+ }
+
+ // Add reference images (up to 5 person images for identity consistency)
+ for _, refImg := range req.ReferenceImages {
+ if len(refImg.Data) == 0 {
+ continue
+ }
+ mimeType := refImg.MimeType
+ if mimeType == "" {
+ mimeType = "image/png"
+ }
+ parts = append(parts, geminiPart{
+ InlineData: &geminiInlineData{
+ MimeType: mimeType,
+ Data: base64.StdEncoding.EncodeToString(refImg.Data),
+ },
+ })
+ }
+
+ // Build Gemini native request
+ geminiReq := geminiRequest{
+ Contents: []geminiContent{
+ {
+ Parts: parts,
+ },
+ },
+ GenerationConfig: geminiGenConfig{
+ ResponseModalities: []string{"TEXT", "IMAGE"},
+ ImageConfig: &geminiImageConfig{
+ ImageSize: req.Size,
+ },
+ },
+ }
+
+ // Add aspect ratio if specified
+ if req.AspectRatio != "" {
+ geminiReq.GenerationConfig.ImageConfig.AspectRatio = req.AspectRatio
+ }
+
+ // Build the Gemini endpoint path
+ // Format: /v1beta/models/{model}:generateContent
+ endpoint := fmt.Sprintf("/v1beta/models/%s:generateContent", req.Model)
+
+ // Make request - use doRequestRaw to bypass the /v1 base URL
+ respBody, err := c.doRequestGemini(ctx, "POST", endpoint, geminiReq)
+ if err != nil {
+ return nil, err
+ }
+
+ // Parse Gemini response
+ var geminiResp geminiResponse
+ if err := json.Unmarshal(respBody, &geminiResp); err != nil {
+ return nil, fmt.Errorf("unmarshal gemini response: %w (body: %s)", err, string(respBody))
+ }
+
+ // Convert to our ImageResponse format
+ imageResp := &ImageResponse{
+ Created: time.Now().Unix(),
+ Data: make([]ImageData, 0),
+ }
+
+ for _, candidate := range geminiResp.Candidates {
+ for _, part := range candidate.Content.Parts {
+ if part.InlineData != nil && part.InlineData.Data != "" {
+ imageResp.Data = append(imageResp.Data, ImageData{
+ B64JSON: part.InlineData.Data,
+ })
+ }
+ }
+ }
+
+ if len(imageResp.Data) == 0 {
+ // Truncate response and prompt for logging
+ bodyPreview := string(respBody)
+ if len(bodyPreview) > 500 {
+ bodyPreview = bodyPreview[:500] + "..."
+ }
+ promptPreview := req.Prompt
+ if len(promptPreview) > 500 {
+ promptPreview = promptPreview[:500] + "..."
+ }
+ return nil, fmt.Errorf("no images returned in response (candidates=%d, aspect=%s, body=%s, prompt=%s)",
+ len(geminiResp.Candidates), req.AspectRatio, bodyPreview, promptPreview)
+ }
+
+ return imageResp, nil
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/laozhang/types.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/laozhang/types.go.tmpl
new file mode 100644
index 0000000..a272434
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/laozhang/types.go.tmpl
@@ -0,0 +1,49 @@
+package laozhang
+
+// ChatCompletionRequest represents an OpenAI-compatible chat completion request
+type ChatCompletionRequest struct {
+ Model string `json:"model"`
+ Messages []ChatMessage `json:"messages"`
+ Temperature float64 `json:"temperature,omitempty"`
+ MaxTokens int `json:"max_tokens,omitempty"`
+ Stream bool `json:"stream,omitempty"`
+}
+
+// ChatMessage represents a single message in a chat conversation
+type ChatMessage struct {
+ Role string `json:"role"` // "system", "user", or "assistant"
+ Content string `json:"content"`
+}
+
+// ChatCompletionResponse represents an OpenAI-compatible chat completion response
+type ChatCompletionResponse struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ Created int64 `json:"created"`
+ Model string `json:"model"`
+ Choices []Choice `json:"choices"`
+ Usage Usage `json:"usage"`
+}
+
+// Choice represents a single completion choice
+type Choice struct {
+ Index int `json:"index"`
+ Message ChatMessage `json:"message"`
+ FinishReason string `json:"finish_reason"`
+}
+
+// Usage represents token usage statistics
+type Usage struct {
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+}
+
+// ErrorResponse represents an API error response
+type ErrorResponse struct {
+ Error struct {
+ Message string `json:"message"`
+ Type string `json:"type"`
+ Code string `json:"code"`
+ } `json:"error"`
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/laozhang/video.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/laozhang/video.go.tmpl
new file mode 100644
index 0000000..af4762f
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/laozhang/video.go.tmpl
@@ -0,0 +1,223 @@
+package laozhang
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+const (
+ defaultVideoModel = "veo-3.1"
+ defaultVideoCount = 1
+)
+
+// VideoRequest represents a video generation request
+type VideoRequest struct {
+ Model string `json:"model"` // Model to use (default: "veo-3.1")
+ Prompt string `json:"prompt"` // Required for text-to-video: text description of the desired video
+ N int `json:"n,omitempty"` // Number of videos to generate (1-4, default: 1)
+ ReferenceImages []string `json:"reference_images,omitempty"` // Optional: base64 or URLs for image-to-video
+}
+
+// VideoResponse represents a video generation response
+type VideoResponse struct {
+ ID string `json:"id"` // Response ID
+ Created int64 `json:"created"` // Unix timestamp of when the video was created
+ Data []VideoData `json:"data"` // List of generated videos
+}
+
+// VideoData represents a single generated video
+type VideoData struct {
+ URL string `json:"url"` // Video URL
+}
+
+// videoChatMessage represents the internal chat message format for video generation (request)
+type videoChatMessage struct {
+ Role string `json:"role"`
+ Content []videoChatContentPart `json:"content"`
+}
+
+// videoChatResponseMessage represents the response message format (content can be string or array)
+type videoChatResponseMessage struct {
+ Role string `json:"role"`
+ Content json.RawMessage `json:"content"` // Can be string or []videoChatContentPart
+}
+
+// videoChatContentPart represents a part of the message content (text or image)
+type videoChatContentPart struct {
+ Type string `json:"type"` // "text" or "image_url"
+ Text string `json:"text,omitempty"` // Text content
+ ImageURL *videoChatImageURL `json:"image_url,omitempty"` // Image URL content
+}
+
+// videoChatImageURL represents an image URL in the chat message
+type videoChatImageURL struct {
+ URL string `json:"url"` // Base64 data URL or HTTP(S) URL
+}
+
+// videoChatRequest represents the internal chat completion request for video generation
+type videoChatRequest struct {
+ Model string `json:"model"`
+ Messages []videoChatMessage `json:"messages"`
+ Stream bool `json:"stream"`
+ N int `json:"n"`
+}
+
+// videoChatResponse represents the internal chat completion response from Veo API
+type videoChatResponse struct {
+ ID string `json:"id"`
+ Created int64 `json:"created"`
+ Choices []videoChatChoice `json:"choices"`
+}
+
+// videoChatChoice represents a single choice in the chat response
+type videoChatChoice struct {
+ Message videoChatResponseMessage `json:"message"`
+}
+
+// GenerateVideo generates videos based on the provided prompt and optional reference images
+// using the Veo 3.1 models via the chat completions API format.
+//
+// For text-to-video, only the Prompt field is required.
+// For image-to-video (first/last frame interpolation), use models ending in "-fl" and provide ReferenceImages.
+//
+// Supported models:
+// - veo-3.1 (standard, $0.25/gen)
+// - veo-3.1-fast ($0.15/gen)
+// - veo-3.1-fl (first/last frame interpolation)
+// - veo-3.1-fast-fl (fast with interpolation)
+// - Add "-landscape" suffix for landscape variants (e.g., "veo-3.1-landscape")
+func (c *Client) GenerateVideo(ctx context.Context, req VideoRequest) (*VideoResponse, error) {
+ // Validate required fields
+ if req.Prompt == "" {
+ return nil, fmt.Errorf("%w: prompt is required", ErrInvalidConfig)
+ }
+
+ // Set defaults
+ if req.Model == "" {
+ req.Model = defaultVideoModel
+ }
+ if req.N == 0 {
+ req.N = defaultVideoCount
+ }
+
+ // Validate N is in valid range
+ if req.N < 1 || req.N > 4 {
+ return nil, fmt.Errorf("%w: n must be between 1 and 4, got %d", ErrInvalidConfig, req.N)
+ }
+
+ // Build message content
+ content := []videoChatContentPart{
+ {
+ Type: "text",
+ Text: req.Prompt,
+ },
+ }
+
+ // Add reference images if provided (for image-to-video)
+ for _, imageURL := range req.ReferenceImages {
+ content = append(content, videoChatContentPart{
+ Type: "image_url",
+ ImageURL: &videoChatImageURL{
+ URL: imageURL,
+ },
+ })
+ }
+
+ // Build chat completion request
+ chatReq := videoChatRequest{
+ Model: req.Model,
+ Messages: []videoChatMessage{
+ {
+ Role: "user",
+ Content: content,
+ },
+ },
+ Stream: false,
+ N: req.N,
+ }
+
+ // Make request with video client (5m timeout) - video generation takes 2-5 minutes
+ respBody, err := c.doRequestVideo(ctx, "POST", "/chat/completions", chatReq)
+ if err != nil {
+ return nil, err
+ }
+
+ // Unmarshal chat response
+ var chatResp videoChatResponse
+ if err := json.Unmarshal(respBody, &chatResp); err != nil {
+ return nil, fmt.Errorf("unmarshal response: %w", err)
+ }
+
+ // Extract video URLs from chat response
+ videoResp := &VideoResponse{
+ ID: chatResp.ID,
+ Created: chatResp.Created,
+ Data: make([]VideoData, 0, len(chatResp.Choices)),
+ }
+
+ for _, choice := range chatResp.Choices {
+ // The video URL can be returned as either:
+ // 1. A plain string (the URL directly)
+ // 2. An array of content parts with type "text"
+ // 3. Markdown link format: [download video](url)
+ content := choice.Message.Content
+
+ // Try to unmarshal as string first
+ var contentStr string
+ if err := json.Unmarshal(content, &contentStr); err == nil {
+ // Content is a plain string (may be URL or markdown link)
+ if url := extractVideoURL(contentStr); url != "" {
+ videoResp.Data = append(videoResp.Data, VideoData{
+ URL: url,
+ })
+ }
+ continue
+ }
+
+ // Try to unmarshal as array of content parts
+ var contentParts []videoChatContentPart
+ if err := json.Unmarshal(content, &contentParts); err == nil {
+ for _, contentPart := range contentParts {
+ if contentPart.Type == "text" && contentPart.Text != "" {
+ if url := extractVideoURL(contentPart.Text); url != "" {
+ videoResp.Data = append(videoResp.Data, VideoData{
+ URL: url,
+ })
+ }
+ }
+ }
+ }
+ }
+
+ if len(videoResp.Data) == 0 {
+ return nil, fmt.Errorf("no video URLs in response (id=%s, choices=%d)", chatResp.ID, len(chatResp.Choices))
+ }
+
+ return videoResp, nil
+}
+
+// extractVideoURL extracts a clean URL from various response formats.
+// LaoZhang sometimes returns URLs wrapped in markdown: [download video](https://...)
+// This function handles both plain URLs and markdown-wrapped URLs.
+func extractVideoURL(raw string) string {
+ // Clean whitespace and newlines
+ raw = strings.TrimSpace(raw)
+
+ // Check for markdown link format: [text](url)
+ mdLinkRegex := regexp.MustCompile(`\[.*?\]\((https?://[^\s\)]+)\)`)
+ if matches := mdLinkRegex.FindStringSubmatch(raw); len(matches) > 1 {
+ return matches[1]
+ }
+
+ // Check for bare URL
+ urlRegex := regexp.MustCompile(`(https?://[^\s]+)`)
+ if matches := urlRegex.FindStringSubmatch(raw); len(matches) > 1 {
+ return matches[1]
+ }
+
+ // Return as-is if no pattern matched
+ return raw
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/mediagen/adapters/download.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/mediagen/adapters/download.go.tmpl
new file mode 100644
index 0000000..02e0a1a
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/mediagen/adapters/download.go.tmpl
@@ -0,0 +1,71 @@
+package adapters
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "time"
+)
+
+const (
+ // videoDownloadTimeout is the timeout for downloading video from URI.
+ videoDownloadTimeout = 60 * time.Second
+
+ // maxVideoSize is the maximum video size to download (500MB).
+ maxVideoSize = 500 * 1024 * 1024
+)
+
+// downloadVideo fetches video bytes from a URL, streaming to a temp file first.
+// Uses explicit timeout and size limit (500MB) to prevent hangs and OOM.
+func downloadVideo(ctx context.Context, url string) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ // Use client with explicit timeout (never use http.DefaultClient)
+ httpClient := &http.Client{Timeout: videoDownloadTimeout}
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("fetch video: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
+ }
+
+ // Stream to temp file to avoid OOM on large videos
+ tmpFile, err := os.CreateTemp("", "video-*.mp4")
+ if err != nil {
+ return nil, fmt.Errorf("create temp file: %w", err)
+ }
+ defer os.Remove(tmpFile.Name())
+ defer tmpFile.Close()
+
+ // Limit to maxVideoSize
+ limitedReader := io.LimitReader(resp.Body, maxVideoSize)
+
+ written, err := io.Copy(tmpFile, limitedReader)
+ if err != nil {
+ return nil, fmt.Errorf("write video to temp: %w", err)
+ }
+
+ if written == maxVideoSize {
+ return nil, fmt.Errorf("video exceeds %dMB limit", maxVideoSize/(1024*1024))
+ }
+
+ // Seek back and read
+ if _, err := tmpFile.Seek(0, 0); err != nil {
+ return nil, fmt.Errorf("seek temp file: %w", err)
+ }
+
+ data, err := io.ReadAll(tmpFile)
+ if err != nil {
+ return nil, fmt.Errorf("read temp file: %w", err)
+ }
+
+ return data, nil
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/mediagen/adapters/gemini.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/mediagen/adapters/gemini.go.tmpl
new file mode 100644
index 0000000..a36c02d
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/mediagen/adapters/gemini.go.tmpl
@@ -0,0 +1,159 @@
+// Package adapters provides mediagen provider adapters for various AI services.
+package adapters
+
+import (
+ "context"
+ "fmt"
+
+ "{{GO_MODULE}}/pkg/gemini"
+ "{{GO_MODULE}}/pkg/mediagen"
+)
+
+// GeminiProvider adapts pkg/gemini to the mediagen interfaces.
+type GeminiProvider struct {
+ client *gemini.Client
+}
+
+// NewGeminiProvider creates a new Gemini provider adapter.
+func NewGeminiProvider(client *gemini.Client) *GeminiProvider {
+ return &GeminiProvider{client: client}
+}
+
+// Name implements mediagen.ImageGenerator and mediagen.VideoGenerator.
+func (p *GeminiProvider) Name() string {
+ return "gemini"
+}
+
+// Health implements mediagen.ImageGenerator and mediagen.VideoGenerator.
+func (p *GeminiProvider) Health(ctx context.Context) error {
+ if err := p.client.Health(ctx); err != nil {
+ return mediagen.NewProviderError("gemini", "Health", err)
+ }
+ return nil
+}
+
+// validImageMIMETypes lists MIME types supported for reference images
+var geminiValidImageMIMETypes = map[string]bool{
+ "image/png": true,
+ "image/jpeg": true,
+ "image/webp": true,
+}
+
+// GenerateImage implements mediagen.ImageGenerator.
+// Note: Gemini's native image generation currently supports only 1 image per request.
+// The Count field is ignored.
+func (p *GeminiProvider) GenerateImage(ctx context.Context, req mediagen.ImageRequest) (*mediagen.ImageResponse, error) {
+ // Validate reference image MIME type if provided
+ if len(req.ReferenceImage) > 0 && req.ReferenceMime != "" {
+ if !geminiValidImageMIMETypes[req.ReferenceMime] {
+ return nil, fmt.Errorf("unsupported reference image MIME type: %s (supported: image/png, image/jpeg, image/webp)", req.ReferenceMime)
+ }
+ }
+
+ // Map unified request to Gemini-specific request
+ gemReq := gemini.ImageRequest{
+ Prompt: req.Prompt,
+ Model: req.Model,
+ Size: req.Size,
+ AspectRatio: req.AspectRatio,
+ ReferenceImage: req.ReferenceImage,
+ ReferenceMime: req.ReferenceMime,
+ Seed: req.Seed,
+ }
+
+ // Call Gemini client
+ gemResp, err := p.client.GenerateImage(ctx, gemReq)
+ if err != nil {
+ return nil, mediagen.NewProviderError("gemini", "GenerateImage", err)
+ }
+
+ // Convert response to unified format
+ images := make([]mediagen.Image, 0, len(gemResp.Images))
+ for _, img := range gemResp.Images {
+ images = append(images, mediagen.Image{
+ Data: img.Data,
+ MimeType: img.MimeType,
+ })
+ }
+
+ return &mediagen.ImageResponse{
+ Images: images,
+ Seed: gemResp.Seed,
+ }, nil
+}
+
+// GenerateVideo implements mediagen.VideoGenerator.
+func (p *GeminiProvider) GenerateVideo(ctx context.Context, req mediagen.VideoRequest) (*mediagen.VideoResponse, error) {
+ // Map unified request to Gemini-specific request
+ gemReq := gemini.VideoRequest{
+ Prompt: req.Prompt,
+ Model: req.Model,
+ AspectRatio: req.AspectRatio,
+ Duration: req.Duration,
+ }
+
+ // Convert first reference image if provided
+ if len(req.ReferenceImages) > 0 {
+ img := req.ReferenceImages[0]
+ gemReq.Image = img.Data
+ gemReq.ImageMimeType = img.MimeType
+ }
+
+ // Call Gemini client
+ gemResp, err := p.client.GenerateVideo(ctx, gemReq)
+ if err != nil {
+ return nil, mediagen.NewProviderError("gemini", "GenerateVideo", err)
+ }
+
+ // If video was returned as URI, download it
+ var videoData []byte
+ var mimeType string
+ var url string
+
+ if len(gemResp.Video.Data) > 0 {
+ videoData = gemResp.Video.Data
+ mimeType = gemResp.Video.MimeType
+ }
+
+ if gemResp.Video.URI != "" {
+ url = gemResp.Video.URI
+
+ // If we don't have data yet, download from URI
+ if len(videoData) == 0 {
+ downloaded, downloadErr := downloadVideo(ctx, gemResp.Video.URI)
+ if downloadErr != nil {
+ // Return URI-only response if download fails (caller can retry download)
+ return &mediagen.VideoResponse{
+ Videos: []mediagen.Video{
+ {
+ URL: url,
+ MimeType: "video/mp4",
+ },
+ },
+ }, nil
+ }
+ videoData = downloaded
+ mimeType = "video/mp4"
+ }
+ }
+
+ if mimeType == "" {
+ mimeType = "video/mp4"
+ }
+
+ return &mediagen.VideoResponse{
+ Videos: []mediagen.Video{
+ {
+ Data: videoData,
+ MimeType: mimeType,
+ URL: url,
+ },
+ },
+ }, nil
+}
+
+// Compile-time interface check
+var (
+ _ mediagen.ImageGenerator = (*GeminiProvider)(nil)
+ _ mediagen.VideoGenerator = (*GeminiProvider)(nil)
+)
diff --git a/internal/adapter/templates/templates/skeleton/pkg/mediagen/adapters/laozhang.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/mediagen/adapters/laozhang.go.tmpl
new file mode 100644
index 0000000..9769682
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/mediagen/adapters/laozhang.go.tmpl
@@ -0,0 +1,189 @@
+package adapters
+
+import (
+ "context"
+ "encoding/base64"
+ "fmt"
+
+ "{{GO_MODULE}}/pkg/laozhang"
+ "{{GO_MODULE}}/pkg/mediagen"
+)
+
+// LaoZhangProvider adapts pkg/laozhang to the mediagen interfaces.
+type LaoZhangProvider struct {
+ client *laozhang.Client
+}
+
+// NewLaoZhangProvider creates a new LaoZhang provider adapter.
+func NewLaoZhangProvider(client *laozhang.Client) *LaoZhangProvider {
+ return &LaoZhangProvider{client: client}
+}
+
+// Name implements mediagen.ImageGenerator and mediagen.VideoGenerator.
+func (p *LaoZhangProvider) Name() string {
+ return "laozhang"
+}
+
+// Health implements mediagen.ImageGenerator and mediagen.VideoGenerator.
+func (p *LaoZhangProvider) Health(ctx context.Context) error {
+ if err := p.client.Health(ctx); err != nil {
+ return mediagen.NewProviderError("laozhang", "Health", err)
+ }
+ return nil
+}
+
+// GenerateImage implements mediagen.ImageGenerator.
+// Supports reference images for identity consistency via Gemini native API.
+// Seed support varies by model - it will be sent but may be ignored.
+func (p *LaoZhangProvider) GenerateImage(ctx context.Context, req mediagen.ImageRequest) (*mediagen.ImageResponse, error) {
+ // Map unified request to LaoZhang-specific request
+ lzReq := laozhang.ImageRequest{
+ Prompt: req.Prompt,
+ Model: req.Model,
+ Size: req.Size,
+ AspectRatio: req.AspectRatio,
+ N: req.Count,
+ Seed: req.Seed,
+ }
+
+ // Add reference image for identity consistency if provided
+ if len(req.ReferenceImage) > 0 {
+ mimeType := req.ReferenceMime
+ if mimeType == "" {
+ mimeType = "image/png"
+ }
+ lzReq.ReferenceImages = []laozhang.ReferenceImage{
+ {
+ Data: req.ReferenceImage,
+ MimeType: mimeType,
+ },
+ }
+ }
+
+ // Set defaults if not provided
+ if lzReq.N == 0 {
+ lzReq.N = 1
+ }
+
+ // Call LaoZhang client
+ lzResp, err := p.client.GenerateImage(ctx, lzReq)
+ if err != nil {
+ return nil, mediagen.NewProviderError("laozhang", "GenerateImage", err)
+ }
+
+ // Convert response to unified format
+ images := make([]mediagen.Image, 0, len(lzResp.Data))
+ for _, img := range lzResp.Data {
+ var data []byte
+ var mimeType string
+ var url string
+
+ if img.B64JSON != "" {
+ // Decode base64 to bytes
+ decoded, err := base64.StdEncoding.DecodeString(img.B64JSON)
+ if err != nil {
+ return nil, fmt.Errorf("decode base64 image: %w", err)
+ }
+ data = decoded
+ mimeType = "image/png" // LaoZhang returns PNG by default
+ } else if img.URL != "" {
+ url = img.URL
+ mimeType = "image/png"
+ }
+
+ images = append(images, mediagen.Image{
+ Data: data,
+ MimeType: mimeType,
+ URL: url,
+ })
+ }
+
+ return &mediagen.ImageResponse{
+ Images: images,
+ Seed: req.Seed, // Echo back seed (LaoZhang API doesn't return it)
+ }, nil
+}
+
+// GenerateVideo implements mediagen.VideoGenerator.
+func (p *LaoZhangProvider) GenerateVideo(ctx context.Context, req mediagen.VideoRequest) (*mediagen.VideoResponse, error) {
+ // Determine model based on aspect ratio
+ // Veo 3.1: default is portrait (9:16), add "-landscape" suffix for 16:9
+ model := req.Model
+ if model == "" {
+ model = "veo-3.1"
+ }
+ if req.AspectRatio == "16:9" {
+ model = model + "-landscape"
+ }
+
+ // Map unified request to LaoZhang-specific request
+ lzReq := laozhang.VideoRequest{
+ Prompt: req.Prompt,
+ Model: model,
+ N: req.Count,
+ }
+
+ // Set defaults if not provided
+ if lzReq.N == 0 {
+ lzReq.N = 1
+ }
+
+ // Convert reference images to base64 data URLs if provided
+ if len(req.ReferenceImages) > 0 {
+ lzReq.ReferenceImages = make([]string, 0, len(req.ReferenceImages))
+ for _, img := range req.ReferenceImages {
+ if len(img.Data) > 0 {
+ // Convert bytes to data URL
+ b64 := base64.StdEncoding.EncodeToString(img.Data)
+ mimeType := img.MimeType
+ if mimeType == "" {
+ mimeType = "image/png"
+ }
+ dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, b64)
+ lzReq.ReferenceImages = append(lzReq.ReferenceImages, dataURL)
+ } else if img.URL != "" {
+ lzReq.ReferenceImages = append(lzReq.ReferenceImages, img.URL)
+ }
+ }
+ }
+
+ // Call LaoZhang client
+ lzResp, err := p.client.GenerateVideo(ctx, lzReq)
+ if err != nil {
+ return nil, mediagen.NewProviderError("laozhang", "GenerateVideo", err)
+ }
+
+ // Convert response to unified format - download video bytes from URLs
+ videos := make([]mediagen.Video, 0, len(lzResp.Data))
+ for _, vid := range lzResp.Data {
+ if vid.URL == "" {
+ continue
+ }
+
+ // Download video from URL
+ data, err := downloadVideo(ctx, vid.URL)
+ if err != nil {
+ return nil, fmt.Errorf("download video from %s: %w", vid.URL, err)
+ }
+
+ videos = append(videos, mediagen.Video{
+ Data: data,
+ URL: vid.URL,
+ MimeType: "video/mp4",
+ })
+ }
+
+ if len(videos) == 0 {
+ return nil, fmt.Errorf("no videos returned from LaoZhang")
+ }
+
+ return &mediagen.VideoResponse{
+ Videos: videos,
+ }, nil
+}
+
+// Compile-time interface check
+var (
+ _ mediagen.ImageGenerator = (*LaoZhangProvider)(nil)
+ _ mediagen.VideoGenerator = (*LaoZhangProvider)(nil)
+)
diff --git a/internal/adapter/templates/templates/skeleton/pkg/mediagen/circuit_breaker.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/mediagen/circuit_breaker.go.tmpl
new file mode 100644
index 0000000..6ec263f
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/mediagen/circuit_breaker.go.tmpl
@@ -0,0 +1,73 @@
+package mediagen
+
+import (
+ "time"
+
+ "{{GO_MODULE}}/pkg/routing"
+)
+
+// CircuitBreaker is an alias for routing.CircuitBreaker.
+//
+// IMPORTANT: All cooldown tracking logic is implemented in pkg/routing.
+// Do NOT implement custom cooldown logic here. Use routing.CircuitBreaker
+// directly for new code.
+//
+// This alias is provided for backward compatibility with existing code
+// that references mediagen.CircuitBreaker.
+type CircuitBreaker = routing.CircuitBreaker
+
+// Cooldown period constants - re-exported from routing for convenience.
+const (
+ // DefaultCooldownPeriod is the cooldown for rate limits and quota errors.
+ DefaultCooldownPeriod = routing.DefaultCooldownPeriod
+
+ // TransientCooldownPeriod is the cooldown for transient server errors (503, 500, etc).
+ TransientCooldownPeriod = routing.TransientCooldownPeriod
+)
+
+// FailureType categorizes errors for cooldown duration selection.
+// Re-exported from routing for convenience.
+type FailureType = routing.FailureType
+
+// FailureType constants.
+const (
+ FailureTypeNone = routing.FailureTypeNone
+ FailureTypeRateLimit = routing.FailureTypeRateLimit
+ FailureTypeTransient = routing.FailureTypeTransient
+)
+
+// NewCircuitBreaker creates a new circuit breaker.
+//
+// IMPORTANT: Prefer using routing.NewCircuitBreaker directly for new code.
+// This function is provided for backward compatibility.
+func NewCircuitBreaker(cooldown time.Duration) *CircuitBreaker {
+ return routing.NewCircuitBreaker(cooldown)
+}
+
+// NewCircuitBreakerWithTransientCooldown creates a circuit breaker with
+// custom cooldown periods for rate limits and transient errors.
+func NewCircuitBreakerWithTransientCooldown(rateLimitCooldown, transientCooldown time.Duration) *CircuitBreaker {
+ return routing.NewCircuitBreakerWithTransientCooldown(rateLimitCooldown, transientCooldown)
+}
+
+// ClassifyError determines the failure type for an error.
+//
+// IMPORTANT: Prefer using routing.ClassifyError directly for new code.
+func ClassifyError(err error) FailureType {
+ return routing.ClassifyError(err)
+}
+
+// IsRateLimitOrQuotaError checks if an error indicates rate limiting or quota exhaustion.
+func IsRateLimitOrQuotaError(err error) bool {
+ return routing.IsRateLimitError(err)
+}
+
+// IsTransientServerError checks if an error indicates a transient server error (5xx).
+func IsTransientServerError(err error) bool {
+ return routing.IsTransientError(err)
+}
+
+// IsCooldownTriggeringError checks if an error should trigger any cooldown.
+func IsCooldownTriggeringError(err error) bool {
+ return routing.IsCooldownTriggeringError(err)
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/mediagen/config.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/mediagen/config.go.tmpl
new file mode 100644
index 0000000..19b763f
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/mediagen/config.go.tmpl
@@ -0,0 +1,113 @@
+package mediagen
+
+import "log/slog"
+
+// Provider Ordering Strategy
+//
+// Production ordering: LaoZhang (primary) -> Gemini (terminus)
+//
+// LaoZhang is primary because:
+// - Pay-per-use pricing, no hard daily limits
+// - More predictable availability for production traffic
+// - Gemini's unpredictable quota exhaustion would frustrate users
+// - Cost is acceptable for revenue-generating features
+//
+// Gemini is terminus (always tried last) because:
+// - Daily quota limits (resets at midnight PT)
+// - Free tier was reduced ~92% in December 2025
+// - No way to check remaining quota before a request
+// - Terminus is ALWAYS tried regardless of cooldown
+//
+// The fallback strategy ensures requests succeed when possible.
+
+// ProviderSet holds image providers for easy configuration.
+type ProviderSet struct {
+ LaoZhang ImageGenerator
+ Gemini ImageGenerator
+}
+
+// VideoProviderSet holds video providers for configuration.
+type VideoProviderSet struct {
+ LaoZhang VideoGenerator
+ Gemini VideoGenerator
+}
+
+// ProductionConfig returns a ManagerConfig optimized for production workloads.
+//
+// Primary: LaoZhang (reliable pay-per-use)
+// Terminus: Gemini (always tried as last resort)
+//
+// Use this for:
+// - User-initiated image generation
+// - Production API endpoints
+// - Any feature where reliability matters more than cost
+func ProductionConfig(providers ProviderSet, opts ...ConfigOption) ManagerConfig {
+ imageProviders := []ImageGenerator{}
+
+ // Build provider list in order: LaoZhang -> Gemini (terminus)
+ if providers.LaoZhang != nil {
+ imageProviders = append(imageProviders, providers.LaoZhang)
+ }
+ if providers.Gemini != nil {
+ imageProviders = append(imageProviders, providers.Gemini)
+ }
+
+ cfg := ManagerConfig{
+ ImageProviders: imageProviders,
+ Strategy: StrategyFallback,
+ }
+ for _, opt := range opts {
+ opt(&cfg)
+ }
+ return cfg
+}
+
+// ProductionVideoConfig returns a video ManagerConfig for production workloads.
+//
+// Primary: LaoZhang (reliable pay-per-use)
+// Terminus: Gemini (always tried as last resort)
+func ProductionVideoConfig(providers VideoProviderSet, opts ...ConfigOption) ManagerConfig {
+ videoProviders := []VideoGenerator{}
+
+ // Build provider list in order: LaoZhang -> Gemini (terminus)
+ if providers.LaoZhang != nil {
+ videoProviders = append(videoProviders, providers.LaoZhang)
+ }
+ if providers.Gemini != nil {
+ videoProviders = append(videoProviders, providers.Gemini)
+ }
+
+ cfg := ManagerConfig{
+ VideoProviders: videoProviders,
+ Strategy: StrategyFallback,
+ }
+ for _, opt := range opts {
+ opt(&cfg)
+ }
+ return cfg
+}
+
+// ConfigOption allows customizing preset configurations.
+type ConfigOption func(*ManagerConfig)
+
+// WithLogger sets a custom logger.
+func WithLogger(logger *slog.Logger) ConfigOption {
+ return func(cfg *ManagerConfig) {
+ cfg.Logger = logger
+ }
+}
+
+// WithMetrics sets a metrics hook for observability.
+func WithMetrics(hook MetricsHook) ConfigOption {
+ return func(cfg *ManagerConfig) {
+ cfg.OnMetrics = hook
+ }
+}
+
+// WithStrategy overrides the default fallback strategy.
+// Use sparingly - the presets use StrategyFallback for a reason.
+func WithStrategy(strategy Strategy) ConfigOption {
+ return func(cfg *ManagerConfig) {
+ cfg.Strategy = strategy
+ }
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/mediagen/errors.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/mediagen/errors.go.tmpl
new file mode 100644
index 0000000..eeefc91
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/mediagen/errors.go.tmpl
@@ -0,0 +1,69 @@
+package mediagen
+
+import (
+ "errors"
+ "fmt"
+
+ "{{GO_MODULE}}/pkg/routing"
+)
+
+// Domain errors for programmatic error handling with errors.Is().
+//
+// IMPORTANT: Core routing errors (ErrRateLimit, ErrQuotaExceeded, ErrServerUnavailable)
+// are defined in pkg/routing. Provider implementations should wrap those errors.
+var (
+ // ErrInvalidConfig indicates invalid configuration.
+ ErrInvalidConfig = errors.New("invalid configuration")
+
+ // ErrInvalidRequest indicates invalid request parameters.
+ ErrInvalidRequest = errors.New("invalid request")
+
+ // ErrNoProvidersConfigured indicates no providers were configured.
+ ErrNoProvidersConfigured = errors.New("no providers configured")
+
+ // ErrAllProvidersFailed indicates all providers failed (fallback strategy).
+ // This is an alias for routing.ErrAllProvidersFailed.
+ ErrAllProvidersFailed = routing.ErrAllProvidersFailed
+
+ // ErrProviderUnavailable indicates a provider is unavailable.
+ ErrProviderUnavailable = errors.New("provider unavailable")
+
+ // ErrRateLimit indicates provider returned a rate limit (429) error.
+ // Re-exported from routing for convenience.
+ ErrRateLimit = routing.ErrRateLimit
+
+ // ErrQuotaExceeded indicates provider's quota has been exhausted.
+ // Re-exported from routing for convenience.
+ ErrQuotaExceeded = routing.ErrQuotaExceeded
+
+ // ErrServerUnavailable indicates a transient server error (5xx).
+ // Re-exported from routing for convenience.
+ ErrServerUnavailable = routing.ErrServerUnavailable
+)
+
+// ProviderLaoZhang is the provider name for LaoZhang (pay-per-use, never rate limited).
+const ProviderLaoZhang = "laozhang"
+
+// ProviderError wraps provider-specific errors with additional context.
+type ProviderError struct {
+ Provider string // Provider name
+ Op string // Operation ("GenerateImage", "GenerateVideo", "Health")
+ Err error // Underlying error
+}
+
+func (e *ProviderError) Error() string {
+ return fmt.Sprintf("%s: %s: %v", e.Provider, e.Op, e.Err)
+}
+
+func (e *ProviderError) Unwrap() error {
+ return e.Err
+}
+
+// NewProviderError creates a new ProviderError.
+func NewProviderError(provider, op string, err error) error {
+ return &ProviderError{
+ Provider: provider,
+ Op: op,
+ Err: err,
+ }
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/mediagen/manager.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/mediagen/manager.go.tmpl
new file mode 100644
index 0000000..6f6b19f
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/mediagen/manager.go.tmpl
@@ -0,0 +1,240 @@
+// Package mediagen provides image and video generation with provider routing.
+//
+// IMPORTANT: This package delegates to pkg/routing for all fallback execution.
+// Do NOT implement custom fallback loops or cooldown logic here.
+// Use pkg/routing directly for new code paths.
+package mediagen
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "sync/atomic"
+ "time"
+
+ "{{GO_MODULE}}/pkg/routing"
+)
+
+// Manager coordinates multiple providers with configurable routing strategies.
+// Safe for concurrent use.
+//
+// IMPORTANT: Internally delegates to pkg/routing for fallback execution.
+// The last provider in the chain (terminus) is ALWAYS tried regardless of
+// cooldown state. See pkg/routing.Execute for details.
+type Manager struct {
+ imageProviders []ImageGenerator
+ videoProviders []VideoGenerator
+ strategy routing.Strategy
+ logger *slog.Logger
+ onMetrics MetricsHook
+ cooldown routing.CooldownTracker
+
+ // Round-robin state
+ imageIndex atomic.Uint64
+ videoIndex atomic.Uint64
+}
+
+// MetricsHook is called after each generation attempt for observability.
+// provider: name of the provider used
+// operation: "GenerateImage" or "GenerateVideo"
+// latency: time taken for the operation
+// err: error if the operation failed, nil on success
+type MetricsHook func(provider, operation string, latency time.Duration, err error)
+
+// ManagerConfig configures the provider manager.
+type ManagerConfig struct {
+ ImageProviders []ImageGenerator // Image generation providers (order matters for fallback, last is terminus)
+ VideoProviders []VideoGenerator // Video generation providers (order matters for fallback, last is terminus)
+ Strategy Strategy // Routing strategy (default: StrategyPrimaryOnly)
+ Logger *slog.Logger // Optional: defaults to slog.Default()
+ OnMetrics MetricsHook // Optional: callback for metrics collection
+
+ // Cooldown configuration - use ONE of these:
+ // - CircuitBreaker: in-memory only (for long-running services)
+ // - Both: combined cooldown tracking
+ // If neither is provided, a default CircuitBreaker is created.
+ CircuitBreaker *CircuitBreaker // Optional: shared circuit breaker (in-memory)
+ CooldownPeriod time.Duration // Optional: cooldown for rate-limited providers (default: 1 hour)
+}
+
+// NewManager creates a new provider manager.
+//
+// IMPORTANT: The last provider in ImageProviders/VideoProviders is the "terminus"
+// and will ALWAYS be tried regardless of cooldown. See pkg/routing for details.
+func NewManager(config ManagerConfig) (*Manager, error) {
+ if len(config.ImageProviders) == 0 && len(config.VideoProviders) == 0 {
+ return nil, fmt.Errorf("%w: at least one provider required", ErrInvalidConfig)
+ }
+
+ strategy := config.Strategy
+ if strategy == "" {
+ strategy = StrategyPrimaryOnly
+ }
+
+ if !strategy.Valid() {
+ return nil, fmt.Errorf("%w: unknown strategy %s", ErrInvalidConfig, strategy)
+ }
+
+ logger := config.Logger
+ if logger == nil {
+ logger = slog.Default()
+ }
+
+ // Build cooldown tracker using routing.BuildCooldownTracker
+ cooldown := routing.BuildCooldownTracker(routing.CooldownConfig{
+ CircuitBreaker: config.CircuitBreaker,
+ CooldownPeriod: config.CooldownPeriod,
+ })
+
+ return &Manager{
+ imageProviders: config.ImageProviders,
+ videoProviders: config.VideoProviders,
+ strategy: strategy,
+ logger: logger,
+ onMetrics: config.OnMetrics,
+ cooldown: cooldown,
+ }, nil
+}
+
+// GenerateImage generates images using the configured strategy.
+//
+// When using StrategyFallback, the last provider is the terminus and will
+// ALWAYS be tried regardless of cooldown state.
+func (m *Manager) GenerateImage(ctx context.Context, req ImageRequest) (*ImageResponse, error) {
+ if len(m.imageProviders) == 0 {
+ return nil, ErrNoProvidersConfigured
+ }
+
+ if req.Prompt == "" {
+ return nil, fmt.Errorf("%w: prompt is required", ErrInvalidRequest)
+ }
+
+ // Apply request timeout if specified
+ if req.Timeout > 0 {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, req.Timeout)
+ defer cancel()
+ }
+
+ // Convert to routing.Provider slice
+ providers := make([]routing.Provider, len(m.imageProviders))
+ for i, p := range m.imageProviders {
+ providers[i] = p
+ }
+
+ // Delegate to pkg/routing for execution
+ result, err := routing.Execute(ctx, providers, routing.ExecuteConfig{
+ Strategy: m.strategy,
+ Cooldown: m.cooldown,
+ Logger: m.logger,
+ RoundRobinIndex: &m.imageIndex,
+ }, func(ctx context.Context, p routing.Provider) (*ImageResponse, error) {
+ provider := p.(ImageGenerator)
+ start := time.Now()
+
+ resp, genErr := provider.GenerateImage(ctx, req)
+ latency := time.Since(start)
+
+ // Call metrics hook
+ if m.onMetrics != nil {
+ m.onMetrics(provider.Name(), "GenerateImage", latency, genErr)
+ }
+
+ if genErr != nil {
+ return nil, genErr
+ }
+
+ resp.Latency = latency
+ return resp, nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ result.Response.Provider = result.Provider
+ return result.Response, nil
+}
+
+// GenerateVideo generates videos using the configured strategy.
+//
+// When using StrategyFallback, the last provider is the terminus and will
+// ALWAYS be tried regardless of cooldown state.
+func (m *Manager) GenerateVideo(ctx context.Context, req VideoRequest) (*VideoResponse, error) {
+ if len(m.videoProviders) == 0 {
+ return nil, ErrNoProvidersConfigured
+ }
+
+ if req.Prompt == "" {
+ return nil, fmt.Errorf("%w: prompt is required", ErrInvalidRequest)
+ }
+
+ // Apply request timeout if specified
+ if req.Timeout > 0 {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, req.Timeout)
+ defer cancel()
+ }
+
+ // Convert to routing.Provider slice
+ providers := make([]routing.Provider, len(m.videoProviders))
+ for i, p := range m.videoProviders {
+ providers[i] = p
+ }
+
+ // Delegate to pkg/routing for execution
+ result, err := routing.Execute(ctx, providers, routing.ExecuteConfig{
+ Strategy: m.strategy,
+ Cooldown: m.cooldown,
+ Logger: m.logger,
+ RoundRobinIndex: &m.videoIndex,
+ }, func(ctx context.Context, p routing.Provider) (*VideoResponse, error) {
+ provider := p.(VideoGenerator)
+ start := time.Now()
+
+ resp, genErr := provider.GenerateVideo(ctx, req)
+ latency := time.Since(start)
+
+ // Call metrics hook
+ if m.onMetrics != nil {
+ m.onMetrics(provider.Name(), "GenerateVideo", latency, genErr)
+ }
+
+ if genErr != nil {
+ return nil, genErr
+ }
+
+ resp.Latency = latency
+ return resp, nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ result.Response.Provider = result.Provider
+ return result.Response, nil
+}
+
+// Cooldown returns the manager's cooldown tracker.
+// Useful for inspection or manual cooldown management.
+func (m *Manager) Cooldown() routing.CooldownTracker {
+ return m.cooldown
+}
+
+// Health checks health of all configured providers.
+func (m *Manager) Health(ctx context.Context) error {
+ for _, provider := range m.imageProviders {
+ if err := provider.Health(ctx); err != nil {
+ return fmt.Errorf("image provider %s unhealthy: %w", provider.Name(), err)
+ }
+ }
+
+ for _, provider := range m.videoProviders {
+ if err := provider.Health(ctx); err != nil {
+ return fmt.Errorf("video provider %s unhealthy: %w", provider.Name(), err)
+ }
+ }
+
+ return nil
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/mediagen/provider.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/mediagen/provider.go.tmpl
new file mode 100644
index 0000000..bd50e7e
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/mediagen/provider.go.tmpl
@@ -0,0 +1,105 @@
+// Package mediagen provides a unified interface for media (image/video) generation
+// across multiple providers with fallback and routing capabilities.
+package mediagen
+
+import (
+ "context"
+ "time"
+)
+
+// Provider defines common methods for all media generation providers.
+// Implementations must be safe for concurrent use.
+type Provider interface {
+ // Name returns the provider name for logging and metrics.
+ Name() string
+
+ // Health checks if the provider is reachable and operational.
+ Health(ctx context.Context) error
+}
+
+// ImageGenerator defines the interface for image generation providers.
+// Implementations must be safe for concurrent use.
+type ImageGenerator interface {
+ Provider
+
+ // GenerateImage generates one or more images from a text prompt.
+ GenerateImage(ctx context.Context, req ImageRequest) (*ImageResponse, error)
+}
+
+// VideoGenerator defines the interface for video generation providers.
+// Implementations must be safe for concurrent use.
+type VideoGenerator interface {
+ Provider
+
+ // GenerateVideo generates a video from a text prompt and optional reference images.
+ // May block for extended periods (30s-5min) while video renders.
+ GenerateVideo(ctx context.Context, req VideoRequest) (*VideoResponse, error)
+}
+
+// MediaGenerator combines both image and video generation capabilities.
+// Use when a provider supports both.
+type MediaGenerator interface {
+ ImageGenerator
+ VideoGenerator
+}
+
+// ImageRequest represents a unified image generation request.
+type ImageRequest struct {
+ Prompt string // Required: text description of desired image
+ Model string // Optional: model override (provider-specific)
+ Size string // Optional: size hint ("1K", "2K", "4K")
+ AspectRatio string // Optional: aspect ratio ("16:9", "1:1", "9:16")
+ Count int // Optional: number of images (default: 1)
+ Timeout time.Duration // Optional: request timeout
+
+ // Reference image for identity consistency (e.g., maintaining same face/character)
+ ReferenceImage []byte // Optional: reference image bytes
+ ReferenceMime string // Optional: MIME type ("image/png", "image/jpeg", "image/webp")
+
+ // Determinism controls for reproducible generation
+ Seed *int32 // Optional: seed for reproducible results (nil = provider chooses random)
+}
+
+// ImageResponse represents a unified image generation response.
+type ImageResponse struct {
+ Images []Image // Generated images
+ Provider string // Name of provider that generated this response
+ Latency time.Duration // Time taken to generate
+ Seed *int32 // Seed used for generation (nil if random, echoed back for reproducibility)
+}
+
+// Image represents a single generated image.
+type Image struct {
+ Data []byte // Raw image bytes (PNG, JPEG, etc.)
+ MimeType string // MIME type ("image/png", "image/jpeg")
+ URL string // Optional: URL if provider returns URL instead of bytes
+}
+
+// VideoRequest represents a unified video generation request.
+type VideoRequest struct {
+ Prompt string // Required: text description of desired video
+ Model string // Optional: model override (provider-specific)
+ ReferenceImages []Image // Optional: reference images for image-to-video
+ AspectRatio string // Optional: aspect ratio ("16:9", "9:16")
+ Duration string // Optional: duration ("5s", "10s")
+ Count int // Optional: number of videos (default: 1)
+ Timeout time.Duration // Optional: request timeout
+
+ // Determinism controls for reproducible generation
+ Seed *int32 // Optional: seed for reproducible results (nil = provider chooses random)
+}
+
+// VideoResponse represents a unified video generation response.
+type VideoResponse struct {
+ Videos []Video // Generated videos
+ Provider string // Name of provider that generated this response
+ Latency time.Duration // Time taken to generate
+ Seed *int32 // Seed used for generation (nil if random, echoed back for reproducibility)
+}
+
+// Video represents a single generated video.
+type Video struct {
+ Data []byte // Raw video bytes (MP4, etc.)
+ MimeType string // MIME type ("video/mp4")
+ URL string // Optional: URL if provider returns URL instead of bytes
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/mediagen/strategy.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/mediagen/strategy.go.tmpl
new file mode 100644
index 0000000..b7e6ef6
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/mediagen/strategy.go.tmpl
@@ -0,0 +1,26 @@
+package mediagen
+
+import "{{GO_MODULE}}/pkg/routing"
+
+// Strategy defines how the manager routes requests to providers.
+//
+// IMPORTANT: This is an alias for routing.Strategy. All routing strategies
+// are defined in pkg/routing. Do NOT define new strategies here.
+type Strategy = routing.Strategy
+
+// Strategy constants - aliases for backward compatibility.
+// New code should use routing.Strategy* directly.
+const (
+ // StrategyPrimaryOnly uses only the first provider in the list.
+ // Fast, deterministic, but no fault tolerance.
+ StrategyPrimaryOnly Strategy = routing.StrategyPrimaryOnly
+
+ // StrategyFallback tries providers in order until one succeeds.
+ // IMPORTANT: The last provider (terminus) is ALWAYS tried regardless
+ // of cooldown state. This is the fallback of last resort.
+ StrategyFallback Strategy = routing.StrategyFallback
+
+ // StrategyRoundRobin distributes requests evenly across providers.
+ // Balances load but requires state management.
+ StrategyRoundRobin Strategy = routing.StrategyRoundRobin
+)
diff --git a/internal/adapter/templates/templates/skeleton/pkg/middleware/logger.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/middleware/logger.go.tmpl
index 67a4c03..146d8d0 100644
--- a/internal/adapter/templates/templates/skeleton/pkg/middleware/logger.go.tmpl
+++ b/internal/adapter/templates/templates/skeleton/pkg/middleware/logger.go.tmpl
@@ -1,6 +1,8 @@
package middleware
import (
+ "bufio"
+ "net"
"net/http"
"time"
@@ -34,6 +36,21 @@ func (rw *responseWriter) Write(b []byte) (int, error) {
return n, err
}
+// Flush implements http.Flusher to support SSE streaming.
+func (rw *responseWriter) Flush() {
+ if flusher, ok := rw.ResponseWriter.(http.Flusher); ok {
+ flusher.Flush()
+ }
+}
+
+// Hijack implements http.Hijacker to support WebSocket upgrades.
+func (rw *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+ if hijacker, ok := rw.ResponseWriter.(http.Hijacker); ok {
+ return hijacker.Hijack()
+ }
+ return nil, nil, http.ErrNotSupported
+}
+
// RequestLogger returns a middleware that logs HTTP requests using slog.
// It logs request completion with status code, duration, and bytes written.
// Log level is determined by response status (error for 5xx, warn for 4xx, info otherwise).
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/background.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/background.go.tmpl
new file mode 100644
index 0000000..f753c18
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/background.go.tmpl
@@ -0,0 +1,156 @@
+package persona
+
+// Background contains the character's backstory and life history.
+type Background struct {
+ // Childhood describes early life experiences
+ Childhood *Childhood `json:"childhood,omitempty" yaml:"childhood,omitempty"`
+
+ // Education describes educational background
+ Education *Education `json:"education,omitempty" yaml:"education,omitempty"`
+
+ // Career describes professional history
+ Career *Career `json:"career,omitempty" yaml:"career,omitempty"`
+
+ // Relationships describes significant relationships
+ Relationships []Relationship `json:"relationships,omitempty" yaml:"relationships,omitempty"`
+
+ // Hobbies describes leisure activities and interests
+ Hobbies []string `json:"hobbies,omitempty" yaml:"hobbies,omitempty"`
+
+ // LifeEvents describes significant life events
+ LifeEvents []LifeEvent `json:"life_events,omitempty" yaml:"life_events,omitempty"`
+}
+
+// Childhood describes early life experiences.
+type Childhood struct {
+ // Location where the character grew up
+ Location string `json:"location,omitempty" yaml:"location,omitempty"`
+
+ // FamilyStructure describes the family setup
+ FamilyStructure string `json:"family_structure,omitempty" yaml:"family_structure,omitempty"`
+
+ // SocioeconomicStatus describes the economic background
+ SocioeconomicStatus string `json:"socioeconomic_status,omitempty" yaml:"socioeconomic_status,omitempty"`
+
+ // KeyExperiences formative experiences
+ KeyExperiences []string `json:"key_experiences,omitempty" yaml:"key_experiences,omitempty"`
+
+ // Challenges difficulties faced
+ Challenges []string `json:"challenges,omitempty" yaml:"challenges,omitempty"`
+
+ // Strengths developed positive traits
+ Strengths []string `json:"strengths,omitempty" yaml:"strengths,omitempty"`
+}
+
+// Education describes educational background.
+type Education struct {
+ // HighestLevel achieved education level
+ HighestLevel string `json:"highest_level,omitempty" yaml:"highest_level,omitempty"`
+
+ // Field of study or major
+ Field string `json:"field,omitempty" yaml:"field,omitempty"`
+
+ // Institutions attended schools/universities
+ Institutions []string `json:"institutions,omitempty" yaml:"institutions,omitempty"`
+
+ // Achievements notable academic achievements
+ Achievements []string `json:"achievements,omitempty" yaml:"achievements,omitempty"`
+
+ // Skills skills gained through education
+ Skills []string `json:"skills,omitempty" yaml:"skills,omitempty"`
+}
+
+// Career describes professional history.
+type Career struct {
+ // CurrentRole current job title
+ CurrentRole string `json:"current_role,omitempty" yaml:"current_role,omitempty"`
+
+ // Industry current industry
+ Industry string `json:"industry,omitempty" yaml:"industry,omitempty"`
+
+ // YearsExperience years in career
+ YearsExperience int `json:"years_experience,omitempty" yaml:"years_experience,omitempty"`
+
+ // PreviousRoles past positions
+ PreviousRoles []string `json:"previous_roles,omitempty" yaml:"previous_roles,omitempty"`
+
+ // Achievements notable career achievements
+ Achievements []string `json:"achievements,omitempty" yaml:"achievements,omitempty"`
+
+ // Skills professional skills
+ Skills []string `json:"skills,omitempty" yaml:"skills,omitempty"`
+
+ // Ambitions career goals
+ Ambitions []string `json:"ambitions,omitempty" yaml:"ambitions,omitempty"`
+}
+
+// Relationship describes a significant relationship.
+type Relationship struct {
+ // Type of relationship (family, friend, romantic, professional)
+ Type string `json:"type" yaml:"type"`
+
+ // Description of the relationship
+ Description string `json:"description,omitempty" yaml:"description,omitempty"`
+
+ // Status current status (active, estranged, etc.)
+ Status string `json:"status,omitempty" yaml:"status,omitempty"`
+
+ // Impact how this relationship affects the character
+ Impact string `json:"impact,omitempty" yaml:"impact,omitempty"`
+}
+
+// LifeEvent describes a significant life event.
+type LifeEvent struct {
+ // Age when the event occurred
+ Age int `json:"age,omitempty" yaml:"age,omitempty"`
+
+ // Type of event (milestone, challenge, achievement, loss)
+ Type string `json:"type,omitempty" yaml:"type,omitempty"`
+
+ // Description of the event
+ Description string `json:"description" yaml:"description"`
+
+ // Impact how this event shaped the character
+ Impact string `json:"impact,omitempty" yaml:"impact,omitempty"`
+}
+
+// ValidRelationshipTypes are the valid relationship type values.
+var ValidRelationshipTypes = []string{
+ "family",
+ "friend",
+ "romantic",
+ "professional",
+ "mentor",
+ "mentee",
+}
+
+// ValidLifeEventTypes are the valid life event type values.
+var ValidLifeEventTypes = []string{
+ "milestone",
+ "challenge",
+ "achievement",
+ "loss",
+ "transition",
+ "discovery",
+}
+
+// ValidEducationLevels are the valid education level values.
+var ValidEducationLevels = []string{
+ "high_school",
+ "some_college",
+ "associates",
+ "bachelors",
+ "masters",
+ "doctorate",
+ "professional",
+ "self_taught",
+}
+
+// ValidSocioeconomicStatuses are the valid socioeconomic status values.
+var ValidSocioeconomicStatuses = []string{
+ "lower",
+ "lower_middle",
+ "middle",
+ "upper_middle",
+ "upper",
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/body.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/body.go.tmpl
new file mode 100644
index 0000000..1c562ab
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/body.go.tmpl
@@ -0,0 +1,148 @@
+package persona
+
+// BodyDNA contains all body specifications for a character.
+// These define the physical build and proportions for consistent rendering.
+type BodyDNA struct {
+ // Height is the descriptive height category.
+ Height HeightCategory `json:"height" yaml:"height"`
+
+ // HeightCM is the exact height in centimeters.
+ HeightCM int `json:"height_cm" yaml:"height_cm"`
+
+ // Build is the overall body type category.
+ Build BodyBuildCategory `json:"build" yaml:"build"`
+
+ // BodyFatPercent is the approximate body fat percentage (for rendering guidance).
+ BodyFatPercent int `json:"body_fat_percent" yaml:"body_fat_percent"`
+
+ // MuscleDefinition describes the level of visible muscle definition.
+ MuscleDefinition MuscleDefinitionCategory `json:"muscle_definition" yaml:"muscle_definition"`
+
+ // ShoulderWidth describes shoulder width relative to body.
+ ShoulderWidth ShoulderWidthCategory `json:"shoulder_width" yaml:"shoulder_width"`
+
+ // HipWidth describes hip width relative to body.
+ HipWidth HipWidthCategory `json:"hip_width" yaml:"hip_width"`
+
+ // WHRatio is the waist-to-hip ratio (e.g., 0.7 for hourglass figure).
+ WHRatio float64 `json:"wh_ratio" yaml:"wh_ratio"`
+
+ // LegLength describes leg length proportion.
+ LegLength LegLengthCategory `json:"leg_length" yaml:"leg_length"`
+
+ // TorsoLength describes torso length proportion.
+ TorsoLength TorsoLengthCategory `json:"torso_length" yaml:"torso_length"`
+
+ // BustSize describes bust size for female/feminine characters.
+ BustSize BustSizeCategory `json:"bust_size,omitempty" yaml:"bust_size,omitempty"`
+
+ // PostureType describes characteristic posture.
+ PostureType PostureCategory `json:"posture_type" yaml:"posture_type"`
+}
+
+// Height categories
+type HeightCategory string
+
+const (
+ HeightPetite HeightCategory = "petite" // < 5'2" / 157cm
+ HeightShort HeightCategory = "short" // 5'2"-5'4" / 157-163cm
+ HeightAverage HeightCategory = "average" // 5'4"-5'6" / 163-168cm
+ HeightTall HeightCategory = "tall" // 5'6"-5'9" / 168-175cm
+ HeightVeryTall HeightCategory = "very_tall" // > 5'9" / 175cm+
+)
+
+// HeightCategoryFromCM returns the height category for a given height in cm.
+func HeightCategoryFromCM(cm int) HeightCategory {
+ switch {
+ case cm < 157:
+ return HeightPetite
+ case cm < 163:
+ return HeightShort
+ case cm < 168:
+ return HeightAverage
+ case cm < 175:
+ return HeightTall
+ default:
+ return HeightVeryTall
+ }
+}
+
+// Body build categories
+type BodyBuildCategory string
+
+const (
+ BodyBuildSlender BodyBuildCategory = "slender"
+ BodyBuildAthletic BodyBuildCategory = "athletic"
+ BodyBuildCurvy BodyBuildCategory = "curvy"
+ BodyBuildMuscular BodyBuildCategory = "muscular"
+ BodyBuildAverage BodyBuildCategory = "average"
+ BodyBuildPlusCurvy BodyBuildCategory = "plus_curvy"
+ BodyBuildPetite BodyBuildCategory = "petite"
+)
+
+// Muscle definition categories
+type MuscleDefinitionCategory string
+
+const (
+ MuscleDefinitionNone MuscleDefinitionCategory = "none"
+ MuscleDefinitionSubtle MuscleDefinitionCategory = "subtle"
+ MuscleDefinitionModerate MuscleDefinitionCategory = "moderate"
+ MuscleDefinitionDefined MuscleDefinitionCategory = "defined"
+ MuscleDefinitionRipped MuscleDefinitionCategory = "ripped"
+)
+
+// Shoulder width categories
+type ShoulderWidthCategory string
+
+const (
+ ShoulderWidthNarrow ShoulderWidthCategory = "narrow"
+ ShoulderWidthAverage ShoulderWidthCategory = "average"
+ ShoulderWidthBroad ShoulderWidthCategory = "broad"
+)
+
+// Hip width categories
+type HipWidthCategory string
+
+const (
+ HipWidthNarrow HipWidthCategory = "narrow"
+ HipWidthAverage HipWidthCategory = "average"
+ HipWidthWide HipWidthCategory = "wide"
+)
+
+// Leg length categories
+type LegLengthCategory string
+
+const (
+ LegLengthShort LegLengthCategory = "short"
+ LegLengthProportional LegLengthCategory = "proportional"
+ LegLengthLong LegLengthCategory = "long"
+)
+
+// Torso length categories
+type TorsoLengthCategory string
+
+const (
+ TorsoLengthShort TorsoLengthCategory = "short"
+ TorsoLengthProportional TorsoLengthCategory = "proportional"
+ TorsoLengthLong TorsoLengthCategory = "long"
+)
+
+// Bust size categories
+type BustSizeCategory string
+
+const (
+ BustSizeSmall BustSizeCategory = "small"
+ BustSizeMedium BustSizeCategory = "medium"
+ BustSizeLarge BustSizeCategory = "large"
+ BustSizeVeryLarge BustSizeCategory = "very_large"
+)
+
+// Posture categories
+type PostureCategory string
+
+const (
+ PostureUpright PostureCategory = "upright"
+ PostureRelaxed PostureCategory = "relaxed"
+ PostureConfident PostureCategory = "confident"
+ PostureAthletic PostureCategory = "athletic"
+)
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/character.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/character.go.tmpl
new file mode 100644
index 0000000..5aef7dd
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/character.go.tmpl
@@ -0,0 +1,146 @@
+package persona
+
+// Character is the aggregate root for a character definition.
+// It combines all DNA, psychology, and background information.
+type Character struct {
+ // ID is the unique identifier for this character.
+ ID string `json:"id" yaml:"id"`
+
+ // Version is the schema version for forward compatibility.
+ Version string `json:"version" yaml:"version"`
+
+ // Species determines validation rules (human, humanoid, android).
+ Species SpeciesType `json:"species" yaml:"species"`
+
+ // DNA contains the biological characteristics.
+ DNA *DNA `json:"dna" yaml:"dna"`
+
+ // Name contains the character's name information.
+ Name NameSpec `json:"name" yaml:"name"`
+
+ // Psychology contains the psychological profile.
+ Psychology Psychology `json:"psychology" yaml:"psychology"`
+
+ // Background contains backstory and life history.
+ Background *Background `json:"background,omitempty" yaml:"background,omitempty"`
+
+ // MorphLevel controls creature transformation intensity (humanoid only).
+ // 0 = human, 25 = subtle, 50 = demi-human, 75 = hybrid, 100 = creature
+ MorphLevel int `json:"morph_level,omitempty" yaml:"morph_level,omitempty"`
+
+ // Morphology contains non-human physical features (humanoid only).
+ // Only applied when MorphLevel >= 25.
+ Morphology *MorphologyHints `json:"morphology,omitempty" yaml:"morphology,omitempty"`
+}
+
+// DNA contains the complete biological DNA specification for a character.
+// This is the single source of truth for all physical characteristics.
+// Once generated during character creation, this is immutable.
+type DNA struct {
+ // Identity contains demographic and heritage information.
+ Identity IdentityDNA `json:"identity" yaml:"identity"`
+
+ // Face contains all facial feature specifications.
+ Face FaceDNA `json:"face" yaml:"face"`
+
+ // Body contains all body specifications.
+ Body BodyDNA `json:"body" yaml:"body"`
+
+ // Voice contains all voice specifications for TTS.
+ Voice VoiceDNA `json:"voice" yaml:"voice"`
+}
+
+// NameSpec contains the character's name information.
+type NameSpec struct {
+ // First is the first/given name.
+ First string `json:"first" yaml:"first"`
+
+ // Last is the last/family name.
+ Last string `json:"last,omitempty" yaml:"last,omitempty"`
+
+ // Middle is the middle name (if any).
+ Middle string `json:"middle,omitempty" yaml:"middle,omitempty"`
+
+ // Nickname is a preferred nickname.
+ Nickname string `json:"nickname,omitempty" yaml:"nickname,omitempty"`
+
+ // DisplayName is the name to show in UIs.
+ // If empty, defaults to First name.
+ DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"`
+}
+
+// FullName returns the full name as "First Last".
+func (n NameSpec) FullName() string {
+ if n.Last == "" {
+ return n.First
+ }
+ return n.First + " " + n.Last
+}
+
+// Display returns the display name, falling back to First name.
+func (n NameSpec) Display() string {
+ if n.DisplayName != "" {
+ return n.DisplayName
+ }
+ if n.Nickname != "" {
+ return n.Nickname
+ }
+ return n.First
+}
+
+// NewCharacter creates a new character with default values.
+func NewCharacter(id string) *Character {
+ return &Character{
+ ID: id,
+ Version: "1.0",
+ Species: SpeciesHuman,
+ DNA: &DNA{},
+ }
+}
+
+// NewHumanCharacter creates a new human character.
+func NewHumanCharacter(id string) *Character {
+ c := NewCharacter(id)
+ c.Species = SpeciesHuman
+ return c
+}
+
+// NewHumanoidCharacter creates a new humanoid character.
+func NewHumanoidCharacter(id string, morphLevel int) *Character {
+ c := NewCharacter(id)
+ c.Species = SpeciesHumanoid
+ c.MorphLevel = morphLevel
+ return c
+}
+
+// NewAndroidCharacter creates a new android character.
+func NewAndroidCharacter(id string) *Character {
+ c := NewCharacter(id)
+ c.Species = SpeciesAndroid
+ return c
+}
+
+// IsHuman returns true if this is a human character.
+func (c *Character) IsHuman() bool {
+ return c.Species == SpeciesHuman
+}
+
+// IsHumanoid returns true if this is a humanoid character.
+func (c *Character) IsHumanoid() bool {
+ return c.Species == SpeciesHumanoid
+}
+
+// IsAndroid returns true if this is an android character.
+func (c *Character) IsAndroid() bool {
+ return c.Species == SpeciesAndroid
+}
+
+// HasMorphology returns true if the character has non-human features.
+func (c *Character) HasMorphology() bool {
+ return c.Morphology != nil && c.Morphology.HasFeatures()
+}
+
+// MorphBand returns the morph level band name.
+func (c *Character) MorphBand() string {
+ return MorphLevelBand(c.MorphLevel)
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/consistency.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/consistency.go.tmpl
new file mode 100644
index 0000000..acf38bb
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/consistency.go.tmpl
@@ -0,0 +1,177 @@
+package persona
+
+import "fmt"
+
+// ConsistencyIssue represents an internally inconsistent attribute combination.
+// Unlike plausibility violations (which compare against ethnicity expectations),
+// consistency issues catch impossible or contradictory combinations regardless
+// of ethnicity.
+type ConsistencyIssue struct {
+ Field1 string // First field involved (e.g., "identity.age")
+ Value1 interface{} // Value of first field
+ Field2 string // Second field involved (e.g., "face.hair_color")
+ Value2 interface{} // Value of second field
+ Severity string // "warning" or "error"
+ Description string // Human-readable explanation
+}
+
+// ConsistencyResult contains cross-attribute validation results.
+type ConsistencyResult struct {
+ Valid bool // True if no errors (warnings are OK)
+ Issues []ConsistencyIssue // All issues found
+}
+
+// ValidateConsistency checks for internally inconsistent attribute combinations
+// that are implausible regardless of ethnicity.
+//
+// Examples of inconsistencies:
+// - Young age (< 25) with gray hair (without dye indication)
+// - Mature skin texture with young age
+// - Height category mismatched with height CM
+// - Muscle definition that contradicts build type
+func ValidateConsistency(dna *DNA) *ConsistencyResult {
+ if dna == nil {
+ return &ConsistencyResult{Valid: true}
+ }
+
+ result := &ConsistencyResult{
+ Valid: true,
+ Issues: []ConsistencyIssue{},
+ }
+
+ // Age vs Hair Color consistency
+ if dna.Identity.Age > 0 && dna.Face.HairColor != "" {
+ if dna.Identity.Age < 30 && dna.Face.HairColor == HairColorGray {
+ result.Issues = append(result.Issues, ConsistencyIssue{
+ Field1: "identity.age",
+ Value1: dna.Identity.Age,
+ Field2: "face.hair_color",
+ Value2: dna.Face.HairColor,
+ Severity: "warning",
+ Description: fmt.Sprintf("Gray hair at age %d is unusual without premature graying or dye", dna.Identity.Age),
+ })
+ }
+ }
+
+ // Age vs Skin Texture consistency
+ if dna.Identity.Age > 0 && dna.Face.SkinTexture != "" {
+ if dna.Identity.Age < 35 && dna.Face.SkinTexture == SkinTextureMature {
+ result.Issues = append(result.Issues, ConsistencyIssue{
+ Field1: "identity.age",
+ Value1: dna.Identity.Age,
+ Field2: "face.skin_texture",
+ Value2: dna.Face.SkinTexture,
+ Severity: "warning",
+ Description: fmt.Sprintf("Mature skin texture at age %d is inconsistent", dna.Identity.Age),
+ })
+ }
+ if dna.Identity.Age > 55 && dna.Face.SkinTexture == SkinTextureSmooth {
+ result.Issues = append(result.Issues, ConsistencyIssue{
+ Field1: "identity.age",
+ Value1: dna.Identity.Age,
+ Field2: "face.skin_texture",
+ Value2: dna.Face.SkinTexture,
+ Severity: "warning",
+ Description: fmt.Sprintf("Smooth skin texture at age %d is unusual", dna.Identity.Age),
+ })
+ }
+ }
+
+ // Height Category vs Height CM consistency
+ if dna.Body.HeightCM > 0 && dna.Body.Height != "" {
+ if issue := validateHeightCMConsistency(dna.Body.HeightCM, dna.Body.Height); issue != nil {
+ result.Issues = append(result.Issues, *issue)
+ if issue.Severity == "error" {
+ result.Valid = false
+ }
+ }
+ }
+
+ // Muscle Definition vs Build consistency
+ if dna.Body.MuscleDefinition != "" && dna.Body.Build != "" {
+ // High muscle definition with slender build is contradictory
+ if dna.Body.MuscleDefinition == MuscleDefinitionDefined || dna.Body.MuscleDefinition == MuscleDefinitionRipped {
+ if dna.Body.Build == BodyBuildSlender || dna.Body.Build == BodyBuildPetite {
+ result.Issues = append(result.Issues, ConsistencyIssue{
+ Field1: "body.muscle_definition",
+ Value1: dna.Body.MuscleDefinition,
+ Field2: "body.build",
+ Value2: dna.Body.Build,
+ Severity: "warning",
+ Description: fmt.Sprintf("High muscle definition (%s) is unusual with %s build", dna.Body.MuscleDefinition, dna.Body.Build),
+ })
+ }
+ }
+ }
+
+ // Body Fat Percent vs Build consistency
+ if dna.Body.BodyFatPercent > 0 && dna.Body.Build != "" {
+ // Very low body fat (< 15%) with curvy/plus_curvy build is contradictory
+ if dna.Body.BodyFatPercent < 15 {
+ if dna.Body.Build == BodyBuildCurvy || dna.Body.Build == BodyBuildPlusCurvy {
+ result.Issues = append(result.Issues, ConsistencyIssue{
+ Field1: "body.body_fat_percent",
+ Value1: dna.Body.BodyFatPercent,
+ Field2: "body.build",
+ Value2: dna.Body.Build,
+ Severity: "error",
+ Description: fmt.Sprintf("Very low body fat (%d%%) is incompatible with %s build", dna.Body.BodyFatPercent, dna.Body.Build),
+ })
+ result.Valid = false
+ }
+ }
+ // High body fat (> 30%) with athletic/muscular build is contradictory
+ if dna.Body.BodyFatPercent > 30 {
+ if dna.Body.Build == BodyBuildAthletic || dna.Body.Build == BodyBuildMuscular {
+ result.Issues = append(result.Issues, ConsistencyIssue{
+ Field1: "body.body_fat_percent",
+ Value1: dna.Body.BodyFatPercent,
+ Field2: "body.build",
+ Value2: dna.Body.Build,
+ Severity: "error",
+ Description: fmt.Sprintf("High body fat (%d%%) is incompatible with %s build", dna.Body.BodyFatPercent, dna.Body.Build),
+ })
+ result.Valid = false
+ }
+ }
+ }
+
+ return result
+}
+
+// validateHeightCMConsistency checks if height CM matches height category.
+func validateHeightCMConsistency(heightCM int, heightCat HeightCategory) *ConsistencyIssue {
+ expectedCat := HeightCategoryFromCM(heightCM)
+
+ if heightCat != expectedCat {
+ return &ConsistencyIssue{
+ Field1: "body.height_cm",
+ Value1: heightCM,
+ Field2: "body.height",
+ Value2: heightCat,
+ Severity: "error",
+ Description: fmt.Sprintf("Height %dcm should be category %s, not %s", heightCM, expectedCat, heightCat),
+ }
+ }
+ return nil
+}
+
+// HasErrors returns true if there are any error-level issues.
+func (r *ConsistencyResult) HasErrors() bool {
+ for _, issue := range r.Issues {
+ if issue.Severity == "error" {
+ return true
+ }
+ }
+ return false
+}
+
+// HasWarnings returns true if there are any warning-level issues.
+func (r *ConsistencyResult) HasWarnings() bool {
+ for _, issue := range r.Issues {
+ if issue.Severity == "warning" {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/data/body_descriptions.yaml b/internal/adapter/templates/templates/skeleton/pkg/persona/data/body_descriptions.yaml
new file mode 100644
index 0000000..585edfc
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/data/body_descriptions.yaml
@@ -0,0 +1,51 @@
+# Body type descriptions for prose generation
+# Each build type has a pool of descriptions that can be selected from
+
+slender:
+ - slim and graceful figure
+ - lean, elegant frame
+ - willowy silhouette
+ - slender physique with long limbs
+ - lithe and graceful build
+
+athletic:
+ - toned, athletic physique
+ - fit and active build
+ - athletic frame with visible muscle tone
+ - strong, athletic silhouette
+ - well-conditioned athletic figure
+
+curvy:
+ - curvy, feminine figure
+ - shapely silhouette with soft curves
+ - hourglass proportions
+ - balanced curves
+ - feminine silhouette with defined waist
+
+muscular:
+ - muscular, powerful build
+ - strong, well-defined physique
+ - athletic frame with prominent muscles
+ - powerful, muscular silhouette
+ - impressive muscle definition
+
+average:
+ - average build
+ - balanced proportions
+ - typical physique
+ - natural proportions
+ - unremarkable but pleasant build
+
+plus_curvy:
+ - full-figured and curvy
+ - generous curves
+ - voluptuous silhouette
+ - soft, full figure
+ - abundant curves
+
+petite:
+ - petite and delicate
+ - small-framed
+ - compact, graceful build
+ - delicate proportions
+ - diminutive but well-proportioned
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/data/ethnicity_distributions.yaml b/internal/adapter/templates/templates/skeleton/pkg/persona/data/ethnicity_distributions.yaml
new file mode 100644
index 0000000..06717c3
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/data/ethnicity_distributions.yaml
@@ -0,0 +1,174 @@
+# Feature probability distributions by ethnicity
+# Used for plausibility validation and weighted random selection
+
+east_asian:
+ eye_colors:
+ dark_brown: 0.85
+ brown: 0.15
+ hazel: 0.005
+ hair_colors:
+ black: 0.85
+ dark_brown: 0.12
+ brown: 0.03
+ hair_textures:
+ straight: 0.85
+ wavy: 0.15
+ skin_tones:
+ - fair
+ - light
+ - medium
+ - tan
+
+southeast_asian:
+ eye_colors:
+ dark_brown: 0.85
+ brown: 0.15
+ hazel: 0.005
+ hair_colors:
+ black: 0.85
+ dark_brown: 0.12
+ brown: 0.03
+ hair_textures:
+ straight: 0.85
+ wavy: 0.15
+ skin_tones:
+ - fair
+ - light
+ - medium
+ - tan
+
+south_asian:
+ eye_colors:
+ dark_brown: 0.75
+ brown: 0.20
+ hazel: 0.03
+ amber: 0.02
+ hair_colors:
+ black: 0.85
+ dark_brown: 0.12
+ brown: 0.03
+ hair_textures:
+ wavy: 0.40
+ straight: 0.35
+ curly: 0.25
+ skin_tones:
+ - light
+ - medium
+ - tan
+ - brown
+ - deep
+
+african:
+ eye_colors:
+ dark_brown: 0.85
+ brown: 0.13
+ amber: 0.02
+ hair_colors:
+ black: 0.92
+ dark_brown: 0.08
+ hair_textures:
+ coily: 0.50
+ kinky: 0.30
+ curly: 0.20
+ skin_tones:
+ - medium
+ - tan
+ - brown
+ - dark_brown
+ - deep
+
+hispanic:
+ eye_colors:
+ brown: 0.55
+ dark_brown: 0.25
+ hazel: 0.12
+ green: 0.05
+ amber: 0.03
+ hair_colors:
+ black: 0.50
+ dark_brown: 0.35
+ brown: 0.12
+ light_brown: 0.03
+ hair_textures:
+ wavy: 0.40
+ straight: 0.30
+ curly: 0.30
+ skin_tones:
+ - light
+ - medium
+ - tan
+ - brown
+ - olive
+ - deep
+
+middle_eastern:
+ eye_colors:
+ brown: 0.50
+ dark_brown: 0.30
+ hazel: 0.10
+ green: 0.07
+ amber: 0.03
+ hair_colors:
+ black: 0.65
+ dark_brown: 0.30
+ brown: 0.05
+ hair_textures:
+ wavy: 0.45
+ straight: 0.30
+ curly: 0.25
+ skin_tones:
+ - light
+ - medium
+ - tan
+ - olive
+
+caucasian:
+ eye_colors:
+ blue: 0.30
+ brown: 0.25
+ green: 0.15
+ hazel: 0.15
+ gray: 0.10
+ dark_brown: 0.05
+ hair_colors:
+ brown: 0.35
+ light_brown: 0.20
+ blonde: 0.20
+ dark_brown: 0.12
+ auburn: 0.08
+ red: 0.05
+ hair_textures:
+ straight: 0.40
+ wavy: 0.40
+ curly: 0.20
+ skin_tones:
+ - fair
+ - light
+ - medium
+
+mixed:
+ eye_colors:
+ brown: 0.40
+ dark_brown: 0.25
+ hazel: 0.20
+ green: 0.10
+ blue: 0.05
+ hair_colors:
+ brown: 0.30
+ dark_brown: 0.25
+ black: 0.25
+ light_brown: 0.10
+ blonde: 0.05
+ red: 0.05
+ hair_textures:
+ wavy: 0.40
+ straight: 0.30
+ curly: 0.30
+ skin_tones:
+ - fair
+ - light
+ - medium
+ - tan
+ - olive
+ - brown
+ - deep
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/data/morphology_features.yaml b/internal/adapter/templates/templates/skeleton/pkg/persona/data/morphology_features.yaml
new file mode 100644
index 0000000..d85f9eb
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/data/morphology_features.yaml
@@ -0,0 +1,90 @@
+# Valid morphology feature values for humanoid characters
+# These are used for validation and random selection
+
+ear_types:
+ - pointed # Subtle pointed tips
+ - elf # Classic elf ears
+ - wolf # Wolf-like ears
+ - cat # Cat ears (anime style)
+ - fox # Fox ears
+ - bat # Bat-like ears
+ - rabbit # Long rabbit ears
+
+tail_types:
+ - wolf # Bushy wolf tail
+ - cat # Sleek cat tail
+ - fox # Fluffy fox tail
+ - demon # Pointed demon tail
+ - dragon # Scaled dragon tail
+ - fluffy # Generic fluffy tail
+
+fang_types:
+ - subtle # Barely visible fangs
+ - vampire # Classic vampire fangs
+ - wolf # Canine-style fangs
+ - cat # Small, sharp cat fangs
+ - demon # Prominent demon fangs
+
+wing_types:
+ - feathered # Bird-like wings
+ - bat # Membranous bat wings
+ - fairy # Delicate fairy wings
+ - demon # Dark demon wings
+ - dragon # Scaled dragon wings
+
+horn_types:
+ - small_curved # Small, subtle curved horns
+ - ram # Ram-style curled horns
+ - demon # Classic demon horns
+ - unicorn # Single spiral horn
+ - antlers # Deer-like antlers
+
+claw_types:
+ - subtle # Slightly pointed nails
+ - retractable # Cat-like retractable claws
+ - prominent # Clearly visible claws
+ - talons # Bird-like talons
+
+# Feature intensity by morph level band
+morph_level_features:
+ subtle: # 25-49
+ max_features: 3
+ allowed:
+ - ears
+ - fangs
+ - eyes
+ excluded:
+ - wings
+ - scales
+ - heavy_fur
+
+ demi_human: # 50-74
+ max_features: 5
+ allowed:
+ - ears
+ - tail
+ - fangs
+ - claws
+ - light_fur
+ excluded:
+ - wings
+ - scales
+ - heavy_fur
+
+ hybrid: # 75-99
+ max_features: 8
+ allowed:
+ - ears
+ - tail
+ - fangs
+ - claws
+ - wings
+ - horns
+ - fur
+ - scales
+ excluded: []
+
+ creature: # 100
+ max_features: unlimited
+ allowed: all
+ excluded: []
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/data/voice_descriptions.yaml b/internal/adapter/templates/templates/skeleton/pkg/persona/data/voice_descriptions.yaml
new file mode 100644
index 0000000..f999b13
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/data/voice_descriptions.yaml
@@ -0,0 +1,89 @@
+# Voice characteristic descriptions for prose generation
+# Organized by tone/quality category
+
+warm:
+ - warm and inviting
+ - comforting timbre
+ - friendly warmth
+ - welcoming and gentle
+ - soothing warmth
+
+cool:
+ - cool and measured
+ - calm, collected tone
+ - composed delivery
+ - reserved and controlled
+ - understated elegance
+
+bright:
+ - bright and energetic
+ - lively, upbeat quality
+ - animated expression
+ - sparkling clarity
+ - cheerful resonance
+
+rich:
+ - rich and resonant
+ - deep, full-bodied tone
+ - luxurious timbre
+ - velvety depth
+ - resonant quality
+
+neutral:
+ - clear and balanced
+ - well-modulated
+ - pleasant neutrality
+ - unaffected clarity
+ - natural delivery
+
+# Pitch descriptions
+very_low:
+ - deep, bass tones
+ - rumbling depth
+ - low resonance
+
+low:
+ - low, smooth register
+ - pleasant depth
+ - grounding tone
+
+medium:
+ - middle register
+ - balanced pitch
+ - comfortable range
+
+high:
+ - higher register
+ - light, elevated tone
+ - clear high notes
+
+very_high:
+ - bright, high pitch
+ - soprano range
+ - crystalline highs
+
+# Timbre descriptions
+clear:
+ - crystal clear articulation
+ - pristine clarity
+ - pure, unmuddied tone
+
+smooth:
+ - silky smooth delivery
+ - polished, effortless sound
+ - flowing quality
+
+husky:
+ - husky undertones
+ - slightly rough texture
+ - intriguing rasp
+
+breathy:
+ - breathy, intimate quality
+ - soft, airy delivery
+ - whispered texture
+
+crisp:
+ - crisp, precise diction
+ - sharp articulation
+ - clean, defined edges
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/data_loader.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/data_loader.go.tmpl
new file mode 100644
index 0000000..7119ef1
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/data_loader.go.tmpl
@@ -0,0 +1,240 @@
+package persona
+
+import (
+ "embed"
+ "log"
+ "sync"
+
+ "gopkg.in/yaml.v3"
+)
+
+//go:embed data/*.yaml
+var dataFS embed.FS
+
+// DataStore holds loaded pool data.
+type DataStore struct {
+ BodyDescriptions map[BodyBuildCategory][]string
+ VoiceDescriptions map[string][]string
+ EthnicityDistribution map[EthnicityCode]EthnicityFeatureDistribution
+ MorphologyFeatures MorphologyFeaturePool
+}
+
+// EthnicityFeatureDistribution holds feature probabilities for an ethnicity.
+type EthnicityFeatureDistribution struct {
+ EyeColors map[EyeColorCategory]float64 `yaml:"eye_colors"`
+ HairColors map[HairColorCategory]float64 `yaml:"hair_colors"`
+ HairTextures map[HairTextureCategory]float64 `yaml:"hair_textures"`
+ SkinTones []SkinToneCategory `yaml:"skin_tones"`
+}
+
+// MorphologyFeaturePool holds valid morphology feature values.
+type MorphologyFeaturePool struct {
+ EarTypes []string `yaml:"ear_types"`
+ TailTypes []string `yaml:"tail_types"`
+ FangTypes []string `yaml:"fang_types"`
+ WingTypes []string `yaml:"wing_types"`
+ HornTypes []string `yaml:"horn_types"`
+ ClawTypes []string `yaml:"claw_types"`
+}
+
+var (
+ dataStore *DataStore
+ dataStoreOnce sync.Once
+ dataStoreErr error
+)
+
+// GetDataStore returns the singleton data store.
+// Data is loaded lazily on first access.
+func GetDataStore() (*DataStore, error) {
+ dataStoreOnce.Do(func() {
+ dataStore, dataStoreErr = loadDataStore()
+ })
+ return dataStore, dataStoreErr
+}
+
+// MustGetDataStore returns the data store or panics on error.
+func MustGetDataStore() *DataStore {
+ ds, err := GetDataStore()
+ if err != nil {
+ log.Fatalf("failed to load persona data: %v", err)
+ }
+ return ds
+}
+
+func loadDataStore() (*DataStore, error) {
+ ds := &DataStore{
+ BodyDescriptions: make(map[BodyBuildCategory][]string),
+ VoiceDescriptions: make(map[string][]string),
+ EthnicityDistribution: make(map[EthnicityCode]EthnicityFeatureDistribution),
+ }
+
+ // Load body descriptions
+ if err := loadYAML("data/body_descriptions.yaml", &ds.BodyDescriptions); err != nil {
+ // Use defaults if file doesn't exist
+ ds.BodyDescriptions = defaultBodyDescriptions()
+ }
+
+ // Load voice descriptions
+ if err := loadYAML("data/voice_descriptions.yaml", &ds.VoiceDescriptions); err != nil {
+ ds.VoiceDescriptions = defaultVoiceDescriptions()
+ }
+
+ // Load ethnicity distributions
+ if err := loadYAML("data/ethnicity_distributions.yaml", &ds.EthnicityDistribution); err != nil {
+ ds.EthnicityDistribution = defaultEthnicityDistributions()
+ }
+
+ // Load morphology features
+ if err := loadYAML("data/morphology_features.yaml", &ds.MorphologyFeatures); err != nil {
+ ds.MorphologyFeatures = defaultMorphologyFeatures()
+ }
+
+ return ds, nil
+}
+
+func loadYAML[T any](path string, target *T) error {
+ data, err := dataFS.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ return yaml.Unmarshal(data, target)
+}
+
+// defaultBodyDescriptions provides fallback body descriptions.
+func defaultBodyDescriptions() map[BodyBuildCategory][]string {
+ return map[BodyBuildCategory][]string{
+ BodyBuildSlender: {
+ "slim and graceful figure",
+ "lean, elegant frame",
+ "willowy silhouette",
+ },
+ BodyBuildAthletic: {
+ "toned, athletic physique",
+ "fit and active build",
+ "athletic frame with visible muscle tone",
+ },
+ BodyBuildCurvy: {
+ "curvy, feminine figure",
+ "shapely silhouette with soft curves",
+ "hourglass proportions",
+ },
+ BodyBuildMuscular: {
+ "muscular, powerful build",
+ "strong, well-defined physique",
+ "athletic frame with prominent muscles",
+ },
+ BodyBuildAverage: {
+ "average build",
+ "balanced proportions",
+ "typical physique",
+ },
+ BodyBuildPlusCurvy: {
+ "full-figured and curvy",
+ "generous curves",
+ "voluptuous silhouette",
+ },
+ BodyBuildPetite: {
+ "petite and delicate",
+ "small-framed",
+ "compact, graceful build",
+ },
+ }
+}
+
+// defaultVoiceDescriptions provides fallback voice descriptions.
+func defaultVoiceDescriptions() map[string][]string {
+ return map[string][]string{
+ "warm": {
+ "warm and inviting",
+ "comforting timbre",
+ "friendly warmth",
+ },
+ "cool": {
+ "cool and measured",
+ "calm, collected tone",
+ "composed delivery",
+ },
+ "bright": {
+ "bright and energetic",
+ "lively, upbeat quality",
+ "animated expression",
+ },
+ }
+}
+
+// defaultEthnicityDistributions provides fallback distributions.
+func defaultEthnicityDistributions() map[EthnicityCode]EthnicityFeatureDistribution {
+ return map[EthnicityCode]EthnicityFeatureDistribution{
+ EthnicityEastAsian: {
+ EyeColors: map[EyeColorCategory]float64{
+ EyeColorDarkBrown: 0.85,
+ EyeColorBrown: 0.15,
+ },
+ HairColors: map[HairColorCategory]float64{
+ HairColorBlack: 0.85,
+ HairColorDarkBrown: 0.15,
+ },
+ HairTextures: map[HairTextureCategory]float64{
+ HairTextureStraight: 0.85,
+ HairTextureWavy: 0.15,
+ },
+ SkinTones: []SkinToneCategory{
+ SkinToneFair, SkinToneLight, SkinToneMedium,
+ },
+ },
+ EthnicityCaucasian: {
+ EyeColors: map[EyeColorCategory]float64{
+ EyeColorBlue: 0.30,
+ EyeColorBrown: 0.25,
+ EyeColorGreen: 0.15,
+ EyeColorHazel: 0.15,
+ EyeColorGray: 0.15,
+ },
+ HairColors: map[HairColorCategory]float64{
+ HairColorBrown: 0.35,
+ HairColorLightBrown: 0.20,
+ HairColorBlonde: 0.20,
+ HairColorDarkBrown: 0.15,
+ HairColorRed: 0.10,
+ },
+ HairTextures: map[HairTextureCategory]float64{
+ HairTextureStraight: 0.40,
+ HairTextureWavy: 0.40,
+ HairTextureCurly: 0.20,
+ },
+ SkinTones: []SkinToneCategory{
+ SkinToneFair, SkinToneLight, SkinToneMedium,
+ },
+ },
+ }
+}
+
+// defaultMorphologyFeatures provides fallback morphology pools.
+func defaultMorphologyFeatures() MorphologyFeaturePool {
+ return MorphologyFeaturePool{
+ EarTypes: ValidEarTypes,
+ TailTypes: ValidTailTypes,
+ FangTypes: ValidFangTypes,
+ WingTypes: ValidWingTypes,
+ HornTypes: ValidHornTypes,
+ ClawTypes: ValidClawTypes,
+ }
+}
+
+// GetBodyDescription returns a description for a body build type.
+func (ds *DataStore) GetBodyDescription(build BodyBuildCategory, characterID string) string {
+ pool, ok := ds.BodyDescriptions[build]
+ if !ok || len(pool) == 0 {
+ return string(build) + " build"
+ }
+ return SelectDescription(pool, characterID, "body_description")
+}
+
+// GetVoiceDescription returns a description for a voice tone.
+func (ds *DataStore) GetVoiceDescription(tone string, characterID string) string {
+ pool, ok := ds.VoiceDescriptions[tone]
+ if !ok || len(pool) == 0 {
+ return tone + " voice"
+ }
+ return SelectDescription(pool, characterID, "voice_description")
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/doc.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/doc.go.tmpl
new file mode 100644
index 0000000..d2a156b
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/doc.go.tmpl
@@ -0,0 +1,66 @@
+// Package persona provides species-agnostic character description primitives.
+//
+// This package defines canonical DNA types for character generation systems,
+// supporting both humans and humanoids with biological DNA, psychology, and
+// validation logic.
+//
+// # Architecture
+//
+// The package is organized into several layers:
+//
+// - Core Types: Character, DNA, IdentityDNA, FaceDNA, BodyDNA, VoiceDNA
+// - Species System: Human, Humanoid, Android with MorphLevel progression
+// - Psychology: HEXACOProfile, AttachmentStyle, Values
+// - Validation: Species-aware plausibility and consistency checks
+// - Pools: YAML-loaded description pools for prose generation
+//
+// # Usage
+//
+// character := &persona.Character{
+// ID: "char_001",
+// Species: persona.SpeciesHuman,
+// DNA: &persona.DNA{
+// Identity: persona.IdentityDNA{
+// Ethnicity: persona.EthnicityEastAsian,
+// Age: 28,
+// Gender: persona.GenderWoman,
+// },
+// Face: persona.FaceDNA{
+// FaceShape: persona.FaceShapeOval,
+// EyeShape: persona.EyeShapeAlmond,
+// // ...
+// },
+// },
+// }
+//
+// // Validate the character
+// if err := persona.Validate(character); err != nil {
+// log.Printf("validation failed: %v", err)
+// }
+//
+// # Species System
+//
+// The Species field determines which validation rules apply:
+//
+// - Human: Ethnicity plausibility checks (eye color, hair, skin tone)
+// - Humanoid: Morphology validation (ears, tail, fangs require MorphLevel >= 25)
+// - Android: Minimal validation (synthetic beings)
+//
+// # MorphLevel
+//
+// For humanoid characters, MorphLevel (0-100) controls transformation intensity:
+//
+// - 0-24: Human appearance with supernatural energy
+// - 25-49: Subtle features (pointed ears, fangs)
+// - 50-74: Demi-human (anime-style ears/tail)
+// - 75-99: Hybrid (fur patterns, scales)
+// - 100: Full creature form
+//
+// # Design Principles
+//
+// - Category enums (not strings) for type safety
+// - Separate struct for each DNA domain
+// - All fields have json/yaml tags for serialization
+// - Validation is opt-in per species
+// - Pools are YAML-loaded at init() for flexibility
+package persona
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/errors.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/errors.go.tmpl
new file mode 100644
index 0000000..bc1e4ad
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/errors.go.tmpl
@@ -0,0 +1,105 @@
+package persona
+
+import "errors"
+
+// Sentinel errors for persona validation.
+var (
+ // ErrNilCharacter is returned when a nil character is passed to validation.
+ ErrNilCharacter = errors.New("character is nil")
+
+ // ErrNilDNA is returned when character DNA is nil.
+ ErrNilDNA = errors.New("character DNA is nil")
+
+ // ErrInvalidAge is returned when age is out of valid range.
+ ErrInvalidAge = errors.New("age must be between 0 and 150")
+
+ // ErrMissingField is returned when a required field is missing.
+ ErrMissingField = errors.New("required field is missing")
+
+ // ErrInvalidSpecies is returned when an unknown species is provided.
+ ErrInvalidSpecies = errors.New("invalid species type")
+
+ // ErrInvalidEthnicity is returned when an unknown ethnicity is provided.
+ ErrInvalidEthnicity = errors.New("invalid ethnicity code")
+
+ // ErrInvalidGender is returned when an unknown gender is provided.
+ ErrInvalidGender = errors.New("invalid gender identity")
+
+ // ErrMorphLevelRequired is returned when morphology features require MorphLevel >= 25.
+ ErrMorphLevelRequired = errors.New("morphology features require MorphLevel >= 25")
+
+ // ErrImplausibleFeature is returned when a feature is biologically implausible.
+ ErrImplausibleFeature = errors.New("feature is implausible for ethnicity")
+
+ // ErrInconsistentAttributes is returned when attributes contradict each other.
+ ErrInconsistentAttributes = errors.New("attributes are inconsistent with each other")
+
+ // ErrInvalidFeatureType is returned when a morphology feature type is not recognized.
+ ErrInvalidFeatureType = errors.New("invalid feature type")
+
+ // ErrInvalidPoolConfig is returned when pool configuration is invalid.
+ ErrInvalidPoolConfig = errors.New("invalid pool configuration")
+)
+
+// ValidationError wraps a validation error with field context.
+type ValidationError struct {
+ Field string
+ Value interface{}
+ Message string
+ Err error
+}
+
+// Error implements the error interface.
+func (e *ValidationError) Error() string {
+ if e.Field != "" {
+ return e.Field + ": " + e.Message
+ }
+ return e.Message
+}
+
+// Unwrap returns the underlying error.
+func (e *ValidationError) Unwrap() error {
+ return e.Err
+}
+
+// ValidationErrors is a collection of validation errors.
+type ValidationErrors []ValidationError
+
+// Error implements the error interface.
+func (e ValidationErrors) Error() string {
+ if len(e) == 0 {
+ return "no validation errors"
+ }
+ if len(e) == 1 {
+ return e[0].Error()
+ }
+ msg := e[0].Error()
+ for i := 1; i < len(e); i++ {
+ msg += "; " + e[i].Error()
+ }
+ return msg
+}
+
+// HasErrors returns true if there are any validation errors.
+func (e ValidationErrors) HasErrors() bool {
+ return len(e) > 0
+}
+
+// Add appends a validation error.
+func (e *ValidationErrors) Add(field, message string, err error) {
+ *e = append(*e, ValidationError{
+ Field: field,
+ Message: message,
+ Err: err,
+ })
+}
+
+// AddValue appends a validation error with a value.
+func (e *ValidationErrors) AddValue(field string, value interface{}, message string, err error) {
+ *e = append(*e, ValidationError{
+ Field: field,
+ Value: value,
+ Message: message,
+ Err: err,
+ })
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/face.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/face.go.tmpl
new file mode 100644
index 0000000..d57a406
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/face.go.tmpl
@@ -0,0 +1,355 @@
+package persona
+
+// FaceDNA contains all facial feature specifications for a character.
+// These features are selected BEFORE generation and must be used verbatim.
+// The combination of these features creates the character's unique facial identity.
+type FaceDNA struct {
+ // ========== Structure ==========
+
+ // FaceShape is the overall face shape category.
+ FaceShape FaceShapeCategory `json:"face_shape" yaml:"face_shape"`
+
+ // BoneStructure describes the underlying facial bone structure.
+ BoneStructure BoneStructureCategory `json:"bone_structure" yaml:"bone_structure"`
+
+ // Jawline describes the jawline shape and definition.
+ Jawline JawlineCategory `json:"jawline" yaml:"jawline"`
+
+ // Cheekbones describes cheekbone prominence and position.
+ Cheekbones CheekbonesCategory `json:"cheekbones" yaml:"cheekbones"`
+
+ // ========== Eyes ==========
+
+ // EyeShape is the structural category of eye shape.
+ EyeShape EyeShapeCategory `json:"eye_shape" yaml:"eye_shape"`
+
+ // EyeColor is the iris color category.
+ EyeColor EyeColorCategory `json:"eye_color" yaml:"eye_color"`
+
+ // EyeSpacing describes the distance between the eyes.
+ EyeSpacing EyeSpacingCategory `json:"eye_spacing" yaml:"eye_spacing"`
+
+ // EyeSize describes the relative size of the eyes.
+ EyeSize EyeSizeCategory `json:"eye_size" yaml:"eye_size"`
+
+ // ========== Nose ==========
+
+ // NoseShape is the structural category of nose shape.
+ NoseShape NoseShapeCategory `json:"nose_shape" yaml:"nose_shape"`
+
+ // NoseBridge describes the nose bridge characteristics.
+ NoseBridge NoseBridgeCategory `json:"nose_bridge" yaml:"nose_bridge"`
+
+ // NoseTip describes the nose tip characteristics.
+ NoseTip NoseTipCategory `json:"nose_tip" yaml:"nose_tip"`
+
+ // ========== Lips ==========
+
+ // LipShape is the structural category of lip shape.
+ LipShape LipShapeCategory `json:"lip_shape" yaml:"lip_shape"`
+
+ // LipFullness describes the volume/fullness of the lips.
+ LipFullness LipFullnessCategory `json:"lip_fullness" yaml:"lip_fullness"`
+
+ // SmileType describes the characteristic smile pattern.
+ SmileType SmileTypeCategory `json:"smile_type" yaml:"smile_type"`
+
+ // ========== Brows ==========
+
+ // BrowShape is the structural category of eyebrow shape.
+ BrowShape BrowShapeCategory `json:"brow_shape" yaml:"brow_shape"`
+
+ // BrowThickness describes the thickness/density of the eyebrows.
+ BrowThickness BrowThicknessCategory `json:"brow_thickness" yaml:"brow_thickness"`
+
+ // ========== Skin ==========
+
+ // SkinTone is the overall skin tone category.
+ SkinTone SkinToneCategory `json:"skin_tone" yaml:"skin_tone"`
+
+ // SkinUndertone is the undertone category (warm, cool, neutral).
+ SkinUndertone SkinUndertoneCategory `json:"skin_undertone" yaml:"skin_undertone"`
+
+ // SkinTexture describes the skin texture characteristics.
+ SkinTexture SkinTextureCategory `json:"skin_texture" yaml:"skin_texture"`
+
+ // ========== Hair ==========
+
+ // HairColor is the natural hair color category.
+ HairColor HairColorCategory `json:"hair_color" yaml:"hair_color"`
+
+ // HairTexture is the natural hair texture category.
+ HairTexture HairTextureCategory `json:"hair_texture" yaml:"hair_texture"`
+
+ // HairLength describes the current hair length.
+ HairLength HairLengthCategory `json:"hair_length" yaml:"hair_length"`
+
+ // HairThickness describes the hair strand thickness/density.
+ HairThickness HairThicknessCategory `json:"hair_thickness" yaml:"hair_thickness"`
+
+ // ========== Unique Features ==========
+
+ // UniqueFeatures contains distinctive facial features.
+ // ALLOWED: dimples, cleft chin, widow's peak
+ UniqueFeatures []UniqueFeatureCategory `json:"unique_features,omitempty" yaml:"unique_features,omitempty"`
+}
+
+// Face shape categories
+type FaceShapeCategory string
+
+const (
+ FaceShapeOval FaceShapeCategory = "oval"
+ FaceShapeHeart FaceShapeCategory = "heart"
+ FaceShapeSquare FaceShapeCategory = "square"
+ FaceShapeRound FaceShapeCategory = "round"
+ FaceShapeDiamond FaceShapeCategory = "diamond"
+ FaceShapeOblong FaceShapeCategory = "oblong"
+)
+
+// Bone structure categories
+type BoneStructureCategory string
+
+const (
+ BoneStructureDelicate BoneStructureCategory = "delicate"
+ BoneStructureModerate BoneStructureCategory = "moderate"
+ BoneStructureStrong BoneStructureCategory = "strong"
+)
+
+// Jawline categories
+type JawlineCategory string
+
+const (
+ JawlineSoft JawlineCategory = "soft"
+ JawlineRounded JawlineCategory = "rounded"
+ JawlineDefined JawlineCategory = "defined"
+ JawlineAngular JawlineCategory = "angular"
+ JawlineSquare JawlineCategory = "square"
+)
+
+// Cheekbones categories
+type CheekbonesCategory string
+
+const (
+ CheekbonesSubtle CheekbonesCategory = "subtle"
+ CheekbonesModerate CheekbonesCategory = "moderate"
+ CheekbonesProminent CheekbonesCategory = "prominent"
+ CheekbonesHigh CheekbonesCategory = "high"
+)
+
+// Eye shape categories
+type EyeShapeCategory string
+
+const (
+ EyeShapeAlmond EyeShapeCategory = "almond"
+ EyeShapeRound EyeShapeCategory = "round"
+ EyeShapeHooded EyeShapeCategory = "hooded"
+ EyeShapeMonolid EyeShapeCategory = "monolid"
+ EyeShapeUpturned EyeShapeCategory = "upturned"
+ EyeShapeDownturned EyeShapeCategory = "downturned"
+ EyeShapeDeepSet EyeShapeCategory = "deep_set"
+)
+
+// Eye color categories
+type EyeColorCategory string
+
+const (
+ EyeColorDarkBrown EyeColorCategory = "dark_brown"
+ EyeColorBrown EyeColorCategory = "brown"
+ EyeColorHazel EyeColorCategory = "hazel"
+ EyeColorAmber EyeColorCategory = "amber"
+ EyeColorGreen EyeColorCategory = "green"
+ EyeColorBlue EyeColorCategory = "blue"
+ EyeColorGray EyeColorCategory = "gray"
+)
+
+// Eye spacing categories
+type EyeSpacingCategory string
+
+const (
+ EyeSpacingClose EyeSpacingCategory = "close_set"
+ EyeSpacingAverage EyeSpacingCategory = "average"
+ EyeSpacingWide EyeSpacingCategory = "wide_set"
+)
+
+// Eye size categories
+type EyeSizeCategory string
+
+const (
+ EyeSizeSmall EyeSizeCategory = "small"
+ EyeSizeAverage EyeSizeCategory = "average"
+ EyeSizeLarge EyeSizeCategory = "large"
+)
+
+// Nose shape categories
+type NoseShapeCategory string
+
+const (
+ NoseShapeButton NoseShapeCategory = "button"
+ NoseShapeStraight NoseShapeCategory = "straight"
+ NoseShapeWide NoseShapeCategory = "wide"
+ NoseShapeRoman NoseShapeCategory = "roman"
+ NoseShapeAquiline NoseShapeCategory = "aquiline"
+)
+
+// Nose bridge categories
+type NoseBridgeCategory string
+
+const (
+ NoseBridgeLow NoseBridgeCategory = "low"
+ NoseBridgeModerate NoseBridgeCategory = "moderate"
+ NoseBridgeHigh NoseBridgeCategory = "high"
+ NoseBridgeBumped NoseBridgeCategory = "bumped"
+)
+
+// Nose tip categories
+type NoseTipCategory string
+
+const (
+ NoseTipRounded NoseTipCategory = "rounded"
+ NoseTipPointed NoseTipCategory = "pointed"
+ NoseTipUpturned NoseTipCategory = "upturned"
+ NoseTipBulbous NoseTipCategory = "bulbous"
+)
+
+// Lip shape categories
+type LipShapeCategory string
+
+const (
+ LipShapeFull LipShapeCategory = "full"
+ LipShapeThin LipShapeCategory = "thin"
+ LipShapeBow LipShapeCategory = "bow"
+ LipShapeRounded LipShapeCategory = "rounded"
+ LipShapeWide LipShapeCategory = "wide"
+)
+
+// Lip fullness categories
+type LipFullnessCategory string
+
+const (
+ LipFullnessSubtle LipFullnessCategory = "subtle"
+ LipFullnessModerate LipFullnessCategory = "moderate"
+ LipFullnessPlump LipFullnessCategory = "plump"
+ LipFullnessVoluptuous LipFullnessCategory = "voluptuous"
+)
+
+// Smile type categories
+type SmileTypeCategory string
+
+const (
+ SmileTypeSubtle SmileTypeCategory = "subtle"
+ SmileTypeBroad SmileTypeCategory = "broad"
+ SmileTypeAsymmetric SmileTypeCategory = "asymmetric"
+ SmileTypeGummy SmileTypeCategory = "gummy"
+ SmileTypeClosed SmileTypeCategory = "closed"
+)
+
+// Brow shape categories
+type BrowShapeCategory string
+
+const (
+ BrowShapeNatural BrowShapeCategory = "natural"
+ BrowShapeArched BrowShapeCategory = "arched"
+ BrowShapeStraight BrowShapeCategory = "straight"
+ BrowShapeRounded BrowShapeCategory = "rounded"
+ BrowShapeAngled BrowShapeCategory = "angled"
+)
+
+// Brow thickness categories
+type BrowThicknessCategory string
+
+const (
+ BrowThicknessThin BrowThicknessCategory = "thin"
+ BrowThicknessNatural BrowThicknessCategory = "natural"
+ BrowThicknessThick BrowThicknessCategory = "thick"
+ BrowThicknessFluffy BrowThicknessCategory = "fluffy"
+ BrowThicknessBleached BrowThicknessCategory = "bleached"
+)
+
+// Skin tone categories
+type SkinToneCategory string
+
+const (
+ SkinToneFair SkinToneCategory = "fair"
+ SkinToneLight SkinToneCategory = "light"
+ SkinToneMedium SkinToneCategory = "medium"
+ SkinToneOlive SkinToneCategory = "olive"
+ SkinToneTan SkinToneCategory = "tan"
+ SkinToneBrown SkinToneCategory = "brown"
+ SkinToneDarkBrown SkinToneCategory = "dark_brown"
+ SkinToneDeep SkinToneCategory = "deep"
+)
+
+// Skin undertone categories
+type SkinUndertoneCategory string
+
+const (
+ SkinUndertoneWarm SkinUndertoneCategory = "warm"
+ SkinUndertoneCool SkinUndertoneCategory = "cool"
+ SkinUndertoneNeutral SkinUndertoneCategory = "neutral"
+)
+
+// Skin texture categories
+type SkinTextureCategory string
+
+const (
+ SkinTextureSmooth SkinTextureCategory = "smooth"
+ SkinTextureNormal SkinTextureCategory = "normal"
+ SkinTextureTextured SkinTextureCategory = "textured"
+ SkinTextureMature SkinTextureCategory = "mature"
+)
+
+// Hair color categories
+type HairColorCategory string
+
+const (
+ HairColorBlack HairColorCategory = "black"
+ HairColorDarkBrown HairColorCategory = "dark_brown"
+ HairColorBrown HairColorCategory = "brown"
+ HairColorLightBrown HairColorCategory = "light_brown"
+ HairColorBlonde HairColorCategory = "blonde"
+ HairColorRed HairColorCategory = "red"
+ HairColorAuburn HairColorCategory = "auburn"
+ HairColorGray HairColorCategory = "gray"
+)
+
+// Hair texture categories
+type HairTextureCategory string
+
+const (
+ HairTextureStraight HairTextureCategory = "straight"
+ HairTextureWavy HairTextureCategory = "wavy"
+ HairTextureCurly HairTextureCategory = "curly"
+ HairTextureCoily HairTextureCategory = "coily"
+ HairTextureKinky HairTextureCategory = "kinky"
+)
+
+// Hair length categories
+type HairLengthCategory string
+
+const (
+ HairLengthPixie HairLengthCategory = "pixie"
+ HairLengthShort HairLengthCategory = "short"
+ HairLengthChin HairLengthCategory = "chin"
+ HairLengthShoulder HairLengthCategory = "shoulder"
+ HairLengthMidBack HairLengthCategory = "mid_back"
+ HairLengthLong HairLengthCategory = "long"
+ HairLengthVeryLong HairLengthCategory = "very_long"
+)
+
+// Hair thickness categories
+type HairThicknessCategory string
+
+const (
+ HairThicknessFine HairThicknessCategory = "fine"
+ HairThicknessMedium HairThicknessCategory = "medium"
+ HairThicknessThick HairThicknessCategory = "thick"
+)
+
+// Unique feature categories (allowed features only)
+type UniqueFeatureCategory string
+
+const (
+ UniqueFeatureDimples UniqueFeatureCategory = "dimples"
+ UniqueFeatureCleftChin UniqueFeatureCategory = "cleft_chin"
+ UniqueFeatureWidowsPeak UniqueFeatureCategory = "widows_peak"
+)
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/humanoid_validation.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/humanoid_validation.go.tmpl
new file mode 100644
index 0000000..678aba8
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/humanoid_validation.go.tmpl
@@ -0,0 +1,184 @@
+package persona
+
+import "fmt"
+
+// ValidateHumanoidMorphology validates morphology features for humanoid characters.
+// Returns errors if morphology features are present without sufficient MorphLevel.
+func ValidateHumanoidMorphology(c *Character) ValidationErrors {
+ var errs ValidationErrors
+
+ if c == nil || c.Species != SpeciesHumanoid {
+ return errs
+ }
+
+ morphology := c.Morphology
+ if morphology == nil || !morphology.HasFeatures() {
+ return errs // No morphology features, nothing to validate
+ }
+
+ // Check if morph level allows features
+ if c.MorphLevel < 25 {
+ errs.Add("morph_level",
+ fmt.Sprintf("morph level %d is too low for morphology features (requires >= 25)", c.MorphLevel),
+ ErrMorphLevelRequired)
+ return errs
+ }
+
+ // Validate individual feature types
+ if morphology.EarType != "" && !IsValidEarType(morphology.EarType) {
+ errs.AddValue("morphology.ear_type", morphology.EarType,
+ fmt.Sprintf("invalid ear type: %s", morphology.EarType), ErrInvalidFeatureType)
+ }
+
+ if morphology.TailType != "" && !IsValidTailType(morphology.TailType) {
+ errs.AddValue("morphology.tail_type", morphology.TailType,
+ fmt.Sprintf("invalid tail type: %s", morphology.TailType), ErrInvalidFeatureType)
+ }
+
+ if morphology.FangType != "" && !IsValidFangType(morphology.FangType) {
+ errs.AddValue("morphology.fang_type", morphology.FangType,
+ fmt.Sprintf("invalid fang type: %s", morphology.FangType), ErrInvalidFeatureType)
+ }
+
+ if morphology.WingType != "" && !IsValidWingType(morphology.WingType) {
+ errs.AddValue("morphology.wing_type", morphology.WingType,
+ fmt.Sprintf("invalid wing type: %s", morphology.WingType), ErrInvalidFeatureType)
+ }
+
+ if morphology.HornType != "" && !IsValidHornType(morphology.HornType) {
+ errs.AddValue("morphology.horn_type", morphology.HornType,
+ fmt.Sprintf("invalid horn type: %s", morphology.HornType), ErrInvalidFeatureType)
+ }
+
+ if morphology.ClawType != "" && !IsValidClawType(morphology.ClawType) {
+ errs.AddValue("morphology.claw_type", morphology.ClawType,
+ fmt.Sprintf("invalid claw type: %s", morphology.ClawType), ErrInvalidFeatureType)
+ }
+
+ // Validate morph level vs feature intensity
+ if err := validateMorphLevelFeatureConsistency(c.MorphLevel, morphology); err != nil {
+ errs.Add("morphology", err.Error(), ErrInconsistentAttributes)
+ }
+
+ return errs
+}
+
+// validateMorphLevelFeatureConsistency ensures features match the morph level band.
+func validateMorphLevelFeatureConsistency(morphLevel int, m *MorphologyHints) error {
+ if m == nil {
+ return nil
+ }
+
+ band := MorphLevelBand(morphLevel)
+
+ // Count features
+ featureCount := 0
+ hasExtremeFeatures := false
+
+ if m.EarType != "" {
+ featureCount++
+ }
+ if m.TailType != "" {
+ featureCount++
+ }
+ if m.FangType != "" {
+ featureCount++
+ }
+ if m.WingType != "" {
+ featureCount++
+ hasExtremeFeatures = true // Wings are extreme
+ }
+ if m.HornType != "" {
+ featureCount++
+ if m.HornType == "demon" || m.HornType == "antlers" {
+ hasExtremeFeatures = true
+ }
+ }
+ if m.ClawType != "" {
+ featureCount++
+ if m.ClawType == "talons" || m.ClawType == "prominent" {
+ hasExtremeFeatures = true
+ }
+ }
+ if len(m.FurPatterns) > 0 {
+ featureCount++
+ if len(m.FurPatterns) > 2 {
+ hasExtremeFeatures = true
+ }
+ }
+ if m.ScalePattern != "" {
+ featureCount++
+ hasExtremeFeatures = true // Scales are extreme
+ }
+
+ // Validate based on band
+ switch band {
+ case "subtle":
+ // At subtle level (25-49), only minor features allowed
+ if hasExtremeFeatures {
+ return fmt.Errorf("extreme features (wings, scales, heavy fur) require morph level >= 75")
+ }
+ if featureCount > 3 {
+ return fmt.Errorf("too many features (%d) for subtle morph level (max 3)", featureCount)
+ }
+
+ case "demi_human":
+ // At demi-human level (50-74), moderate features allowed
+ if hasExtremeFeatures {
+ return fmt.Errorf("extreme features (wings, scales, heavy fur) require morph level >= 75")
+ }
+
+ case "hybrid", "creature":
+ // At hybrid (75-99) and creature (100), all features allowed
+ // No restrictions
+ }
+
+ return nil
+}
+
+// RecommendMorphLevel recommends a minimum morph level for given morphology.
+func RecommendMorphLevel(m *MorphologyHints) int {
+ if m == nil || !m.HasFeatures() {
+ return 0
+ }
+
+ // Check for extreme features
+ hasExtreme := m.WingType != "" ||
+ m.ScalePattern != "" ||
+ m.ClawType == "talons" ||
+ m.ClawType == "prominent" ||
+ m.HornType == "demon" ||
+ m.HornType == "antlers" ||
+ len(m.FurPatterns) > 2
+
+ if hasExtreme {
+ return 75 // Hybrid level
+ }
+
+ // Check for moderate features
+ featureCount := 0
+ if m.EarType != "" {
+ featureCount++
+ }
+ if m.TailType != "" {
+ featureCount++
+ }
+ if m.FangType != "" {
+ featureCount++
+ }
+ if m.HornType != "" {
+ featureCount++
+ }
+ if m.ClawType != "" {
+ featureCount++
+ }
+ if len(m.FurPatterns) > 0 {
+ featureCount++
+ }
+
+ if featureCount > 3 {
+ return 50 // Demi-human level
+ }
+
+ return 25 // Subtle level
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/identity.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/identity.go.tmpl
new file mode 100644
index 0000000..3f50fd0
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/identity.go.tmpl
@@ -0,0 +1,114 @@
+package persona
+
+// IdentityDNA contains demographic and heritage information for a character.
+// This is the foundational identity layer that influences all other DNA selections.
+type IdentityDNA struct {
+ // Ethnicity is the primary ethnic background used for feature distribution.
+ Ethnicity EthnicityCode `json:"ethnicity" yaml:"ethnicity"`
+
+ // Age in years (exact chronological age).
+ Age int `json:"age" yaml:"age"`
+
+ // Gender is the character's gender identity.
+ Gender GenderIdentity `json:"gender" yaml:"gender"`
+
+ // Nationality is the country/cultural background (e.g., "American", "Japanese").
+ Nationality string `json:"nationality" yaml:"nationality"`
+
+ // BirthCity is the city where the character was born.
+ BirthCity string `json:"birth_city,omitempty" yaml:"birth_city,omitempty"`
+
+ // CurrentCity is the city where the character currently resides.
+ CurrentCity string `json:"current_city,omitempty" yaml:"current_city,omitempty"`
+
+ // PrimaryHeritage is the dominant ethnic heritage (used for feature weighting).
+ PrimaryHeritage EthnicityCode `json:"primary_heritage" yaml:"primary_heritage"`
+
+ // SecondaryHeritage is the secondary ethnic heritage for mixed-race characters.
+ // When set, MixPercentage determines the blend ratio.
+ SecondaryHeritage *EthnicityCode `json:"secondary_heritage,omitempty" yaml:"secondary_heritage,omitempty"`
+
+ // MixPercentage is the percentage of primary heritage (0-100).
+ // E.g., 60 means 60% primary heritage, 40% secondary heritage.
+ // Only relevant when SecondaryHeritage is set.
+ MixPercentage int `json:"mix_percentage,omitempty" yaml:"mix_percentage,omitempty"`
+}
+
+// EthnicityCode represents standardized ethnicity categories.
+// These map to feature distribution weights for face, hair, and eye selection.
+type EthnicityCode string
+
+const (
+ EthnicityEastAsian EthnicityCode = "east_asian"
+ EthnicitySouthAsian EthnicityCode = "south_asian"
+ EthnicitySoutheastAsian EthnicityCode = "southeast_asian"
+ EthnicityAfrican EthnicityCode = "african"
+ EthnicityHispanic EthnicityCode = "hispanic"
+ EthnicityMiddleEastern EthnicityCode = "middle_eastern"
+ EthnicityCaucasian EthnicityCode = "caucasian"
+ EthnicityMixed EthnicityCode = "mixed"
+)
+
+// String returns the string representation.
+func (e EthnicityCode) String() string {
+ return string(e)
+}
+
+// IsValid returns true if the ethnicity code is recognized.
+func (e EthnicityCode) IsValid() bool {
+ switch e {
+ case EthnicityEastAsian, EthnicitySouthAsian, EthnicitySoutheastAsian,
+ EthnicityAfrican, EthnicityHispanic, EthnicityMiddleEastern,
+ EthnicityCaucasian, EthnicityMixed:
+ return true
+ default:
+ return false
+ }
+}
+
+// AllEthnicities returns all valid ethnicity codes.
+func AllEthnicities() []EthnicityCode {
+ return []EthnicityCode{
+ EthnicityEastAsian,
+ EthnicitySouthAsian,
+ EthnicitySoutheastAsian,
+ EthnicityAfrican,
+ EthnicityHispanic,
+ EthnicityMiddleEastern,
+ EthnicityCaucasian,
+ EthnicityMixed,
+ }
+}
+
+// GenderIdentity represents the character's gender.
+type GenderIdentity string
+
+const (
+ GenderWoman GenderIdentity = "woman"
+ GenderMan GenderIdentity = "man"
+ GenderNonBinary GenderIdentity = "non_binary"
+)
+
+// String returns the string representation.
+func (g GenderIdentity) String() string {
+ return string(g)
+}
+
+// IsValid returns true if the gender identity is recognized.
+func (g GenderIdentity) IsValid() bool {
+ switch g {
+ case GenderWoman, GenderMan, GenderNonBinary:
+ return true
+ default:
+ return false
+ }
+}
+
+// AllGenders returns all valid gender identities.
+func AllGenders() []GenderIdentity {
+ return []GenderIdentity{
+ GenderWoman,
+ GenderMan,
+ GenderNonBinary,
+ }
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/morphology.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/morphology.go.tmpl
new file mode 100644
index 0000000..2532609
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/morphology.go.tmpl
@@ -0,0 +1,123 @@
+package persona
+
+// MorphologyHints describes non-human physical features for humanoid characters.
+// These are only applied when MorphLevel >= 25, allowing progressive creature
+// features based on the transformation intensity.
+type MorphologyHints struct {
+ // EarType specifies non-human ear style.
+ // Values: "pointed", "elf", "wolf", "cat", "fox", "bat", "rabbit"
+ EarType string `json:"ear_type,omitempty" yaml:"ear_type,omitempty"`
+
+ // TailType specifies tail style if present.
+ // Values: "wolf", "cat", "fox", "demon", "dragon", "fluffy"
+ TailType string `json:"tail_type,omitempty" yaml:"tail_type,omitempty"`
+
+ // FangType specifies fang/teeth style.
+ // Values: "subtle", "vampire", "wolf", "cat", "demon"
+ FangType string `json:"fang_type,omitempty" yaml:"fang_type,omitempty"`
+
+ // FurPatterns describes fur distribution at higher morph levels.
+ // Example: ["light dusting on forearms", "along spine", "full coverage"]
+ FurPatterns []string `json:"fur_patterns,omitempty" yaml:"fur_patterns,omitempty"`
+
+ // ScalePattern describes scale distribution for reptilian creatures.
+ // Example: "iridescent patches on shoulders and cheeks"
+ ScalePattern string `json:"scale_pattern,omitempty" yaml:"scale_pattern,omitempty"`
+
+ // WingType specifies wing style if present.
+ // Values: "feathered", "bat", "fairy", "demon", "dragon"
+ WingType string `json:"wing_type,omitempty" yaml:"wing_type,omitempty"`
+
+ // HornType specifies horn style if present.
+ // Values: "small_curved", "ram", "demon", "unicorn", "antlers"
+ HornType string `json:"horn_type,omitempty" yaml:"horn_type,omitempty"`
+
+ // ClawType specifies claw/nail style.
+ // Values: "subtle", "retractable", "prominent", "talons"
+ ClawType string `json:"claw_type,omitempty" yaml:"claw_type,omitempty"`
+}
+
+// HasFeatures returns true if any morphology features are defined.
+func (m *MorphologyHints) HasFeatures() bool {
+ if m == nil {
+ return false
+ }
+ return m.EarType != "" ||
+ m.TailType != "" ||
+ m.FangType != "" ||
+ len(m.FurPatterns) > 0 ||
+ m.ScalePattern != "" ||
+ m.WingType != "" ||
+ m.HornType != "" ||
+ m.ClawType != ""
+}
+
+// ValidEarTypes are the supported ear type values.
+var ValidEarTypes = []string{
+ "pointed", "elf", "wolf", "cat", "fox", "bat", "rabbit",
+}
+
+// ValidTailTypes are the supported tail type values.
+var ValidTailTypes = []string{
+ "wolf", "cat", "fox", "demon", "dragon", "fluffy",
+}
+
+// ValidFangTypes are the supported fang type values.
+var ValidFangTypes = []string{
+ "subtle", "vampire", "wolf", "cat", "demon",
+}
+
+// ValidWingTypes are the supported wing type values.
+var ValidWingTypes = []string{
+ "feathered", "bat", "fairy", "demon", "dragon",
+}
+
+// ValidHornTypes are the supported horn type values.
+var ValidHornTypes = []string{
+ "small_curved", "ram", "demon", "unicorn", "antlers",
+}
+
+// ValidClawTypes are the supported claw type values.
+var ValidClawTypes = []string{
+ "subtle", "retractable", "prominent", "talons",
+}
+
+// isValidType checks if a value is in the allowed list.
+func isValidType(value string, allowed []string) bool {
+ for _, v := range allowed {
+ if v == value {
+ return true
+ }
+ }
+ return false
+}
+
+// IsValidEarType returns true if the ear type is valid.
+func IsValidEarType(t string) bool {
+ return t == "" || isValidType(t, ValidEarTypes)
+}
+
+// IsValidTailType returns true if the tail type is valid.
+func IsValidTailType(t string) bool {
+ return t == "" || isValidType(t, ValidTailTypes)
+}
+
+// IsValidFangType returns true if the fang type is valid.
+func IsValidFangType(t string) bool {
+ return t == "" || isValidType(t, ValidFangTypes)
+}
+
+// IsValidWingType returns true if the wing type is valid.
+func IsValidWingType(t string) bool {
+ return t == "" || isValidType(t, ValidWingTypes)
+}
+
+// IsValidHornType returns true if the horn type is valid.
+func IsValidHornType(t string) bool {
+ return t == "" || isValidType(t, ValidHornTypes)
+}
+
+// IsValidClawType returns true if the claw type is valid.
+func IsValidClawType(t string) bool {
+ return t == "" || isValidType(t, ValidClawTypes)
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/options.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/options.go.tmpl
new file mode 100644
index 0000000..38bfab3
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/options.go.tmpl
@@ -0,0 +1,60 @@
+package persona
+
+// ValidateOption configures validation behavior.
+type ValidateOption func(*validateConfig)
+
+type validateConfig struct {
+ strictMode bool
+ allowRareFeatures bool
+ skipPlausibility bool
+ skipConsistency bool
+ skipMorphology bool
+}
+
+func defaultValidateConfig() *validateConfig {
+ return &validateConfig{
+ strictMode: false,
+ allowRareFeatures: false,
+ skipPlausibility: false,
+ skipConsistency: false,
+ skipMorphology: false,
+ }
+}
+
+// WithStrictMode enables strict validation that fails on warnings.
+// By default, only errors cause validation to fail.
+func WithStrictMode() ValidateOption {
+ return func(c *validateConfig) {
+ c.strictMode = true
+ }
+}
+
+// WithAllowRareFeatures allows rare but possible feature combinations.
+// By default, rare features (< 1% probability) are treated as errors.
+func WithAllowRareFeatures() ValidateOption {
+ return func(c *validateConfig) {
+ c.allowRareFeatures = true
+ }
+}
+
+// WithSkipPlausibility skips ethnicity plausibility checks.
+// Use this for fantasy characters that don't need realistic features.
+func WithSkipPlausibility() ValidateOption {
+ return func(c *validateConfig) {
+ c.skipPlausibility = true
+ }
+}
+
+// WithSkipConsistency skips cross-attribute consistency checks.
+func WithSkipConsistency() ValidateOption {
+ return func(c *validateConfig) {
+ c.skipConsistency = true
+ }
+}
+
+// WithSkipMorphology skips humanoid morphology validation.
+func WithSkipMorphology() ValidateOption {
+ return func(c *validateConfig) {
+ c.skipMorphology = true
+ }
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/plausibility.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/plausibility.go.tmpl
new file mode 100644
index 0000000..c1a1e16
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/plausibility.go.tmpl
@@ -0,0 +1,508 @@
+package persona
+
+import (
+ "fmt"
+ "math"
+)
+
+// PlausibilityLevel represents how biologically plausible a feature combination is.
+type PlausibilityLevel string
+
+const (
+ PlausibilityHighlyPlausible PlausibilityLevel = "highly_plausible" // 50%+ probability
+ PlausibilityPlausible PlausibilityLevel = "plausible" // 15-50% probability
+ PlausibilityUnusual PlausibilityLevel = "unusual" // 5-15% probability
+ PlausibilityRare PlausibilityLevel = "rare" // 1-5% probability
+ PlausibilityImplausible PlausibilityLevel = "implausible" // < 1% probability
+)
+
+// PlausibilityViolation represents a single biological plausibility issue.
+type PlausibilityViolation struct {
+ Field string // e.g., "face.eye_color"
+ Value interface{} // The actual value
+ Ethnicity EthnicityCode // The ethnicity being validated against
+ Level PlausibilityLevel // How plausible this is
+ Probability float64 // Actual probability (0.0-1.0)
+ Message string // Human-readable explanation
+}
+
+// PlausibilityResult contains the validation results for DNA.
+type PlausibilityResult struct {
+ Valid bool // True if no implausible violations
+ Violations []PlausibilityViolation // All violations found
+ Score float64 // Overall plausibility score (0-1)
+}
+
+// ValidateHumanPlausibility checks if face/hair features are biologically plausible
+// for the given ethnicity. Returns violations for implausible combinations.
+func ValidateHumanPlausibility(dna *DNA) *PlausibilityResult {
+ if dna == nil {
+ return &PlausibilityResult{
+ Valid: true,
+ Score: 1.0,
+ }
+ }
+
+ result := &PlausibilityResult{
+ Valid: true,
+ Violations: []PlausibilityViolation{},
+ Score: 1.0,
+ }
+
+ ethnicity := dna.Identity.Ethnicity
+ if ethnicity == "" || ethnicity == EthnicityMixed {
+ // Mixed or unspecified ethnicity - skip plausibility checks
+ return result
+ }
+
+ // Validate eye color
+ if violation := validateEyeColor(dna.Face.EyeColor, ethnicity); violation != nil {
+ result.Violations = append(result.Violations, *violation)
+ if violation.Level == PlausibilityImplausible {
+ result.Valid = false
+ }
+ }
+
+ // Validate hair color
+ if violation := validateHairColor(dna.Face.HairColor, ethnicity); violation != nil {
+ result.Violations = append(result.Violations, *violation)
+ if violation.Level == PlausibilityImplausible {
+ result.Valid = false
+ }
+ }
+
+ // Validate hair texture
+ if violation := validateHairTexture(dna.Face.HairTexture, ethnicity); violation != nil {
+ result.Violations = append(result.Violations, *violation)
+ if violation.Level == PlausibilityImplausible {
+ result.Valid = false
+ }
+ }
+
+ // Validate skin tone
+ if violation := validateSkinTone(dna.Face.SkinTone, ethnicity); violation != nil {
+ result.Violations = append(result.Violations, *violation)
+ if violation.Level == PlausibilityImplausible {
+ result.Valid = false
+ }
+ }
+
+ // Calculate overall plausibility score (geometric mean of probabilities)
+ if len(result.Violations) > 0 {
+ product := 1.0
+ for _, v := range result.Violations {
+ product *= v.Probability
+ }
+ numFeatures := 4.0 // eye_color, hair_color, hair_texture, skin_tone
+ result.Score = math.Pow(product, 1.0/numFeatures)
+ }
+
+ return result
+}
+
+// validateEyeColor checks if eye color is plausible for the ethnicity.
+func validateEyeColor(eyeColor EyeColorCategory, ethnicity EthnicityCode) *PlausibilityViolation {
+ if eyeColor == "" {
+ return nil
+ }
+
+ prob := getEyeColorProbability(eyeColor, ethnicity)
+ level := getProbabilityLevel(prob)
+
+ if level == PlausibilityUnusual || level == PlausibilityRare || level == PlausibilityImplausible {
+ return &PlausibilityViolation{
+ Field: "face.eye_color",
+ Value: eyeColor,
+ Ethnicity: ethnicity,
+ Level: level,
+ Probability: prob,
+ Message: fmt.Sprintf("Eye color %s is %s for %s ethnicity (%.1f%% probability)", eyeColor, level, ethnicity, prob*100),
+ }
+ }
+
+ return nil
+}
+
+// validateHairColor checks if hair color is plausible for the ethnicity.
+func validateHairColor(hairColor HairColorCategory, ethnicity EthnicityCode) *PlausibilityViolation {
+ if hairColor == "" {
+ return nil
+ }
+
+ prob := getHairColorProbability(hairColor, ethnicity)
+ level := getProbabilityLevel(prob)
+
+ if level == PlausibilityUnusual || level == PlausibilityRare || level == PlausibilityImplausible {
+ return &PlausibilityViolation{
+ Field: "face.hair_color",
+ Value: hairColor,
+ Ethnicity: ethnicity,
+ Level: level,
+ Probability: prob,
+ Message: fmt.Sprintf("Hair color %s is %s for %s ethnicity (%.1f%% probability)", hairColor, level, ethnicity, prob*100),
+ }
+ }
+
+ return nil
+}
+
+// validateHairTexture checks if hair texture is plausible for the ethnicity.
+func validateHairTexture(hairTexture HairTextureCategory, ethnicity EthnicityCode) *PlausibilityViolation {
+ if hairTexture == "" {
+ return nil
+ }
+
+ prob := getHairTextureProbability(hairTexture, ethnicity)
+ level := getProbabilityLevel(prob)
+
+ if level == PlausibilityUnusual || level == PlausibilityRare || level == PlausibilityImplausible {
+ return &PlausibilityViolation{
+ Field: "face.hair_texture",
+ Value: hairTexture,
+ Ethnicity: ethnicity,
+ Level: level,
+ Probability: prob,
+ Message: fmt.Sprintf("Hair texture %s is %s for %s ethnicity (%.1f%% probability)", hairTexture, level, ethnicity, prob*100),
+ }
+ }
+
+ return nil
+}
+
+// validateSkinTone checks if skin tone is plausible for the ethnicity.
+func validateSkinTone(skinTone SkinToneCategory, ethnicity EthnicityCode) *PlausibilityViolation {
+ if skinTone == "" {
+ return nil
+ }
+
+ if !isSkinTonePlausible(skinTone, ethnicity) {
+ return &PlausibilityViolation{
+ Field: "face.skin_tone",
+ Value: skinTone,
+ Ethnicity: ethnicity,
+ Level: PlausibilityImplausible,
+ Probability: 0.005,
+ Message: fmt.Sprintf("Skin tone %s is implausible for %s ethnicity", skinTone, ethnicity),
+ }
+ }
+
+ return nil
+}
+
+// getProbabilityLevel converts a probability to a PlausibilityLevel.
+func getProbabilityLevel(prob float64) PlausibilityLevel {
+ if prob >= 0.5 {
+ return PlausibilityHighlyPlausible
+ } else if prob >= 0.15 {
+ return PlausibilityPlausible
+ } else if prob >= 0.05 {
+ return PlausibilityUnusual
+ } else if prob >= 0.01 {
+ return PlausibilityRare
+ } else {
+ return PlausibilityImplausible
+ }
+}
+
+// getEyeColorProbability returns the probability of an eye color for an ethnicity.
+func getEyeColorProbability(eyeColor EyeColorCategory, ethnicity EthnicityCode) float64 {
+ switch ethnicity {
+ case EthnicityEastAsian, EthnicitySoutheastAsian:
+ switch eyeColor {
+ case EyeColorDarkBrown:
+ return 0.85
+ case EyeColorBrown:
+ return 0.15
+ case EyeColorHazel:
+ return 0.005
+ default:
+ return 0.001
+ }
+
+ case EthnicitySouthAsian:
+ switch eyeColor {
+ case EyeColorDarkBrown:
+ return 0.75
+ case EyeColorBrown:
+ return 0.20
+ case EyeColorHazel:
+ return 0.03
+ case EyeColorAmber:
+ return 0.02
+ default:
+ return 0.005
+ }
+
+ case EthnicityAfrican:
+ switch eyeColor {
+ case EyeColorDarkBrown:
+ return 0.85
+ case EyeColorBrown:
+ return 0.13
+ case EyeColorAmber:
+ return 0.02
+ default:
+ return 0.002
+ }
+
+ case EthnicityHispanic:
+ switch eyeColor {
+ case EyeColorBrown:
+ return 0.55
+ case EyeColorDarkBrown:
+ return 0.25
+ case EyeColorHazel:
+ return 0.12
+ case EyeColorGreen:
+ return 0.05
+ case EyeColorAmber:
+ return 0.03
+ default:
+ return 0.01
+ }
+
+ case EthnicityMiddleEastern:
+ switch eyeColor {
+ case EyeColorBrown:
+ return 0.50
+ case EyeColorDarkBrown:
+ return 0.30
+ case EyeColorHazel:
+ return 0.10
+ case EyeColorGreen:
+ return 0.07
+ case EyeColorAmber:
+ return 0.03
+ default:
+ return 0.005
+ }
+
+ case EthnicityCaucasian:
+ switch eyeColor {
+ case EyeColorBlue:
+ return 0.30
+ case EyeColorBrown:
+ return 0.25
+ case EyeColorGreen:
+ return 0.15
+ case EyeColorHazel:
+ return 0.15
+ case EyeColorGray:
+ return 0.10
+ case EyeColorDarkBrown:
+ return 0.05
+ default:
+ return 0.02
+ }
+
+ default:
+ return 0.15 // Mixed/unknown - moderate probability
+ }
+}
+
+// getHairColorProbability returns the probability of a hair color for an ethnicity.
+func getHairColorProbability(hairColor HairColorCategory, ethnicity EthnicityCode) float64 {
+ switch ethnicity {
+ case EthnicityEastAsian, EthnicitySoutheastAsian:
+ switch hairColor {
+ case HairColorBlack:
+ return 0.85
+ case HairColorDarkBrown:
+ return 0.12
+ case HairColorBrown:
+ return 0.03
+ default:
+ return 0.001
+ }
+
+ case EthnicitySouthAsian:
+ switch hairColor {
+ case HairColorBlack:
+ return 0.85
+ case HairColorDarkBrown:
+ return 0.12
+ case HairColorBrown:
+ return 0.03
+ default:
+ return 0.001
+ }
+
+ case EthnicityAfrican:
+ switch hairColor {
+ case HairColorBlack:
+ return 0.92
+ case HairColorDarkBrown:
+ return 0.08
+ default:
+ return 0.001
+ }
+
+ case EthnicityHispanic:
+ switch hairColor {
+ case HairColorBlack:
+ return 0.50
+ case HairColorDarkBrown:
+ return 0.35
+ case HairColorBrown:
+ return 0.12
+ case HairColorLightBrown:
+ return 0.03
+ default:
+ return 0.005
+ }
+
+ case EthnicityMiddleEastern:
+ switch hairColor {
+ case HairColorBlack:
+ return 0.65
+ case HairColorDarkBrown:
+ return 0.30
+ case HairColorBrown:
+ return 0.05
+ default:
+ return 0.005
+ }
+
+ case EthnicityCaucasian:
+ switch hairColor {
+ case HairColorBrown:
+ return 0.35
+ case HairColorLightBrown:
+ return 0.20
+ case HairColorBlonde:
+ return 0.20
+ case HairColorDarkBrown:
+ return 0.12
+ case HairColorAuburn:
+ return 0.08
+ case HairColorRed:
+ return 0.05
+ default:
+ return 0.02
+ }
+
+ default:
+ return 0.10
+ }
+}
+
+// getHairTextureProbability returns the probability of a hair texture for an ethnicity.
+func getHairTextureProbability(hairTexture HairTextureCategory, ethnicity EthnicityCode) float64 {
+ switch ethnicity {
+ case EthnicityEastAsian, EthnicitySoutheastAsian:
+ switch hairTexture {
+ case HairTextureStraight:
+ return 0.85
+ case HairTextureWavy:
+ return 0.15
+ case HairTextureCurly:
+ return 0.005
+ case HairTextureCoily, HairTextureKinky:
+ return 0.001
+ default:
+ return 0.01
+ }
+
+ case EthnicitySouthAsian:
+ switch hairTexture {
+ case HairTextureWavy:
+ return 0.40
+ case HairTextureStraight:
+ return 0.35
+ case HairTextureCurly:
+ return 0.25
+ default:
+ return 0.01
+ }
+
+ case EthnicityAfrican:
+ switch hairTexture {
+ case HairTextureCoily:
+ return 0.50
+ case HairTextureKinky:
+ return 0.30
+ case HairTextureCurly:
+ return 0.20
+ case HairTextureWavy:
+ return 0.005
+ case HairTextureStraight:
+ return 0.001
+ default:
+ return 0.01
+ }
+
+ case EthnicityHispanic:
+ switch hairTexture {
+ case HairTextureWavy:
+ return 0.40
+ case HairTextureStraight:
+ return 0.30
+ case HairTextureCurly:
+ return 0.30
+ default:
+ return 0.01
+ }
+
+ case EthnicityMiddleEastern:
+ switch hairTexture {
+ case HairTextureWavy:
+ return 0.45
+ case HairTextureStraight:
+ return 0.30
+ case HairTextureCurly:
+ return 0.25
+ default:
+ return 0.01
+ }
+
+ case EthnicityCaucasian:
+ switch hairTexture {
+ case HairTextureStraight:
+ return 0.40
+ case HairTextureWavy:
+ return 0.40
+ case HairTextureCurly:
+ return 0.20
+ default:
+ return 0.01
+ }
+
+ default:
+ return 0.15
+ }
+}
+
+// isSkinTonePlausible checks if a skin tone is plausible for an ethnicity.
+func isSkinTonePlausible(skinTone SkinToneCategory, ethnicity EthnicityCode) bool {
+ switch ethnicity {
+ case EthnicityEastAsian, EthnicitySoutheastAsian:
+ return skinTone == SkinToneFair || skinTone == SkinToneLight ||
+ skinTone == SkinToneMedium || skinTone == SkinToneTan
+
+ case EthnicitySouthAsian:
+ return skinTone == SkinToneLight || skinTone == SkinToneMedium ||
+ skinTone == SkinToneTan || skinTone == SkinToneBrown || skinTone == SkinToneDeep
+
+ case EthnicityAfrican:
+ return skinTone == SkinToneMedium || skinTone == SkinToneTan ||
+ skinTone == SkinToneBrown || skinTone == SkinToneDarkBrown || skinTone == SkinToneDeep
+
+ case EthnicityHispanic:
+ return skinTone == SkinToneLight || skinTone == SkinToneMedium ||
+ skinTone == SkinToneTan || skinTone == SkinToneBrown ||
+ skinTone == SkinToneDeep || skinTone == SkinToneOlive
+
+ case EthnicityMiddleEastern:
+ return skinTone == SkinToneLight || skinTone == SkinToneMedium ||
+ skinTone == SkinToneTan || skinTone == SkinToneOlive
+
+ case EthnicityCaucasian:
+ return skinTone == SkinToneFair || skinTone == SkinToneLight || skinTone == SkinToneMedium
+
+ case EthnicityMixed:
+ return true // Mixed allows all skin tones
+
+ default:
+ return true // Unknown allows all
+ }
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/pools.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/pools.go.tmpl
new file mode 100644
index 0000000..4eadb82
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/pools.go.tmpl
@@ -0,0 +1,187 @@
+package persona
+
+import (
+ "fmt"
+ "hash/fnv"
+ "math/rand"
+)
+
+// Pool represents a collection of items that can be selected from.
+type Pool[T any] struct {
+ Items []T
+}
+
+// NewPool creates a new pool with the given items.
+func NewPool[T any](items []T) *Pool[T] {
+ return &Pool[T]{Items: items}
+}
+
+// Random selects a random item from the pool.
+// Returns the zero value if the pool is empty.
+func (p *Pool[T]) Random() T {
+ var zero T
+ if len(p.Items) == 0 {
+ return zero
+ }
+ return p.Items[rand.Intn(len(p.Items))]
+}
+
+// RandomWithSeed selects an item deterministically based on a seed.
+// The same seed will always return the same item.
+func (p *Pool[T]) RandomWithSeed(seed int64) T {
+ var zero T
+ if len(p.Items) == 0 {
+ return zero
+ }
+ r := rand.New(rand.NewSource(seed))
+ return p.Items[r.Intn(len(p.Items))]
+}
+
+// RandomN selects n random items from the pool.
+// May return duplicates. Returns up to n items or all items if less available.
+func (p *Pool[T]) RandomN(n int) []T {
+ if len(p.Items) == 0 || n <= 0 {
+ return nil
+ }
+ if n > len(p.Items) {
+ n = len(p.Items)
+ }
+ result := make([]T, n)
+ for i := 0; i < n; i++ {
+ result[i] = p.Items[rand.Intn(len(p.Items))]
+ }
+ return result
+}
+
+// SelectDescription selects a description from a pool using a deterministic seed.
+// This ensures the same character always gets the same descriptions.
+func SelectDescription(pool []string, characterID string, fieldName string) string {
+ if len(pool) == 0 {
+ return ""
+ }
+
+ // Create a deterministic seed from character ID and field name
+ h := fnv.New64a()
+ h.Write([]byte(characterID))
+ h.Write([]byte(fieldName))
+ seed := int64(h.Sum64())
+
+ r := rand.New(rand.NewSource(seed))
+ return pool[r.Intn(len(pool))]
+}
+
+// SelectDescriptionN selects n unique descriptions from a pool.
+func SelectDescriptionN(pool []string, characterID string, fieldName string, n int) []string {
+ if len(pool) == 0 || n <= 0 {
+ return nil
+ }
+
+ // Create a deterministic seed
+ h := fnv.New64a()
+ h.Write([]byte(characterID))
+ h.Write([]byte(fieldName))
+ seed := int64(h.Sum64())
+
+ r := rand.New(rand.NewSource(seed))
+
+ // If n >= pool size, shuffle and return all
+ if n >= len(pool) {
+ result := make([]string, len(pool))
+ copy(result, pool)
+ r.Shuffle(len(result), func(i, j int) {
+ result[i], result[j] = result[j], result[i]
+ })
+ return result
+ }
+
+ // Select n unique items
+ indices := r.Perm(len(pool))[:n]
+ result := make([]string, n)
+ for i, idx := range indices {
+ result[i] = pool[idx]
+ }
+ return result
+}
+
+// WeightedPool allows weighted random selection.
+type WeightedPool[T any] struct {
+ Items []T
+ Weights []float64
+ total float64
+}
+
+// NewWeightedPool creates a weighted pool.
+// Weights should be positive numbers; they don't need to sum to 1.
+// Returns an error if items and weights have different lengths or if any weight is negative.
+func NewWeightedPool[T any](items []T, weights []float64) (*WeightedPool[T], error) {
+ if len(items) != len(weights) {
+ return nil, fmt.Errorf("%w: items length (%d) != weights length (%d)",
+ ErrInvalidPoolConfig, len(items), len(weights))
+ }
+
+ var total float64
+ for i, w := range weights {
+ if w < 0 {
+ return nil, fmt.Errorf("%w: negative weight at index %d: %f",
+ ErrInvalidPoolConfig, i, w)
+ }
+ total += w
+ }
+
+ return &WeightedPool[T]{
+ Items: items,
+ Weights: weights,
+ total: total,
+ }, nil
+}
+
+// MustNewWeightedPool creates a weighted pool or panics on error.
+// Use this only at initialization time. For runtime use, prefer NewWeightedPool.
+func MustNewWeightedPool[T any](items []T, weights []float64) *WeightedPool[T] {
+ pool, err := NewWeightedPool(items, weights)
+ if err != nil {
+ panic(err)
+ }
+ return pool
+}
+
+// Random selects a weighted random item.
+func (p *WeightedPool[T]) Random() T {
+ var zero T
+ if len(p.Items) == 0 {
+ return zero
+ }
+
+ r := rand.Float64() * p.total
+ var cumulative float64
+
+ for i, w := range p.Weights {
+ cumulative += w
+ if r <= cumulative {
+ return p.Items[i]
+ }
+ }
+
+ return p.Items[len(p.Items)-1]
+}
+
+// RandomWithSeed selects a weighted random item deterministically.
+func (p *WeightedPool[T]) RandomWithSeed(seed int64) T {
+ var zero T
+ if len(p.Items) == 0 {
+ return zero
+ }
+
+ rng := rand.New(rand.NewSource(seed))
+ r := rng.Float64() * p.total
+ var cumulative float64
+
+ for i, w := range p.Weights {
+ cumulative += w
+ if r <= cumulative {
+ return p.Items[i]
+ }
+ }
+
+ return p.Items[len(p.Items)-1]
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/psychology.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/psychology.go.tmpl
new file mode 100644
index 0000000..1161f21
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/psychology.go.tmpl
@@ -0,0 +1,112 @@
+package persona
+
+// Psychology contains the complete psychological profile.
+type Psychology struct {
+ // HEXACO personality model
+ HEXACO HEXACOProfile `json:"hexaco" yaml:"hexaco"`
+
+ // Attachment style and patterns
+ Attachment AttachmentStyle `json:"attachment" yaml:"attachment"`
+
+ // Values core beliefs and principles
+ Values Values `json:"values" yaml:"values"`
+}
+
+// HEXACOProfile represents the six-factor personality model.
+// Each dimension is scored 1-10 with facets and behavioral implications.
+type HEXACOProfile struct {
+ // HonestyHumility: sincerity, fairness, greed-avoidance, modesty
+ HonestyHumility TraitScore `json:"honesty_humility" yaml:"honesty_humility"`
+
+ // Emotionality: fearfulness, anxiety, dependence, sentimentality
+ Emotionality TraitScore `json:"emotionality" yaml:"emotionality"`
+
+ // Extraversion: social self-esteem, boldness, sociability, liveliness
+ Extraversion TraitScore `json:"extraversion" yaml:"extraversion"`
+
+ // Agreeableness: forgivingness, gentleness, flexibility, patience
+ Agreeableness TraitScore `json:"agreeableness" yaml:"agreeableness"`
+
+ // Conscientiousness: organization, diligence, perfectionism, prudence
+ Conscientiousness TraitScore `json:"conscientiousness" yaml:"conscientiousness"`
+
+ // Openness: aesthetic appreciation, inquisitiveness, creativity, unconventionality
+ Openness TraitScore `json:"openness" yaml:"openness"`
+}
+
+// TraitScore represents a single HEXACO dimension.
+type TraitScore struct {
+ // Score from 1 (very low) to 10 (very high)
+ Score int `json:"score" yaml:"score"`
+
+ // Facets map facet names to levels (high/mid/low)
+ Facets map[string]string `json:"facets,omitempty" yaml:"facets,omitempty"`
+
+ // BehavioralImplications describes how this shows in behavior
+ BehavioralImplications string `json:"behavioral_implications,omitempty" yaml:"behavioral_implications,omitempty"`
+}
+
+// Level returns a descriptive level for the score.
+func (t TraitScore) Level() string {
+ switch {
+ case t.Score <= 3:
+ return "low"
+ case t.Score <= 7:
+ return "moderate"
+ default:
+ return "high"
+ }
+}
+
+// AttachmentStyle represents relationship attachment patterns.
+type AttachmentStyle struct {
+ // Primary attachment style (secure, anxious, avoidant, disorganized)
+ Primary string `json:"primary" yaml:"primary"`
+
+ // Pattern detailed description of the pattern
+ Pattern string `json:"pattern,omitempty" yaml:"pattern,omitempty"`
+
+ // Triggers what activates attachment behaviors
+ Triggers []string `json:"triggers,omitempty" yaml:"triggers,omitempty"`
+
+ // GrowthEdge area of potential development
+ GrowthEdge string `json:"growth_edge,omitempty" yaml:"growth_edge,omitempty"`
+}
+
+// Values contains core beliefs and principles.
+type Values struct {
+ // Core top 5 values
+ Core []string `json:"core,omitempty" yaml:"core,omitempty"`
+
+ // LifePhilosophy guiding principle
+ LifePhilosophy string `json:"life_philosophy,omitempty" yaml:"life_philosophy,omitempty"`
+
+ // Spirituality relationship with spirituality/religion
+ Spirituality string `json:"spirituality,omitempty" yaml:"spirituality,omitempty"`
+
+ // Political general political stance (if any)
+ Political string `json:"political,omitempty" yaml:"political,omitempty"`
+}
+
+// ValidAttachmentStyles are the valid primary attachment styles.
+var ValidAttachmentStyles = []string{"secure", "anxious", "avoidant", "disorganized"}
+
+// IsValidAttachmentStyle returns true if the style is valid.
+func IsValidAttachmentStyle(style string) bool {
+ for _, v := range ValidAttachmentStyles {
+ if v == style {
+ return true
+ }
+ }
+ return false
+}
+
+// HEXACOFacets defines the facets for each HEXACO dimension.
+var HEXACOFacets = map[string][]string{
+ "honesty_humility": {"sincerity", "fairness", "greed_avoidance", "modesty"},
+ "emotionality": {"fearfulness", "anxiety", "dependence", "sentimentality"},
+ "extraversion": {"social_self_esteem", "boldness", "sociability", "liveliness"},
+ "agreeableness": {"forgivingness", "gentleness", "flexibility", "patience"},
+ "conscientiousness": {"organization", "diligence", "perfectionism", "prudence"},
+ "openness": {"aesthetic_appreciation", "inquisitiveness", "creativity", "unconventionality"},
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/species.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/species.go.tmpl
new file mode 100644
index 0000000..733757a
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/species.go.tmpl
@@ -0,0 +1,118 @@
+package persona
+
+import "strings"
+
+// SpeciesType defines the species of a character.
+// This determines which validation rules apply.
+type SpeciesType string
+
+const (
+ // SpeciesHuman represents a human character.
+ // Validation includes ethnicity plausibility checks.
+ SpeciesHuman SpeciesType = "human"
+
+ // SpeciesHumanoid represents a humanoid/fantasy character.
+ // Validation includes morphology consistency checks.
+ SpeciesHumanoid SpeciesType = "humanoid"
+
+ // SpeciesAndroid represents a synthetic/android character.
+ // Minimal validation as features can be artificially constructed.
+ SpeciesAndroid SpeciesType = "android"
+)
+
+// String returns the string representation.
+func (s SpeciesType) String() string {
+ return string(s)
+}
+
+// IsValid returns true if the species type is recognized.
+func (s SpeciesType) IsValid() bool {
+ switch s {
+ case SpeciesHuman, SpeciesHumanoid, SpeciesAndroid:
+ return true
+ default:
+ return false
+ }
+}
+
+// ParseSpeciesType converts a string to SpeciesType.
+// Returns SpeciesHuman for unrecognized values.
+func ParseSpeciesType(s string) SpeciesType {
+ switch strings.ToLower(strings.TrimSpace(s)) {
+ case "human":
+ return SpeciesHuman
+ case "humanoid":
+ return SpeciesHumanoid
+ case "android":
+ return SpeciesAndroid
+ default:
+ return SpeciesHuman
+ }
+}
+
+// MorphLevel represents creature transformation intensity.
+// Controls how much the subject deviates from human appearance.
+type MorphLevel int
+
+const (
+ // MorphLevelHuman (0-24): Human with supernatural energy/expression.
+ MorphLevelHuman MorphLevel = 0
+
+ // MorphLevelSubtle (25-49): Subtle non-human features.
+ // Pointed ears, unusual eye colors, small fangs, slightly elongated features.
+ MorphLevelSubtle MorphLevel = 25
+
+ // MorphLevelDemiHuman (50-74): Anime-style demi-human aesthetic.
+ // Animal ears, tail, but human face and body. Catgirl/foxgirl territory.
+ MorphLevelDemiHuman MorphLevel = 50
+
+ // MorphLevelHybrid (75-99): Visible creature features.
+ // Fur patterns, scale patches, claws, more pronounced non-human anatomy.
+ MorphLevelHybrid MorphLevel = 75
+
+ // MorphLevelCreature (100): Full anthropomorphic form.
+ // Fully transformed while maintaining attractiveness and personality.
+ MorphLevelCreature MorphLevel = 100
+)
+
+// Band returns the band name for a given morph level value.
+func (m MorphLevel) Band() string {
+ return MorphLevelBand(int(m))
+}
+
+// MorphLevelBand returns the band name for a given morph level value.
+func MorphLevelBand(level int) string {
+ switch {
+ case level >= 100:
+ return "creature"
+ case level >= 75:
+ return "hybrid"
+ case level >= 50:
+ return "demi_human"
+ case level >= 25:
+ return "subtle"
+ default:
+ return "human"
+ }
+}
+
+// RequiresFeatures returns true if the morph level allows non-human features.
+func MorphLevelRequiresFeatures(level int) bool {
+ return level >= 25
+}
+
+// ParseMorphLevel parses a band name to its minimum MorphLevel value.
+func ParseMorphLevel(band string) MorphLevel {
+ switch strings.ToLower(strings.TrimSpace(band)) {
+ case "creature":
+ return MorphLevelCreature
+ case "hybrid":
+ return MorphLevelHybrid
+ case "demi_human", "demihuman":
+ return MorphLevelDemiHuman
+ case "subtle":
+ return MorphLevelSubtle
+ default:
+ return MorphLevelHuman
+ }
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/validation.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/validation.go.tmpl
new file mode 100644
index 0000000..9d075c8
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/validation.go.tmpl
@@ -0,0 +1,138 @@
+package persona
+
+import "fmt"
+
+// Validate performs comprehensive validation on a character.
+// Validation is species-aware and applies appropriate rules:
+// - Human: Ethnicity plausibility + consistency checks
+// - Humanoid: Morphology validation + consistency checks
+// - Android: Minimal validation (synthetic beings)
+func Validate(c *Character, opts ...ValidateOption) error {
+ if c == nil {
+ return ErrNilCharacter
+ }
+
+ cfg := defaultValidateConfig()
+ for _, opt := range opts {
+ opt(cfg)
+ }
+
+ var errs ValidationErrors
+
+ // Basic validation
+ if err := validateBasics(c); err != nil {
+ errs = append(errs, ValidationError{
+ Message: err.Error(),
+ Err: err,
+ })
+ }
+
+ // Species-specific validation
+ switch c.Species {
+ case SpeciesHuman:
+ if !cfg.skipPlausibility {
+ if violations := ValidateHumanPlausibility(c.DNA); violations != nil && !violations.Valid {
+ for _, v := range violations.Violations {
+ if v.Level == PlausibilityImplausible && !cfg.allowRareFeatures {
+ errs.AddValue(v.Field, v.Value, v.Message, ErrImplausibleFeature)
+ } else if cfg.strictMode && v.Level == PlausibilityRare {
+ errs.AddValue(v.Field, v.Value, v.Message, ErrImplausibleFeature)
+ }
+ }
+ }
+ }
+
+ case SpeciesHumanoid:
+ if !cfg.skipMorphology {
+ if morphErrs := ValidateHumanoidMorphology(c); morphErrs.HasErrors() {
+ errs = append(errs, morphErrs...)
+ }
+ }
+
+ case SpeciesAndroid:
+ // Minimal validation for synthetic beings
+ // Just ensure basic structure is valid
+
+ default:
+ errs.Add("species", fmt.Sprintf("unknown species type: %s", c.Species), ErrInvalidSpecies)
+ }
+
+ // Cross-attribute consistency checks
+ if !cfg.skipConsistency && c.DNA != nil {
+ if result := ValidateConsistency(c.DNA); !result.Valid {
+ for _, issue := range result.Issues {
+ if issue.Severity == "error" || cfg.strictMode {
+ errs.Add(issue.Field1, issue.Description, ErrInconsistentAttributes)
+ }
+ }
+ }
+ }
+
+ if errs.HasErrors() {
+ return errs
+ }
+ return nil
+}
+
+// validateBasics performs basic structural validation.
+func validateBasics(c *Character) error {
+ if c.ID == "" {
+ return fmt.Errorf("character ID is required")
+ }
+
+ if !c.Species.IsValid() {
+ return ErrInvalidSpecies
+ }
+
+ if c.DNA == nil {
+ return ErrNilDNA
+ }
+
+ // Validate identity
+ if c.DNA.Identity.Age < 0 || c.DNA.Identity.Age > 150 {
+ return ErrInvalidAge
+ }
+
+ if c.DNA.Identity.Ethnicity != "" && !c.DNA.Identity.Ethnicity.IsValid() {
+ return ErrInvalidEthnicity
+ }
+
+ if c.DNA.Identity.Gender != "" && !c.DNA.Identity.Gender.IsValid() {
+ return ErrInvalidGender
+ }
+
+ return nil
+}
+
+// ValidateHuman is a convenience function for validating human characters.
+func ValidateHuman(c *Character, opts ...ValidateOption) error {
+ if c == nil {
+ return ErrNilCharacter
+ }
+ if c.Species != SpeciesHuman {
+ return fmt.Errorf("expected human species, got %s", c.Species)
+ }
+ return Validate(c, opts...)
+}
+
+// ValidateHumanoid is a convenience function for validating humanoid characters.
+func ValidateHumanoid(c *Character, opts ...ValidateOption) error {
+ if c == nil {
+ return ErrNilCharacter
+ }
+ if c.Species != SpeciesHumanoid {
+ return fmt.Errorf("expected humanoid species, got %s", c.Species)
+ }
+ return Validate(c, opts...)
+}
+
+// ValidateAndroid is a convenience function for validating android characters.
+func ValidateAndroid(c *Character, opts ...ValidateOption) error {
+ if c == nil {
+ return ErrNilCharacter
+ }
+ if c.Species != SpeciesAndroid {
+ return fmt.Errorf("expected android species, got %s", c.Species)
+ }
+ return Validate(c, opts...)
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/voice.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/voice.go.tmpl
new file mode 100644
index 0000000..f7b7bd6
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/persona/voice.go.tmpl
@@ -0,0 +1,149 @@
+package persona
+
+// VoiceDNA contains all voice specifications for audio/video generation.
+// These characteristics define the character's unique vocal identity for TTS.
+type VoiceDNA struct {
+ // Pitch is the fundamental vocal pitch category.
+ Pitch PitchCategory `json:"pitch" yaml:"pitch"`
+
+ // PitchRange describes the range of pitch variation.
+ PitchRange PitchRangeCategory `json:"pitch_range" yaml:"pitch_range"`
+
+ // Tone is the overall vocal tone quality.
+ Tone ToneCategory `json:"tone" yaml:"tone"`
+
+ // Timbre is the vocal texture/quality.
+ Timbre TimbreCategory `json:"timbre" yaml:"timbre"`
+
+ // Accent is the accent family.
+ Accent AccentCategory `json:"accent" yaml:"accent"`
+
+ // AccentStrength is the intensity of the accent.
+ AccentStrength AccentStrengthCategory `json:"accent_strength" yaml:"accent_strength"`
+
+ // Cadence is the speaking rhythm pattern.
+ Cadence CadenceCategory `json:"cadence" yaml:"cadence"`
+
+ // RhythmPattern describes the speech rhythm.
+ RhythmPattern RhythmPatternCategory `json:"rhythm_pattern" yaml:"rhythm_pattern"`
+
+ // Volume is the characteristic speaking volume.
+ Volume VolumeCategory `json:"volume" yaml:"volume"`
+
+ // Clarity is the articulation clarity.
+ Clarity ClarityCategory `json:"clarity" yaml:"clarity"`
+
+ // Expressiveness is the emotional range in speech.
+ Expressiveness ExpressivenessCategory `json:"expressiveness" yaml:"expressiveness"`
+}
+
+// Pitch categories
+type PitchCategory string
+
+const (
+ PitchVeryLow PitchCategory = "very_low"
+ PitchLow PitchCategory = "low"
+ PitchMedium PitchCategory = "medium"
+ PitchHigh PitchCategory = "high"
+ PitchVeryHigh PitchCategory = "very_high"
+)
+
+// Pitch range categories
+type PitchRangeCategory string
+
+const (
+ PitchRangeNarrow PitchRangeCategory = "narrow"
+ PitchRangeModerate PitchRangeCategory = "moderate"
+ PitchRangeWide PitchRangeCategory = "wide"
+)
+
+// Tone categories
+type ToneCategory string
+
+const (
+ ToneWarm ToneCategory = "warm"
+ ToneCool ToneCategory = "cool"
+ ToneNeutral ToneCategory = "neutral"
+ ToneRich ToneCategory = "rich"
+ ToneBright ToneCategory = "bright"
+)
+
+// Timbre categories
+type TimbreCategory string
+
+const (
+ TimbreClear TimbreCategory = "clear"
+ TimbreSmooth TimbreCategory = "smooth"
+ TimbreHusky TimbreCategory = "husky"
+ TimbreBreathy TimbreCategory = "breathy"
+ TimbreRich TimbreCategory = "rich"
+ TimbreCrisp TimbreCategory = "crisp"
+)
+
+// Accent categories
+type AccentCategory string
+
+const (
+ AccentNorthAmerican AccentCategory = "north_american"
+ AccentBritish AccentCategory = "british"
+ AccentAustralian AccentCategory = "australian"
+ AccentGlobalEnglish AccentCategory = "global_english"
+ AccentNonNative AccentCategory = "non_native"
+)
+
+// Accent strength categories
+type AccentStrengthCategory string
+
+const (
+ AccentStrengthSubtle AccentStrengthCategory = "subtle"
+ AccentStrengthModerate AccentStrengthCategory = "moderate"
+ AccentStrengthStrong AccentStrengthCategory = "strong"
+)
+
+// Cadence categories
+type CadenceCategory string
+
+const (
+ CadenceVerySlow CadenceCategory = "very_slow"
+ CadenceSlow CadenceCategory = "slow"
+ CadenceMedium CadenceCategory = "medium"
+ CadenceFast CadenceCategory = "fast"
+ CadenceVeryFast CadenceCategory = "very_fast"
+)
+
+// Rhythm pattern categories
+type RhythmPatternCategory string
+
+const (
+ RhythmPatternSteady RhythmPatternCategory = "steady"
+ RhythmPatternVariable RhythmPatternCategory = "variable"
+ RhythmPatternDynamic RhythmPatternCategory = "dynamic"
+)
+
+// Volume categories
+type VolumeCategory string
+
+const (
+ VolumeSoft VolumeCategory = "soft"
+ VolumeModerate VolumeCategory = "moderate"
+ VolumeLoud VolumeCategory = "loud"
+)
+
+// Clarity categories
+type ClarityCategory string
+
+const (
+ ClarityCrisp ClarityCategory = "crisp"
+ ClarityNatural ClarityCategory = "natural"
+ ClarityRelaxed ClarityCategory = "relaxed"
+)
+
+// Expressiveness categories
+type ExpressivenessCategory string
+
+const (
+ ExpressivenessMonotone ExpressivenessCategory = "monotone"
+ ExpressivenessModerate ExpressivenessCategory = "moderate"
+ ExpressivenessExpressive ExpressivenessCategory = "expressive"
+ ExpressivenessAnimated ExpressivenessCategory = "animated"
+)
diff --git a/internal/adapter/templates/templates/skeleton/pkg/queue/memory.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/queue/memory.go.tmpl
new file mode 100644
index 0000000..1003a55
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/queue/memory.go.tmpl
@@ -0,0 +1,86 @@
+package queue
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// MemoryQueue is an in-memory job queue that dispatches jobs to registered handlers
+// in goroutines. Use this for local development when no database is available.
+// Implements Producer so the service handlers can enqueue jobs without caring about the backend.
+type MemoryQueue struct {
+ handlers map[string]Handler
+ mu sync.RWMutex
+ logger *slog.Logger
+}
+
+// NewMemoryQueue creates an in-memory queue for standalone/development mode.
+func NewMemoryQueue(logger *slog.Logger) *MemoryQueue {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &MemoryQueue{
+ handlers: make(map[string]Handler),
+ logger: logger,
+ }
+}
+
+// RegisterHandler registers a handler for a specific job type.
+// When Enqueue is called with this job type, the handler runs in a goroutine.
+func (q *MemoryQueue) RegisterHandler(jobType string, handler Handler) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ q.handlers[jobType] = handler
+ q.logger.Info("registered in-memory job handler", "job_type", jobType)
+}
+
+// Enqueue creates a job and immediately dispatches it to the registered handler in a goroutine.
+func (q *MemoryQueue) Enqueue(ctx context.Context, jobType string, payload map[string]any) (string, error) {
+ return q.EnqueueWithOptions(ctx, Job{
+ Type: jobType,
+ Payload: payload,
+ })
+}
+
+// EnqueueWithOptions creates a job with custom options and dispatches it.
+func (q *MemoryQueue) EnqueueWithOptions(_ context.Context, job Job) (string, error) {
+ if job.ID == "" {
+ job.ID = uuid.New().String()
+ }
+ if job.CreatedAt.IsZero() {
+ job.CreatedAt = time.Now().UTC()
+ }
+
+ q.mu.RLock()
+ handler, ok := q.handlers[job.Type]
+ q.mu.RUnlock()
+
+ if !ok {
+ return "", fmt.Errorf("no handler registered for job type %q", job.Type)
+ }
+
+ q.logger.Info("dispatching in-memory job", "job_id", job.ID, "job_type", job.Type)
+
+ // Process in background goroutine (mirrors worker behavior).
+ go func() {
+ job.Status = StatusRunning
+ now := time.Now().UTC()
+ job.StartedAt = &now
+
+ if err := handler(context.Background(), &job); err != nil {
+ q.logger.Error("in-memory job failed", "job_id", job.ID, "job_type", job.Type, "error", err)
+ } else {
+ q.logger.Info("in-memory job completed", "job_id", job.ID, "job_type", job.Type)
+ }
+ }()
+
+ return job.ID, nil
+}
+
+// Compile-time check that MemoryQueue implements Producer.
+var _ Producer = (*MemoryQueue)(nil)
diff --git a/internal/adapter/templates/templates/skeleton/pkg/queue/postgres.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/queue/postgres.go.tmpl
index 7721f02..a9f2cea 100644
--- a/internal/adapter/templates/templates/skeleton/pkg/queue/postgres.go.tmpl
+++ b/internal/adapter/templates/templates/skeleton/pkg/queue/postgres.go.tmpl
@@ -3,6 +3,7 @@ package queue
import (
"context"
"database/sql"
+ "embed"
"encoding/json"
"errors"
"fmt"
@@ -11,29 +12,39 @@ import (
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
+ "{{GO_MODULE}}/pkg/database"
"{{GO_MODULE}}/pkg/logging"
)
-// PostgresQueue implements Producer and Consumer using PostgreSQL.
+//go:embed migrations/*.sql
+var migrationsFS embed.FS
+
+// RunMigrations creates the jobs table if it doesn't exist.
+// Safe to call from both service and worker (idempotent).
+func RunMigrations(ctx context.Context, pool *database.Pool) error {
+ return database.RunMigrations(ctx, pool, migrationsFS, "migrations")
+}
+
+// DBQueue implements Producer and Consumer using SQL (PostgreSQL or CockroachDB).
// Uses FOR UPDATE SKIP LOCKED for atomic, non-blocking dequeue.
-type PostgresQueue struct {
+type DBQueue struct {
db *sqlx.DB
logger *logging.Logger
}
-// Ensure PostgresQueue implements Queue at compile time.
-var _ Queue = (*PostgresQueue)(nil)
+// Ensure DBQueue implements Queue at compile time.
+var _ Queue = (*DBQueue)(nil)
-// NewPostgresQueue creates a queue backed by PostgreSQL.
-func NewPostgresQueue(db *sqlx.DB, logger *logging.Logger) *PostgresQueue {
- return &PostgresQueue{
+// NewQueue creates a queue backed by a SQL database (PostgreSQL or CockroachDB).
+func NewQueue(db *sqlx.DB, logger *logging.Logger) *DBQueue {
+ return &DBQueue{
db: db,
logger: logger.WithComponent("queue"),
}
}
// Enqueue adds a job to the queue with default options.
-func (q *PostgresQueue) Enqueue(ctx context.Context, jobType string, payload map[string]any) (string, error) {
+func (q *DBQueue) Enqueue(ctx context.Context, jobType string, payload map[string]any) (string, error) {
return q.EnqueueWithOptions(ctx, Job{
Type: jobType,
Payload: payload,
@@ -43,7 +54,7 @@ func (q *PostgresQueue) Enqueue(ctx context.Context, jobType string, payload map
}
// EnqueueWithOptions adds a job with custom configuration.
-func (q *PostgresQueue) EnqueueWithOptions(ctx context.Context, job Job) (string, error) {
+func (q *DBQueue) EnqueueWithOptions(ctx context.Context, job Job) (string, error) {
// Validate required fields
if job.Type == "" {
return "", fmt.Errorf("job type is required: %w", ErrJobNotFound)
@@ -83,7 +94,7 @@ func (q *PostgresQueue) EnqueueWithOptions(ctx context.Context, job Job) (string
// Dequeue atomically claims the next pending job.
// Uses UPDATE with subquery + FOR UPDATE SKIP LOCKED for atomic, non-blocking claim.
-func (q *PostgresQueue) Dequeue(ctx context.Context, workerID string) (*Job, error) {
+func (q *DBQueue) Dequeue(ctx context.Context, workerID string) (*Job, error) {
now := time.Now().UTC()
// Atomic claim: UPDATE with subquery + FOR UPDATE SKIP LOCKED
@@ -120,7 +131,7 @@ func (q *PostgresQueue) Dequeue(ctx context.Context, workerID string) (*Job, err
}
// Ack marks a job as successfully completed.
-func (q *PostgresQueue) Ack(ctx context.Context, jobID string) error {
+func (q *DBQueue) Ack(ctx context.Context, jobID string) error {
now := time.Now().UTC()
result, err := q.db.ExecContext(ctx, `
UPDATE jobs SET status = $1, completed_at = $2 WHERE id = $3
@@ -143,7 +154,7 @@ func (q *PostgresQueue) Ack(ctx context.Context, jobID string) error {
// Fail marks a job as failed, requeuing if retries remain.
// Uses atomic UPDATE to handle retry logic in a single query.
-func (q *PostgresQueue) Fail(ctx context.Context, jobID string, errMsg string) error {
+func (q *DBQueue) Fail(ctx context.Context, jobID string, errMsg string) error {
// Atomic: increment retry_count, check if should requeue or fail permanently.
// When retrying: clear worker_id and started_at, set status to pending.
// When exhausted: set status to failed, set completed_at.
@@ -194,7 +205,7 @@ func (q *PostgresQueue) Fail(ctx context.Context, jobID string, errMsg string) e
// Heartbeat extends the job's visibility timeout.
// Updates started_at to prevent RequeueStale from reclaiming the job.
-func (q *PostgresQueue) Heartbeat(ctx context.Context, jobID string) error {
+func (q *DBQueue) Heartbeat(ctx context.Context, jobID string) error {
result, err := q.db.ExecContext(ctx, `
UPDATE jobs SET started_at = $1 WHERE id = $2 AND status = $3
`, time.Now().UTC(), jobID, StatusRunning)
@@ -216,7 +227,7 @@ func (q *PostgresQueue) Heartbeat(ctx context.Context, jobID string) error {
// RequeueStale requeues jobs that have been running too long without heartbeat.
// Call this periodically (e.g., every minute) to recover from crashed workers.
// Returns the number of jobs requeued.
-func (q *PostgresQueue) RequeueStale(ctx context.Context, timeout time.Duration) (int64, error) {
+func (q *DBQueue) RequeueStale(ctx context.Context, timeout time.Duration) (int64, error) {
cutoff := time.Now().UTC().Add(-timeout)
result, err := q.db.ExecContext(ctx, `
UPDATE jobs
@@ -238,7 +249,7 @@ func (q *PostgresQueue) RequeueStale(ctx context.Context, timeout time.Duration)
}
// GetJob retrieves a job by ID (for inspection/debugging).
-func (q *PostgresQueue) GetJob(ctx context.Context, jobID string) (*Job, error) {
+func (q *DBQueue) GetJob(ctx context.Context, jobID string) (*Job, error) {
var job jobRow
err := q.db.QueryRowxContext(ctx, `
SELECT id, job_type, payload, status, priority, created_at, started_at,
diff --git a/internal/adapter/templates/templates/skeleton/pkg/queue/queue.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/queue/queue.go.tmpl
index 7b1f9e7..fb83026 100644
--- a/internal/adapter/templates/templates/skeleton/pkg/queue/queue.go.tmpl
+++ b/internal/adapter/templates/templates/skeleton/pkg/queue/queue.go.tmpl
@@ -1,4 +1,5 @@
-// Package queue provides a PostgreSQL-backed job queue for async processing.
+// Package queue provides a SQL-compatible job queue for async processing.
+// Works with PostgreSQL and CockroachDB.
//
// This package implements a reliable producer/consumer pattern using:
// - Atomic dequeue with FOR UPDATE SKIP LOCKED
@@ -8,15 +9,18 @@
//
// Usage:
//
+// // Run migrations (idempotent, call from service and worker)
+// queue.RunMigrations(ctx, pool)
+//
// // Producer: enqueue a job
-// producer := queue.NewPostgresQueue(pool.DB, logger)
+// producer := queue.NewQueue(pool.DB, logger)
// jobID, err := producer.Enqueue(ctx, "send_email", map[string]any{
// "to": "user@example.com",
// "subject": "Welcome!",
// })
//
// // Consumer: process jobs
-// consumer := queue.NewPostgresQueue(pool.DB, logger)
+// consumer := queue.NewQueue(pool.DB, logger)
// job, err := consumer.Dequeue(ctx, "worker-1")
// if err == queue.ErrNoJob {
// // Queue is empty
diff --git a/internal/adapter/templates/templates/skeleton/pkg/realtime/handler.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/realtime/handler.go.tmpl
index 1984d22..ec50303 100644
--- a/internal/adapter/templates/templates/skeleton/pkg/realtime/handler.go.tmpl
+++ b/internal/adapter/templates/templates/skeleton/pkg/realtime/handler.go.tmpl
@@ -3,8 +3,10 @@ package realtime
import (
"context"
"net/http"
+ "time"
"github.com/go-chi/chi/v5"
+ "github.com/google/uuid"
"{{GO_MODULE}}/pkg/auth"
"{{GO_MODULE}}/pkg/logging"
@@ -29,6 +31,15 @@ type HandlerConfig struct {
// AuthRequired requires authentication for WebSocket connections.
// If true, unauthenticated connections are rejected.
AuthRequired bool
+
+ // JWTValidator validates JWT tokens from query parameters (optional).
+ // If set, tokens passed via ?token= query param will be validated.
+ JWTValidator auth.Validator
+
+ // AllowedOrigins is a whitelist of allowed origins for WebSocket connections.
+ // If empty, all origins are allowed (suitable for development only).
+ // In production, set this to your frontend domain(s).
+ AllowedOrigins []string
}
// Handler handles WebSocket connections.
@@ -66,10 +77,30 @@ func (h *Handler) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
room = r.URL.Query().Get("room")
}
- // Extract user from auth context
+ // Extract user from auth context OR from query parameters
var userID string
- if claims := auth.ClaimsFromContext(r.Context()); claims != nil {
- userID = claims.Subject
+ var userName string
+
+ if user := auth.GetUser(r.Context()); user != nil {
+ userID = user.ID
+ userName = user.Email // Use email as display name from auth context
+ } else if token := r.URL.Query().Get("token"); token != "" && h.config.JWTValidator != nil {
+ // Validate JWT token from query parameter
+ user, err := h.config.JWTValidator.Validate(r.Context(), token)
+ if err != nil {
+ h.logger.Debug("token validation failed", "error", err)
+ } else {
+ userID = user.ID
+ userName = user.Email
+ }
+ }
+
+ // Fall back to query params for userId/userName (for testing or anonymous users)
+ if userID == "" {
+ userID = r.URL.Query().Get("userId")
+ }
+ if userName == "" {
+ userName = r.URL.Query().Get("userName")
}
// Check auth requirement
@@ -78,8 +109,8 @@ func (h *Handler) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
return
}
- // Upgrade connection
- conn, err := UpgradeConnection(w, r)
+ // Upgrade connection with origin check
+ conn, err := UpgradeConnectionWithOrigins(w, r, h.config.AllowedOrigins)
if err != nil {
h.logger.Warn("websocket upgrade failed", "error", err)
return
@@ -88,12 +119,14 @@ func (h *Handler) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
// Create client
client := NewWSClient(h.hub, conn, h.logger, WSClientConfig{
UserID: userID,
+ UserName: userName,
OnMessage: h.makeMessageHandler(room),
})
h.logger.Info("websocket connection established",
"client_id", client.ID(),
"user_id", userID,
+ "user_name", userName,
"room", room,
)
@@ -102,6 +135,11 @@ func (h *Handler) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
h.hub.JoinRoom(client, room)
}
+ // Broadcast presence (user joined)
+ if room != "" {
+ h.broadcastPresence(client, room, userID, userName, PresenceOnline)
+ }
+
// Notify connect callback
if h.config.OnConnect != nil {
h.config.OnConnect(client)
@@ -110,6 +148,11 @@ func (h *Handler) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
// Run connection (blocks until closed)
client.Run(r.Context())
+ // Broadcast presence (user left)
+ if room != "" {
+ h.broadcastPresence(client, room, userID, userName, PresenceOffline)
+ }
+
// Notify disconnect callback
if h.config.OnDisconnect != nil {
h.config.OnDisconnect(client)
@@ -124,10 +167,16 @@ func (h *Handler) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
// makeMessageHandler creates the message callback for a client.
func (h *Handler) makeMessageHandler(defaultRoom string) func(*WSClient, *Message) {
return func(client *WSClient, msg *Message) {
- // Set room if not specified
+ // Assign server-side fields for proper message tracking
+ if msg.ID == "" {
+ msg.ID = uuid.New().String()
+ }
if msg.Room == "" {
msg.Room = defaultRoom
}
+ if msg.Timestamp.IsZero() {
+ msg.Timestamp = time.Now().UTC()
+ }
// Call user callback for message transformation/filtering
if h.config.OnMessage != nil {
@@ -153,6 +202,38 @@ func (h *Handler) makeMessageHandler(defaultRoom string) func(*WSClient, *Messag
}
}
+// broadcastPresence sends a presence message to the room.
+func (h *Handler) broadcastPresence(client Connection, room, userID, userName, status string) {
+ presenceData, err := SystemMessage(MessageTypePresence, PresenceData{
+ UserID: userID,
+ UserName: userName,
+ Status: status,
+ })
+ if err != nil {
+ h.logger.Warn("failed to create presence message", "error", err)
+ return
+ }
+
+ presenceData.ID = uuid.New().String()
+ presenceData.Room = room
+ presenceData.From = client.ID()
+ presenceData.Timestamp = time.Now().UTC()
+
+ // Broadcast via Redis if available, otherwise local only
+ if h.broadcaster != nil {
+ if err := h.broadcaster.Publish(context.Background(), presenceData); err != nil {
+ h.logger.Warn("failed to publish presence to broadcaster",
+ "error", err,
+ "status", status,
+ )
+ // Fall back to local broadcast
+ h.hub.Broadcast(presenceData)
+ }
+ } else {
+ h.hub.Broadcast(presenceData)
+ }
+}
+
// Stats returns connection statistics.
type Stats struct {
TotalConnections int `json:"total_connections"`
diff --git a/internal/adapter/templates/templates/skeleton/pkg/realtime/hub.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/realtime/hub.go.tmpl
index f8abd68..c9fcdae 100644
--- a/internal/adapter/templates/templates/skeleton/pkg/realtime/hub.go.tmpl
+++ b/internal/adapter/templates/templates/skeleton/pkg/realtime/hub.go.tmpl
@@ -46,8 +46,20 @@ func NewHub(logger *logging.Logger) *LocalHub {
}
}
+// NewLocalHub is an alias for NewHub for clarity.
+func NewLocalHub(logger *logging.Logger) *LocalHub {
+ return NewHub(logger)
+}
+
// Run starts the hub's event loop. Call this in a goroutine.
-func (h *LocalHub) Run(ctx context.Context) {
+// This version runs indefinitely until the process exits.
+func (h *LocalHub) Run() {
+ h.RunContext(context.Background())
+}
+
+// RunContext starts the hub's event loop with context support.
+// The hub will shut down when the context is cancelled.
+func (h *LocalHub) RunContext(ctx context.Context) {
h.logger.Info("hub started")
defer h.logger.Info("hub stopped")
diff --git a/internal/adapter/templates/templates/skeleton/pkg/realtime/publisher.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/realtime/publisher.go.tmpl
new file mode 100644
index 0000000..89dfbc8
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/realtime/publisher.go.tmpl
@@ -0,0 +1,39 @@
+package realtime
+
+// EventPublisher sends SSE events to users and channels.
+// Implementations:
+// - SSEPublisher: publishes via Redis for cross-process delivery (production)
+// - LocalPublisher: delivers directly to SSEHub in-process (development)
+type EventPublisher interface {
+ SendToUser(userID string, event *SSEEvent) error
+ SendToChannel(channel string, event *SSEEvent) error
+}
+
+// Compile-time interface checks.
+var (
+ _ EventPublisher = (*SSEPublisher)(nil)
+ _ EventPublisher = (*LocalPublisher)(nil)
+)
+
+// LocalPublisher delivers SSE events directly to the local SSEHub.
+// Use this when the service and worker run in the same process (standalone/dev mode).
+type LocalPublisher struct {
+ hub *SSEHub
+}
+
+// NewLocalPublisher creates a publisher that sends events directly to the SSEHub.
+func NewLocalPublisher(hub *SSEHub) *LocalPublisher {
+ return &LocalPublisher{hub: hub}
+}
+
+// SendToUser sends an event to a user's SSE channel.
+func (p *LocalPublisher) SendToUser(userID string, event *SSEEvent) error {
+ p.hub.SendToUser(userID, event)
+ return nil
+}
+
+// SendToChannel sends an event to a named SSE channel.
+func (p *LocalPublisher) SendToChannel(channel string, event *SSEEvent) error {
+ p.hub.SendToChannel(channel, event)
+ return nil
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/realtime/realtime.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/realtime/realtime.go.tmpl
index f147655..d82f5bc 100644
--- a/internal/adapter/templates/templates/skeleton/pkg/realtime/realtime.go.tmpl
+++ b/internal/adapter/templates/templates/skeleton/pkg/realtime/realtime.go.tmpl
@@ -1,26 +1,32 @@
-// Package realtime provides WebSocket communication with Redis-backed broadcasting.
+// Package realtime provides SSE (Server-Sent Events) with Redis-backed broadcasting.
//
-// This package enables real-time bidirectional communication with:
-// - WebSocket connections with automatic ping/pong heartbeat
-// - Room-based message grouping
-// - Cross-pod broadcasting via Redis Pub/Sub
-// - Graceful connection lifecycle management
+// IMPORTANT: NO WEBSOCKETS. All real-time communication uses HTTP2 + SSE.
+// - User → Server: HTTP2 POST/PUT/DELETE (standard REST)
+// - Server → User: SSE (one-way event stream)
+//
+// Event flow: server → redis → redis listeners → SSE hub → user
+//
+// This pattern applies to ALL real-time features: chat, notifications, progress, presence.
//
// Usage:
//
-// // Create a hub (local connection registry)
-// hub := realtime.NewHub(logger)
-// go hub.Run(ctx)
+// // Create SSE hub
+// sseHub := realtime.NewSSEHub(logger)
//
// // Optional: Add Redis for multi-pod scaling
-// redisBroadcaster := realtime.NewRedisBroadcaster(redisClient, hub, logger)
+// redisBroadcaster := realtime.NewRedisBroadcaster(redisClient, sseHub, logger)
// go redisBroadcaster.Run(ctx)
//
-// // Mount the WebSocket handler
-// handler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{
-// Broadcaster: redisBroadcaster, // nil for single-pod
+// // Mount SSE handler
+// sseHandler := realtime.NewSSEHandler(sseHub, logger)
+// r.Mount("/api/events", sseHandler.Routes())
+//
+// // In your API handlers, publish events:
+// sseHub.SendToUser(userID, &realtime.SSEEvent{
+// Type: "generation_complete",
+// JobID: jobID,
+// Result: result,
// })
-// r.Mount("/ws", handler.Routes())
package realtime
import (
@@ -72,6 +78,9 @@ type Connection interface {
// UserID returns the authenticated user ID (empty if anonymous).
UserID() string
+ // UserName returns the display name for the user.
+ UserName() string
+
// Send queues a message for delivery to this connection.
// Returns false if the connection is closed or send buffer is full.
Send(msg *Message) bool
@@ -121,6 +130,8 @@ type Broadcaster interface {
// MessageType constants for common message types.
const (
MessageTypeChat = "chat"
+ MessageTypeAIChat = "ai_chat"
+ MessageTypeAIChatChunk = "ai_chat_chunk"
MessageTypePresence = "presence"
MessageTypeNotification = "notification"
MessageTypeSystem = "system"
@@ -138,12 +149,69 @@ const (
// PresenceData represents presence change data.
type PresenceData struct {
- Status string `json:"status"`
- UserID string `json:"user_id"`
+ Status string `json:"status"`
+ UserID string `json:"userId"`
+ UserName string `json:"userName,omitempty"`
}
// JoinLeaveData represents room join/leave data.
type JoinLeaveData struct {
Room string `json:"room"`
- UserID string `json:"user_id"`
+ UserID string `json:"userId"`
+}
+
+// ChatData represents user chat message data.
+type ChatData struct {
+ Content string `json:"content"`
+ UserID string `json:"userId"`
+ UserName string `json:"userName,omitempty"`
+}
+
+// AIResponseData represents AI-generated response data.
+type AIResponseData struct {
+ Content string `json:"content"`
+ Provider string `json:"provider"`
+}
+
+// AIChunkData represents a streaming chunk of AI response.
+type AIChunkData struct {
+ // StreamID identifies this stream (for correlating chunks).
+ StreamID string `json:"streamId"`
+ // Text is the chunk content.
+ Text string `json:"text"`
+ // Done indicates this is the final chunk.
+ Done bool `json:"done"`
+ // Provider name (only set on final chunk).
+ Provider string `json:"provider,omitempty"`
+}
+
+// ---------------------------------------------------------------------------
+// Generation/Upload Events (sent via SSE, not WebSocket)
+// ---------------------------------------------------------------------------
+
+// GenerationEvent types for async job progress.
+const (
+ EventGenerationStarted = "generation_started"
+ EventGenerationProgress = "generation_progress"
+ EventGenerationComplete = "generation_complete"
+ EventGenerationFailed = "generation_failed"
+ EventUploadStarted = "upload_started"
+ EventUploadProgress = "upload_progress"
+ EventUploadComplete = "upload_complete"
+ EventUploadFailed = "upload_failed"
+)
+
+// GenerationResult is the result payload for generation_complete events.
+type GenerationResult struct {
+ URL string `json:"url"`
+ Provider string `json:"provider"`
+ LatencyMs int64 `json:"latencyMs"`
+}
+
+// UploadResult is the result payload for upload_complete events.
+type UploadResult struct {
+ ID string `json:"id"`
+ Original string `json:"original"`
+ Optimized string `json:"optimized"`
+ Thumbnail string `json:"thumbnail"`
}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/realtime/sse.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/realtime/sse.go.tmpl
new file mode 100644
index 0000000..2e9851a
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/realtime/sse.go.tmpl
@@ -0,0 +1,247 @@
+package realtime
+
+import (
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "sync"
+ "time"
+)
+
+// SSEHub manages Server-Sent Events connections for user and channel subscriptions.
+// Use this for one-way server-to-client events (generation progress, uploads, notifications).
+// For bidirectional communication (chat), use WebSocket Hub instead.
+type SSEHub struct {
+ // connections maps channel names to subscriber connections
+ connections map[string]map[*sseConn]struct{}
+ mu sync.RWMutex
+ logger *slog.Logger
+}
+
+// sseConn represents a single SSE connection.
+type sseConn struct {
+ writer http.ResponseWriter
+ flusher http.Flusher
+ done chan struct{}
+}
+
+// NewSSEHub creates a new SSE hub for event distribution.
+func NewSSEHub(logger *slog.Logger) *SSEHub {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &SSEHub{
+ connections: make(map[string]map[*sseConn]struct{}),
+ logger: logger,
+ }
+}
+
+// SSEEvent represents an event to send via SSE.
+type SSEEvent struct {
+ Type string `json:"type"`
+ Timestamp time.Time `json:"timestamp"`
+ JobID string `json:"jobId,omitempty"`
+ Progress int `json:"progress,omitempty"`
+ Message string `json:"message,omitempty"`
+ Result any `json:"result,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// SendToChannel sends an event to all connections subscribed to a channel.
+// Channel format: "user:" for user-specific events, "channel:" for shared events.
+func (h *SSEHub) SendToChannel(channel string, event *SSEEvent) {
+ if event.Timestamp.IsZero() {
+ event.Timestamp = time.Now().UTC()
+ }
+
+ data, err := json.Marshal(event)
+ if err != nil {
+ h.logger.Error("failed to marshal SSE event", "error", err)
+ return
+ }
+
+ h.mu.RLock()
+ conns, ok := h.connections[channel]
+ if !ok || len(conns) == 0 {
+ // Log active channels to help diagnose channel mismatches
+ channels := make([]string, 0, len(h.connections))
+ for ch, cs := range h.connections {
+ channels = append(channels, fmt.Sprintf("%s(%d)", ch, len(cs)))
+ }
+ h.mu.RUnlock()
+ h.logger.Warn("SSE event dropped: no subscribers on channel",
+ "target_channel", channel,
+ "event_type", event.Type,
+ "active_channels", channels,
+ )
+ return
+ }
+
+ // Copy connections to avoid holding lock during send
+ connList := make([]*sseConn, 0, len(conns))
+ for conn := range conns {
+ connList = append(connList, conn)
+ }
+ h.mu.RUnlock()
+
+ for _, conn := range connList {
+ select {
+ case <-conn.done:
+ continue
+ default:
+ h.writeEvent(conn, data)
+ }
+ }
+}
+
+// SendToUser sends an event to all connections for a specific user.
+// Convenience wrapper for SendToChannel("user:", event).
+func (h *SSEHub) SendToUser(userID string, event *SSEEvent) {
+ h.SendToChannel("user:"+userID, event)
+}
+
+// writeEvent writes a single SSE event to a connection.
+func (h *SSEHub) writeEvent(conn *sseConn, data []byte) {
+ select {
+ case <-conn.done:
+ return
+ default:
+ }
+
+ _, err := fmt.Fprintf(conn.writer, "data: %s\n\n", data)
+ if err != nil {
+ return
+ }
+ conn.flusher.Flush()
+}
+
+// subscribe adds a connection to a channel.
+func (h *SSEHub) subscribe(channel string, conn *sseConn) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ if h.connections[channel] == nil {
+ h.connections[channel] = make(map[*sseConn]struct{})
+ }
+ h.connections[channel][conn] = struct{}{}
+}
+
+// unsubscribe removes a connection from a channel.
+func (h *SSEHub) unsubscribe(channel string, conn *sseConn) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ if conns, ok := h.connections[channel]; ok {
+ delete(conns, conn)
+ if len(conns) == 0 {
+ delete(h.connections, channel)
+ }
+ }
+}
+
+// ChannelCount returns the number of active connections for a channel.
+func (h *SSEHub) ChannelCount(channel string) int {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+
+ if conns, ok := h.connections[channel]; ok {
+ return len(conns)
+ }
+ return 0
+}
+
+// SSEHandler handles HTTP requests for SSE event subscriptions.
+type SSEHandler struct {
+ hub *SSEHub
+ logger *slog.Logger
+}
+
+// NewSSEHandler creates a new SSE HTTP handler.
+func NewSSEHandler(hub *SSEHub, logger *slog.Logger) *SSEHandler {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &SSEHandler{
+ hub: hub,
+ logger: logger,
+ }
+}
+
+// ServeHTTP handles SSE subscription requests.
+// Query params:
+// - channel: Channel to subscribe to (e.g., "user:123")
+//
+// Example: GET /api/events?channel=user:123
+func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ channel := r.URL.Query().Get("channel")
+ if channel == "" {
+ http.Error(w, "channel parameter required", http.StatusBadRequest)
+ return
+ }
+
+ // Verify channel format: must be "user:" or "channel:"
+ validUser := len(channel) > 5 && channel[:5] == "user:"
+ validChannel := len(channel) > 8 && channel[:8] == "channel:"
+ if !validUser && !validChannel {
+ http.Error(w, "channel must be user: or channel:", http.StatusBadRequest)
+ return
+ }
+
+ // Check for SSE support
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "streaming not supported", http.StatusInternalServerError)
+ return
+ }
+
+ // Set SSE headers
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering
+
+ // Create connection
+ conn := &sseConn{
+ writer: w,
+ flusher: flusher,
+ done: make(chan struct{}),
+ }
+
+ // Subscribe to channel
+ h.hub.subscribe(channel, conn)
+ defer h.hub.unsubscribe(channel, conn)
+
+ h.logger.Info("SSE client connected", "channel", channel)
+
+ // Send initial connection event
+ h.hub.writeEvent(conn, []byte(`{"type":"connected"}`))
+
+ // Keep connection alive until client disconnects
+ ctx := r.Context()
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ close(conn.done)
+ h.logger.Debug("SSE client disconnected", "channel", channel)
+ return
+ case <-ticker.C:
+ // Send keepalive comment
+ _, err := fmt.Fprintf(w, ": keepalive\n\n")
+ if err != nil {
+ close(conn.done)
+ return
+ }
+ flusher.Flush()
+ }
+ }
+}
+
+// Routes returns an http.Handler for the SSE endpoint.
+// Mount at /api/events or similar.
+func (h *SSEHandler) Routes() http.Handler {
+ return h
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/realtime/sse_redis.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/realtime/sse_redis.go.tmpl
new file mode 100644
index 0000000..dc18b9b
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/realtime/sse_redis.go.tmpl
@@ -0,0 +1,97 @@
+package realtime
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "strings"
+
+ "github.com/redis/go-redis/v9"
+)
+
+const sseChannelPrefix = "sse:"
+
+// SSEPublisher publishes SSE events to Redis for cross-process delivery.
+// Used by the worker to send events that reach SSE-connected clients via the service.
+type SSEPublisher struct {
+ client *redis.Client
+ logger *slog.Logger
+}
+
+// NewSSEPublisher creates a publisher that sends SSE events via Redis pub/sub.
+func NewSSEPublisher(client *redis.Client, logger *slog.Logger) *SSEPublisher {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &SSEPublisher{
+ client: client,
+ logger: logger,
+ }
+}
+
+// SendToUser publishes an SSE event to a user-specific channel.
+// The service's SSE subscriber picks this up and delivers to the connected client.
+func (p *SSEPublisher) SendToUser(userID string, event *SSEEvent) error {
+ return p.SendToChannel("user:"+userID, event)
+}
+
+// SendToChannel publishes an SSE event to a named channel via Redis.
+func (p *SSEPublisher) SendToChannel(channel string, event *SSEEvent) error {
+ data, err := json.Marshal(event)
+ if err != nil {
+ return fmt.Errorf("marshal SSE event: %w", err)
+ }
+
+ redisChannel := sseChannelPrefix + channel
+ if err := p.client.Publish(context.Background(), redisChannel, data).Err(); err != nil {
+ p.logger.Error("failed to publish SSE event to Redis",
+ "channel", channel, "event_type", event.Type, "error", err)
+ return fmt.Errorf("publish SSE event: %w", err)
+ }
+
+ p.logger.Debug("published SSE event to Redis",
+ "channel", channel, "event_type", event.Type)
+ return nil
+}
+
+// RunSSESubscriber subscribes to all SSE Redis channels and forwards events
+// to the local SSEHub for delivery to connected clients.
+// Blocks until ctx is cancelled.
+func RunSSESubscriber(ctx context.Context, client *redis.Client, hub *SSEHub, logger *slog.Logger) error {
+ if logger == nil {
+ logger = slog.Default()
+ }
+
+ // PSubscribe to all SSE channels
+ pubsub := client.PSubscribe(ctx, sseChannelPrefix+"*")
+ defer pubsub.Close()
+
+ logger.Info("SSE Redis subscriber started", "pattern", sseChannelPrefix+"*")
+
+ ch := pubsub.Channel()
+ for {
+ select {
+ case <-ctx.Done():
+ logger.Info("SSE Redis subscriber stopping")
+ return ctx.Err()
+ case msg, ok := <-ch:
+ if !ok {
+ return fmt.Errorf("SSE Redis subscription closed")
+ }
+
+ // Extract channel name by stripping the "sse:" prefix
+ channel := strings.TrimPrefix(msg.Channel, sseChannelPrefix)
+
+ var event SSEEvent
+ if err := json.Unmarshal([]byte(msg.Payload), &event); err != nil {
+ logger.Error("failed to parse SSE event from Redis",
+ "channel", channel, "error", err)
+ continue
+ }
+
+ // Deliver to local SSE hub
+ hub.SendToChannel(channel, &event)
+ }
+ }
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/realtime/websocket.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/realtime/websocket.go.tmpl
index 67230f8..fc49e95 100644
--- a/internal/adapter/templates/templates/skeleton/pkg/realtime/websocket.go.tmpl
+++ b/internal/adapter/templates/templates/skeleton/pkg/realtime/websocket.go.tmpl
@@ -32,13 +32,14 @@ const (
// WSClient represents a WebSocket connection to the hub.
type WSClient struct {
- id string
- userID string
- hub Hub
- conn *websocket.Conn
- send chan *Message
- logger *logging.Logger
- onMsg func(*WSClient, *Message) // Optional message callback
+ id string
+ userID string
+ userName string
+ hub Hub
+ conn *websocket.Conn
+ send chan *Message
+ logger *logging.Logger
+ onMsg func(*WSClient, *Message) // Optional message callback
closeOnce sync.Once
}
@@ -50,6 +51,9 @@ type WSClientConfig struct {
// UserID is the authenticated user ID (empty if anonymous).
UserID string
+ // UserName is the display name for the user (optional).
+ UserName string
+
// OnMessage is called for each incoming message.
// If nil, messages are ignored (useful for broadcast-only connections).
OnMessage func(*WSClient, *Message)
@@ -58,13 +62,14 @@ type WSClientConfig struct {
// NewWSClient creates a new WebSocket client from an upgraded connection.
func NewWSClient(hub Hub, conn *websocket.Conn, logger *logging.Logger, cfg WSClientConfig) *WSClient {
return &WSClient{
- id: uuid.New().String(),
- userID: cfg.UserID,
- hub: hub,
- conn: conn,
- send: make(chan *Message, sendBufferSize),
- logger: logger.WithComponent("ws-client"),
- onMsg: cfg.OnMessage,
+ id: uuid.New().String(),
+ userID: cfg.UserID,
+ userName: cfg.UserName,
+ hub: hub,
+ conn: conn,
+ send: make(chan *Message, sendBufferSize),
+ logger: logger.WithComponent("ws-client"),
+ onMsg: cfg.OnMessage,
}
}
@@ -78,6 +83,11 @@ func (c *WSClient) UserID() string {
return c.userID
}
+// UserName returns the display name for the user.
+func (c *WSClient) UserName() string {
+ return c.userName
+}
+
// Send queues a message for delivery.
func (c *WSClient) Send(msg *Message) bool {
select {
@@ -206,18 +216,46 @@ func (c *WSClient) writePump(ctx context.Context) {
}
}
-// Upgrader is a pre-configured WebSocket upgrader.
-// Customize CheckOrigin in production for security.
-var Upgrader = websocket.Upgrader{
- ReadBufferSize: 1024,
- WriteBufferSize: 1024,
- CheckOrigin: func(r *http.Request) bool {
- // TODO: Configure for production (check Origin header)
- return true
- },
+// UpgradeConnection upgrades an HTTP connection to WebSocket.
+// Deprecated: Use UpgradeConnectionWithOrigins for production use.
+func UpgradeConnection(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) {
+ return UpgradeConnectionWithOrigins(w, r, nil)
}
-// UpgradeConnection upgrades an HTTP connection to WebSocket.
-func UpgradeConnection(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) {
- return Upgrader.Upgrade(w, r, nil)
+// UpgradeConnectionWithOrigins upgrades an HTTP connection to WebSocket with origin checking.
+// If allowedOrigins is empty, all origins are allowed (development mode).
+// In production, pass a list of allowed origins (e.g., ["https://example.com"]).
+func UpgradeConnectionWithOrigins(w http.ResponseWriter, r *http.Request, allowedOrigins []string) (*websocket.Conn, error) {
+ upgrader := websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ CheckOrigin: makeOriginChecker(allowedOrigins),
+ }
+ return upgrader.Upgrade(w, r, nil)
+}
+
+// makeOriginChecker creates an origin check function for the WebSocket upgrader.
+// If allowedOrigins is empty, all origins are allowed (development mode).
+func makeOriginChecker(allowedOrigins []string) func(r *http.Request) bool {
+ // If no origins specified, allow all (development mode)
+ if len(allowedOrigins) == 0 {
+ return func(r *http.Request) bool {
+ return true
+ }
+ }
+
+ // Build a set for O(1) lookup
+ allowed := make(map[string]bool, len(allowedOrigins))
+ for _, origin := range allowedOrigins {
+ allowed[origin] = true
+ }
+
+ return func(r *http.Request) bool {
+ origin := r.Header.Get("Origin")
+ if origin == "" {
+ // No Origin header (same-origin request or non-browser client)
+ return true
+ }
+ return allowed[origin]
+ }
}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/routing/circuit_breaker.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/routing/circuit_breaker.go.tmpl
new file mode 100644
index 0000000..f428804
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/routing/circuit_breaker.go.tmpl
@@ -0,0 +1,133 @@
+package routing
+
+import (
+ "sync"
+ "time"
+)
+
+// CircuitBreaker tracks provider failures with tiered cooldowns.
+// Thread-safe for concurrent use. Implements CooldownTracker.
+//
+// The circuit breaker applies different cooldown durations based on error type:
+// - Rate limits (429) and quota errors: DefaultCooldownPeriod (1 hour)
+// - Transient server errors (5xx): TransientCooldownPeriod (30 seconds)
+//
+// This allows quick fallback to backup providers while still giving
+// the primary provider time to recover from transient issues.
+//
+// Note: Exempt providers (ExemptProviders map) never enter cooldown.
+// This is enforced by the executor, not the circuit breaker itself.
+type CircuitBreaker struct {
+ mu sync.RWMutex
+ failedUntil map[string]time.Time // provider name -> time when cooldown ends
+ cooldownPeriod time.Duration // for rate limits/quota
+ transientCooldownPeriod time.Duration // for server errors
+}
+
+// NewCircuitBreaker creates a circuit breaker with default tiered cooldowns.
+// If cooldown is 0, DefaultCooldownPeriod (1 hour) is used for rate limits.
+func NewCircuitBreaker(cooldown time.Duration) *CircuitBreaker {
+ if cooldown == 0 {
+ cooldown = DefaultCooldownPeriod
+ }
+ return &CircuitBreaker{
+ failedUntil: make(map[string]time.Time),
+ cooldownPeriod: cooldown,
+ transientCooldownPeriod: TransientCooldownPeriod,
+ }
+}
+
+// NewCircuitBreakerWithTransientCooldown creates a circuit breaker with
+// custom cooldown periods for rate limits and transient errors.
+func NewCircuitBreakerWithTransientCooldown(rateLimitCooldown, transientCooldown time.Duration) *CircuitBreaker {
+ if rateLimitCooldown == 0 {
+ rateLimitCooldown = DefaultCooldownPeriod
+ }
+ if transientCooldown == 0 {
+ transientCooldown = TransientCooldownPeriod
+ }
+ return &CircuitBreaker{
+ failedUntil: make(map[string]time.Time),
+ cooldownPeriod: rateLimitCooldown,
+ transientCooldownPeriod: transientCooldown,
+ }
+}
+
+// RecordFailure applies tiered cooldowns based on error classification.
+// Returns true if the provider entered cooldown.
+//
+// The cooldown duration depends on the error type:
+// - Rate limit (429) or quota errors: long cooldown
+// - Transient server errors (5xx): short cooldown
+// - Other errors: no cooldown
+func (cb *CircuitBreaker) RecordFailure(providerName string, err error) bool {
+ failureType := ClassifyError(err)
+ if failureType == FailureTypeNone {
+ return false
+ }
+
+ cb.mu.Lock()
+ defer cb.mu.Unlock()
+
+ var cooldown time.Duration
+ switch failureType {
+ case FailureTypeRateLimit:
+ cooldown = cb.cooldownPeriod
+ case FailureTypeTransient:
+ cooldown = cb.transientCooldownPeriod
+ default:
+ return false
+ }
+
+ cb.failedUntil[providerName] = time.Now().Add(cooldown)
+ return true
+}
+
+// IsAvailable returns true if the provider is not in cooldown.
+func (cb *CircuitBreaker) IsAvailable(providerName string) bool {
+ cb.mu.RLock()
+ defer cb.mu.RUnlock()
+
+ until, exists := cb.failedUntil[providerName]
+ if !exists {
+ return true
+ }
+ return time.Now().After(until)
+}
+
+// CooldownRemaining returns how long until a provider's cooldown expires.
+// Returns 0 if the provider is not in cooldown.
+func (cb *CircuitBreaker) CooldownRemaining(providerName string) time.Duration {
+ cb.mu.RLock()
+ defer cb.mu.RUnlock()
+
+ until, exists := cb.failedUntil[providerName]
+ if !exists {
+ return 0
+ }
+
+ remaining := time.Until(until)
+ if remaining < 0 {
+ return 0
+ }
+ return remaining
+}
+
+// Reset removes a provider from cooldown, making it available immediately.
+func (cb *CircuitBreaker) Reset(providerName string) {
+ cb.mu.Lock()
+ defer cb.mu.Unlock()
+
+ delete(cb.failedUntil, providerName)
+}
+
+// ResetAll clears all cooldowns.
+func (cb *CircuitBreaker) ResetAll() {
+ cb.mu.Lock()
+ defer cb.mu.Unlock()
+
+ cb.failedUntil = make(map[string]time.Time)
+}
+
+// Compile-time interface check
+var _ CooldownTracker = (*CircuitBreaker)(nil)
diff --git a/internal/adapter/templates/templates/skeleton/pkg/routing/cooldown.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/routing/cooldown.go.tmpl
new file mode 100644
index 0000000..461fbcd
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/routing/cooldown.go.tmpl
@@ -0,0 +1,150 @@
+package routing
+
+import "time"
+
+// Default cooldown periods.
+const (
+ // DefaultCooldownPeriod is the cooldown for rate limits and quota errors.
+ // 1 hour gives provider APIs time to reset their quotas.
+ DefaultCooldownPeriod = 1 * time.Hour
+
+ // TransientCooldownPeriod is the cooldown for transient server errors (5xx).
+ // These errors typically resolve quickly, so we use a shorter cooldown to
+ // trigger fallback but allow quick recovery.
+ TransientCooldownPeriod = 30 * time.Second
+)
+
+// CooldownTracker defines the interface for tracking provider cooldowns.
+// CircuitBreaker (in-memory) implements this interface. Additional implementations
+// (e.g., file-based persistence) can be added via CombinedCooldown.
+//
+// IMPORTANT: All cooldown tracking in the codebase MUST use this interface.
+// Do NOT implement custom cooldown tracking elsewhere.
+type CooldownTracker interface {
+ // IsAvailable returns true if the provider is not in cooldown.
+ IsAvailable(providerName string) bool
+
+ // CooldownRemaining returns time until cooldown expires (0 if available).
+ CooldownRemaining(providerName string) time.Duration
+
+ // RecordFailure records a failure and potentially enters cooldown.
+ // Returns true if the provider entered cooldown.
+ // Note: Exempt providers (ExemptProviders) never enter cooldown.
+ RecordFailure(providerName string, err error) bool
+
+ // Reset removes a provider from cooldown immediately.
+ Reset(providerName string)
+
+ // ResetAll clears all cooldowns.
+ ResetAll()
+}
+
+// CombinedCooldown wraps multiple CooldownTrackers, checking all of them.
+// A provider is only available if ALL trackers report it as available.
+//
+// Typical usage: combine multiple CooldownTracker implementations
+// (e.g., in-memory CircuitBreaker with custom persistence) for layered tracking.
+type CombinedCooldown struct {
+ trackers []CooldownTracker
+}
+
+// NewCombinedCooldown creates a tracker that combines multiple sources.
+// Pass nil trackers to skip them (they will be filtered out).
+func NewCombinedCooldown(trackers ...CooldownTracker) *CombinedCooldown {
+ // Filter out nil trackers
+ nonNil := make([]CooldownTracker, 0, len(trackers))
+ for _, t := range trackers {
+ if t != nil {
+ nonNil = append(nonNil, t)
+ }
+ }
+ return &CombinedCooldown{trackers: nonNil}
+}
+
+// IsAvailable returns true only if ALL trackers report the provider as available.
+func (c *CombinedCooldown) IsAvailable(providerName string) bool {
+ for _, t := range c.trackers {
+ if !t.IsAvailable(providerName) {
+ return false
+ }
+ }
+ return true
+}
+
+// CooldownRemaining returns the maximum remaining cooldown from all trackers.
+func (c *CombinedCooldown) CooldownRemaining(providerName string) time.Duration {
+ var max time.Duration
+ for _, t := range c.trackers {
+ if remaining := t.CooldownRemaining(providerName); remaining > max {
+ max = remaining
+ }
+ }
+ return max
+}
+
+// RecordFailure records the failure to all trackers.
+// Returns true if any tracker opened a cooldown.
+func (c *CombinedCooldown) RecordFailure(providerName string, err error) bool {
+ var anyOpened bool
+ for _, t := range c.trackers {
+ if t.RecordFailure(providerName, err) {
+ anyOpened = true
+ }
+ }
+ return anyOpened
+}
+
+// Reset removes the provider from cooldown in all trackers.
+func (c *CombinedCooldown) Reset(providerName string) {
+ for _, t := range c.trackers {
+ t.Reset(providerName)
+ }
+}
+
+// ResetAll clears all cooldowns in all trackers.
+func (c *CombinedCooldown) ResetAll() {
+ for _, t := range c.trackers {
+ t.ResetAll()
+ }
+}
+
+// Compile-time interface check
+var _ CooldownTracker = (*CombinedCooldown)(nil)
+
+// CooldownConfig holds the configuration for building a CooldownTracker.
+// This struct is used by mediagen.Manager and textgen.Manager to configure
+// their cooldown behavior.
+//
+// Usage: Provide a CircuitBreaker for in-memory tracking, or leave nil
+// to auto-create one with the specified CooldownPeriod.
+type CooldownConfig struct {
+ // CircuitBreaker provides in-memory cooldown tracking.
+ // Good for long-running services where state is maintained in memory.
+ // If nil, a default CircuitBreaker is created with CooldownPeriod.
+ CircuitBreaker *CircuitBreaker
+
+ // CooldownPeriod is the default cooldown duration for rate-limited providers.
+ // Only used when creating a default CircuitBreaker (when CircuitBreaker is nil).
+ // Defaults to DefaultCooldownPeriod (1 hour) if zero.
+ CooldownPeriod time.Duration
+}
+
+// BuildCooldownTracker creates a CooldownTracker from the configuration.
+// This is the standard way to construct cooldown trackers in the codebase.
+//
+// Logic:
+// 1. If CircuitBreaker is provided, returns it directly
+// 2. If CircuitBreaker is nil, creates a default one with the specified
+// cooldown period (or DefaultCooldownPeriod if zero)
+func BuildCooldownTracker(config CooldownConfig) CooldownTracker {
+ if config.CircuitBreaker != nil {
+ return config.CircuitBreaker
+ }
+
+ // Create default circuit breaker
+ cooldown := config.CooldownPeriod
+ if cooldown == 0 {
+ cooldown = DefaultCooldownPeriod
+ }
+ return NewCircuitBreaker(cooldown)
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/routing/doc.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/routing/doc.go.tmpl
new file mode 100644
index 0000000..b4ac47c
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/routing/doc.go.tmpl
@@ -0,0 +1,110 @@
+// Package routing provides unified provider routing with fallback execution
+// and cooldown management for LLM and media generation requests.
+//
+// ============================================================================
+// MANDATORY USAGE - READ THIS FIRST
+// ============================================================================
+//
+// All provider routing in this codebase MUST go through this package.
+// This is the SINGLE SOURCE OF TRUTH for provider orchestration.
+//
+// DO NOT:
+// - Implement custom fallback loops
+// - Implement custom cooldown tracking
+// - Pre-filter providers based on cooldown state
+// - Skip the terminus provider for any reason
+//
+// The routing.Execute function enforces critical invariants:
+// 1. Terminus provider is ALWAYS attempted (guarantees no "all providers unavailable" failures)
+// 2. Tiered cooldowns are applied correctly (rate limit vs transient)
+// 3. Exempt providers never enter cooldown
+// 4. Thread-safe concurrent access
+//
+// ============================================================================
+// KEY CONCEPTS
+// ============================================================================
+//
+// # Terminus Semantics
+//
+// When using StrategyFallback, the LAST provider in the chain is the "terminus".
+// The terminus is ALWAYS attempted regardless of cooldown state. This ensures
+// there's always a fallback of last resort that will be tried.
+//
+// Example: With providers [Gemini, LaoZhang]:
+// - Gemini: Respects cooldown (may be skipped if rate-limited)
+// - LaoZhang (terminus): ALWAYS tried, even if in cooldown
+//
+// CRITICAL: Provider order matters! Place your pay-per-use fallback LAST.
+//
+// # Tiered Cooldowns
+//
+// Failures trigger different cooldown durations based on error type:
+// - Rate limit (429) / Quota errors: 1 hour cooldown (DefaultCooldownPeriod)
+// - Transient server errors (5xx): 30 second cooldown (TransientCooldownPeriod)
+// - Other errors: No cooldown (normal failures)
+//
+// # Exempt Providers
+//
+// Some providers (like LaoZhang) are pay-per-use and never rate-limited.
+// These are listed in ExemptProviders and never enter cooldown.
+//
+// ============================================================================
+// USAGE PATTERNS
+// ============================================================================
+//
+// For image generation (through mediagen.Manager):
+//
+// manager, _ := mediagen.NewManager(mediagen.ManagerConfig{
+// ImageProviders: []mediagen.ImageGenerator{geminiProvider, laozhangProvider},
+// Strategy: mediagen.StrategyFallback,
+// // LaoZhang is LAST = terminus = always tried
+// })
+// resp, err := manager.GenerateImage(ctx, req)
+//
+// For text generation (through textgen.Manager):
+//
+// manager, _ := textgen.NewManager(textgen.ManagerConfig{
+// Providers: []textgen.TextGenerator{geminiProvider, laozhangProvider},
+// Strategy: textgen.StrategyFallback,
+// // LaoZhang is LAST = terminus = always tried
+// })
+// resp, err := manager.GenerateText(ctx, req)
+//
+// ============================================================================
+// INTEGRATION WITH MEDIAGEN AND TEXTGEN
+// ============================================================================
+//
+// The mediagen.Manager and textgen.Manager packages delegate to this package
+// for the actual fallback execution logic. They provide domain-specific
+// interfaces (ImageGenerator, TextGenerator) while routing handles the
+// cross-cutting concerns of provider selection, cooldowns, and fallback.
+//
+// DO NOT bypass these managers to call routing.Execute directly from
+// application code. Always use mediagen.Manager or textgen.Manager.
+//
+// The package hierarchy is:
+//
+// Application Code
+// |
+// v
+// mediagen.Manager / textgen.Manager (domain interfaces)
+// |
+// v
+// pkg/routing (THIS PACKAGE - execution logic)
+// |
+// v
+// Provider Implementations (gemini, laozhang)
+//
+// ============================================================================
+// CODE REVIEW CHECKLIST
+// ============================================================================
+//
+// When reviewing code that touches provider routing, verify:
+//
+// [ ] Uses mediagen.Manager or textgen.Manager (not custom loops)
+// [ ] Provider order is correct (terminus last)
+// [ ] Does NOT pre-filter providers based on cooldown
+// [ ] Does NOT skip terminus for any reason
+// [ ] Uses StrategyFallback for production workloads
+// [ ] Has CircuitBreaker for services
+package routing
diff --git a/internal/adapter/templates/templates/skeleton/pkg/routing/errors.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/routing/errors.go.tmpl
new file mode 100644
index 0000000..664a5e8
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/routing/errors.go.tmpl
@@ -0,0 +1,35 @@
+package routing
+
+import "errors"
+
+// Sentinel errors for programmatic handling with errors.Is().
+//
+// Provider implementations should wrap these errors to enable proper
+// error classification and cooldown behavior:
+//
+// return fmt.Errorf("gemini API error: %w", routing.ErrRateLimit)
+var (
+ // ErrRateLimit indicates provider returned a rate limit (429) error.
+ // Triggers long cooldown (DefaultCooldownPeriod, typically 1 hour).
+ ErrRateLimit = errors.New("routing: rate limit exceeded")
+
+ // ErrQuotaExceeded indicates provider's quota has been exhausted.
+ // Triggers long cooldown (DefaultCooldownPeriod, typically 1 hour).
+ ErrQuotaExceeded = errors.New("routing: quota exceeded")
+
+ // ErrServerUnavailable indicates a transient server error (5xx).
+ // Triggers short cooldown (TransientCooldownPeriod, typically 30 seconds).
+ ErrServerUnavailable = errors.New("routing: server unavailable")
+
+ // ErrAllProvidersFailed indicates all providers (including terminus) failed.
+ // This error means the request could not be completed despite trying
+ // all available providers.
+ ErrAllProvidersFailed = errors.New("routing: all providers failed")
+
+ // ErrNoProviders indicates no providers were configured.
+ // This is a configuration error that should be caught at startup.
+ ErrNoProviders = errors.New("routing: no providers configured")
+
+ // ErrInvalidConfig indicates invalid routing configuration.
+ ErrInvalidConfig = errors.New("routing: invalid configuration")
+)
diff --git a/internal/adapter/templates/templates/skeleton/pkg/routing/executor.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/routing/executor.go.tmpl
new file mode 100644
index 0000000..c6dd221
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/routing/executor.go.tmpl
@@ -0,0 +1,301 @@
+package routing
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "sync/atomic"
+ "time"
+)
+
+// Provider is the minimal interface that all providers must implement.
+// This is intentionally minimal to work with any provider type.
+type Provider interface {
+ Name() string
+}
+
+// ExemptProviders lists providers that never enter cooldown.
+// These are typically pay-per-use providers with no rate limits.
+//
+// The executor checks this map and skips cooldown recording for exempt providers.
+// This is a package-level decision (business logic) rather than per-request config.
+var ExemptProviders = map[string]bool{
+ "laozhang": true,
+}
+
+// ExecuteConfig configures the fallback executor.
+type ExecuteConfig struct {
+ // Strategy for routing requests to providers.
+ // Required. Use StrategyFallback for production workloads.
+ Strategy Strategy
+
+ // Cooldown tracker for managing provider cooldowns.
+ // Optional. If nil, cooldowns are not tracked.
+ Cooldown CooldownTracker
+
+ // Logger for debug and operational logging.
+ // Optional. If nil, uses slog.Default().
+ Logger *slog.Logger
+
+ // RoundRobinIndex is an atomic counter for round-robin distribution.
+ // Required only for StrategyRoundRobin. Pass a shared pointer.
+ RoundRobinIndex *atomic.Uint64
+}
+
+// ExecuteResult contains the result of executing across providers.
+type ExecuteResult[T any] struct {
+ // Response is the successful response from the provider.
+ Response T
+
+ // Provider is the name of the provider that succeeded.
+ Provider string
+
+ // Latency is the time taken for the successful call.
+ Latency time.Duration
+
+ // AttemptNum is which attempt succeeded (1-based).
+ // 1 = first provider succeeded, 2 = first failed + second succeeded, etc.
+ AttemptNum int
+
+ // WasTerminus is true if success came from the terminus (last) provider.
+ // This can indicate that all earlier providers are having issues.
+ WasTerminus bool
+}
+
+// ExecuteFunc is the function signature for provider execution.
+// T is the response type (e.g., *ImageResponse, *TextResponse).
+type ExecuteFunc[T any] func(ctx context.Context, provider Provider) (T, error)
+
+// Execute runs the given function across providers using the configured strategy.
+//
+// MANDATORY: All provider routing in the codebase MUST use this function.
+// Do NOT implement custom fallback loops elsewhere.
+//
+// # Terminus Semantics (StrategyFallback)
+//
+// The LAST provider in the list is the "terminus" and will ALWAYS be attempted
+// regardless of cooldown state. This ensures there's always a fallback of last resort.
+//
+// Example: With providers [Gemini, Grok, LaoZhang]:
+// - Gemini: Checked against cooldown, may be skipped
+// - Grok: Checked against cooldown, may be skipped
+// - LaoZhang (terminus): ALWAYS tried, even if in cooldown
+//
+// # Exempt Providers
+//
+// Providers listed in ExemptProviders (like "laozhang") never enter cooldown,
+// even if they fail. This is appropriate for pay-per-use providers.
+//
+// # Type Safety
+//
+// The generic type parameter T allows compile-time type checking for the response.
+// Usage:
+//
+// result, err := routing.Execute(ctx, providers, config,
+// func(ctx context.Context, p routing.Provider) (*MyResponse, error) {
+// return p.(MyProvider).DoSomething(ctx, req)
+// })
+func Execute[T any](
+ ctx context.Context,
+ providers []Provider,
+ config ExecuteConfig,
+ fn ExecuteFunc[T],
+) (*ExecuteResult[T], error) {
+ if len(providers) == 0 {
+ var zero T
+ return &ExecuteResult[T]{Response: zero}, ErrNoProviders
+ }
+
+ logger := config.Logger
+ if logger == nil {
+ logger = slog.Default()
+ }
+
+ switch config.Strategy {
+ case StrategyPrimaryOnly, "":
+ return executePrimaryOnly(ctx, providers, fn, logger)
+ case StrategyFallback:
+ return executeFallback(ctx, providers, config.Cooldown, fn, logger)
+ case StrategyRoundRobin:
+ return executeRoundRobin(ctx, providers, config.RoundRobinIndex, fn, logger)
+ default:
+ var zero T
+ return &ExecuteResult[T]{Response: zero}, fmt.Errorf("%w: unknown strategy %s", ErrInvalidConfig, config.Strategy)
+ }
+}
+
+func executePrimaryOnly[T any](
+ ctx context.Context,
+ providers []Provider,
+ fn ExecuteFunc[T],
+ logger *slog.Logger,
+) (*ExecuteResult[T], error) {
+ provider := providers[0]
+ start := time.Now()
+
+ resp, err := fn(ctx, provider)
+ latency := time.Since(start)
+
+ if err != nil {
+ var zero T
+ logger.Error("primary provider failed",
+ "provider", provider.Name(),
+ "error", err,
+ "latency", latency,
+ )
+ return &ExecuteResult[T]{Response: zero}, fmt.Errorf("primary provider %s failed: %w", provider.Name(), err)
+ }
+
+ return &ExecuteResult[T]{
+ Response: resp,
+ Provider: provider.Name(),
+ Latency: latency,
+ AttemptNum: 1,
+ }, nil
+}
+
+func executeFallback[T any](
+ ctx context.Context,
+ providers []Provider,
+ cooldown CooldownTracker,
+ fn ExecuteFunc[T],
+ logger *slog.Logger,
+) (*ExecuteResult[T], error) {
+ var lastErr error
+ attemptNum := 0
+ terminusIdx := len(providers) - 1
+
+ for i, provider := range providers {
+ isTerminus := i == terminusIdx
+ providerName := provider.Name()
+
+ // Check cooldown UNLESS this is the terminus provider.
+ // TERMINUS IS ALWAYS ATTEMPTED as the fallback of last resort.
+ if !isTerminus && cooldown != nil && !cooldown.IsAvailable(providerName) {
+ remaining := cooldown.CooldownRemaining(providerName)
+ logger.Debug("skipping provider in cooldown",
+ "provider", providerName,
+ "cooldown_remaining", remaining.Round(time.Second),
+ )
+ continue
+ }
+
+ // Log when terminus is being attempted despite cooldown
+ if isTerminus && cooldown != nil && !cooldown.IsAvailable(providerName) {
+ remaining := cooldown.CooldownRemaining(providerName)
+ logger.Info("attempting terminus provider despite cooldown",
+ "provider", providerName,
+ "cooldown_remaining", remaining.Round(time.Second),
+ "reason", "terminus_always_tried",
+ )
+ }
+
+ attemptNum++
+ logger.Debug("attempting provider",
+ "provider", providerName,
+ "attempt", attemptNum,
+ "is_terminus", isTerminus,
+ "provider_index", i+1,
+ "total_providers", len(providers),
+ )
+
+ start := time.Now()
+ resp, err := fn(ctx, provider)
+ latency := time.Since(start)
+
+ if err == nil {
+ if attemptNum > 1 {
+ logger.Info("succeeded on fallback provider",
+ "provider", providerName,
+ "attempt", attemptNum,
+ "latency", latency,
+ "is_terminus", isTerminus,
+ )
+ }
+ return &ExecuteResult[T]{
+ Response: resp,
+ Provider: providerName,
+ Latency: latency,
+ AttemptNum: attemptNum,
+ WasTerminus: isTerminus,
+ }, nil
+ }
+
+ // Record failure for cooldown tracking.
+ // Exempt providers (like laozhang) never enter cooldown.
+ if cooldown != nil && !ExemptProviders[providerName] {
+ if cooldown.RecordFailure(providerName, err) {
+ remaining := cooldown.CooldownRemaining(providerName)
+ logger.Warn("provider entering cooldown",
+ "provider", providerName,
+ "cooldown", remaining.Round(time.Second),
+ "error", err,
+ )
+ }
+ }
+
+ logger.Warn("provider failed",
+ "provider", providerName,
+ "error", err,
+ "attempt", attemptNum,
+ "is_terminus", isTerminus,
+ )
+ lastErr = err
+
+ // Check context before trying next provider
+ if ctx.Err() != nil {
+ var zero T
+ return &ExecuteResult[T]{Response: zero}, ctx.Err()
+ }
+ }
+
+ if attemptNum == 0 {
+ var zero T
+ return &ExecuteResult[T]{Response: zero}, fmt.Errorf("%w: all %d providers are in cooldown",
+ ErrAllProvidersFailed, len(providers))
+ }
+
+ var zero T
+ return &ExecuteResult[T]{Response: zero}, fmt.Errorf("%w: all %d providers failed (last error: %v)",
+ ErrAllProvidersFailed, len(providers), lastErr)
+}
+
+func executeRoundRobin[T any](
+ ctx context.Context,
+ providers []Provider,
+ index *atomic.Uint64,
+ fn ExecuteFunc[T],
+ logger *slog.Logger,
+) (*ExecuteResult[T], error) {
+ n := uint64(len(providers))
+
+ // Get next provider index atomically
+ var idx uint64
+ if index != nil {
+ idx = index.Add(1) - 1
+ }
+
+ provider := providers[idx%n]
+ start := time.Now()
+
+ resp, err := fn(ctx, provider)
+ latency := time.Since(start)
+
+ if err != nil {
+ var zero T
+ logger.Error("round-robin provider failed",
+ "provider", provider.Name(),
+ "index", idx%n,
+ "error", err,
+ "latency", latency,
+ )
+ return &ExecuteResult[T]{Response: zero}, fmt.Errorf("provider %s failed: %w", provider.Name(), err)
+ }
+
+ return &ExecuteResult[T]{
+ Response: resp,
+ Provider: provider.Name(),
+ Latency: latency,
+ AttemptNum: 1,
+ }, nil
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/routing/failure.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/routing/failure.go.tmpl
new file mode 100644
index 0000000..0205569
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/routing/failure.go.tmpl
@@ -0,0 +1,114 @@
+package routing
+
+import (
+ "errors"
+ "strings"
+)
+
+// FailureType categorizes errors for cooldown duration selection.
+type FailureType int
+
+const (
+ // FailureTypeNone indicates the error doesn't warrant a cooldown.
+ // Examples: content blocked, invalid request, timeout.
+ FailureTypeNone FailureType = iota
+
+ // FailureTypeRateLimit indicates rate limiting or quota exhaustion.
+ // Uses long cooldown (DefaultCooldownPeriod).
+ // Examples: HTTP 429, quota_exceeded, resource_exhausted.
+ FailureTypeRateLimit
+
+ // FailureTypeTransient indicates transient server errors.
+ // Uses short cooldown (TransientCooldownPeriod).
+ // Examples: HTTP 500, 503, server unavailable, overloaded.
+ FailureTypeTransient
+)
+
+// String returns a human-readable name for the failure type.
+func (f FailureType) String() string {
+ switch f {
+ case FailureTypeNone:
+ return "none"
+ case FailureTypeRateLimit:
+ return "rate-limit"
+ case FailureTypeTransient:
+ return "transient"
+ default:
+ return "unknown"
+ }
+}
+
+// ClassifyError determines the failure type for cooldown selection.
+//
+// Returns:
+// - FailureTypeRateLimit for 429/quota errors (long cooldown)
+// - FailureTypeTransient for 5xx errors (short cooldown)
+// - FailureTypeNone for errors that don't warrant cooldown
+//
+// The function checks wrapped sentinel errors first (using errors.Is),
+// then falls back to error message pattern matching for untyped errors.
+func ClassifyError(err error) FailureType {
+ if err == nil {
+ return FailureTypeNone
+ }
+
+ // Check wrapped sentinel errors first (most specific)
+ if errors.Is(err, ErrRateLimit) || errors.Is(err, ErrQuotaExceeded) {
+ return FailureTypeRateLimit
+ }
+ if errors.Is(err, ErrServerUnavailable) {
+ return FailureTypeTransient
+ }
+
+ // Pattern matching on error message for untyped errors
+ errStr := strings.ToLower(err.Error())
+
+ // Rate limit patterns (long cooldown)
+ rateLimitPatterns := []string{
+ "rate limit",
+ "ratelimit",
+ "too many requests",
+ "429",
+ "quota",
+ "resource_exhausted",
+ }
+ for _, pattern := range rateLimitPatterns {
+ if strings.Contains(errStr, pattern) {
+ return FailureTypeRateLimit
+ }
+ }
+
+ // Transient server error patterns (short cooldown)
+ transientPatterns := []string{
+ "503",
+ "500",
+ "unavailable",
+ "overloaded",
+ "service unavailable",
+ "temporarily unavailable",
+ "server error",
+ "internal server error",
+ }
+ for _, pattern := range transientPatterns {
+ if strings.Contains(errStr, pattern) {
+ return FailureTypeTransient
+ }
+ }
+
+ return FailureTypeNone
+}
+
+// IsRateLimitError returns true if the error indicates rate limiting.
+func IsRateLimitError(err error) bool {
+ return ClassifyError(err) == FailureTypeRateLimit
+}
+
+// IsTransientError returns true if the error is transient (quick recovery expected).
+func IsTransientError(err error) bool {
+ return ClassifyError(err) == FailureTypeTransient
+}
+
+// IsCooldownTriggeringError returns true if the error should trigger cooldown.
+func IsCooldownTriggeringError(err error) bool {
+ return ClassifyError(err) != FailureTypeNone
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/routing/strategy.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/routing/strategy.go.tmpl
new file mode 100644
index 0000000..f683687
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/routing/strategy.go.tmpl
@@ -0,0 +1,45 @@
+package routing
+
+// Strategy defines how requests are routed to providers.
+// Uses kebab-case consistently for JSON/config serialization.
+//
+// IMPORTANT: Use the constants below, not string literals.
+// This enables compile-time checking and refactoring support.
+type Strategy string
+
+const (
+ // StrategyPrimaryOnly uses only the first provider, failing immediately on error.
+ // Use when you want deterministic behavior with no automatic failover.
+ // The provider is NOT put in cooldown on failure.
+ StrategyPrimaryOnly Strategy = "primary-only"
+
+ // StrategyFallback tries providers in order until one succeeds.
+ //
+ // CRITICAL: The LAST provider in the chain (terminus) is ALWAYS attempted
+ // regardless of cooldown state. This is the fallback of last resort.
+ //
+ // Providers that fail with rate-limit or transient errors enter cooldown
+ // and will be skipped on subsequent requests until the cooldown expires.
+ // The terminus never enters cooldown.
+ StrategyFallback Strategy = "fallback"
+
+ // StrategyRoundRobin distributes requests across providers evenly.
+ // Uses atomic counter to rotate through providers.
+ // Does NOT respect cooldowns; each call rotates to the next provider.
+ StrategyRoundRobin Strategy = "round-robin"
+)
+
+// Valid returns true if the strategy is recognized.
+func (s Strategy) Valid() bool {
+ switch s {
+ case StrategyPrimaryOnly, StrategyFallback, StrategyRoundRobin:
+ return true
+ default:
+ return false
+ }
+}
+
+// String returns the strategy value as a string.
+func (s Strategy) String() string {
+ return string(s)
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/storage/gcs.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/storage/gcs.go.tmpl
new file mode 100644
index 0000000..88d1209
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/storage/gcs.go.tmpl
@@ -0,0 +1,125 @@
+package storage
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "time"
+
+ gcsstorage "cloud.google.com/go/storage"
+ "google.golang.org/api/iterator"
+ "google.golang.org/api/option"
+)
+
+// GCSStore implements Store using Google Cloud Storage.
+type GCSStore struct {
+ client *gcsstorage.Client
+ bucket string
+ logger *slog.Logger
+}
+
+// NewGCSStore creates a GCS-backed store.
+// credentialsJSON may be empty to use Application Default Credentials.
+func NewGCSStore(bucket string, credentialsJSON string, logger *slog.Logger) (*GCSStore, error) {
+ var opts []option.ClientOption
+ if credentialsJSON != "" {
+ opts = append(opts, option.WithCredentialsJSON([]byte(credentialsJSON)))
+ }
+
+ client, err := gcsstorage.NewClient(context.Background(), opts...)
+ if err != nil {
+ return nil, fmt.Errorf("storage: failed to create GCS client: %w", err)
+ }
+
+ return &GCSStore{
+ client: client,
+ bucket: bucket,
+ logger: logger,
+ }, nil
+}
+
+func (s *GCSStore) Upload(ctx context.Context, path string, data []byte, contentType string) (string, error) {
+ obj := s.client.Bucket(s.bucket).Object(path)
+ w := obj.NewWriter(ctx)
+ w.ContentType = contentType
+
+ if _, err := w.Write(data); err != nil {
+ _ = w.Close()
+ return "", fmt.Errorf("storage: write failed: %w", err)
+ }
+ if err := w.Close(); err != nil {
+ return "", fmt.Errorf("storage: close failed: %w", err)
+ }
+
+ // Return a signed URL consistent with GetURL (bucket is private).
+ return s.GetURL(ctx, path)
+}
+
+func (s *GCSStore) UploadPresigned(ctx context.Context, path string, contentType string) (*PresignedUpload, error) {
+ expires := time.Now().Add(15 * time.Minute)
+ url, err := s.client.Bucket(s.bucket).SignedURL(path, &gcsstorage.SignedURLOptions{
+ Method: "PUT",
+ ContentType: contentType,
+ Expires: expires,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("storage: presigned URL failed: %w", err)
+ }
+
+ return &PresignedUpload{
+ URL: url,
+ Headers: map[string]string{"Content-Type": contentType},
+ Method: "PUT",
+ Expires: expires,
+ }, nil
+}
+
+func (s *GCSStore) GetURL(ctx context.Context, path string) (string, error) {
+ url, err := s.client.Bucket(s.bucket).SignedURL(path, &gcsstorage.SignedURLOptions{
+ Method: "GET",
+ Expires: time.Now().Add(1 * time.Hour),
+ })
+ if err != nil {
+ return "", fmt.Errorf("storage: signed URL failed: %w", err)
+ }
+ return url, nil
+}
+
+func (s *GCSStore) Delete(ctx context.Context, path string) error {
+ if err := s.client.Bucket(s.bucket).Object(path).Delete(ctx); err != nil {
+ return fmt.Errorf("storage: delete failed: %w", err)
+ }
+ return nil
+}
+
+func (s *GCSStore) List(ctx context.Context, prefix string) ([]MediaObject, error) {
+ it := s.client.Bucket(s.bucket).Objects(ctx, &gcsstorage.Query{Prefix: prefix})
+ var objects []MediaObject
+ for {
+ attrs, err := it.Next()
+ if err == iterator.Done {
+ break
+ }
+ if err != nil {
+ return nil, fmt.Errorf("storage: list failed: %w", err)
+ }
+ signedURL, urlErr := s.GetURL(ctx, attrs.Name)
+ if urlErr != nil {
+ s.logger.Warn("failed to sign URL for listed object", "path", attrs.Name, "error", urlErr)
+ continue
+ }
+ objects = append(objects, MediaObject{
+ Path: attrs.Name,
+ URL: signedURL,
+ ContentType: attrs.ContentType,
+ Size: attrs.Size,
+ CreatedAt: attrs.Created,
+ })
+ }
+ return objects, nil
+}
+
+// Close releases GCS client resources.
+func (s *GCSStore) Close() error {
+ return s.client.Close()
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/storage/memory.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/storage/memory.go.tmpl
new file mode 100644
index 0000000..fa82085
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/storage/memory.go.tmpl
@@ -0,0 +1,136 @@
+package storage
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+)
+
+type memObject struct {
+ data []byte
+ contentType string
+ createdAt time.Time
+}
+
+// MemoryStore implements Store using in-memory storage.
+// Mount ServeHTTP at /storage/ to serve stored objects.
+type MemoryStore struct {
+ objects map[string]*memObject
+ mu sync.RWMutex
+ baseURL string // e.g., "http://localhost:8001/storage"
+}
+
+// NewMemoryStore creates an in-memory store. baseURL is the URL prefix for serving objects
+// (e.g., "http://localhost:8001/storage").
+func NewMemoryStore(baseURL string) *MemoryStore {
+ return &MemoryStore{
+ objects: make(map[string]*memObject),
+ baseURL: strings.TrimRight(baseURL, "/"),
+ }
+}
+
+func (s *MemoryStore) Upload(_ context.Context, path string, data []byte, contentType string) (string, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.objects[path] = &memObject{
+ data: append([]byte(nil), data...),
+ contentType: contentType,
+ createdAt: time.Now(),
+ }
+ return fmt.Sprintf("%s/%s", s.baseURL, path), nil
+}
+
+func (s *MemoryStore) UploadPresigned(_ context.Context, path string, contentType string) (*PresignedUpload, error) {
+ // In dev mode, presigned uploads go through the same /storage/ endpoint.
+ return &PresignedUpload{
+ URL: fmt.Sprintf("%s/%s", s.baseURL, path),
+ Headers: map[string]string{"Content-Type": contentType},
+ Method: "PUT",
+ Expires: time.Now().Add(15 * time.Minute),
+ }, nil
+}
+
+func (s *MemoryStore) GetURL(_ context.Context, path string) (string, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ if _, ok := s.objects[path]; !ok {
+ return "", fmt.Errorf("storage: object not found: %s", path)
+ }
+ return fmt.Sprintf("%s/%s", s.baseURL, path), nil
+}
+
+func (s *MemoryStore) Delete(_ context.Context, path string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.objects, path)
+ return nil
+}
+
+func (s *MemoryStore) List(_ context.Context, prefix string) ([]MediaObject, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ var result []MediaObject
+ for path, obj := range s.objects {
+ if strings.HasPrefix(path, prefix) {
+ result = append(result, MediaObject{
+ Path: path,
+ URL: fmt.Sprintf("%s/%s", s.baseURL, path),
+ ContentType: obj.contentType,
+ Size: int64(len(obj.data)),
+ CreatedAt: obj.createdAt,
+ })
+ }
+ }
+ return result, nil
+}
+
+// ServeHTTP serves stored objects and accepts PUT uploads (for dev presigned URL flow).
+// Mount at /storage/ in the application router.
+func (s *MemoryStore) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ // Strip /storage/ prefix to get the object path.
+ path := strings.TrimPrefix(r.URL.Path, "/storage/")
+ if path == "" {
+ http.Error(w, "path required", http.StatusBadRequest)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ s.mu.RLock()
+ obj, ok := s.objects[path]
+ s.mu.RUnlock()
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", obj.contentType)
+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(obj.data)))
+ _, _ = w.Write(obj.data)
+
+ case http.MethodPut:
+ data, err := io.ReadAll(r.Body)
+ if err != nil {
+ http.Error(w, "read body failed", http.StatusInternalServerError)
+ return
+ }
+ ct := r.Header.Get("Content-Type")
+ if ct == "" {
+ ct = "application/octet-stream"
+ }
+ s.mu.Lock()
+ s.objects[path] = &memObject{
+ data: data,
+ contentType: ct,
+ createdAt: time.Now(),
+ }
+ s.mu.Unlock()
+ w.WriteHeader(http.StatusOK)
+
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/storage/store.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/storage/store.go.tmpl
new file mode 100644
index 0000000..17dd20b
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/storage/store.go.tmpl
@@ -0,0 +1,43 @@
+// Package storage provides object storage for media files.
+// In production, uses GCS. In development (standalone mode), uses in-memory storage.
+package storage
+
+import (
+ "context"
+ "time"
+)
+
+// Store abstracts object storage operations.
+type Store interface {
+ // Upload stores data at the given path and returns its public/signed URL.
+ Upload(ctx context.Context, path string, data []byte, contentType string) (string, error)
+
+ // UploadPresigned returns a presigned URL for direct client-to-storage uploads.
+ UploadPresigned(ctx context.Context, path string, contentType string) (*PresignedUpload, error)
+
+ // GetURL returns a signed or public URL for the object.
+ GetURL(ctx context.Context, path string) (string, error)
+
+ // Delete removes an object.
+ Delete(ctx context.Context, path string) error
+
+ // List returns objects matching the given prefix.
+ List(ctx context.Context, prefix string) ([]MediaObject, error)
+}
+
+// PresignedUpload contains the details for a direct-upload to storage.
+type PresignedUpload struct {
+ URL string `json:"url"`
+ Headers map[string]string `json:"headers"`
+ Method string `json:"method"` // "PUT"
+ Expires time.Time `json:"expires"`
+}
+
+// MediaObject represents a stored media file.
+type MediaObject struct {
+ Path string `json:"path"`
+ URL string `json:"url"`
+ ContentType string `json:"contentType"`
+ Size int64 `json:"size"`
+ CreatedAt time.Time `json:"createdAt"`
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/synap/chat.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/synap/chat.go.tmpl
new file mode 100644
index 0000000..fe1ab5a
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/synap/chat.go.tmpl
@@ -0,0 +1,360 @@
+package synap
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// ChatMessage represents a single chat message for context building.
+type ChatMessage struct {
+ // ID is the message identifier
+ ID string
+
+ // Role is "user" or "agent"
+ Role string
+
+ // Content is the message text
+ Content string
+
+ // CreatedAt is when the message was sent
+ CreatedAt time.Time
+
+ // TokenCount estimates tokens in content (for 2000 token limit)
+ TokenCount int
+}
+
+// ChatContext holds both recent messages and recalled memories for AI response generation.
+type ChatContext struct {
+ // RecentMessages contains the last N messages (up to 2000 tokens)
+ RecentMessages []ChatMessage
+
+ // RecalledMemories contains relevant memories from Synap
+ RecalledMemories []Memory
+
+ // TotalTokens is the estimated token count for all content
+ TotalTokens int
+
+ // MemoryContext is a formatted string of recalled memories for prompt injection
+ MemoryContext string
+}
+
+// BuildChatContext creates context for an AI response by combining recent messages
+// and recalled memories from Synap.
+//
+// This function:
+// 1. Includes the last 20 messages (or as many as fit in 2000 tokens)
+// 2. Recalls relevant memories from Synap based on the current message
+// 3. Formats everything into a ChatContext for prompt injection
+//
+// Example usage in peach-connection-worker:
+//
+// ctx := context.Background()
+// client, err := synap.NewClient("http://localhost:7432", &synap.Config{
+// DefaultSpace: fmt.Sprintf("conversation_%s", conversationID),
+// })
+// if err != nil {
+// log.Fatal(err)
+// }
+//
+// chatCtx, err := synap.BuildChatContext(ctx, client, &synap.BuildContextRequest{
+// CurrentMessage: "What hobbies do you enjoy?",
+// RecentMessages: last20Messages,
+// MaxTokens: 2000,
+// RecallQuery: "hobbies interests activities enjoyment",
+// })
+//
+// // Use chatCtx.MemoryContext in your Comm10 template variables
+// variables := map[string]string{
+// "recalled_memories": chatCtx.MemoryContext,
+// "recent_context": formatMessages(chatCtx.RecentMessages),
+// }
+func BuildChatContext(ctx context.Context, client *Client, req *BuildContextRequest) (*ChatContext, error) {
+ if req == nil {
+ return nil, fmt.Errorf("request is required")
+ }
+
+ // 1. Select recent messages that fit within token budget
+ recentMessages := selectRecentMessages(req.RecentMessages, req.MaxTokens)
+
+ // 2. Recall relevant memories from Synap
+ var recalledMemories []Memory
+ var memoryContext string
+
+ if req.RecallQuery != "" {
+ recallResp, err := client.Recall(ctx, &RecallRequest{
+ Query: req.RecallQuery,
+ Mode: RecallModeHybrid,
+ MaxResults: req.MaxRecalledMemories,
+ Threshold: req.RecallThreshold,
+ })
+
+ if err != nil {
+ // Log error but don't fail - proceed without recalled memories
+ // This matches the timeout handling in the architecture docs
+ client.logger.Warn("failed to recall memories, proceeding without context",
+ "error", err,
+ "query", req.RecallQuery)
+ } else {
+ // Combine all memory categories
+ recalledMemories = append(recalledMemories, recallResp.Memories.Vivid...)
+ recalledMemories = append(recalledMemories, recallResp.Memories.Associated...)
+
+ // Format memories for prompt injection
+ memoryContext = formatMemoriesForPrompt(recalledMemories)
+ }
+ }
+
+ // 3. Calculate total tokens
+ totalTokens := 0
+ for _, msg := range recentMessages {
+ totalTokens += msg.TokenCount
+ }
+
+ // Add rough token count for memory context (4 chars ≈ 1 token)
+ totalTokens += len(memoryContext) / 4
+
+ return &ChatContext{
+ RecentMessages: recentMessages,
+ RecalledMemories: recalledMemories,
+ TotalTokens: totalTokens,
+ MemoryContext: memoryContext,
+ }, nil
+}
+
+// BuildContextRequest specifies parameters for building chat context.
+type BuildContextRequest struct {
+ // CurrentMessage is the user's current message (used for memory recall)
+ CurrentMessage string
+
+ // RecentMessages is the conversation history (newest last)
+ // Should be the last 20-30 messages to allow token-based filtering
+ RecentMessages []ChatMessage
+
+ // MaxTokens is the maximum tokens for recent messages (default: 2000)
+ // Memories from Synap are NOT included in this limit
+ MaxTokens int
+
+ // RecallQuery is the search query for Synap (optional)
+ // If empty, no memory recall is performed
+ // Typically this is the current message + keywords from recent context
+ RecallQuery string
+
+ // MaxRecalledMemories limits Synap recall results (default: 5)
+ MaxRecalledMemories int
+
+ // RecallThreshold is minimum confidence for recalled memories (default: 0.5)
+ RecallThreshold float64
+}
+
+// selectRecentMessages filters messages to fit within token budget.
+//
+// Returns messages in chronological order (oldest first), limited by token count.
+func selectRecentMessages(messages []ChatMessage, maxTokens int) []ChatMessage {
+ if maxTokens == 0 {
+ maxTokens = 2000
+ }
+
+ // Start from newest messages and work backwards
+ totalTokens := 0
+ cutoffIndex := 0
+
+ for i := len(messages) - 1; i >= 0; i-- {
+ msgTokens := messages[i].TokenCount
+ if msgTokens == 0 {
+ // Estimate: ~4 characters per token
+ msgTokens = len(messages[i].Content) / 4
+ }
+
+ if totalTokens+msgTokens > maxTokens {
+ cutoffIndex = i + 1
+ break
+ }
+
+ totalTokens += msgTokens
+ }
+
+ // Return selected messages in chronological order
+ return messages[cutoffIndex:]
+}
+
+// formatMemoriesForPrompt converts Synap memories into a prompt-friendly format.
+//
+// Example output:
+//
+// "Previous relevant memories:
+// - (2 days ago) User mentioned they love hiking and photography
+// - (1 week ago) User discussed favorite travel destinations: Japan, Iceland
+// - (Related) User enjoys outdoor activities and nature"
+func formatMemoriesForPrompt(memories []Memory) string {
+ if len(memories) == 0 {
+ return ""
+ }
+
+ var parts []string
+ parts = append(parts, "Previous relevant memories:")
+
+ for _, mem := range memories {
+ // Format time reference
+ var timeRef string
+ if !mem.ObservedAt.IsZero() {
+ timeRef = formatTimeReference(mem.ObservedAt)
+ } else {
+ timeRef = "Previously"
+ }
+
+ // Get memory content
+ content := mem.Content
+ if mem.Episode != nil {
+ content = mem.Episode.What
+ }
+
+ // Format with confidence indicator if medium/low
+ confidenceIndicator := ""
+ if mem.Confidence.Category == "Medium" || mem.Confidence.Category == "Low" {
+ confidenceIndicator = " (uncertain)"
+ }
+
+ parts = append(parts, fmt.Sprintf("- (%s) %s%s", timeRef, content, confidenceIndicator))
+ }
+
+ return strings.Join(parts, "\n")
+}
+
+// formatTimeReference converts a timestamp to human-readable relative time.
+//
+// Examples: "2 hours ago", "3 days ago", "2 weeks ago"
+func formatTimeReference(t time.Time) string {
+ duration := time.Since(t)
+
+ if duration < time.Hour {
+ minutes := int(duration.Minutes())
+ if minutes == 1 {
+ return "1 minute ago"
+ }
+ return fmt.Sprintf("%d minutes ago", minutes)
+ }
+
+ if duration < 24*time.Hour {
+ hours := int(duration.Hours())
+ if hours == 1 {
+ return "1 hour ago"
+ }
+ return fmt.Sprintf("%d hours ago", hours)
+ }
+
+ if duration < 7*24*time.Hour {
+ days := int(duration.Hours() / 24)
+ if days == 1 {
+ return "1 day ago"
+ }
+ return fmt.Sprintf("%d days ago", days)
+ }
+
+ if duration < 30*24*time.Hour {
+ weeks := int(duration.Hours() / (24 * 7))
+ if weeks == 1 {
+ return "1 week ago"
+ }
+ return fmt.Sprintf("%d weeks ago", weeks)
+ }
+
+ months := int(duration.Hours() / (24 * 30))
+ if months == 1 {
+ return "1 month ago"
+ }
+ return fmt.Sprintf("%d months ago", months)
+}
+
+// StoreConversationTurn stores both user and agent messages as episodic memories in Synap.
+//
+// This should be called by peach-connection-worker after successfully generating
+// and storing an agent response.
+//
+// Example usage:
+//
+// err := synap.StoreConversationTurn(ctx, client, &synap.ConversationTurn{
+// UserMessage: "What are your favorite hobbies?",
+// AgentResponse: "I love photography and hiking in nature...",
+// Timestamp: time.Now(),
+// ConversationID: "conv_abc123",
+// UserID: "user_alice",
+// AgentID: "agent_sarah",
+// })
+func StoreConversationTurn(ctx context.Context, client *Client, turn *ConversationTurn) error {
+ if turn == nil {
+ return fmt.Errorf("turn is required")
+ }
+
+ // Store user message as episode
+ userEpisode := &Episode{
+ What: fmt.Sprintf("User said: %s", turn.UserMessage),
+ When: turn.Timestamp,
+ Where: "chat",
+ Who: []string{turn.UserID, turn.AgentID},
+ Why: "conversation",
+ Confidence: 0.9,
+ Tags: []string{"user_message", "conversation"},
+ }
+
+ userResp, err := client.RememberEpisode(ctx, userEpisode)
+ if err != nil {
+ return fmt.Errorf("store user message: %w", err)
+ }
+
+ client.logger.Debug("stored user message in synap",
+ "memory_id", userResp.MemoryID,
+ "conversation_id", turn.ConversationID)
+
+ // Store agent response as episode
+ agentEpisode := &Episode{
+ What: fmt.Sprintf("Agent responded: %s", turn.AgentResponse),
+ When: turn.Timestamp,
+ Where: "chat",
+ Who: []string{turn.UserID, turn.AgentID},
+ Why: "conversation",
+ Confidence: 0.9,
+ Tags: []string{"agent_message", "conversation"},
+ }
+
+ agentResp, err := client.RememberEpisode(ctx, agentEpisode)
+ if err != nil {
+ return fmt.Errorf("store agent response: %w", err)
+ }
+
+ client.logger.Debug("stored agent response in synap",
+ "memory_id", agentResp.MemoryID,
+ "conversation_id", turn.ConversationID)
+
+ return nil
+}
+
+// ConversationTurn represents a single user-agent exchange for storage in Synap.
+type ConversationTurn struct {
+ // UserMessage is what the user said
+ UserMessage string
+
+ // AgentResponse is what the agent replied
+ AgentResponse string
+
+ // Timestamp is when this exchange occurred
+ Timestamp time.Time
+
+ // ConversationID identifies the conversation (for logging only)
+ ConversationID string
+
+ // UserID identifies the user
+ UserID string
+
+ // AgentID identifies the agent
+ AgentID string
+}
+
+// EstimateTokenCount provides a rough token count estimate for text.
+//
+// Uses the approximation: 1 token ≈ 4 characters (common for English text)
+// For more accuracy, integrate with tiktoken or your LLM's tokenizer.
+func EstimateTokenCount(text string) int {
+ return len(text) / 4
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/synap/client.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/synap/client.go.tmpl
new file mode 100644
index 0000000..45fe35e
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/synap/client.go.tmpl
@@ -0,0 +1,670 @@
+// Package synap provides a production-ready Go client for the Synap cognitive memory system.
+//
+// Synap is a biologically-inspired memory database with spreading activation, episodic memory,
+// and automatic consolidation. This client is designed for use in chat systems to provide
+// context-aware AI responses with memory continuity.
+//
+// Usage Example:
+//
+// client, err := synap.NewClient("http://localhost:7432", &synap.Config{
+// Timeout: 10 * time.Second,
+// MaxRetries: 3,
+// DefaultSpace: "conversation_abc123",
+// })
+// if err != nil {
+// log.Fatal(err)
+// }
+//
+// // Store a chat message as episodic memory
+// episode := &synap.Episode{
+// What: "User asked about their favorite hobbies",
+// When: time.Now(),
+// Who: []string{"user_alice", "agent_sarah"},
+// Where: "chat",
+// Confidence: 0.85,
+// }
+// memoryID, err := client.RememberEpisode(ctx, episode)
+//
+// // Recall relevant memories for context
+// memories, err := client.Recall(ctx, &synap.RecallRequest{
+// Query: "hobbies photography travel",
+// Mode: synap.RecallModeHybrid,
+// MaxResults: 10,
+// Threshold: 0.5,
+// })
+package synap
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "{{GO_MODULE}}/pkg/httpclient"
+)
+
+// Client provides access to the Synap memory system.
+type Client struct {
+ baseURL string
+ httpClient *httpclient.Client
+ logger *slog.Logger
+ config *Config
+}
+
+// Config holds configuration for the Synap client.
+type Config struct {
+ // Timeout for HTTP requests (default: 10s)
+ Timeout time.Duration
+
+ // MaxRetries for failed requests (default: 3)
+ MaxRetries int
+
+ // DefaultSpace for multi-tenant isolation (optional)
+ // If set, all requests will use this space unless overridden
+ DefaultSpace string
+
+ // APIKey for authentication (optional)
+ // If set, requests will include "Authorization: Bearer " header
+ APIKey string
+
+ // Logger for structured logging (optional)
+ Logger *slog.Logger
+}
+
+// NewClient creates a new Synap client.
+//
+// baseURL should be the Synap server URL (e.g., "http://localhost:7432")
+// config is optional - pass nil to use defaults
+//
+// Returns nil and an error if baseURL is invalid.
+func NewClient(baseURL string, config *Config) (*Client, error) {
+ if baseURL == "" {
+ return nil, fmt.Errorf("baseURL is required")
+ }
+
+ // Validate baseURL format
+ parsedURL, err := url.Parse(baseURL)
+ if err != nil {
+ return nil, fmt.Errorf("invalid baseURL: %w", err)
+ }
+
+ if parsedURL.Scheme == "" {
+ return nil, fmt.Errorf("baseURL must include scheme (http:// or https://)")
+ }
+
+ if parsedURL.Host == "" {
+ return nil, fmt.Errorf("baseURL must include host")
+ }
+
+ if config == nil {
+ config = &Config{}
+ }
+
+ if config.Timeout == 0 {
+ config.Timeout = 10 * time.Second
+ }
+
+ if config.MaxRetries == 0 {
+ config.MaxRetries = 3
+ }
+
+ if config.Logger == nil {
+ config.Logger = slog.Default()
+ }
+
+ return &Client{
+ baseURL: baseURL,
+ httpClient: httpclient.New(httpclient.Config{
+ Timeout: config.Timeout,
+ MaxRetries: config.MaxRetries,
+ Logger: config.Logger,
+ }),
+ logger: config.Logger,
+ config: config,
+ }, nil
+}
+
+// Episode represents an episodic memory (what/when/where/who/why/how).
+//
+// Episodic memories are rich contextual memories that capture events with
+// multiple dimensions. They consolidate over time into semantic patterns.
+type Episode struct {
+ // What happened (required)
+ What string `json:"what"`
+
+ // When it happened (required for temporal context)
+ When time.Time `json:"when"`
+
+ // Where it happened (optional, e.g., "chat", "email", "phone")
+ Where string `json:"where,omitempty"`
+
+ // Who was involved (optional, e.g., ["user_alice", "agent_sarah"])
+ Who []string `json:"who,omitempty"`
+
+ // Why it happened (optional, context/motivation)
+ Why string `json:"why,omitempty"`
+
+ // How it happened (optional, method/process)
+ How string `json:"how,omitempty"`
+
+ // Confidence in memory accuracy (0.0-1.0, default: 0.7)
+ // Higher confidence = stronger memory encoding
+ Confidence float64 `json:"confidence"`
+
+ // Tags for categorization (optional)
+ Tags []string `json:"tags,omitempty"`
+}
+
+// RememberEpisodeResponse contains the result of storing an episode.
+type RememberEpisodeResponse struct {
+ // MemoryID is the unique identifier for this episode
+ MemoryID string `json:"memory_id"`
+
+ // StorageConfidence reflects Synap's assessment of storage quality
+ StorageConfidence ConfidenceScore `json:"storage_confidence"`
+
+ // ConsolidationState indicates memory age ("Recent", "Consolidating", "Semantic")
+ ConsolidationState string `json:"consolidation_state"`
+
+ // ObservedAt is when the event originally occurred
+ ObservedAt time.Time `json:"observed_at"`
+
+ // StoredAt is when Synap stored the memory
+ StoredAt time.Time `json:"stored_at"`
+
+ // SystemMessage provides feedback about storage
+ SystemMessage string `json:"system_message"`
+}
+
+// ConfidenceScore represents Synap's confidence in a memory or recall.
+type ConfidenceScore struct {
+ // Value is the numeric confidence (0.0-1.0)
+ Value float64 `json:"value"`
+
+ // Category is the human-readable category ("Low", "Medium", "High", "Very High")
+ Category string `json:"category"`
+
+ // Reasoning explains how confidence was calculated (optional)
+ Reasoning string `json:"reasoning,omitempty"`
+}
+
+// RememberResponse contains the result of storing a simple semantic memory.
+type RememberResponse struct {
+ // MemoryID is the unique identifier for this memory
+ MemoryID string `json:"memory_id"`
+
+ // ObservedAt is when the memory was created
+ ObservedAt time.Time `json:"observed_at"`
+
+ // StoredAt is when Synap stored the memory
+ StoredAt time.Time `json:"stored_at"`
+
+ // StorageConfidence reflects Synap's assessment of storage quality
+ StorageConfidence ConfidenceScore `json:"storage_confidence"`
+}
+
+// Remember stores a simple semantic memory in Synap.
+//
+// This is a convenience method for storing straightforward facts or observations
+// without the full episodic context (what/when/where/who/why/how).
+// For richer contextual memories, use RememberEpisode instead.
+//
+// The memory will be stored in the space specified in the client config,
+// or you can override it with WithSpace in the context.
+//
+// Example:
+//
+// memoryID, err := client.Remember(ctx, "User loves hiking and photography", 0.85)
+func (c *Client) Remember(ctx context.Context, content string, confidence float64) (*RememberResponse, error) {
+ if content == "" {
+ return nil, fmt.Errorf("content is required")
+ }
+
+ if confidence == 0 {
+ confidence = 0.7 // Default confidence
+ }
+
+ space := getSpace(ctx, c.config.DefaultSpace)
+ endpoint := c.buildURL("/api/v1/memories/remember", map[string]string{
+ "space": space,
+ })
+
+ reqBody := map[string]interface{}{
+ "content": content,
+ "confidence": confidence,
+ }
+
+ body, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("marshal request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body))
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ if c.config.APIKey != "" {
+ req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
+ }
+
+ c.logger.Debug("storing semantic memory in synap",
+ "content_length", len(content),
+ "space", space,
+ "confidence", confidence)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("execute request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ bodyBytes, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ return nil, fmt.Errorf("synap API error (HTTP %d): failed to read error body: %w", resp.StatusCode, readErr)
+ }
+ return nil, fmt.Errorf("synap API error (HTTP %d): %s", resp.StatusCode, bodyBytes)
+ }
+
+ var result RememberResponse
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("decode response: %w", err)
+ }
+
+ c.logger.Info("semantic memory stored in synap",
+ "memory_id", result.MemoryID,
+ "confidence", result.StorageConfidence.Value,
+ "space", space)
+
+ return &result, nil
+}
+
+// RememberEpisode stores an episodic memory in Synap.
+//
+// The memory will be stored in the space specified in the client config,
+// or you can override it with WithSpace in the context.
+//
+// Example:
+//
+// episode := &synap.Episode{
+// What: "User mentioned they love hiking and photography",
+// When: time.Now(),
+// Who: []string{"user_alice"},
+// Confidence: 0.85,
+// }
+// memoryID, err := client.RememberEpisode(ctx, episode)
+func (c *Client) RememberEpisode(ctx context.Context, episode *Episode) (*RememberEpisodeResponse, error) {
+ if episode.What == "" {
+ return nil, fmt.Errorf("episode.What is required")
+ }
+
+ if episode.When.IsZero() {
+ return nil, fmt.Errorf("episode.When is required")
+ }
+
+ if episode.Confidence == 0 {
+ episode.Confidence = 0.7 // Default confidence
+ }
+
+ space := getSpace(ctx, c.config.DefaultSpace)
+ endpoint := c.buildURL("/api/v1/episodes/remember", map[string]string{
+ "space": space,
+ })
+
+ body, err := json.Marshal(episode)
+ if err != nil {
+ return nil, fmt.Errorf("marshal episode: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body))
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ if c.config.APIKey != "" {
+ req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
+ }
+
+ c.logger.Debug("storing episode in synap",
+ "what", episode.What,
+ "space", space,
+ "confidence", episode.Confidence)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("execute request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ bodyBytes, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ return nil, fmt.Errorf("synap API error (HTTP %d): failed to read error body: %w", resp.StatusCode, readErr)
+ }
+ return nil, fmt.Errorf("synap API error (HTTP %d): %s", resp.StatusCode, bodyBytes)
+ }
+
+ var result RememberEpisodeResponse
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("decode response: %w", err)
+ }
+
+ c.logger.Info("episode stored in synap",
+ "memory_id", result.MemoryID,
+ "confidence", result.StorageConfidence.Value,
+ "space", space)
+
+ return &result, nil
+}
+
+// RecallMode specifies how memories should be retrieved.
+type RecallMode string
+
+const (
+ // RecallModeSimilarity uses vector similarity search only
+ RecallModeSimilarity RecallMode = "similarity"
+
+ // RecallModeSpreading uses spreading activation through memory graph
+ RecallModeSpreading RecallMode = "spreading"
+
+ // RecallModeHybrid combines similarity and spreading (recommended for chat)
+ RecallModeHybrid RecallMode = "hybrid"
+)
+
+// RecallRequest specifies parameters for memory recall.
+type RecallRequest struct {
+ // Query is the natural language search query (required)
+ Query string
+
+ // Mode specifies recall strategy (default: hybrid)
+ Mode RecallMode
+
+ // MaxResults limits the number of memories returned (default: 10)
+ MaxResults int
+
+ // Threshold is the minimum confidence for results (0.0-1.0, default: 0.5)
+ Threshold float64
+
+ // FromTime filters memories after this time (optional)
+ FromTime *time.Time
+
+ // ToTime filters memories before this time (optional)
+ ToTime *time.Time
+
+ // RequiredTags filters to memories with these tags (optional)
+ RequiredTags []string
+
+ // ExcludedTags filters out memories with these tags (optional)
+ ExcludedTags []string
+
+ // TraceActivation returns activation spreading details (for debugging)
+ TraceActivation bool
+}
+
+// Memory represents a recalled memory from Synap.
+type Memory struct {
+ // ID is the unique memory identifier
+ ID string `json:"id"`
+
+ // Content is the memory content (for semantic memories)
+ // For episodes, use Episode field instead
+ Content string `json:"content,omitempty"`
+
+ // Episode contains episodic memory details (what/when/where/who/why/how)
+ Episode *EpisodeDetails `json:"episode,omitempty"`
+
+ // Confidence in the memory accuracy
+ Confidence ConfidenceScore `json:"confidence"`
+
+ // ActivationLevel shows how "awake" this memory is (0.0-1.0)
+ // Higher activation = more recently/frequently accessed
+ ActivationLevel float64 `json:"activation_level"`
+
+ // SimilarityScore shows relevance to the query (0.0-1.0)
+ SimilarityScore float64 `json:"similarity_score"`
+
+ // ObservedAt is when the original event occurred
+ ObservedAt time.Time `json:"observed_at,omitempty"`
+
+ // Tags categorize the memory
+ Tags []string `json:"tags,omitempty"`
+}
+
+// EpisodeDetails contains the rich context of an episodic memory.
+type EpisodeDetails struct {
+ What string `json:"what"`
+ When time.Time `json:"when"`
+ Where string `json:"where,omitempty"`
+ Who []string `json:"who,omitempty"`
+ Why string `json:"why,omitempty"`
+ How string `json:"how,omitempty"`
+}
+
+// RecallResponse contains memories recalled from Synap.
+type RecallResponse struct {
+ // Memories organized by vividness
+ Memories MemoryCategories `json:"memories"`
+
+ // RecallConfidence indicates overall recall quality
+ RecallConfidence ConfidenceScore `json:"recall_confidence"`
+
+ // QueryAnalysis provides insights into the search
+ QueryAnalysis QueryAnalysis `json:"query_analysis"`
+
+ // SystemMessage provides feedback
+ SystemMessage string `json:"system_message"`
+}
+
+// MemoryCategories groups memories by their vividness/confidence.
+type MemoryCategories struct {
+ // Vivid memories are high-confidence, directly relevant
+ Vivid []Memory `json:"vivid"`
+
+ // Associated memories are related through spreading activation
+ Associated []Memory `json:"associated"`
+
+ // Reconstructed memories are pattern-completed from partial information
+ Reconstructed []Memory `json:"reconstructed"`
+}
+
+// QueryAnalysis provides insights into how the query was processed.
+type QueryAnalysis struct {
+ // UnderstoodIntent is Synap's interpretation of the query
+ UnderstoodIntent string `json:"understood_intent"`
+
+ // SearchStrategy describes the retrieval approach
+ SearchStrategy string `json:"search_strategy"`
+
+ // CognitiveLoad indicates query complexity ("Low", "Medium", "High")
+ CognitiveLoad string `json:"cognitive_load"`
+
+ // Suggestions for improving recall (optional)
+ Suggestions []string `json:"suggestions,omitempty"`
+}
+
+// Recall retrieves relevant memories from Synap.
+//
+// Example:
+//
+// memories, err := client.Recall(ctx, &synap.RecallRequest{
+// Query: "hobbies and interests",
+// Mode: synap.RecallModeHybrid,
+// MaxResults: 10,
+// Threshold: 0.5,
+// })
+//
+// for _, memory := range memories.Memories.Vivid {
+// fmt.Printf("Memory: %s (confidence: %.2f)\n", memory.Content, memory.Confidence.Value)
+// }
+func (c *Client) Recall(ctx context.Context, req *RecallRequest) (*RecallResponse, error) {
+ if req.Query == "" {
+ return nil, fmt.Errorf("query is required")
+ }
+
+ if req.Mode == "" {
+ req.Mode = RecallModeHybrid
+ }
+
+ if req.MaxResults == 0 {
+ req.MaxResults = 10
+ }
+
+ if req.Threshold == 0 {
+ req.Threshold = 0.5
+ }
+
+ space := getSpace(ctx, c.config.DefaultSpace)
+
+ // Build query parameters
+ params := map[string]string{
+ "query": req.Query,
+ "mode": string(req.Mode),
+ "max_results": fmt.Sprintf("%d", req.MaxResults),
+ "threshold": fmt.Sprintf("%.2f", req.Threshold),
+ "space": space,
+ }
+
+ if req.FromTime != nil {
+ params["from_time"] = req.FromTime.Format(time.RFC3339)
+ }
+
+ if req.ToTime != nil {
+ params["to_time"] = req.ToTime.Format(time.RFC3339)
+ }
+
+ if len(req.RequiredTags) > 0 {
+ params["required_tags"] = strings.Join(req.RequiredTags, ",")
+ }
+
+ if len(req.ExcludedTags) > 0 {
+ params["excluded_tags"] = strings.Join(req.ExcludedTags, ",")
+ }
+
+ if req.TraceActivation {
+ params["trace_activation"] = "true"
+ }
+
+ endpoint := c.buildURL("/api/v1/memories/recall", params)
+
+ httpReq, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ if c.config.APIKey != "" {
+ httpReq.Header.Set("Authorization", "Bearer "+c.config.APIKey)
+ }
+
+ c.logger.Debug("recalling memories from synap",
+ "query", req.Query,
+ "mode", req.Mode,
+ "space", space)
+
+ resp, err := c.httpClient.Do(httpReq)
+ if err != nil {
+ return nil, fmt.Errorf("execute request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ bodyBytes, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ return nil, fmt.Errorf("synap API error (HTTP %d): failed to read error body: %w", resp.StatusCode, readErr)
+ }
+ return nil, fmt.Errorf("synap API error (HTTP %d): %s", resp.StatusCode, bodyBytes)
+ }
+
+ var result RecallResponse
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("decode response: %w", err)
+ }
+
+ totalMemories := len(result.Memories.Vivid) + len(result.Memories.Associated) + len(result.Memories.Reconstructed)
+ c.logger.Info("recalled memories from synap",
+ "total", totalMemories,
+ "vivid", len(result.Memories.Vivid),
+ "associated", len(result.Memories.Associated),
+ "confidence", result.RecallConfidence.Value,
+ "space", space)
+
+ return &result, nil
+}
+
+// Health checks if the Synap server is reachable and healthy.
+func (c *Client) Health(ctx context.Context) error {
+ endpoint := c.baseURL + "/api/v1/system/health"
+
+ req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
+ if err != nil {
+ return fmt.Errorf("create request: %w", err)
+ }
+
+ if c.config.APIKey != "" {
+ req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
+ }
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("execute request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ bodyBytes, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ return fmt.Errorf("synap health check failed (HTTP %d): failed to read error body: %w", resp.StatusCode, readErr)
+ }
+ return fmt.Errorf("synap health check failed (HTTP %d): %s", resp.StatusCode, bodyBytes)
+ }
+
+ return nil
+}
+
+// buildURL constructs a URL with query parameters.
+func (c *Client) buildURL(path string, params map[string]string) string {
+ u, _ := url.Parse(c.baseURL + path)
+
+ q := u.Query()
+ for k, v := range params {
+ if v != "" {
+ q.Set(k, v)
+ }
+ }
+
+ u.RawQuery = q.Encode()
+ return u.String()
+}
+
+// Context keys for per-request configuration.
+type contextKey string
+
+const spaceContextKey contextKey = "synap_space"
+
+// WithSpace returns a context with a specific memory space override.
+//
+// This overrides the default space configured in the client for a single request.
+//
+// Example:
+//
+// ctx := synap.WithSpace(ctx, "conversation_xyz")
+// client.RememberEpisode(ctx, episode) // Uses conversation_xyz space
+func WithSpace(ctx context.Context, space string) context.Context {
+ return context.WithValue(ctx, spaceContextKey, space)
+}
+
+// getSpace retrieves the space from context, falling back to default.
+func getSpace(ctx context.Context, defaultSpace string) string {
+ if space, ok := ctx.Value(spaceContextKey).(string); ok && space != "" {
+ return space
+ }
+ return defaultSpace
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/textgen/adapters/gemini.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/textgen/adapters/gemini.go.tmpl
new file mode 100644
index 0000000..4d2357c
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/textgen/adapters/gemini.go.tmpl
@@ -0,0 +1,201 @@
+// Package adapters provides textgen provider adapters for various AI services.
+package adapters
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "{{GO_MODULE}}/pkg/textgen"
+ "google.golang.org/genai"
+)
+
+const (
+ // IMPORTANT: Always use gemini-3-flash-preview for text generation.
+ // DO NOT use gemini-2.5-flash or any 2.x models.
+ defaultGeminiTextModel = "gemini-3-flash-preview"
+)
+
+// GeminiTextProvider implements textgen.TextGenerator using Gemini API.
+type GeminiTextProvider struct {
+ client *genai.Client
+ model string
+}
+
+// GeminiTextConfig holds configuration for the Gemini text provider.
+type GeminiTextConfig struct {
+ // APIKey for Gemini API (required if Client is nil)
+ APIKey string
+
+ // Client is an existing genai.Client (optional, takes precedence over APIKey)
+ Client *genai.Client
+
+ // Model to use for text generation (default: gemini-3-flash-preview)
+ // IMPORTANT: DO NOT use gemini-2.5-flash or any 2.x models.
+ Model string
+}
+
+// NewGeminiTextProvider creates a new Gemini text generation provider.
+func NewGeminiTextProvider(ctx context.Context, cfg GeminiTextConfig) (*GeminiTextProvider, error) {
+ var client *genai.Client
+ var err error
+
+ if cfg.Client != nil {
+ client = cfg.Client
+ } else if cfg.APIKey != "" {
+ client, err = genai.NewClient(ctx, &genai.ClientConfig{
+ APIKey: cfg.APIKey,
+ Backend: genai.BackendGeminiAPI,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("create genai client: %w", err)
+ }
+ } else {
+ return nil, fmt.Errorf("%w: APIKey or Client required", textgen.ErrInvalidConfig)
+ }
+
+ model := cfg.Model
+ if model == "" {
+ model = defaultGeminiTextModel
+ }
+
+ return &GeminiTextProvider{
+ client: client,
+ model: model,
+ }, nil
+}
+
+// Name implements textgen.Provider.
+func (p *GeminiTextProvider) Name() string {
+ return "gemini"
+}
+
+// Health implements textgen.Provider.
+func (p *GeminiTextProvider) Health(ctx context.Context) error {
+ // Try a minimal request to verify the API is working
+ _, err := p.client.Models.Get(ctx, p.model, nil)
+ if err != nil {
+ return fmt.Errorf("gemini health check: %w", err)
+ }
+ return nil
+}
+
+// GenerateText implements textgen.TextGenerator.
+func (p *GeminiTextProvider) GenerateText(ctx context.Context, req textgen.TextRequest) (*textgen.TextResponse, error) {
+ model := req.Model
+ if model == "" {
+ model = p.model
+ }
+
+ // Build content from request
+ var content []*genai.Content
+
+ // Add system prompt if provided
+ if req.SystemPrompt != "" {
+ content = append(content, &genai.Content{
+ Role: "user",
+ Parts: []*genai.Part{
+ {Text: "System: " + req.SystemPrompt},
+ },
+ })
+ }
+
+ // Add messages if provided (multi-turn conversation)
+ if len(req.Messages) > 0 {
+ for _, msg := range req.Messages {
+ role := msg.Role
+ if role == "assistant" {
+ role = "model"
+ }
+ content = append(content, &genai.Content{
+ Role: role,
+ Parts: []*genai.Part{
+ {Text: msg.Content},
+ },
+ })
+ }
+ } else if req.Prompt != "" {
+ // Single prompt
+ content = append(content, &genai.Content{
+ Role: "user",
+ Parts: []*genai.Part{
+ {Text: req.Prompt},
+ },
+ })
+ }
+
+ // Configure generation
+ var config *genai.GenerateContentConfig
+ if req.MaxTokens > 0 || req.Temperature > 0 {
+ config = &genai.GenerateContentConfig{}
+ if req.MaxTokens > 0 {
+ config.MaxOutputTokens = int32(req.MaxTokens)
+ }
+ if req.Temperature > 0 {
+ temp := float32(req.Temperature)
+ config.Temperature = &temp
+ }
+ }
+
+ // Call Gemini API
+ resp, err := p.client.Models.GenerateContent(ctx, model, content, config)
+ if err != nil {
+ return nil, classifyGeminiError(err)
+ }
+
+ // Extract text from response
+ var responseText string
+ if resp != nil && len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
+ for _, part := range resp.Candidates[0].Content.Parts {
+ if part.Text != "" {
+ responseText += part.Text
+ }
+ }
+ }
+
+ if responseText == "" {
+ return nil, fmt.Errorf("empty response from Gemini")
+ }
+
+ // Build response
+ result := &textgen.TextResponse{
+ Text: responseText,
+ }
+
+ // Add usage if available
+ if resp.UsageMetadata != nil {
+ result.Usage = &textgen.Usage{
+ PromptTokens: int(resp.UsageMetadata.PromptTokenCount),
+ CompletionTokens: int(resp.UsageMetadata.CandidatesTokenCount),
+ TotalTokens: int(resp.UsageMetadata.TotalTokenCount),
+ }
+ }
+
+ return result, nil
+}
+
+// classifyGeminiError converts Gemini errors to textgen sentinel errors.
+func classifyGeminiError(err error) error {
+ if err == nil {
+ return nil
+ }
+
+ errStr := strings.ToLower(err.Error())
+
+ // Check for common error patterns
+ switch {
+ case strings.Contains(errStr, "quota") || strings.Contains(errStr, "429"):
+ return fmt.Errorf("%w: %v", textgen.ErrQuotaExceeded, err)
+ case strings.Contains(errStr, "rate") || strings.Contains(errStr, "limit"):
+ return fmt.Errorf("%w: %v", textgen.ErrRateLimited, err)
+ case strings.Contains(errStr, "safety") || strings.Contains(errStr, "blocked"):
+ return fmt.Errorf("%w: %v", textgen.ErrContentBlocked, err)
+ case strings.Contains(errStr, "timeout") || strings.Contains(errStr, "deadline"):
+ return fmt.Errorf("%w: %v", textgen.ErrTimeout, err)
+ default:
+ return err
+ }
+}
+
+// Compile-time interface check
+var _ textgen.TextGenerator = (*GeminiTextProvider)(nil)
diff --git a/internal/adapter/templates/templates/skeleton/pkg/textgen/adapters/laozhang.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/textgen/adapters/laozhang.go.tmpl
new file mode 100644
index 0000000..3a361ec
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/textgen/adapters/laozhang.go.tmpl
@@ -0,0 +1,195 @@
+package adapters
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "{{GO_MODULE}}/pkg/laozhang"
+ "{{GO_MODULE}}/pkg/textgen"
+)
+
+const (
+ // IMPORTANT: Always use gemini-3-flash-preview for text generation.
+ // DO NOT use gemini-2.5-flash or any 2.x models.
+ defaultLaoZhangTextModel = "gemini-3-flash-preview"
+)
+
+// LaoZhangTextProvider implements textgen.TextGenerator using LaoZhang API.
+type LaoZhangTextProvider struct {
+ client *laozhang.Client
+ model string
+}
+
+// NewLaoZhangTextProvider creates a new LaoZhang text generation provider.
+func NewLaoZhangTextProvider(client *laozhang.Client, model string) *LaoZhangTextProvider {
+ if model == "" {
+ model = defaultLaoZhangTextModel
+ }
+ return &LaoZhangTextProvider{
+ client: client,
+ model: model,
+ }
+}
+
+// Name implements textgen.Provider.
+func (p *LaoZhangTextProvider) Name() string {
+ return "laozhang"
+}
+
+// Health implements textgen.Provider.
+func (p *LaoZhangTextProvider) Health(ctx context.Context) error {
+ return p.client.Health(ctx)
+}
+
+// GenerateText implements textgen.TextGenerator.
+func (p *LaoZhangTextProvider) GenerateText(ctx context.Context, req textgen.TextRequest) (*textgen.TextResponse, error) {
+ model := req.Model
+ if model == "" {
+ model = p.model
+ }
+
+ // Build messages from request
+ var messages []laozhang.ChatMessage
+
+ // Add system prompt if provided
+ if req.SystemPrompt != "" {
+ messages = append(messages, laozhang.ChatMessage{
+ Role: "system",
+ Content: req.SystemPrompt,
+ })
+ }
+
+ // Add messages if provided (multi-turn conversation)
+ if len(req.Messages) > 0 {
+ for _, msg := range req.Messages {
+ messages = append(messages, laozhang.ChatMessage{
+ Role: msg.Role,
+ Content: msg.Content,
+ })
+ }
+ } else if req.Prompt != "" {
+ // Single prompt
+ messages = append(messages, laozhang.ChatMessage{
+ Role: "user",
+ Content: req.Prompt,
+ })
+ }
+
+ // Build chat request
+ chatReq := laozhang.ChatCompletionRequest{
+ Model: model,
+ Messages: messages,
+ }
+
+ if req.MaxTokens > 0 {
+ chatReq.MaxTokens = req.MaxTokens
+ }
+ if req.Temperature > 0 {
+ chatReq.Temperature = req.Temperature
+ }
+
+ // Call LaoZhang API
+ resp, err := p.client.ChatCompletion(ctx, chatReq)
+ if err != nil {
+ return nil, classifyLaoZhangError(err)
+ }
+
+ // Extract text from response
+ if len(resp.Choices) == 0 {
+ return nil, fmt.Errorf("empty response from LaoZhang")
+ }
+
+ responseText := resp.Choices[0].Message.Content
+ if responseText == "" {
+ return nil, fmt.Errorf("empty content in LaoZhang response")
+ }
+
+ // Build response
+ result := &textgen.TextResponse{
+ Text: responseText,
+ Usage: &textgen.Usage{
+ PromptTokens: resp.Usage.PromptTokens,
+ CompletionTokens: resp.Usage.CompletionTokens,
+ TotalTokens: resp.Usage.TotalTokens,
+ },
+ }
+
+ return result, nil
+}
+
+// classifyLaoZhangError converts LaoZhang errors to textgen sentinel errors.
+func classifyLaoZhangError(err error) error {
+ if err == nil {
+ return nil
+ }
+
+ errStr := strings.ToLower(err.Error())
+
+ // Check for common error patterns
+ switch {
+ case strings.Contains(errStr, "quota"):
+ return fmt.Errorf("%w: %v", textgen.ErrQuotaExceeded, err)
+ case strings.Contains(errStr, "rate") || strings.Contains(errStr, "429"):
+ return fmt.Errorf("%w: %v", textgen.ErrRateLimited, err)
+ case strings.Contains(errStr, "safety") || strings.Contains(errStr, "blocked") || strings.Contains(errStr, "content"):
+ return fmt.Errorf("%w: %v", textgen.ErrContentBlocked, err)
+ case strings.Contains(errStr, "timeout") || strings.Contains(errStr, "deadline"):
+ return fmt.Errorf("%w: %v", textgen.ErrTimeout, err)
+ default:
+ return err
+ }
+}
+
+// GenerateStream implements textgen.TextStreamer using LaoZhang streaming API.
+func (p *LaoZhangTextProvider) GenerateStream(ctx context.Context, req textgen.TextRequest, onChunk func(textgen.StreamChunk)) error {
+ model := req.Model
+ if model == "" {
+ model = p.model
+ }
+
+ // Build messages from request
+ var messages []laozhang.ChatMessage
+ if req.SystemPrompt != "" {
+ messages = append(messages, laozhang.ChatMessage{
+ Role: "system",
+ Content: req.SystemPrompt,
+ })
+ }
+ if len(req.Messages) > 0 {
+ for _, msg := range req.Messages {
+ messages = append(messages, laozhang.ChatMessage{
+ Role: msg.Role,
+ Content: msg.Content,
+ })
+ }
+ } else if req.Prompt != "" {
+ messages = append(messages, laozhang.ChatMessage{
+ Role: "user",
+ Content: req.Prompt,
+ })
+ }
+
+ chatReq := laozhang.ChatCompletionRequest{
+ Model: model,
+ Messages: messages,
+ }
+ if req.MaxTokens > 0 {
+ chatReq.MaxTokens = req.MaxTokens
+ }
+ if req.Temperature > 0 {
+ chatReq.Temperature = req.Temperature
+ }
+
+ return p.client.ChatCompletionStream(ctx, chatReq, func(chunk laozhang.StreamChunk) {
+ onChunk(textgen.StreamChunk{
+ Text: chunk.Text,
+ Done: chunk.Done,
+ Provider: func() string { if chunk.Done { return p.Name() }; return "" }(),
+ })
+ })
+}
+
+// Compile-time interface checks
+var _ textgen.TextGenerator = (*LaoZhangTextProvider)(nil)
+var _ textgen.TextStreamer = (*LaoZhangTextProvider)(nil)
diff --git a/internal/adapter/templates/templates/skeleton/pkg/textgen/circuit_breaker.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/textgen/circuit_breaker.go.tmpl
new file mode 100644
index 0000000..b8d4536
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/textgen/circuit_breaker.go.tmpl
@@ -0,0 +1,28 @@
+package textgen
+
+import (
+ "time"
+
+ "{{GO_MODULE}}/pkg/routing"
+)
+
+// CircuitBreaker is an alias for routing.CircuitBreaker.
+//
+// IMPORTANT: All cooldown tracking logic is implemented in pkg/routing.
+// Do NOT implement custom cooldown logic here. Use routing.CircuitBreaker
+// directly for new code.
+type CircuitBreaker = routing.CircuitBreaker
+
+// Cooldown period constants - re-exported from routing for convenience.
+const (
+ // DefaultCooldownPeriod is the cooldown for rate limits and quota errors.
+ DefaultCooldownPeriod = routing.DefaultCooldownPeriod
+
+ // TransientCooldownPeriod is the cooldown for transient server errors (503, 500, etc).
+ TransientCooldownPeriod = routing.TransientCooldownPeriod
+)
+
+// NewCircuitBreaker creates a new circuit breaker.
+func NewCircuitBreaker(cooldown time.Duration) *CircuitBreaker {
+ return routing.NewCircuitBreaker(cooldown)
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/textgen/config.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/textgen/config.go.tmpl
new file mode 100644
index 0000000..d3c3426
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/textgen/config.go.tmpl
@@ -0,0 +1,81 @@
+package textgen
+
+import "log/slog"
+
+// Provider Ordering Strategy
+//
+// Production ordering: LaoZhang (primary) -> Gemini (terminus)
+//
+// LaoZhang is primary because:
+// - Pay-per-use pricing, no hard daily limits
+// - More predictable availability for production traffic
+// - Gemini's unpredictable quota exhaustion would frustrate users
+// - Cost is acceptable for revenue-generating features
+//
+// Gemini is terminus because:
+// - Daily quota limits (resets at midnight PT)
+// - Free tier was reduced ~92% in December 2025
+// - Terminus is ALWAYS tried regardless of cooldown
+//
+// The fallback strategy ensures requests succeed when possible.
+
+// ProviderSet holds text providers for easy configuration.
+type ProviderSet struct {
+ LaoZhang TextGenerator
+ Gemini TextGenerator
+}
+
+// ProductionConfig returns a ManagerConfig optimized for production workloads.
+//
+// Primary: LaoZhang (reliable pay-per-use)
+// Terminus: Gemini (may fail on quota, but always tried as last resort)
+//
+// Use this for:
+// - User-initiated text generation
+// - Production API endpoints
+// - Any feature where reliability matters more than cost
+func ProductionConfig(providers ProviderSet, opts ...ConfigOption) ManagerConfig {
+ textProviders := []TextGenerator{}
+
+ // Build provider list in order: LaoZhang -> Gemini (terminus)
+ if providers.LaoZhang != nil {
+ textProviders = append(textProviders, providers.LaoZhang)
+ }
+ if providers.Gemini != nil {
+ textProviders = append(textProviders, providers.Gemini)
+ }
+
+ cfg := ManagerConfig{
+ Providers: textProviders,
+ Strategy: StrategyFallback,
+ }
+ for _, opt := range opts {
+ opt(&cfg)
+ }
+ return cfg
+}
+
+// ConfigOption allows customizing preset configurations.
+type ConfigOption func(*ManagerConfig)
+
+// WithLogger sets a custom logger.
+func WithLogger(logger *slog.Logger) ConfigOption {
+ return func(cfg *ManagerConfig) {
+ cfg.Logger = logger
+ }
+}
+
+// WithMetrics sets a metrics hook for observability.
+func WithMetrics(hook MetricsHook) ConfigOption {
+ return func(cfg *ManagerConfig) {
+ cfg.OnMetrics = hook
+ }
+}
+
+// WithStrategy overrides the default fallback strategy.
+// Use sparingly - the presets use StrategyFallback for a reason.
+func WithStrategy(strategy Strategy) ConfigOption {
+ return func(cfg *ManagerConfig) {
+ cfg.Strategy = strategy
+ }
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/textgen/errors.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/textgen/errors.go.tmpl
new file mode 100644
index 0000000..c5317aa
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/textgen/errors.go.tmpl
@@ -0,0 +1,51 @@
+package textgen
+
+import (
+ "errors"
+
+ "{{GO_MODULE}}/pkg/routing"
+)
+
+// Domain errors for programmatic error handling with errors.Is().
+//
+// IMPORTANT: Core routing errors (ErrRateLimit, ErrQuotaExceeded, ErrServerUnavailable)
+// are defined in pkg/routing. Provider implementations should wrap those errors.
+var (
+ // ErrInvalidConfig indicates invalid manager or provider configuration.
+ ErrInvalidConfig = errors.New("textgen: invalid configuration")
+
+ // ErrInvalidRequest indicates an invalid generation request.
+ ErrInvalidRequest = errors.New("textgen: invalid request")
+
+ // ErrNoProvidersConfigured indicates no providers were configured.
+ ErrNoProvidersConfigured = errors.New("textgen: no providers configured")
+
+ // ErrAllProvidersFailed indicates all providers failed to generate.
+ // This is an alias for routing.ErrAllProvidersFailed.
+ ErrAllProvidersFailed = routing.ErrAllProvidersFailed
+
+ // ErrQuotaExceeded indicates the provider's quota has been exceeded.
+ // Re-exported from routing for convenience.
+ ErrQuotaExceeded = routing.ErrQuotaExceeded
+
+ // ErrRateLimited indicates the request was rate limited.
+ // Re-exported from routing for convenience.
+ ErrRateLimited = routing.ErrRateLimit
+
+ // ErrContentBlocked indicates the content was blocked by safety filters.
+ ErrContentBlocked = errors.New("textgen: content blocked")
+
+ // ErrTimeout indicates the request timed out.
+ ErrTimeout = errors.New("textgen: timeout")
+)
+
+// IsRetryableError returns true if the error is transient and worth retrying.
+func IsRetryableError(err error) bool {
+ if err == nil {
+ return false
+ }
+ return errors.Is(err, ErrQuotaExceeded) ||
+ errors.Is(err, ErrRateLimited) ||
+ errors.Is(err, ErrTimeout) ||
+ errors.Is(err, routing.ErrServerUnavailable)
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/textgen/manager.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/textgen/manager.go.tmpl
new file mode 100644
index 0000000..e328a02
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/textgen/manager.go.tmpl
@@ -0,0 +1,202 @@
+package textgen
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "sync/atomic"
+ "time"
+
+ "{{GO_MODULE}}/pkg/routing"
+)
+
+// Manager coordinates multiple providers with configurable routing strategies.
+// Safe for concurrent use.
+//
+// IMPORTANT: All text generation MUST go through this Manager.
+// Do NOT implement custom provider routing elsewhere. The Manager delegates to
+// pkg/routing for consistent terminus semantics and cooldown handling.
+type Manager struct {
+ providers []TextGenerator
+ strategy Strategy
+ logger *slog.Logger
+ onMetrics MetricsHook
+ cooldown routing.CooldownTracker
+
+ // Round-robin state
+ index atomic.Uint64
+}
+
+// MetricsHook is called after each generation attempt for observability.
+// provider: name of the provider used
+// operation: "GenerateText"
+// latency: time taken for the operation
+// err: error if the operation failed, nil on success
+type MetricsHook func(provider, operation string, latency time.Duration, err error)
+
+// ManagerConfig configures the provider manager.
+type ManagerConfig struct {
+ Providers []TextGenerator // Text generation providers (order matters for fallback)
+ Strategy Strategy // Routing strategy (default: StrategyPrimaryOnly)
+ Logger *slog.Logger // Optional: defaults to slog.Default()
+ OnMetrics MetricsHook // Optional: callback for metrics collection
+ CircuitBreaker *CircuitBreaker // Optional: in-memory cooldown tracker
+ CooldownPeriod time.Duration // Optional: cooldown for rate-limited providers (default: 1 hour)
+}
+
+// NewManager creates a new provider manager.
+//
+// IMPORTANT: The Manager uses pkg/routing for all provider routing.
+// The LAST provider in the list is the "terminus" and will ALWAYS be attempted
+// regardless of cooldown state when using StrategyFallback.
+func NewManager(config ManagerConfig) (*Manager, error) {
+ if len(config.Providers) == 0 {
+ return nil, fmt.Errorf("%w: at least one provider required", ErrInvalidConfig)
+ }
+
+ if config.Strategy == "" {
+ config.Strategy = StrategyPrimaryOnly
+ }
+
+ if !config.Strategy.Valid() {
+ return nil, fmt.Errorf("%w: unknown strategy %s", ErrInvalidConfig, config.Strategy)
+ }
+
+ if config.Logger == nil {
+ config.Logger = slog.Default()
+ }
+
+ // Build cooldown tracker using routing.BuildCooldownTracker
+ cooldown := routing.BuildCooldownTracker(routing.CooldownConfig{
+ CircuitBreaker: config.CircuitBreaker,
+ CooldownPeriod: config.CooldownPeriod,
+ })
+
+ return &Manager{
+ providers: config.Providers,
+ strategy: config.Strategy,
+ logger: config.Logger,
+ onMetrics: config.OnMetrics,
+ cooldown: cooldown,
+ }, nil
+}
+
+// recordMetrics calls the metrics hook if configured.
+func (m *Manager) recordMetrics(provider, operation string, latency time.Duration, err error) {
+ if m.onMetrics != nil {
+ m.onMetrics(provider, operation, latency, err)
+ }
+}
+
+// GenerateText generates text using the configured strategy.
+//
+// IMPORTANT: This method delegates to pkg/routing for consistent terminus semantics.
+// The LAST provider is ALWAYS attempted regardless of cooldown when using StrategyFallback.
+func (m *Manager) GenerateText(ctx context.Context, req TextRequest) (*TextResponse, error) {
+ if len(m.providers) == 0 {
+ return nil, ErrNoProvidersConfigured
+ }
+
+ if req.Prompt == "" && len(req.Messages) == 0 {
+ return nil, fmt.Errorf("%w: prompt or messages required", ErrInvalidRequest)
+ }
+
+ // Apply request timeout if specified
+ if req.Timeout > 0 {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, req.Timeout)
+ defer cancel()
+ }
+
+ // Convert to routing.Provider slice
+ providers := make([]routing.Provider, len(m.providers))
+ for i, p := range m.providers {
+ providers[i] = p
+ }
+
+ // Use routing.Execute for consistent terminus semantics
+ result, err := routing.Execute(ctx, providers, routing.ExecuteConfig{
+ Strategy: m.strategy,
+ Cooldown: m.cooldown,
+ Logger: m.logger,
+ RoundRobinIndex: &m.index,
+ }, func(ctx context.Context, p routing.Provider) (*TextResponse, error) {
+ provider := p.(TextGenerator)
+ start := time.Now()
+
+ resp, err := provider.GenerateText(ctx, req)
+ latency := time.Since(start)
+
+ // Record metrics for observability
+ m.recordMetrics(provider.Name(), "GenerateText", latency, err)
+
+ if err != nil {
+ return nil, err
+ }
+
+ // Attach provider metadata to response
+ resp.Provider = provider.Name()
+ resp.Latency = latency
+ return resp, nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Response, nil
+}
+
+// GenerateStream generates text with streaming delivery via onChunk callback.
+// Routes to the first provider implementing TextStreamer.
+// Falls back to non-streaming GenerateText + single chunk if no streaming provider.
+func (m *Manager) GenerateStream(ctx context.Context, req TextRequest, onChunk func(StreamChunk)) error {
+ if len(m.providers) == 0 {
+ return ErrNoProvidersConfigured
+ }
+
+ if req.Prompt == "" && len(req.Messages) == 0 {
+ return fmt.Errorf("%w: prompt or messages required", ErrInvalidRequest)
+ }
+
+ if req.Timeout > 0 {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, req.Timeout)
+ defer cancel()
+ }
+
+ // Try to find a streaming provider
+ for _, p := range m.providers {
+ if streamer, ok := p.(TextStreamer); ok {
+ start := time.Now()
+ err := streamer.GenerateStream(ctx, req, onChunk)
+ latency := time.Since(start)
+ m.recordMetrics(p.Name(), "GenerateStream", latency, err)
+ return err
+ }
+ }
+
+ // Fallback: use non-streaming GenerateText and deliver as single chunk
+ resp, err := m.GenerateText(ctx, req)
+ if err != nil {
+ return err
+ }
+
+ onChunk(StreamChunk{Text: resp.Text, Done: true, Provider: resp.Provider})
+ return nil
+}
+
+// Health checks all providers and returns the first error encountered.
+func (m *Manager) Health(ctx context.Context) error {
+ for _, provider := range m.providers {
+ if err := provider.Health(ctx); err != nil {
+ return fmt.Errorf("provider %s unhealthy: %w", provider.Name(), err)
+ }
+ }
+ return nil
+}
+
+// Name returns "textgen-manager" for logging purposes.
+func (m *Manager) Name() string {
+ return "textgen-manager"
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/textgen/provider.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/textgen/provider.go.tmpl
new file mode 100644
index 0000000..7ae2e01
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/textgen/provider.go.tmpl
@@ -0,0 +1,84 @@
+// Package textgen provides a unified interface for text generation
+// across multiple LLM providers with fallback and routing capabilities.
+package textgen
+
+import (
+ "context"
+ "time"
+)
+
+// Provider defines common methods for all text generation providers.
+// Implementations must be safe for concurrent use.
+type Provider interface {
+ // Name returns the provider name for logging and metrics.
+ Name() string
+
+ // Health checks if the provider is reachable and operational.
+ Health(ctx context.Context) error
+}
+
+// TextGenerator defines the interface for text generation providers.
+// Implementations must be safe for concurrent use.
+type TextGenerator interface {
+ Provider
+
+ // GenerateText generates text content from a prompt.
+ GenerateText(ctx context.Context, req TextRequest) (*TextResponse, error)
+}
+
+// TextRequest represents a unified text generation request.
+type TextRequest struct {
+ Prompt string // Required: the prompt to generate from
+ Model string // Optional: model override (provider-specific)
+ MaxTokens int // Optional: maximum tokens to generate
+ Temperature float64 // Optional: sampling temperature (0.0-2.0)
+ Timeout time.Duration // Optional: request timeout
+
+ // System prompt for chat-style models
+ SystemPrompt string // Optional: system/instruction prompt
+
+ // Messages for multi-turn conversation (alternative to Prompt)
+ Messages []Message // Optional: conversation history
+}
+
+// Message represents a single message in a conversation.
+type Message struct {
+ Role string // "system", "user", or "assistant"
+ Content string // Message content
+}
+
+// TextResponse represents a unified text generation response.
+type TextResponse struct {
+ Text string // Generated text content
+ Provider string // Name of provider that generated this response
+ Latency time.Duration // Time taken to generate
+ Usage *Usage // Token usage statistics (if available)
+}
+
+// Usage represents token usage statistics.
+type Usage struct {
+ PromptTokens int // Tokens in the prompt
+ CompletionTokens int // Tokens in the completion
+ TotalTokens int // Total tokens used
+}
+
+// StreamChunk represents a single chunk from streaming text generation.
+type StreamChunk struct {
+ // Text is the chunk content.
+ Text string
+ // Done indicates this is the final chunk.
+ Done bool
+ // Provider name (only set on final chunk).
+ Provider string
+}
+
+// TextStreamer extends TextGenerator with streaming support.
+// Implementations deliver chunks via the onChunk callback as tokens arrive.
+type TextStreamer interface {
+ TextGenerator
+
+ // GenerateStream generates text with streaming delivery.
+ // onChunk is called for each token/chunk as it arrives.
+ // The final call has Done=true and Provider set.
+ GenerateStream(ctx context.Context, req TextRequest, onChunk func(StreamChunk)) error
+}
diff --git a/internal/adapter/templates/templates/skeleton/pkg/textgen/strategy.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/textgen/strategy.go.tmpl
new file mode 100644
index 0000000..dd280f5
--- /dev/null
+++ b/internal/adapter/templates/templates/skeleton/pkg/textgen/strategy.go.tmpl
@@ -0,0 +1,26 @@
+package textgen
+
+import "{{GO_MODULE}}/pkg/routing"
+
+// Strategy defines how the manager routes requests to providers.
+//
+// IMPORTANT: This is an alias for routing.Strategy. All routing strategies
+// are defined in pkg/routing. Do NOT define new strategies here.
+type Strategy = routing.Strategy
+
+// Strategy constants - aliases for backward compatibility.
+// New code should use routing.Strategy* directly.
+const (
+ // StrategyPrimaryOnly uses only the first provider in the list.
+ // Fast, deterministic, but no fault tolerance.
+ StrategyPrimaryOnly Strategy = routing.StrategyPrimaryOnly
+
+ // StrategyFallback tries providers in order until one succeeds.
+ // IMPORTANT: The last provider (terminus) is ALWAYS tried regardless
+ // of cooldown state. This is the fallback of last resort.
+ StrategyFallback Strategy = routing.StrategyFallback
+
+ // StrategyRoundRobin distributes requests evenly across providers.
+ // Balances load but requires state management.
+ StrategyRoundRobin Strategy = routing.StrategyRoundRobin
+)
diff --git a/internal/db/migrations/025_citadel_tenant_id.sql b/internal/db/migrations/025_citadel_tenant_id.sql
new file mode 100644
index 0000000..71362ac
--- /dev/null
+++ b/internal/db/migrations/025_citadel_tenant_id.sql
@@ -0,0 +1,8 @@
+-- Add citadel_tenant_id to projects for log environment routing.
+-- Each project gets its own Citadel environment, and the tenant_id
+-- is used by the agent DaemonSet to route container logs correctly.
+
+ALTER TABLE projects ADD COLUMN IF NOT EXISTS
+ citadel_tenant_id VARCHAR(64);
+
+COMMENT ON COLUMN projects.citadel_tenant_id IS 'Citadel environment tenant ID for log routing (auto-provisioned on project create)';
diff --git a/internal/domain/credential.go b/internal/domain/credential.go
index c35db69..287e360 100644
--- a/internal/domain/credential.go
+++ b/internal/domain/credential.go
@@ -37,6 +37,7 @@ const (
CredentialCategoryRegistry = "registry"
CredentialCategoryWorker = "worker"
CredentialCategoryStorage = "storage"
+ CredentialCategoryAI = "ai"
)
// Known credential keys.
@@ -60,4 +61,8 @@ const (
// GCS
CredKeyGCSBucket = "GCS_BUCKET"
CredKeyGCSServiceAccountJSON = "GCS_SERVICE_ACCOUNT_JSON"
+
+ // AI Providers
+ CredKeyLaozhangAPIKey = "LAOZHANG_API_KEY"
+ CredKeyGeminiAPIKey = "GEMINI_API_KEY"
)
diff --git a/internal/domain/deployment.go b/internal/domain/deployment.go
index eb614e4..34f6869 100644
--- a/internal/domain/deployment.go
+++ b/internal/domain/deployment.go
@@ -19,6 +19,10 @@ type DeploySpec struct {
// Used for service discovery - each component gets env vars for all sibling services.
// Example: {"AUTH_SVC_URL": "http://myproject-auth-svc:8001", "CHAT_SVC_URL": "http://myproject-chat-svc:8002"}
SiblingServices map[string]string
+
+ // ExtraLabels are additional k8s labels applied to the Deployment and Pod template.
+ // Used for Citadel agent routing (citadel.io/environment, citadel.io/service).
+ ExtraLabels map[string]string
}
// DeploymentName returns the K8s resource name for this deployment.
diff --git a/internal/port/citadel.go b/internal/port/citadel.go
new file mode 100644
index 0000000..64e6470
--- /dev/null
+++ b/internal/port/citadel.go
@@ -0,0 +1,35 @@
+// Package port defines interfaces (ports) for external dependencies.
+package port
+
+import "context"
+
+// CitadelEnvironment represents a Citadel log environment.
+type CitadelEnvironment struct {
+ // TenantID is the unique identifier for this environment in Citadel.
+ TenantID string `json:"tenant_id"`
+ // Name is the human-readable environment name.
+ Name string `json:"name"`
+}
+
+// CitadelClient manages Citadel environments and log shipping.
+type CitadelClient interface {
+ // CreateEnvironment creates a new Citadel environment for a project.
+ // Returns the tenant ID for the new environment.
+ CreateEnvironment(ctx context.Context, name string) (*CitadelEnvironment, error)
+
+ // DeleteEnvironment removes a Citadel environment.
+ DeleteEnvironment(ctx context.Context, tenantID string) error
+
+ // GetEnvironment returns an environment by name.
+ // Returns nil, nil if not found.
+ GetEnvironment(ctx context.Context, name string) (*CitadelEnvironment, error)
+
+ // IngestEvent sends a single log event to Citadel.
+ IngestEvent(ctx context.Context, tenantID string, event map[string]any) error
+
+ // IngestBatch sends a batch of log events to Citadel.
+ IngestBatch(ctx context.Context, tenantID string, events []map[string]any) error
+
+ // Healthy returns true if the Citadel instance is reachable.
+ Healthy(ctx context.Context) bool
+}
diff --git a/internal/service/component.go b/internal/service/component.go
index 5b71ace..b6d00f6 100644
--- a/internal/service/component.go
+++ b/internal/service/component.go
@@ -173,6 +173,14 @@ func (s *ComponentService) AddComponent(ctx context.Context, projectID string, r
"DOMAIN": projectDomain,
}
+ // For frontend apps, inject the primary service name/port for API proxy
+ if componentType.IsAppComponent() {
+ if svc := s.findFirstServiceComponent(ctx, projectID); svc != nil {
+ vars["SERVICE_NAME"] = svc.Name
+ vars["SERVICE_PORT"] = strconv.Itoa(svc.Port)
+ }
+ }
+
// 8. Get component template files
componentFiles, err := s.templateProvider.GetComponentFiles(ctx, req.Type, componentPath, vars)
if err != nil {
@@ -318,3 +326,17 @@ func (s *ComponentService) prepareMonorepoUpdates(
return fileOps, nil
}
+
+// findFirstServiceComponent returns the first service component in a project, or nil.
+func (s *ComponentService) findFirstServiceComponent(ctx context.Context, projectID string) *domain.Component {
+ components, err := s.ListComponents(ctx, projectID)
+ if err != nil {
+ return nil
+ }
+ for i := range components {
+ if components[i].Type == domain.ComponentTypeService {
+ return &components[i]
+ }
+ }
+ return nil
+}
diff --git a/internal/service/component_deploy.go b/internal/service/component_deploy.go
index 4d595c6..f753ed2 100644
--- a/internal/service/component_deploy.go
+++ b/internal/service/component_deploy.go
@@ -39,6 +39,9 @@ func (s *ComponentService) createInitialComponentDeployment(
// Fetch project credentials (DATABASE_URL, REDIS_URL, etc.) from credential store
secrets := s.fetchProjectCredentials(ctx, projectID)
+ // Look up Citadel labels for log routing
+ extraLabels := s.fetchCitadelLabels(ctx, projectID, component.Name)
+
spec := domain.DeploySpec{
ProjectName: projectID,
ComponentPath: component.Path,
@@ -49,6 +52,7 @@ func (s *ComponentService) createInitialComponentDeployment(
BasePath: basePath,
SiblingServices: siblingServices,
Secrets: secrets,
+ ExtraLabels: extraLabels,
}
log := logging.FromContext(ctx).WithService("component")
@@ -169,45 +173,69 @@ func (s *ComponentService) fetchProjectCredentials(ctx context.Context, projectI
return nil
}
- // List of infrastructure credentials to fetch
- credentialKeys := []string{
+ // Project-scoped credentials (stored as "{projectID}:{key}")
+ projectScopedKeys := []string{
"DATABASE_URL",
"DATABASE_URL_STAGING",
"REDIS_URL",
"REDIS_URL_STAGING",
"REDIS_PREFIX",
+ domain.CredKeyGCSBucket,
+ domain.CredKeyGCSServiceAccountJSON,
}
- // Build scoped keys: "{projectID}:{key}"
- scopedKeys := make([]string, len(credentialKeys))
- for i, key := range credentialKeys {
- scopedKeys[i] = projectID + ":" + key
+ // Global credentials (stored without project prefix, shared across all projects)
+ globalKeys := []string{
+ domain.CredKeyLaozhangAPIKey,
+ domain.CredKeyGeminiAPIKey,
}
- // Fetch all credentials in one call
- creds, err := s.credentialStore.GetMultiple(ctx, scopedKeys)
- if err != nil {
- log := logging.FromContext(ctx).WithService("component")
- log.Warn("failed to fetch project credentials",
- logging.FieldProjectID, projectID,
- logging.FieldError, err,
- )
- return nil
- }
-
- // Convert scoped keys back to env var names
secrets := make(map[string]string)
- for scopedKey, value := range creds {
- // Extract env var name from scoped key: "myproject:DATABASE_URL" -> "DATABASE_URL"
- parts := strings.SplitN(scopedKey, ":", 2)
- if len(parts) == 2 && value != "" {
- secrets[parts[1]] = value
+ log := logging.FromContext(ctx).WithService("component")
+
+ // Fetch project-scoped credentials
+ if len(projectScopedKeys) > 0 {
+ scopedKeys := make([]string, len(projectScopedKeys))
+ for i, key := range projectScopedKeys {
+ scopedKeys[i] = projectID + ":" + key
+ }
+
+ creds, err := s.credentialStore.GetMultiple(ctx, scopedKeys)
+ if err != nil {
+ log.Warn("failed to fetch project-scoped credentials",
+ logging.FieldProjectID, projectID,
+ logging.FieldError, err,
+ )
+ } else {
+ for scopedKey, value := range creds {
+ // Extract env var name: "myproject:DATABASE_URL" -> "DATABASE_URL"
+ parts := strings.SplitN(scopedKey, ":", 2)
+ if len(parts) == 2 && value != "" {
+ secrets[parts[1]] = value
+ }
+ }
+ }
+ }
+
+ // Fetch global credentials (AI providers, etc.)
+ if len(globalKeys) > 0 {
+ creds, err := s.credentialStore.GetMultiple(ctx, globalKeys)
+ if err != nil {
+ log.Warn("failed to fetch global credentials",
+ logging.FieldProjectID, projectID,
+ logging.FieldError, err,
+ )
+ } else {
+ for key, value := range creds {
+ if value != "" {
+ secrets[key] = value
+ }
+ }
}
}
if len(secrets) > 0 {
- log := logging.FromContext(ctx).WithService("component")
- log.Debug("fetched project credentials for deployment",
+ log.Debug("fetched credentials for deployment",
logging.FieldProjectID, projectID,
"credential_count", len(secrets),
)
@@ -215,3 +243,19 @@ func (s *ComponentService) fetchProjectCredentials(ctx context.Context, projectI
return secrets
}
+
+// fetchCitadelLabels looks up the project's Citadel tenant_id and slug from the database
+// and returns k8s labels for agent log routing. Returns nil if no Citadel environment exists.
+func (s *ComponentService) fetchCitadelLabels(ctx context.Context, projectID, serviceName string) map[string]string {
+ var slug, tenantID string
+ err := s.db.QueryRowContext(ctx, `
+ SELECT COALESCE(slug, ''), COALESCE(citadel_tenant_id, '') FROM projects WHERE id = $1
+ `, projectID).Scan(&slug, &tenantID)
+ if err != nil || tenantID == "" {
+ return nil
+ }
+ return map[string]string{
+ "citadel.io/environment": slug,
+ "citadel.io/service": serviceName,
+ }
+}
diff --git a/internal/service/project_infra.go b/internal/service/project_infra.go
index 30732bc..f5b5a76 100644
--- a/internal/service/project_infra.go
+++ b/internal/service/project_infra.go
@@ -36,6 +36,7 @@ type ProjectInfraService struct {
cacheProvisioner port.CacheProvisioner
storageProvisioner port.StorageProvisioner
registryProvider port.RegistryProvider
+ citadelClient port.CitadelClient
// Config
defaultGitOwner string
@@ -114,6 +115,12 @@ func (s *ProjectInfraService) WithRegistryProvider(rp port.RegistryProvider) *Pr
return s
}
+// WithCitadelClient sets the Citadel client for auto-provisioning log environments.
+func (s *ProjectInfraService) WithCitadelClient(cc port.CitadelClient) *ProjectInfraService {
+ s.citadelClient = cc
+ return s
+}
+
// CreateProjectRequest contains parameters for creating a new project.
type CreateProjectRequest struct {
Name string
@@ -147,6 +154,9 @@ type CreateProjectResult struct {
// All domains associated with the project
Domains []*domain.ProjectDomain
+ // Citadel log environment (set during provisioning, used for k8s label routing)
+ CitadelTenantID string
+
// Next steps
NextSteps []string
}
diff --git a/internal/service/project_infra_crud.go b/internal/service/project_infra_crud.go
index 85583d3..12b8d49 100644
--- a/internal/service/project_infra_crud.go
+++ b/internal/service/project_infra_crud.go
@@ -76,13 +76,16 @@ func (s *ProjectInfraService) CreateProject(ctx context.Context, req CreateProje
// 9. Provision database and cache
s.provisionResources(ctx, result)
- // 10. Create initial K8s deployment (before triggering CI build)
+ // 10. Provision Citadel log environment
+ s.provisionCitadel(ctx, result)
+
+ // 11. Create initial K8s deployment (before triggering CI build)
// This ensures the deployment exists for `kubectl set image` in CI pipeline
if templateSeeded {
s.createInitialDeployment(ctx, req, result)
}
- // 11. Trigger initial CI build if both CI and template are ready
+ // 12. Trigger initial CI build if both CI and template are ready
if ciActivated && templateSeeded && s.ciProvider != nil {
pipelineNum, err := s.ciProvider.TriggerBuild(ctx, result.GitRepoOwner, result.GitRepoName, "main")
if err != nil {
@@ -463,6 +466,126 @@ func (s *ProjectInfraService) provisionResources(ctx context.Context, result *Cr
}
}
}
+
+ // Provision storage (idempotent)
+ if s.storageProvisioner != nil {
+ existing, _ := s.storageProvisioner.GetProjectBucket(ctx, projectID)
+ if existing != nil {
+ log.Info("storage already provisioned, skipping", logging.FieldProjectID, projectID)
+ } else {
+ storageCreds, err := s.storageProvisioner.CreateProjectBucket(ctx, projectID)
+ if err != nil {
+ log.Error("failed to provision storage", logging.FieldProjectID, projectID, logging.FieldError, err)
+ result.NextSteps = append(result.NextSteps, "Storage provisioning failed - contact admin")
+ } else if s.credentialStore != nil {
+ // Store credentials - rollback on failure to prevent orphaned bucket
+ var storeErr error
+ if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryStorage, domain.CredKeyGCSBucket, storageCreds.BucketName); err != nil {
+ storeErr = err
+ log.Error("failed to store GCS_BUCKET", logging.FieldProjectID, projectID, logging.FieldError, err)
+ }
+ if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryStorage, domain.CredKeyGCSServiceAccountJSON, storageCreds.ServiceAccountJSON); err != nil {
+ storeErr = err
+ log.Error("failed to store GCS_SERVICE_ACCOUNT_JSON", logging.FieldProjectID, projectID, logging.FieldError, err)
+ }
+
+ // Rollback storage if credential storage failed
+ if storeErr != nil {
+ log.Warn("rolling back storage due to credential storage failure", logging.FieldProjectID, projectID)
+ if rollbackErr := s.storageProvisioner.DeleteProjectBucket(ctx, projectID, true); rollbackErr != nil {
+ log.Error("failed to rollback storage", logging.FieldProjectID, projectID, logging.FieldError, rollbackErr)
+ result.NextSteps = append(result.NextSteps, "Storage created but credentials not stored - manual cleanup required")
+ } else {
+ result.NextSteps = append(result.NextSteps, "Storage provisioning rolled back due to credential storage failure")
+ }
+ } else {
+ log.Info("storage provisioned", logging.FieldProjectID, projectID, "bucket", storageCreds.BucketName)
+ }
+ }
+ }
+ }
+}
+
+// provisionCitadel creates a Citadel log environment for the project.
+// The tenant_id is stored on the project record and used by the agent DaemonSet
+// to route container logs to the correct Citadel environment.
+func (s *ProjectInfraService) provisionCitadel(ctx context.Context, result *CreateProjectResult) {
+ if s.citadelClient == nil {
+ return // Citadel not configured, skip silently
+ }
+
+ log := logging.FromContext(ctx).WithService("project_infra")
+ envName := result.Slug // Use project slug as Citadel environment name
+
+ env, err := s.citadelClient.CreateEnvironment(ctx, envName)
+ if err != nil {
+ log.Warn("failed to create citadel environment",
+ logging.FieldError, err,
+ logging.FieldProjectID, result.ProjectID,
+ )
+ result.NextSteps = append(result.NextSteps, "Citadel log environment not created - create manually with: citadel env create "+envName)
+ return
+ }
+
+ // Store tenant_id on project record and result (for downstream label injection)
+ result.CitadelTenantID = env.TenantID
+ _, err = s.db.ExecContext(ctx, `
+ UPDATE projects SET citadel_tenant_id = $1, updated_at = $2 WHERE id = $3
+ `, env.TenantID, time.Now(), result.ProjectID)
+ if err != nil {
+ log.Warn("failed to store citadel tenant_id",
+ logging.FieldError, err,
+ logging.FieldProjectID, result.ProjectID,
+ )
+ result.NextSteps = append(result.NextSteps, "Citadel environment created but tenant_id not persisted")
+ }
+
+ log.Info("citadel environment provisioned",
+ logging.FieldProjectID, result.ProjectID,
+ "citadel_tenant_id", env.TenantID,
+ "citadel_env_name", envName,
+ )
+}
+
+// deleteCitadelEnvironment removes the Citadel log environment for a project.
+func (s *ProjectInfraService) deleteCitadelEnvironment(ctx context.Context, projectID string) {
+ if s.citadelClient == nil {
+ return
+ }
+
+ log := logging.FromContext(ctx).WithService("project_infra")
+
+ // Look up the citadel_tenant_id from the project record
+ var tenantID sql.NullString
+ err := s.db.QueryRowContext(ctx, `SELECT citadel_tenant_id FROM projects WHERE id = $1`, projectID).Scan(&tenantID)
+ if err != nil || !tenantID.Valid || tenantID.String == "" {
+ log.Debug("no citadel tenant_id found for project, skipping cleanup", logging.FieldProjectID, projectID)
+ return
+ }
+
+ if err := s.citadelClient.DeleteEnvironment(ctx, tenantID.String); err != nil {
+ log.Warn("failed to delete citadel environment",
+ logging.FieldError, err,
+ logging.FieldProjectID, projectID,
+ "citadel_tenant_id", tenantID.String,
+ )
+ return
+ }
+
+ log.Info("citadel environment deleted", logging.FieldProjectID, projectID, "citadel_tenant_id", tenantID.String)
+}
+
+// citadelLabels builds k8s labels for Citadel agent log routing.
+// The agent DaemonSet uses these labels to determine which Citadel environment
+// to ship container logs to. Returns nil if no tenant ID is available.
+func citadelLabels(envName, serviceName, tenantID string) map[string]string {
+ if tenantID == "" {
+ return nil
+ }
+ return map[string]string{
+ "citadel.io/environment": envName,
+ "citadel.io/service": serviceName,
+ }
}
// storeCredential stores a project-scoped credential in the credential store.
@@ -514,6 +637,7 @@ func (s *ProjectInfraService) createInitialDeployment(ctx context.Context, req C
Domain: result.Domain,
Port: port,
Replicas: 1,
+ ExtraLabels: citadelLabels(result.Slug, req.Name, result.CitadelTenantID),
}
err := s.deployer.Deploy(ctx, spec)
@@ -720,6 +844,9 @@ func (s *ProjectInfraService) DeleteProject(ctx context.Context, projectID strin
}
}
+ // 5b. Delete Citadel log environment
+ s.deleteCitadelEnvironment(ctx, projectID)
+
// 6. Delete container images from registry
if s.registryProvider != nil {
if err := s.registryProvider.DeleteProjectRepositories(ctx, projectID); err != nil {
diff --git a/scripts/load-credentials.sh b/scripts/load-credentials.sh
index bb4d529..07166e3 100755
--- a/scripts/load-credentials.sh
+++ b/scripts/load-credentials.sh
@@ -69,6 +69,7 @@ get_category() {
CLOUDFLARE_API_TOKEN|CLOUDFLARE_ZONE_ID) echo "cloudflare" ;;
WOODPECKER_URL|WOODPECKER_API_TOKEN|WOODPECKER_WEBHOOK_SECRET) echo "woodpecker" ;;
REGISTRY_URL) echo "registry" ;;
+ LAOZHANG_API_KEY|GEMINI_API_KEY) echo "ai" ;;
*) echo "other" ;;
esac
}
@@ -84,6 +85,8 @@ get_description() {
WOODPECKER_API_TOKEN) echo "Woodpecker CI API token for repo activation" ;;
WOODPECKER_WEBHOOK_SECRET) echo "HMAC secret for Woodpecker webhook verification" ;;
REGISTRY_URL) echo "Container registry URL" ;;
+ LAOZHANG_API_KEY) echo "LaoZhang API key for text/image generation (also proxies Grok)" ;;
+ GEMINI_API_KEY) echo "Google Gemini API key for text/image generation" ;;
*) echo "$1 credential" ;;
esac
}
diff --git a/scripts/verify-skeleton.sh b/scripts/verify-skeleton.sh
new file mode 100755
index 0000000..1b58b5e
--- /dev/null
+++ b/scripts/verify-skeleton.sh
@@ -0,0 +1,159 @@
+#!/usr/bin/env bash
+# Verify skeleton templates are correct and in-sync with committed example.
+#
+# This script:
+# 1. Builds the render-skeleton tool
+# 2. Renders a fresh skeleton to a temp directory
+# 3. Compares with the committed examples/full-monorepo
+# 4. Attempts to build Go and TypeScript code
+#
+# Usage:
+# ./scripts/verify-skeleton.sh # Full verification
+# ./scripts/verify-skeleton.sh --quick # Skip TypeScript (faster)
+# ./scripts/verify-skeleton.sh --update # Update examples/full-monorepo
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$SCRIPT_DIR/.."
+EXAMPLE_DIR="$ROOT_DIR/examples/full-monorepo"
+
+# Parse args
+QUICK_MODE=false
+UPDATE_MODE=false
+for arg in "$@"; do
+ case $arg in
+ --quick)
+ QUICK_MODE=true
+ ;;
+ --update)
+ UPDATE_MODE=true
+ ;;
+ esac
+done
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+info() { echo -e "${GREEN}==>${NC} $1"; }
+warn() { echo -e "${YELLOW}==>${NC} $1"; }
+error() { echo -e "${RED}==>${NC} $1"; }
+
+# Build render tool
+info "Building render-skeleton tool..."
+go build -o /tmp/render-skeleton ./cmd/render-skeleton
+
+# Handle update mode
+if [ "$UPDATE_MODE" = true ]; then
+ info "Updating examples/full-monorepo..."
+ /tmp/render-skeleton -output "$EXAMPLE_DIR"
+ info "Done! Review changes with: git diff examples/full-monorepo"
+ exit 0
+fi
+
+# Render to temp directory
+TEMP_DIR=$(mktemp -d)
+trap 'rm -rf "$TEMP_DIR"' EXIT
+
+info "Rendering skeleton to temp directory..."
+/tmp/render-skeleton -output "$TEMP_DIR" > /dev/null
+
+# Compare with committed example
+info "Comparing with committed example..."
+
+# Check if example directory exists
+if [ ! -d "$EXAMPLE_DIR" ]; then
+ error "examples/full-monorepo does not exist!"
+ echo ""
+ echo "Run: ./scripts/verify-skeleton.sh --update"
+ exit 1
+fi
+
+# Compare directories (excluding common build artifacts)
+DIFF_OUTPUT=$(diff -rq \
+ --exclude='.git' \
+ --exclude='node_modules' \
+ --exclude='pnpm-lock.yaml' \
+ --exclude='.next' \
+ --exclude='.astro' \
+ --exclude='dist' \
+ --exclude='go.sum' \
+ --exclude='go.work.sum' \
+ "$TEMP_DIR" "$EXAMPLE_DIR" 2>&1 || true)
+
+if [ -n "$DIFF_OUTPUT" ]; then
+ error "examples/full-monorepo is OUT OF SYNC with templates!"
+ echo ""
+ echo "$DIFF_OUTPUT" | head -20
+ echo ""
+ echo "To update, run: ./scripts/verify-skeleton.sh --update"
+ exit 1
+fi
+info "Example in sync with templates"
+
+# Build Go code
+info "Building Go code..."
+
+pushd "$EXAMPLE_DIR" > /dev/null
+
+# Sync go.work first
+if ! go work sync 2>&1; then
+ warn "go work sync had warnings (may need network)"
+fi
+
+# Try to build all modules in the workspace
+# Build each module explicitly since go.work doesn't have root go.mod
+BUILD_OUTPUT=""
+for mod_dir in pkg services/example-api workers/example-worker cli/example-cli; do
+ if [ -d "$mod_dir" ]; then
+ MOD_OUTPUT=$(cd "$mod_dir" && go build ./... 2>&1 || true)
+ if [ -n "$MOD_OUTPUT" ]; then
+ BUILD_OUTPUT="${BUILD_OUTPUT}${MOD_OUTPUT}\n"
+ fi
+ fi
+done
+if [ -n "$BUILD_OUTPUT" ]; then
+ warn "Go build has errors:"
+ echo "$BUILD_OUTPUT" | head -30
+ echo ""
+ echo "These are likely template bugs that need fixing."
+ # Don't exit - continue to show all issues
+else
+ info "Go builds successfully"
+fi
+
+popd > /dev/null
+
+# TypeScript type check (unless --quick)
+if [ "$QUICK_MODE" = false ]; then
+ info "Installing TypeScript dependencies..."
+ pushd "$EXAMPLE_DIR" > /dev/null
+
+ if command -v pnpm &> /dev/null; then
+ if ! pnpm install --prefer-offline 2>&1 | tail -5; then
+ warn "pnpm install had issues"
+ fi
+
+ info "Running TypeScript type check..."
+ if ! pnpm -r typecheck 2>&1; then
+ warn "TypeScript type check failed"
+ else
+ info "TypeScript types check"
+ fi
+ else
+ warn "pnpm not found, skipping TypeScript checks"
+ fi
+
+ popd > /dev/null
+fi
+
+# Summary
+echo ""
+if [ -z "$BUILD_OUTPUT" ]; then
+ info "All skeleton verification passed!"
+else
+ warn "Skeleton rendered but has build errors - templates need fixes"
+ exit 1
+fi