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.

+
+ +
+
+
+ Danger Zone @@ -237,9 +276,21 @@ function SettingsPage() { ); } -function App() { +function LoadingScreen() { + return ( +
+
+ +

Loading...

+
+
+ ); +} + +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 */} +
+ + +
+ + setPrompt(e.target.value)} + placeholder={ + mode === 'image' + ? 'A serene mountain landscape at sunset...' + : 'A cat playing piano in a jazz club...' + } + /> + +
+
+ + +
+ + {mode === 'image' ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+ + +
+
+ + {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 && } + +
+
+ + ({ + 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 && } + +
+
+ + ({ + 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 + + +
+ + {generalError && ( + + {generalError} + + )} + + + + + + + + + +

+ Demo accounts: test@example.com / password123 +
+ or admin@example.com / admin123 +

+
+
+
+
+ ); +} 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 &&
+ * ); + * ``` + */ +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 ( +
+