feat: add GCS-based persistent media storage, AI generation pipeline, and composable skeleton packages
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Adds complete media storage pipeline with GCS presigned uploads, AI image/video/text generation
via queue-based workers, realtime SSE event streaming, and comprehensive skeleton packages
(storage, mediagen, textgen, generation, realtime, persona, routing, ai-client). Includes
security fixes for media delete authorization, nil pointer guards in handlers, video persistence
via download-then-upload, consistent signed URLs, and Image→ImageIcon rename to avoid DOM collision.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-19 21:29:09 -07:00
parent 7249575dea
commit a8c8a0a14d
186 changed files with 18723 additions and 571 deletions

4
.gitignore vendored
View File

@ -43,3 +43,7 @@ tmp/
/rdev-api /rdev-api
/claudebox-sidecar /claudebox-sidecar
/sdlc /sdlc
/render-skeleton
# Rendered example monorepo (regenerated from templates)
examples/full-monorepo/

View File

@ -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) | | **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) | | **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) | | **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 orchestration** | [services/build-orchestration.md](.claude/guides/services/build-orchestration.md) |
| **Build event streaming** | [services/build-streaming.md](.claude/guides/services/build-streaming.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) | | **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) | | **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) | | **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) | | **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 | | **Structured logging** | `internal/logging/` - field constants, context propagation, redaction |
## Critical Rules ## 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. - **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. - **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. - **Pod git ops:** Git operations run inside pods via `PodGitOperations` (kubectl exec), never locally.

View File

@ -34,6 +34,11 @@ type Config struct {
// Internal API token for service-to-service callbacks // Internal API token for service-to-service callbacks
InternalToken string 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 // Infrastructure adapters (threesix.ai) - fallback values if not in credential store
GiteaURL string GiteaURL string
GiteaToken string GiteaToken string
@ -107,6 +112,11 @@ func loadConfig() Config {
// Internal API token for service-to-service callbacks (e.g., SDLC callbacks) // Internal API token for service-to-service callbacks (e.g., SDLC callbacks)
InternalToken: os.Getenv("INTERNAL_TOKEN"), 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) // Infrastructure adapters (fallback if not in credential store)
GiteaURL: envutil.GetEnv("GITEA_URL", "https://git.threesix.ai"), GiteaURL: envutil.GetEnv("GITEA_URL", "https://git.threesix.ai"),
GiteaToken: os.Getenv("GITEA_TOKEN"), GiteaToken: os.Getenv("GITEA_TOKEN"),

View File

@ -7,6 +7,7 @@ import (
"strings" "strings"
"time" "time"
citadeladapter "github.com/orchard9/rdev/internal/adapter/citadel"
"github.com/orchard9/rdev/internal/adapter/cloudflare" "github.com/orchard9/rdev/internal/adapter/cloudflare"
"github.com/orchard9/rdev/internal/adapter/cockroach" "github.com/orchard9/rdev/internal/adapter/cockroach"
"github.com/orchard9/rdev/internal/adapter/codeagent" "github.com/orchard9/rdev/internal/adapter/codeagent"
@ -96,6 +97,16 @@ func main() {
// Load infrastructure config from credential store (falls back to env vars) // Load infrastructure config from credential store (falls back to env vars)
infraCfg := loadInfraConfig(context.Background(), credentialStore, cfg, logger) 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) // Create adapters (dependency injection)
namespace := envutil.GetEnv("K8S_NAMESPACE", "rdev") 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) rateLimiter := postgres.NewRateLimiter(database.DB)
stopRateLimitCleanup := rateLimiter.StartCleanupWorker(context.Background(), 5*time.Minute) stopRateLimitCleanup := rateLimiter.StartCleanupWorker(context.Background(), 5*time.Minute)
commandQueue := postgres.NewCommandQueueRepository(database.DB) commandQueue := postgres.NewCommandQueueRepository(database.DB)
@ -459,6 +479,9 @@ func main() {
if registryClient != nil { if registryClient != nil {
projectInfraService = projectInfraService.WithRegistryProvider(registryClient) projectInfraService = projectInfraService.WithRegistryProvider(registryClient)
} }
if citadelClient != nil {
projectInfraService = projectInfraService.WithCitadelClient(citadelClient)
}
// Create domain service adapter for infrastructure handler // Create domain service adapter for infrastructure handler
domainServiceAdapter := handlers.NewDomainServiceAdapter(projectInfraService) domainServiceAdapter := handlers.NewDomainServiceAdapter(projectInfraService)
@ -761,6 +784,9 @@ func main() {
} }
queueProcessor.Stop() queueProcessor.Stop()
webhookDispatcher.Stop() webhookDispatcher.Stop()
if auditShipper != nil {
auditShipper.Close()
}
projectRepo.StopWatching() projectRepo.StopWatching()
stopRateLimitCleanup() stopRateLimitCleanup()
closeProvisioner(dbProvisioner, "database", logger) closeProvisioner(dbProvisioner, "database", logger)

241
cmd/render-skeleton/main.go Normal file
View File

@ -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 <dir> [-project <name>] [-module <path>] [-domain <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
}

View File

@ -95,7 +95,7 @@ api_call() {
# Returns: 0 on success, 1 on failure, 2 on timeout # Returns: 0 on success, 1 on failure, 2 on timeout
wait_for_build() { wait_for_build() {
local task_id="$1" 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 poll_interval="${3:-5}"
local attempt=0 local attempt=0
@ -155,7 +155,7 @@ wait_for_build() {
# the pipeline has already failed. # the pipeline has already failed.
wait_for_pipeline() { wait_for_pipeline() {
local project_id="$1" 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 poll_interval="${3:-5}"
local attempt=0 local attempt=0
local tracked_pipeline="" # Track specific pipeline once found local tracked_pipeline="" # Track specific pipeline once found

View File

@ -120,7 +120,7 @@ execute_wait_pipeline_step() {
local project_id max_attempts poll_interval local project_id max_attempts poll_interval
project_id=$(echo "$step_json" | jq -r '.project_id') 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') poll_interval=$(echo "$step_json" | jq -r '.poll_interval // 5')
wait_for_pipeline "$project_id" "$max_attempts" "$poll_interval" wait_for_pipeline "$project_id" "$max_attempts" "$poll_interval"

View File

@ -77,7 +77,7 @@ steps:
depends_on: [spec-feature] depends_on: [spec-feature]
action: wait_build action: wait_build
build_id: "{{ .outputs.spec-feature.build_id }}" build_id: "{{ .outputs.spec-feature.build_id }}"
max_attempts: 60 max_attempts: 120
poll_interval: 5 poll_interval: 5
implement-backend: implement-backend:

View File

@ -62,7 +62,7 @@ steps:
depends_on: [verify-components] depends_on: [verify-components]
action: wait_pipeline action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}" project_id: "{{ .outputs.create-project.project_id }}"
max_attempts: 60 max_attempts: 120
poll_interval: 5 poll_interval: 5
on_error: continue on_error: continue

View File

@ -39,7 +39,7 @@ steps:
depends_on: [add-service] depends_on: [add-service]
action: wait_pipeline action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}" project_id: "{{ .outputs.create-project.project_id }}"
max_attempts: 60 max_attempts: 120
# --- Phase 2: Evolve (Add Feature) --- # --- Phase 2: Evolve (Add Feature) ---
create-feature: create-feature:
@ -71,7 +71,7 @@ steps:
depends_on: [generate-spec] depends_on: [generate-spec]
action: wait_build action: wait_build
build_id: "{{ .outputs.generate-spec.build_id }}" build_id: "{{ .outputs.generate-spec.build_id }}"
max_attempts: 60 max_attempts: 120
poll_interval: 5 poll_interval: 5
check-artifact: check-artifact:

View File

@ -136,7 +136,7 @@ steps:
depends_on: [wait-deploy-2] depends_on: [wait-deploy-2]
action: wait_site action: wait_site
domain: "{{ .vars.domain }}" domain: "{{ .vars.domain }}"
max_attempts: 60 max_attempts: 120
verify-complete: verify-complete:
description: "Print success summary" description: "Print success summary"

View File

@ -86,7 +86,7 @@ steps:
depends_on: [wait-components] depends_on: [wait-components]
action: wait_site action: wait_site
domain: "{{ .outputs.create-project.domain }}" domain: "{{ .outputs.create-project.domain }}"
max_attempts: 60 max_attempts: 120
on_error: continue on_error: continue
# ============================================================ # ============================================================

View File

@ -39,7 +39,7 @@ steps:
depends_on: [add-service] depends_on: [add-service]
action: wait_pipeline action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}" project_id: "{{ .outputs.create-project.project_id }}"
max_attempts: 60 max_attempts: 120
# --- Phase 2: SDLC Process (Spec & Design) --- # --- Phase 2: SDLC Process (Spec & Design) ---
create-feature: create-feature:
@ -71,7 +71,7 @@ steps:
depends_on: [generate-spec] depends_on: [generate-spec]
action: wait_build action: wait_build
build_id: "{{ .outputs.generate-spec.build_id }}" build_id: "{{ .outputs.generate-spec.build_id }}"
max_attempts: 60 max_attempts: 120
poll_interval: 5 poll_interval: 5
approve-spec: approve-spec:
@ -101,7 +101,7 @@ steps:
depends_on: [generate-design] depends_on: [generate-design]
action: wait_build action: wait_build
build_id: "{{ .outputs.generate-design.build_id }}" build_id: "{{ .outputs.generate-design.build_id }}"
max_attempts: 60 max_attempts: 120
poll_interval: 5 poll_interval: 5
approve-design: approve-design:
@ -152,7 +152,7 @@ steps:
depends_on: [wait-implementation] depends_on: [wait-implementation]
action: wait_pipeline action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}" project_id: "{{ .outputs.create-project.project_id }}"
max_attempts: 60 max_attempts: 120
# --- Phase 4: Verification --- # --- Phase 4: Verification ---
verify-crud: verify-crud:

View File

@ -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 }}"

View File

@ -38,7 +38,7 @@ steps:
depends_on: [add-component] depends_on: [add-component]
action: wait_pipeline action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}" project_id: "{{ .outputs.create-project.project_id }}"
max_attempts: 60 max_attempts: 120
poll_interval: 5 poll_interval: 5
on_error: continue on_error: continue

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: observability
labels:
app.kubernetes.io/part-of: citadel

View File

@ -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"

View File

@ -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

View File

@ -19,6 +19,9 @@ spec:
app.kubernetes.io/name: claudebox app.kubernetes.io/name: claudebox
app.kubernetes.io/part-of: rdev app.kubernetes.io/part-of: rdev
rdev.orchard9.ai/role: worker 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: spec:
containers: containers:
- name: claudebox - name: claudebox

View File

@ -36,3 +36,6 @@ resources:
# Wildcard TLS for session preview URLs # Wildcard TLS for session preview URLs
- preview-cert.yaml - preview-cert.yaml
# Citadel log agent (ships container logs to partner-hosted Citadel)
- citadel-agent/

View File

@ -20,6 +20,9 @@ spec:
app: rdev-api app: rdev-api
app.kubernetes.io/name: rdev-api app.kubernetes.io/name: rdev-api
app.kubernetes.io/part-of: rdev 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: spec:
serviceAccountName: rdev-api serviceAccountName: rdev-api
containers: containers:

186
docs/citadel-integration.md Normal file
View File

@ -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: "<project-slug>" (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 <project-slug> --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

45
examples/README.md Normal file
View File

@ -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

4
go.mod
View File

@ -3,6 +3,7 @@ module github.com/orchard9/rdev
go 1.25.0 go 1.25.0
require ( require (
cloud.google.com/go/storage v1.59.2
code.gitea.io/sdk/gitea v0.22.1 code.gitea.io/sdk/gitea v0.22.1
github.com/bdpiprava/scalar-go v0.13.0 github.com/bdpiprava/scalar-go v0.13.0
github.com/go-chi/chi/v5 v5.1.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/sdk v1.39.0
go.opentelemetry.io/otel/trace v1.39.0 go.opentelemetry.io/otel/trace v1.39.0
go.woodpecker-ci.org/woodpecker/v3 v3.13.0 go.woodpecker-ci.org/woodpecker/v3 v3.13.0
google.golang.org/api v0.265.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.35.0 k8s.io/api v0.35.0
k8s.io/apimachinery 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/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.5.3 // indirect cloud.google.com/go/iam v1.5.3 // indirect
cloud.google.com/go/monitoring v1.24.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/42wim/httpsig v1.2.3 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // 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 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/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.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 v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/api 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 google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect

17
go.sum
View File

@ -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/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 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= 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 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= 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 h1:gmOAuG1opU8YvycMNpP+DvHfT9BfzzK5Cy+arP+Nocw=
cloud.google.com/go/storage v1.59.2/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= 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 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA=
code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= 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/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 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw= 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 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= 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= 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 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 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 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 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/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 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= 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= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/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 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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= 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 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 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/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 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= 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 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 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= 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 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= 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= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=

View File

@ -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"
}
}

View File

@ -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))
}

View File

@ -156,6 +156,10 @@ func (d *Deployer) buildDeployment(spec domain.DeploySpec, ns string, replicas i
if spec.ComponentPath != "" { if spec.ComponentPath != "" {
labels["component"] = sanitizeLabelValue(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{ return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{

View File

@ -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
})
}

View File

@ -43,52 +43,6 @@ var skeletonTemplate = port.TemplateInfo{
Stack: "monorepo", 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). // templateNameRegex validates template names (alphanumeric, dash only).
var templateNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) 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 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
}

View File

@ -1,6 +1,5 @@
import type { NextConfig } from 'next'; /** @type {import('next').NextConfig} */
const nextConfig = {
const nextConfig: NextConfig = {
// Enable React strict mode for better development experience // Enable React strict mode for better development experience
reactStrictMode: true, reactStrictMode: true,

View File

@ -11,9 +11,13 @@
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"@{{PROJECT_NAME}}/logger": "workspace:*", "@{{PROJECT_NAME}}/ai-client": "workspace:*",
"@{{PROJECT_NAME}}/ui": "workspace:*", "@{{PROJECT_NAME}}/api-client": "workspace:*",
"@{{PROJECT_NAME}}/auth": "workspace:*",
"@{{PROJECT_NAME}}/layout": "workspace:*", "@{{PROJECT_NAME}}/layout": "workspace:*",
"@{{PROJECT_NAME}}/logger": "workspace:*",
"@{{PROJECT_NAME}}/realtime": "workspace:*",
"@{{PROJECT_NAME}}/ui": "workspace:*",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.23.1" "react-router-dom": "^6.23.1"

View File

@ -1,4 +1,4 @@
export default { module.exports = {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},

View File

@ -1,4 +1,5 @@
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom'; 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 { DashboardShell, Sidebar, Header, type NavItem } from '@{{PROJECT_NAME}}/layout';
import { import {
Button, Button,
@ -9,13 +10,24 @@ import {
CardContent, CardContent,
Badge, Badge,
Home, Home,
ImageIcon,
Users, Users,
Settings, Settings,
BarChart3, BarChart3,
MessageSquare,
Sparkles,
Loader2,
} from '@{{PROJECT_NAME}}/ui'; } 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[] = [ const navItems: NavItem[] = [
{ label: 'Dashboard', href: '/', icon: Home }, { 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: 'Analytics', href: '/analytics', icon: BarChart3 },
{ label: 'Users', href: '/users', icon: Users, badge: '12' }, { label: 'Users', href: '/users', icon: Users, badge: '12' },
{ label: 'Settings', href: '/settings', icon: Settings }, { label: 'Settings', href: '/settings', icon: Settings },
@ -23,6 +35,9 @@ const navItems: NavItem[] = [
const pageTitles: Record<string, string> = { const pageTitles: Record<string, string> = {
'/': 'Dashboard', '/': 'Dashboard',
'/chat': 'Chat',
'/generate': 'Generate',
'/media': 'Media',
'/analytics': 'Analytics', '/analytics': 'Analytics',
'/users': 'Users', '/users': 'Users',
'/settings': 'Settings', '/settings': 'Settings',
@ -195,6 +210,14 @@ function AnalyticsPage() {
} }
function SettingsPage() { function SettingsPage() {
const { logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
await logout();
navigate('/login');
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
@ -218,6 +241,22 @@ function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Manage your account settings.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">Sign Out</p>
<p className="text-sm text-[var(--text-muted)]">Sign out of your account on this device.</p>
</div>
<Button variant="outline" onClick={handleLogout}>Sign Out</Button>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Danger Zone</CardTitle> <CardTitle>Danger Zone</CardTitle>
@ -237,9 +276,21 @@ function SettingsPage() {
); );
} }
function App() { function LoadingScreen() {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-[var(--accent)]" />
<p className="text-sm text-[var(--text-muted)]">Loading...</p>
</div>
</div>
);
}
function AppLayout() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth();
const itemsWithActive = navItems.map((item) => ({ const itemsWithActive = navItems.map((item) => ({
...item, ...item,
@ -259,7 +310,7 @@ function App() {
onNavigate={(href) => navigate(href)} onNavigate={(href) => navigate(href)}
footer={ footer={
<div className="text-sm text-[var(--text-muted)]"> <div className="text-sm text-[var(--text-muted)]">
v0.0.1 {user?.email || 'v0.0.1'}
</div> </div>
} }
/> />
@ -274,6 +325,9 @@ function App() {
> >
<Routes> <Routes>
<Route path="/" element={<DashboardPage />} /> <Route path="/" element={<DashboardPage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/generate" element={<GeneratePage />} />
<Route path="/media" element={<MediaPage />} />
<Route path="/users" element={<UsersPage />} /> <Route path="/users" element={<UsersPage />} />
<Route path="/analytics" element={<AnalyticsPage />} /> <Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
@ -282,4 +336,41 @@ function App() {
); );
} }
function AppRoutes() {
const location = useLocation();
const navigate = useNavigate();
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/*"
element={
<ProtectedRoute
redirectTo="/login"
onRedirect={(path) => {
// Navigate to login, storing current location for redirect after login
navigate(path, { state: { from: location.pathname }, replace: true });
}}
fallback={<LoadingScreen />}
>
<AppLayout />
</ProtectedRoute>
}
/>
</Routes>
);
}
function App() {
// Determine API base URL from environment or current origin
const apiBaseUrl = import.meta.env.VITE_API_URL || '';
return (
<AuthProvider loginUrl={`${apiBaseUrl}/api/{{SERVICE_NAME}}/auth/login`}>
<AppRoutes />
</AuthProvider>
);
}
export default App; export default App;

View File

@ -7,7 +7,12 @@ import './lib/logger';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<App /> <App />
</BrowserRouter> </BrowserRouter>
</React.StrictMode> </React.StrictMode>

View File

@ -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<HTMLDivElement>(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<string | null>(null);
// Merge user messages + AI messages into a single sorted timeline
const timeline = useMemo<TimelineMessage[]>(() => {
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 <Badge variant="success">Connected</Badge>;
case 'connecting':
return <Badge variant="warning">Connecting...</Badge>;
case 'disconnected':
return <Badge variant="error">Disconnected</Badge>;
case 'error':
return <Badge variant="error">Error</Badge>;
default:
return null;
}
};
return (
<div className="h-[calc(100vh-8rem)] flex flex-col">
<Card className="flex-1 flex flex-col">
<CardHeader className="flex-shrink-0 flex flex-row items-center justify-between">
<div>
<CardTitle>AI Chat</CardTitle>
<CardDescription>
Chat with AI in real-time
</CardDescription>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-[var(--text-muted)]">
{onlineUsers.length} online
</span>
{connectionBadge()}
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0">
{/* Messages area */}
<div className="flex-1 overflow-y-auto space-y-4 pr-2 mb-4">
{timeline.length === 0 ? (
<div className="h-full flex items-center justify-center">
<p className="text-[var(--text-muted)] text-sm">
No messages yet. Start the conversation!
</p>
</div>
) : (
timeline.map((msg) => (
<div key={msg.id}>
<ChatBubble
role={msg.role}
content={msg.content}
timestamp={msg.timestamp}
isStreaming={msg.isStreaming}
/>
{msg.provider && (
<div className={msg.role === 'user' ? 'text-right' : 'text-left'}>
<ProviderBadge provider={msg.provider} className="mt-1" />
</div>
)}
</div>
))
)}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div className="flex-shrink-0 space-y-2">
{sendError && (
<div className="px-3 py-2 text-sm text-[var(--error)] bg-[var(--error)]/10 rounded-lg">
{sendError}
</div>
)}
<ChatInput
onSubmit={handleSendMessage}
disabled={connectionState !== 'connected'}
placeholder={
connectionState === 'connected'
? 'Type a message... (Cmd+Enter to send)'
: 'Connecting...'
}
/>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -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<GenerateMode>('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<ImageResult>({
endpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/generate/image`,
sseEndpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/events`,
userId: user?.id || 'anonymous',
headers: authHeaders,
});
const videoGen = useMediaGeneration<VideoResult>({
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 (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>AI Generation</CardTitle>
<CardDescription>
Generate images and videos using AI (Gemini / LaoZhang)
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Mode toggle */}
<div className="flex gap-2">
<Button
variant={mode === 'image' ? 'default' : 'outline'}
onClick={() => setMode('image')}
disabled={isGenerating}
>
Images
</Button>
<Button
variant={mode === 'video' ? 'default' : 'outline'}
onClick={() => {
setMode('video');
if (aspectRatio === '1:1') setAspectRatio('16:9');
}}
disabled={isGenerating}
>
Video
</Button>
</div>
<FormField
label="Prompt"
name="prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={
mode === 'image'
? 'A serene mountain landscape at sunset...'
: 'A cat playing piano in a jazz club...'
}
/>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-primary)]">Aspect Ratio</label>
<Select value={aspectRatio} onValueChange={(v) => setAspectRatio(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{mode === 'image' && <SelectItem value="1:1">Square (1:1)</SelectItem>}
<SelectItem value="16:9">Landscape (16:9)</SelectItem>
<SelectItem value="9:16">Portrait (9:16)</SelectItem>
</SelectContent>
</Select>
</div>
{mode === 'image' ? (
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-primary)]">Count</label>
<Select value={String(count)} onValueChange={(v) => setCount(Number(v))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 image</SelectItem>
<SelectItem value="2">2 images</SelectItem>
<SelectItem value="4">4 images</SelectItem>
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-primary)]">Duration</label>
<Select value={duration} onValueChange={(v) => setDuration(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5s">5 seconds</SelectItem>
<SelectItem value="10s">10 seconds</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<Button onClick={handleGenerate} disabled={isGenerating || !prompt.trim()}>
{isGenerating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isGenerating ? 'Generating...' : `Generate ${mode === 'image' ? 'Images' : 'Video'}`}
</Button>
</CardContent>
</Card>
{isGenerating && (
<Card>
<CardContent className="py-4 space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-[var(--text-muted)]">{gen.message || 'Starting...'}</span>
<span className="text-[var(--text-muted)]">{gen.progress}%</span>
</div>
<GenerationProgress percent={gen.progress} />
{gen.sseState !== 'connected' && (
<p className="text-xs text-[var(--warning)]">
SSE {gen.sseState} — events may be delayed
</p>
)}
</CardContent>
</Card>
)}
{gen.status === 'failed' && gen.error && (
<Card className="border-[var(--error)]">
<CardContent className="py-4 text-[var(--error)]">
{gen.error}
</CardContent>
</Card>
)}
{gen.status === 'complete' && imageGen.result && mode === 'image' && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Results</CardTitle>
<div className="flex items-center gap-2">
{imageGen.result.provider && <ProviderBadge provider={imageGen.result.provider} />}
<Button variant="outline" size="sm" onClick={() => navigate('/media')}>
View in Library
</Button>
</div>
</CardHeader>
<CardContent>
<ImageGrid
images={imageGen.result.images.map((img) => ({
src: img.isUrl ? img.data : `data:image/png;base64,${img.data}`,
alt: prompt,
}))}
columns={imageGen.result.images.length > 1 ? 2 : 1}
/>
</CardContent>
</Card>
)}
{gen.status === 'complete' && videoGen.result && mode === 'video' && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Results</CardTitle>
<div className="flex items-center gap-2">
{videoGen.result.provider && <ProviderBadge provider={videoGen.result.provider} />}
<Button variant="outline" size="sm" onClick={() => navigate('/media')}>
View in Library
</Button>
</div>
</CardHeader>
<CardContent>
<VideoGrid
videos={videoGen.result.videos.map((vid) => ({
src: vid.isUrl ? vid.data : `data:${vid.mimeType};base64,${vid.data}`,
mimeType: vid.mimeType,
alt: prompt,
}))}
/>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -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<string | null>(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<HTMLFormElement>) => {
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 (
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)] p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Welcome back</CardTitle>
<CardDescription>Sign in to your {{PROJECT_NAME}} account</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{generalError && (
<Alert variant="destructive">
<AlertDescription>{generalError}</AlertDescription>
</Alert>
)}
<FormField
label="Email"
name="email"
type="email"
placeholder="you@example.com"
error={getError('email')}
required
autoComplete="email"
autoFocus
/>
<FormField
label="Password"
name="password"
type="password"
placeholder="Enter your password"
error={getError('password')}
required
autoComplete="current-password"
/>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign in
</Button>
<p className="text-sm text-center text-[var(--text-muted)]">
Demo accounts: test@example.com / password123
<br />
or admin@example.com / admin123
</p>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@ -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<MediaItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [deleteError, setDeleteError] = useState<string | null>(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<string | null>(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 (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Upload Media</CardTitle>
<CardDescription>
Upload images and videos to your media library
</CardDescription>
</CardHeader>
<CardContent>
<MediaUploader
upload={mediaUpload.upload}
isUploading={mediaUpload.isUploading}
progress={mediaUpload.progress}
onUploadComplete={handleUploadComplete}
onError={(err) => console.error('Upload error:', err)}
/>
{mediaUpload.error && (
<p className="mt-2 text-sm text-[var(--error)]">{mediaUpload.error}</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Media Library</CardTitle>
<CardDescription>
Your uploaded and generated media files
</CardDescription>
</div>
{items.length > 0 && (
<Badge variant="outline">{items.length} files</Badge>
)}
</CardHeader>
<CardContent>
{(deleteError || fetchError) && (
<p className="mb-4 text-sm text-[var(--error)]">{deleteError || fetchError}</p>
)}
{isLoading ? (
<div className="text-center py-8 text-[var(--text-muted)]">Loading...</div>
) : (
<MediaLibrary
items={items}
onDelete={handleDelete}
/>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */ import type { Config } from 'tailwindcss';
export default {
const config: Config = {
content: [ content: [
'./index.html', './index.html',
'./src/**/*.{js,ts,jsx,tsx}', './src/**/*.{js,ts,jsx,tsx}',
@ -12,3 +13,5 @@ export default {
}, },
plugins: [], plugins: [],
}; };
export default config;

View File

@ -6,6 +6,27 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: {{PORT}}, 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: { preview: {
port: {{PORT}}, port: {{PORT}},

View File

@ -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}} SERVICE := {{COMPONENT_NAME}}
BINARY := bin/$(SERVICE) BINARY := bin/$(SERVICE)
@ -12,6 +12,10 @@ build:
run: run:
go run ./cmd/server go run ./cmd/server
# Run the service in development mode (alias for run)
dev:
go run ./cmd/server
# Run tests # Run tests
test: test:
go test -v ./... go test -v ./...

View File

@ -2,14 +2,30 @@
package main package main
import ( import (
"context"
"flag" "flag"
"fmt" "fmt"
"os" "os"
"time"
"github.com/redis/go-redis/v9"
"{{GO_MODULE}}/pkg/app" "{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/database"
"{{GO_MODULE}}/pkg/gemini"
"{{GO_MODULE}}/pkg/laozhang"
"{{GO_MODULE}}/pkg/logging" "{{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/adapter/memory"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/config"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service"
) )
@ -30,21 +46,247 @@ func main() {
os.Exit(0) os.Exit(0)
} }
// Load config
cfg := config.Load()
// Create logger // Create logger
logger := logging.Default() 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) // Create adapters (repositories)
exampleRepo := memory.NewExampleRepository() exampleRepo := memory.NewExampleRepository()
userRepo := memory.NewUserRepository()
// Create services (business logic) // Create services (business logic)
exampleService := service.NewExampleService(exampleRepo, logger) exampleService := service.NewExampleService(exampleRepo, logger)
authService := service.NewAuthService(userRepo, cfg.JWTSecret, logger)
// Create application // Create application
application := app.New("{{COMPONENT_NAME}}", app.WithDefaultPort({{PORT}})) 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 // 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 // Start server
application.Run() 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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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:<userId>` 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)
}

View File

@ -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
}

View File

@ -4,6 +4,9 @@ package api
import ( import (
"{{GO_MODULE}}/pkg/app" "{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/auth" "{{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/api/handlers"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/config" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/config"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service"
@ -14,23 +17,52 @@ import (
// This allows the monorepo to expose multiple services under a single domain: // This allows the monorepo to expose multiple services under a single domain:
// - https://domain/api/{{COMPONENT_NAME}}/health // - https://domain/api/{{COMPONENT_NAME}}/health
// - https://domain/api/{{COMPONENT_NAME}}/examples // - 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() logger := application.Logger()
cfg := config.Load() cfg := config.Load()
// Initialize handlers with injected services // Initialize handlers with injected services
healthHandler := handlers.NewHealth(logger) 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 // Build and mount OpenAPI spec
spec := NewServiceSpec() spec := NewServiceSpec()
application.EnableDocs(spec) 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. // Register API routes under /api/{service-name} to match ingress path routing.
// The ingress routes /api/{{COMPONENT_NAME}}/* to this service. // The ingress routes /api/{{COMPONENT_NAME}}/* to this service.
application.Route("/api/{{COMPONENT_NAME}}", func(r app.Router) { application.Route("/api/{{COMPONENT_NAME}}", func(r app.Router) {
r.Get("/health", healthHandler.Check) 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) // Public routes (no auth required)
r.Get("/examples", app.Wrap(exampleHandler.List)) r.Get("/examples", app.Wrap(exampleHandler.List))
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get)) 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) { r.Group(func(r app.Router) {
if cfg.AuthEnabled { if cfg.AuthEnabled {
r.Use(auth.Middleware(auth.MiddlewareConfig{ r.Use(auth.Middleware(auth.MiddlewareConfig{
Validator: auth.NewJWTValidator(auth.JWTConfig{ Validator: jwtValidator,
Secret: []byte(cfg.JWTSecret),
Issuer: "{{PROJECT_NAME}}",
}),
})) }))
} }
@ -50,5 +79,34 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update)) r.Put("/examples/{id}", app.Wrap(exampleHandler.Update))
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete)) r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
}) })
// ----- Chat + Generate + Media routes (auth required) -----
// Auth is required because SSE events are delivered to user:<userId> 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
}

View File

@ -18,6 +18,9 @@ type Config struct {
// Auth // Auth
AuthEnabled bool AuthEnabled bool
JWTSecret string JWTSecret string
// Redis for cross-process SSE event delivery
RedisURL string
} }
// Load reads configuration from environment variables. // Load reads configuration from environment variables.
@ -30,5 +33,6 @@ func Load() *Config {
AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"), AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"),
JWTSecret: os.Getenv("JWT_SECRET"), JWTSecret: os.Getenv("JWT_SECRET"),
RedisURL: os.Getenv("REDIS_URL"),
} }
} }

View File

@ -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
}

View File

@ -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
}

View File

@ -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}} WORKER := {{COMPONENT_NAME}}
BINARY := bin/$(WORKER) BINARY := bin/$(WORKER)
@ -12,6 +12,10 @@ build:
run: run:
go run ./cmd/worker go run ./cmd/worker
# Run the worker in development mode (alias for run)
dev:
go run ./cmd/worker
# Run tests # Run tests
test: test:
go test -v ./... go test -v ./...

View File

@ -3,22 +3,28 @@ package main
import ( import (
"context" "context"
"embed"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time" "time"
"github.com/redis/go-redis/v9"
"{{GO_MODULE}}/pkg/database" "{{GO_MODULE}}/pkg/database"
"{{GO_MODULE}}/pkg/gemini"
"{{GO_MODULE}}/pkg/laozhang"
"{{GO_MODULE}}/pkg/logging" "{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/pkg/mediagen"
mediagenAdapters "{{GO_MODULE}}/pkg/mediagen/adapters"
"{{GO_MODULE}}/pkg/queue" "{{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/config"
"{{GO_MODULE}}/workers/{{COMPONENT_NAME}}/internal/handlers" "{{GO_MODULE}}/workers/{{COMPONENT_NAME}}/internal/handlers"
) )
//go:embed migrations/*.sql
var migrationsFS embed.FS
func main() { func main() {
// Initialize logger first (with defaults) so we can log config errors // Initialize logger first (with defaults) so we can log config errors
logger := logging.New(logging.Config{ logger := logging.New(logging.Config{
@ -60,12 +66,125 @@ func main() {
defer pool.Close() defer pool.Close()
logger.Info("connected to database", "url", pool.URL) logger.Info("connected to database", "url", pool.URL)
// Run migrations // Run queue migrations (idempotent — safe for both service and worker)
database.MustRunMigrations(ctx, pool, migrationsFS, "migrations") if err := queue.RunMigrations(ctx, pool); err != nil {
logger.Info("migrations complete") logger.Error("failed to run queue migrations", "error", err)
os.Exit(1)
}
logger.Info("queue migrations complete")
// Initialize queue // 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 // Initialize and start handler
handler := handlers.New(logger, jobQueue, handlers.Config{ handler := handlers.New(logger, jobQueue, handlers.Config{
@ -74,10 +193,29 @@ func main() {
JobTimeout: cfg.Worker.JobTimeout, 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 // Register job handlers
// TODO: Register your job handlers here if mediagenManager != nil {
// handler.RegisterHandler("send_email", emailHandler) handler.RegisterHandler("generate_image", handlers.ImageHandler(mediagenManager, mediaStore, ssePub, logger))
// handler.RegisterHandler("process_image", imageHandler) 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 // Setup signal handling
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
@ -98,7 +236,6 @@ func main() {
cancel() cancel()
// Give in-flight jobs time to complete (grace period) // Give in-flight jobs time to complete (grace period)
// This allows handlers to notice context cancellation and finish cleanly.
const shutdownGracePeriod = 5 * time.Second const shutdownGracePeriod = 5 * time.Second
time.Sleep(shutdownGracePeriod) time.Sleep(shutdownGracePeriod)
@ -106,7 +243,7 @@ func main() {
} }
// runStaleJobRecovery periodically requeues jobs that have been running too long. // 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 const staleCheckInterval = time.Minute
ticker := time.NewTicker(staleCheckInterval) ticker := time.NewTicker(staleCheckInterval)
defer ticker.Stop() defer ticker.Stop()

View File

@ -2,6 +2,7 @@
package config package config
import ( import (
"os"
"time" "time"
"github.com/spf13/viper" "github.com/spf13/viper"
@ -15,6 +16,9 @@ type Config struct {
Database config.DatabaseConfig Database config.DatabaseConfig
Logging config.LoggingConfig Logging config.LoggingConfig
Worker WorkerConfig Worker WorkerConfig
// Redis for publishing SSE events to the service
RedisURL string
} }
// WorkerConfig holds worker-specific settings. // WorkerConfig holds worker-specific settings.
@ -62,5 +66,6 @@ func Load() (*Config, error) {
StaleJobTimeout: viper.GetDuration("WORKER_STALE_JOB_TIMEOUT"), StaleJobTimeout: viper.GetDuration("WORKER_STALE_JOB_TIMEOUT"),
JobTimeout: viper.GetDuration("WORKER_JOB_TIMEOUT"), JobTimeout: viper.GetDuration("WORKER_JOB_TIMEOUT"),
}, },
RedisURL: os.Getenv("REDIS_URL"),
}, nil }, nil
} }

View File

@ -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)
}

View File

@ -1,40 +1,42 @@
--- ---
name: realtime-specialist 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 color: cyan
--- ---
# Realtime Specialist # 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 ## When to Use
- Adding WebSocket endpoints to a service - Adding SSE endpoints to a service
- Implementing chat or notification features - Implementing chat, notifications, or progress features
- Broadcasting messages to connected clients - Broadcasting events to connected clients
- Scaling real-time features across multiple pods - Scaling real-time features across multiple pods
- Handling client reconnection and presence - Handling client reconnection and presence
## Architecture Overview ## Architecture Overview
``` ```
┌─────────────────────────────────────┐ ┌─────────────┐ HTTP2 POST ┌─────────────┐ publish ┌─────────────┐
│ Redis Pub/Sub │ │ Browser │ ───────────────▶│ API │ ────────────▶│ Redis │
└─────────────┬───────────┬───────────┘ │ │ │ Handler │ │ Pub/Sub │
│ │ │ │ └─────────────┘ └──────┬──────┘
┌───────────────────────┼───────────┼───────────────────────┐ │ │ │
│ │ │ │ │ │ subscribe
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ │ │ │
│ Pod A │ │ Pod B │ │ Pod C │ │ │ ┌──────▼──────┐
│ │ │ │ │ │ │ │ SSE stream ┌─────────────┐ notify │ Redis │
│ ┌───────┐ │ │ ┌───────┐ │ │ ┌───────┐ │ │ │ ◀───────────────│ SSE Hub │ ◀────────────│ Listener │
│ │ Hub │ │ │ │ Hub │ │ │ │ Hub │ │ └─────────────┘ └─────────────┘ └─────────────┘
│ └───┬───┘ │ │ └───┬───┘ │ │ └───┬───┘ │
│ │ │ │ │ │ │ │ │
│ ┌───▼───┐ │ │ ┌───▼───┐ │ │ ┌───▼───┐ │
│ │Clients│ │ │ │Clients│ │ │ │Clients│ │
└─────────┘ └─────────┘ └─────────┘
``` ```
## Quick Start ## Quick Start
@ -45,15 +47,14 @@ You design and implement real-time communication features for {{PROJECT_NAME}} u
func main() { func main() {
logger := logging.NewDevelopment() logger := logging.NewDevelopment()
// Create hub // Create SSE hub
hub := realtime.NewHub(logger) sseHub := realtime.NewSSEHub(logger)
go hub.Run(ctx)
// Create handler (no Redis needed for single pod) // Create handler
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{}) sseHandler := realtime.NewSSEHandler(sseHub, logger)
// Mount on router // Mount on router
r.Mount("/ws", wsHandler.Routes()) r.Mount("/api/events", sseHandler.Routes())
} }
``` ```
@ -63,160 +64,212 @@ func main() {
func main() { func main() {
logger := logging.NewProduction() logger := logging.NewProduction()
// Create hub // Create SSE hub
hub := realtime.NewHub(logger) sseHub := realtime.NewSSEHub(logger)
go hub.Run(ctx)
// Create Redis broadcaster for cross-pod messaging // Create Redis broadcaster for cross-pod messaging
redisClient := redis.NewClient(&redis.Options{Addr: os.Getenv("REDIS_URL")}) 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) go broadcaster.Run(ctx)
// Create handler with broadcaster // Create handler with broadcaster
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{ sseHandler := realtime.NewSSEHandler(sseHub, logger)
Broadcaster: broadcaster,
})
r.Mount("/ws", wsHandler.Routes()) r.Mount("/api/events", sseHandler.Routes())
} }
``` ```
## Message Protocol ## Channel Types
Messages use JSON format: | Pattern | Use For | Example |
|---------|---------|---------|
| `user:<id>` | Private events for one user | `user:u_abc123` |
| `channel:<id>` | Shared events for a room/topic | `channel:general` |
## Event Structure
Every event MUST follow this structure:
```json ```json
{ {
"id": "uuid",
"type": "chat", "type": "chat",
"room": "general", "timestamp": "2024-01-15T10:30:00Z",
"from": "client-id", "userId": "u_abc123",
"data": { "text": "Hello world" }, "content": "Hello world"
"timestamp": "2024-01-15T10:30:00Z"
} }
``` ```
### 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 ## Patterns
### Room-Based Chat ### Chat Room
```go **Client sends message (HTTP POST):**
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)
},
})
// 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 ```go
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{ func (h *ChatHandler) PostMessage(w http.ResponseWriter, r *http.Request) error {
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=<jwt>
// 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) {
var req struct { var req struct {
Room string `json:"room"` Channel string `json:"channel"`
Text string `json:"text"` Content string `json:"content"`
}
if err := app.Bind(r, &req); err != nil {
return err
} }
// ... decode request ...
msg := &realtime.Message{ user := auth.GetUser(r.Context())
Type: realtime.MessageTypeChat,
Room: req.Room, // Publish to Redis (reaches all pods)
Data: json.RawMessage(`{"text":"` + req.Text + `"}`), 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(), Timestamp: time.Now().UTC(),
} })
// Publish via broadcaster (reaches all pods) return httpresponse.NoContent(w, r)
if h.broadcaster != nil { }
h.broadcaster.Publish(r.Context(), msg) ```
} else {
h.hub.Broadcast(msg) **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 ## Client Reconnection
Clients should implement reconnection with exponential backoff: SSE clients should implement reconnection with exponential backoff:
```javascript ```typescript
class RealtimeClient { function useEventChannel(channel: string, config: Config) {
connect() { const [retries, setRetries] = useState(0);
this.ws = new WebSocket(`${this.url}?last_id=${this.lastMessageId}`);
this.ws.onclose = () => this.scheduleReconnect(); const connect = useCallback(() => {
this.ws.onmessage = (e) => { const eventSource = new EventSource(`/api/events?channel=${channel}`);
const msg = JSON.parse(e.data);
this.lastMessageId = msg.id; eventSource.onopen = () => setRetries(0);
this.onMessage(msg);
eventSource.onerror = () => {
eventSource.close();
const delay = Math.min(1000 * Math.pow(2, retries), 30000);
setTimeout(connect, delay);
setRetries(r => r + 1);
}; };
}
scheduleReconnect() { eventSource.onmessage = (e) => {
const delay = Math.min(1000 * Math.pow(2, this.retries), 30000); config.onEvent(JSON.parse(e.data));
setTimeout(() => this.connect(), delay); };
this.retries++; }, [channel, retries]);
}
} }
``` ```
## Scaling Considerations ## Scaling Considerations
### Redis Channel Strategy
- One channel per room: `realtime:channel:{channelId}`
- One channel per user: `realtime:user:{userId}`
- Pattern subscription: `realtime:*`
### Connection Limits ### Connection Limits
Set reasonable limits per pod: Set reasonable limits per pod:
@ -224,26 +277,20 @@ Set reasonable limits per pod:
```go ```go
const maxConnectionsPerPod = 10000 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 { if h.hub.ConnectionCount() >= maxConnectionsPerPod {
http.Error(w, "server at capacity", http.StatusServiceUnavailable) http.Error(w, "server at capacity", http.StatusServiceUnavailable)
return 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 ### Memory Considerations
Each connection uses ~10KB for buffers. Plan accordingly: Each SSE connection uses ~5KB for buffers. Plan accordingly:
- 10,000 connections ≈ 100MB - 10,000 connections ≈ 50MB
- 100,000 connections ≈ 1GB - 100,000 connections ≈ 500MB
## Monitoring ## Monitoring
@ -251,50 +298,26 @@ Track these metrics:
| Metric | Description | | Metric | Description |
|--------|-------------| |--------|-------------|
| `realtime_connections_total` | Total active connections | | `sse_connections_total` | Total active SSE connections |
| `realtime_rooms_total` | Number of active rooms | | `sse_channels_total` | Number of active channels |
| `realtime_messages_sent` | Messages sent per second | | `sse_events_sent` | Events sent per second |
| `realtime_messages_received` | Messages received per second | | `redis_publish_errors` | Failed Redis publishes |
| `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
## Do ## Do
1. ALWAYS use room-based broadcasting for multi-tenant apps 1. USE HTTP POST for all client→server messages
2. SET connection limits per pod 2. USE SSE for all server→client events
3. IMPLEMENT client reconnection with backoff 3. USE Redis pub/sub for multi-pod deployments
4. USE Redis for multi-pod deployments 4. SET connection limits per pod
5. AUTHENTICATE WebSocket connections in production 5. IMPLEMENT client reconnection with backoff
6. MONITOR connection count and message rates 6. AUTHENTICATE SSE connections in production
7. DOCUMENT all channels in `docs/channels.md`
## Do Not ## Do Not
1. STORE large payloads in messages (send IDs, fetch data separately) 1. USE WebSocket for anything — SSE only
2. BROADCAST without rate limiting 2. STORE large payloads in events (send IDs, fetch data separately)
3. RELY on message ordering (out-of-order is possible) 3. BROADCAST without rate limiting
4. SKIP ping/pong (connections will time out) 4. SIMULATE progress with fake timers
5. USE synchronous operations in message handlers (blocks hub) 5. SKIP ping/pong (connections will time out)
6. TRUST client-provided user IDs (extract from auth token) 6. TRUST client-provided user IDs (extract from auth token)

View File

@ -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:<id>` or `channel:<id>`. 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:<id>` | Private events for one user | `user:u_abc123` |
| `channel:<id>` | 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<ImageResult>({
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
<!-- Document all channels used in this project below -->
| Channel | Events | Purpose |
|---------|--------|---------|
| `user:<userId>` | `generation_*`, `ai_chat_chunk` | Async job results, text streaming |
| `channel:<room>` | `chat`, `ai_chat_chunk`, `presence` | Real-time chat |

View File

@ -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
<MediaUploader
upload={mediaUpload.upload}
isUploading={mediaUpload.isUploading}
progress={mediaUpload.progress}
onUploadComplete={() => refetchMedia()}
/>
// Media grid with preview
<MediaLibrary
items={items}
onDelete={(path) => 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 });
```

View File

@ -17,8 +17,9 @@ coverage.html
# Dependency directories # Dependency directories
vendor/ vendor/
# Go workspace file (local only) # Go workspace files (local only)
go.work.sum go.work.sum
*.go.sum
# IDE # IDE
.idea/ .idea/
@ -34,6 +35,7 @@ go.work.sum
# Node # Node
node_modules/ node_modules/
.npm/ .npm/
pnpm-lock.yaml
# Shared packages # Shared packages
packages/*/node_modules/ packages/*/node_modules/

View File

@ -10,6 +10,8 @@
| **Build a feature** | [feature-development.md](.claude/guides/feature-development.md) | | **Build a feature** | [feature-development.md](.claude/guides/feature-development.md) |
| **Backend API patterns** | [backend/api-patterns.md](.claude/guides/backend/api-patterns.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) | | **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) | | **Deploy** | [ops/deploying.md](.claude/guides/ops/deploying.md) |
## Quick Reference ## Quick Reference
@ -36,6 +38,13 @@
- **OpenAPI first:** Document endpoints in `spec.go` using `openapi.*` helpers. Mount with `application.EnableDocs(spec)`. - **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. - **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}}/*`. - **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:<id>` = events for a specific user. `channel:<id>` = 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 ## Architecture

View File

@ -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"
}
}

View File

@ -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';

View File

@ -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<string, string>;
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Parse image array from backend response. */
function parseImages(data: Record<string, unknown>): GeneratedImage[] {
const images = data.images as Record<string, unknown>[] | 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<string, unknown>): GeneratedVideo[] {
const videos = data.videos as Record<string, unknown>[] | 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<string, unknown>): 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<ImageGenResponse> {
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<ImageGenResponse> {
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<VideoGenResponse> {
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<VideoGenResponse> {
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();
}
}

View File

@ -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<string, string>;
}
/**
* 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<TextGenResponse> {
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);
}
}

View File

@ -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];

View File

@ -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"]
}

View File

@ -5,6 +5,12 @@
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": { "scripts": {
"generate": "../scripts/generate-client.sh", "generate": "../scripts/generate-client.sh",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",

View File

@ -1,3 +1,6 @@
import type { ApiError } from './types';
import { ApiClientError } from './errors';
/** /**
* API Client Configuration * API Client Configuration
*/ */
@ -6,7 +9,85 @@ export interface ClientConfig {
apiKey?: string; apiKey?: string;
bearerToken?: string; bearerToken?: string;
headers?: Record<string, string>; headers?: Record<string, string>;
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<ApiError> {
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 users = await client.get('/users');
* const newUser = await client.post('/users', { name: 'John' }); * 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) { export function createClient(config: ClientConfig) {
const { baseUrl, apiKey, bearerToken, headers = {}, onError } = config; const { baseUrl, apiKey, bearerToken, headers = {}, onError, onAuthError } = config;
async function request<T>( async function request<T>(
method: string, method: string,
@ -65,10 +157,19 @@ export function createClient(config: ClientConfig) {
}); });
if (!response.ok) { 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) { if (onError) {
onError(error); onError(error);
} }
throw error; throw error;
} }
@ -77,19 +178,22 @@ export function createClient(config: ClientConfig) {
return undefined as T; 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 { return {
get: <T>(path: string, params?: Record<string, string | number | boolean | undefined>) => get: <T>(path: string, params?: Record<string, string | number | boolean | undefined>) =>
request<T>('GET', path, { params }), request<T>('GET', path, { params }),
post: <T>(path: string, body?: unknown) => post: <T>(path: string, body?: unknown) => request<T>('POST', path, { body }),
request<T>('POST', path, { body }), put: <T>(path: string, body?: unknown) => request<T>('PUT', path, { body }),
put: <T>(path: string, body?: unknown) => patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, { body }),
request<T>('PUT', path, { body }), delete: <T>(path: string) => request<T>('DELETE', path),
patch: <T>(path: string, body?: unknown) =>
request<T>('PATCH', path, { body }),
delete: <T>(path: string) =>
request<T>('DELETE', path),
}; };
} }

View File

@ -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<string, unknown>;
/** 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<string, string> {
const errors: Record<string, string> = {};
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;
}

View File

@ -1,3 +1,5 @@
export * from './client'; export * from './client';
export * from './types';
export * from './errors';
// Note: schema.d.ts is generated by running `pnpm generate` // Note: schema.d.ts is generated by running `pnpm generate`
// export type { paths, components, operations } from './schema'; // export type { paths, components, operations } from './schema';

View File

@ -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<string, unknown>;
}
/**
* Standard API response wrapper.
*/
export interface ApiResponse<T> {
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;
}

View File

@ -5,6 +5,12 @@
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build": "tsc", "build": "tsc",

View File

@ -34,8 +34,6 @@ export interface AuthProviderProps {
loginUrl?: string; loginUrl?: string;
/** API endpoint for logout */ /** API endpoint for logout */
logoutUrl?: string; logoutUrl?: string;
/** API endpoint for fetching current user */
userUrl?: string;
/** Custom login handler */ /** Custom login handler */
onLogin?: (credentials: LoginCredentials) => Promise<{ token: string; user: User }>; onLogin?: (credentials: LoginCredentials) => Promise<{ token: string; user: User }>;
/** Custom logout handler */ /** Custom logout handler */
@ -68,7 +66,6 @@ export function AuthProvider({
children, children,
loginUrl = '/api/auth/login', loginUrl = '/api/auth/login',
logoutUrl = '/api/auth/logout', logoutUrl = '/api/auth/logout',
userUrl = '/api/auth/me',
onLogin, onLogin,
onLogout, onLogout,
storage = 'localStorage', storage = 'localStorage',
@ -140,8 +137,9 @@ export function AuthProvider({
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({})); const errBody = await response.json().catch(() => ({}));
throw new Error(error.message || 'Login failed'); const errMsg = errBody.error?.message || errBody.message || 'Login failed';
throw new Error(errMsg);
} }
const data = await response.json(); const data = await response.json();

View File

@ -5,6 +5,12 @@
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build": "tsc", "build": "tsc",

View File

@ -5,6 +5,12 @@
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build": "tsc" "build": "tsc"

View File

@ -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"
}
}

View File

@ -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';

View File

@ -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;
}

View File

@ -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<string, string>;
}
/**
* 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<string, AIStreamingMessage>;
/** List of online users */
onlineUsers: OnlineUser[];
/** SSE connection state */
connectionState: 'connecting' | 'connected' | 'disconnected' | 'error';
/** Send a chat message */
sendMessage: (content: string) => Promise<void>;
/** 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 (
* <div>
* {messages.map(msg => (
* <ChatBubble key={msg.id} {...msg} />
* ))}
* </div>
* );
* ```
*/
export function useChat(config: UseChatConfig): UseChatResult {
const {
endpoint,
sseEndpoint = '/api/events',
channel,
userId,
userName,
headers,
} = config;
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [aiMessages, setAIMessages] = useState<AIMessage[]>([]);
const [streamingMessages, setStreamingMessages] = useState<Map<string, AIStreamingMessage>>(new Map());
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
const seenMessageIds = useRef(new Set<string>());
// 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<string, unknown>) || 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<string, unknown>) || 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<string, unknown>) || 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,
};
}

View File

@ -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<SSEState>('disconnected');
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// 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,
};
}

View File

@ -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<string, string>;
}
/**
* Result from the media generation hook.
*/
export interface UseMediaGenerationResult<T> {
/** 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<string, unknown>) => Promise<void>;
/** 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<VideoResult>({
* 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 (
* <div>
* <p>Status: {status}</p>
* <p>Progress: {progress}%</p>
* <p>{message}</p>
* {result && <video src={result.url} />}
* {error && <p className="error">{error}</p>}
* </div>
* );
* ```
*/
export function useMediaGeneration<T = unknown>(
config: UseMediaGenerationConfig
): UseMediaGenerationResult<T> {
const {
endpoint,
sseEndpoint = '/api/events',
userId,
headers,
} = config;
const [status, setStatus] = useState<GenerationStatus>('idle');
const [progress, setProgress] = useState(0);
const [message, setMessage] = useState('');
const [result, setResult] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [jobId, setJobId] = useState<string | null>(null);
const jobIdRef = useRef<string | null>(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<string, unknown>) => {
// 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,
};
}

View File

@ -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<string, string>;
/** 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<UploadResult>;
/** 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<string | null>(null);
const reset = useCallback(() => {
setIsUploading(false);
setProgress(0);
setError(null);
}, []);
const upload = useCallback(async (file: File): Promise<UploadResult> => {
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<void>((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 };
}

View File

@ -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"]
}

View File

@ -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<React.HTMLAttributes<HTMLDivElement>, 'role'>,
VariantProps<typeof chatBubbleVariants> {
content: string;
timestamp?: Date;
avatar?: string;
isStreaming?: boolean;
}
const ChatBubble = React.forwardRef<HTMLDivElement, ChatBubbleProps>(
({ className, role, content, timestamp, avatar, isStreaming, ...props }, ref) => {
const isUser = role === 'user';
return (
<div
ref={ref}
className={cn(
'flex items-end gap-2',
isUser ? 'flex-row-reverse' : 'flex-row',
className
)}
{...props}
>
{avatar && role !== 'system' && (
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-[var(--surface-300)] overflow-hidden">
<img src={avatar} alt="" className="w-full h-full object-cover" />
</div>
)}
<div className="flex flex-col gap-1">
<div className={cn(chatBubbleVariants({ role }))}>
<span className="whitespace-pre-wrap">{content}</span>
{isStreaming && (
<span className="inline-block w-2 h-4 ml-1 bg-current animate-pulse" />
)}
</div>
{timestamp && (
<span
className={cn(
'text-xs text-[var(--text-muted)]',
isUser ? 'text-right' : 'text-left'
)}
>
{timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
</div>
);
}
);
ChatBubble.displayName = 'ChatBubble';
export { ChatBubble, chatBubbleVariants };

View File

@ -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<HTMLDivElement, ChatInputProps>(
({ placeholder = 'Type a message...', maxLength = 4000, disabled, onSubmit, onSendError, className }, ref) => {
const [value, setValue] = React.useState('');
const textareaRef = React.useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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 (
<div
ref={ref}
className={cn(
'relative flex items-end gap-2 rounded-xl border border-[var(--border)] bg-[var(--surface-100)] p-2 transition-colors focus-within:border-[var(--accent)]',
disabled && 'opacity-50 cursor-not-allowed',
className
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
className={cn(
'flex-1 resize-none bg-transparent text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:outline-none disabled:cursor-not-allowed',
'min-h-[24px] max-h-[200px] py-1 px-2'
)}
/>
<div className="flex items-center gap-2 flex-shrink-0">
{isNearLimit && (
<span
className={cn(
'text-xs tabular-nums',
isOverLimit ? 'text-[var(--error)]' : 'text-[var(--text-muted)]'
)}
>
{charCount}/{maxLength}
</span>
)}
<button
type="button"
onClick={handleSubmit}
disabled={disabled || !value.trim() || isOverLimit}
className={cn(
'flex items-center justify-center w-8 h-8 rounded-lg transition-colors',
'bg-[var(--accent)] text-[var(--accent-foreground)]',
'hover:bg-[var(--accent-hover)]',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-[var(--accent)]'
)}
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
);
}
);
ChatInput.displayName = 'ChatInput';
export { ChatInput };

View File

@ -0,0 +1,43 @@
import * as React from 'react';
import { cn } from '../utils/cn';
import { AlertCircle } from 'lucide-react';
export interface ErrorMessageProps extends React.HTMLAttributes<HTMLParagraphElement> {
/** The error message to display */
message?: string;
/** Show an icon alongside the message */
showIcon?: boolean;
}
/**
* ErrorMessage displays a field-level error message.
*
* @example
* <ErrorMessage message="Email is required" />
*
* @example
* <ErrorMessage message={errors.email} showIcon />
*/
const ErrorMessage = React.forwardRef<HTMLParagraphElement, ErrorMessageProps>(
({ className, message, showIcon = false, ...props }, ref) => {
if (!message) return null;
return (
<p
ref={ref}
role="alert"
className={cn(
'flex items-center gap-1.5 text-sm text-[var(--error)] mt-1.5',
className
)}
{...props}
>
{showIcon && <AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />}
<span>{message}</span>
</p>
);
}
);
ErrorMessage.displayName = 'ErrorMessage';
export { ErrorMessage };

View File

@ -0,0 +1,93 @@
import * as React from 'react';
import { cn } from '../utils/cn';
import { Label } from './Label';
import { Input, type InputProps } from './Input';
import { ErrorMessage } from './ErrorMessage';
export interface FormFieldProps extends Omit<InputProps, 'id' | 'error'> {
/** Field label text */
label: string;
/** Field name (used for id and error lookup) */
name: string;
/** Error message for this field */
error?: string;
/** Additional description text */
description?: string;
/** Whether the field is required */
required?: boolean;
/** Container className */
containerClassName?: string;
}
/**
* FormField is a compound component that combines Label, Input, and ErrorMessage.
*
* @example
* <FormField
* label="Email"
* name="email"
* type="email"
* error={errors.email}
* required
* />
*
* @example
* <FormField
* label="Password"
* name="password"
* type="password"
* description="Must be at least 8 characters"
* error={errors.password}
* />
*/
const FormField = React.forwardRef<HTMLInputElement, FormFieldProps>(
(
{
label,
name,
error,
description,
required,
containerClassName,
className,
...inputProps
},
ref
) => {
const id = `field-${name}`;
const descriptionId = description ? `${id}-description` : undefined;
const errorId = error ? `${id}-error` : undefined;
return (
<div className={cn('space-y-1.5', containerClassName)}>
<Label htmlFor={id} className="flex items-center gap-1">
{label}
{required && <span className="text-[var(--error)]">*</span>}
</Label>
{description && (
<p id={descriptionId} className="text-sm text-[var(--text-muted)]">
{description}
</p>
)}
<Input
ref={ref}
id={id}
name={name}
error={!!error}
aria-describedby={descriptionId}
aria-invalid={!!error}
aria-errormessage={errorId}
className={className}
{...inputProps}
/>
<ErrorMessage id={errorId} message={error} />
</div>
);
}
);
FormField.displayName = 'FormField';
export { FormField };

View File

@ -0,0 +1,45 @@
import * as React from 'react';
import { cn } from '../utils/cn';
export interface GenerationProgressProps extends React.HTMLAttributes<HTMLDivElement> {
/** Optional: 0-100. When provided, shows determinate progress. Otherwise indeterminate. */
percent?: number;
}
const GenerationProgress = React.forwardRef<HTMLDivElement, GenerationProgressProps>(
({ percent, className, ...props }, ref) => {
const isDeterminate = percent != null && percent > 0;
const clampedPercent = isDeterminate ? Math.max(0, Math.min(100, percent)) : 0;
return (
<div
ref={ref}
className={cn('relative h-1 rounded-full bg-[var(--surface-200)] overflow-hidden', className)}
role="progressbar"
aria-valuenow={isDeterminate ? clampedPercent : undefined}
aria-valuemin={0}
aria-valuemax={100}
{...props}
>
{isDeterminate ? (
<div
className="absolute inset-y-0 left-0 rounded-full bg-[var(--accent)] transition-all duration-300 ease-out"
style={{ width: `${clampedPercent}%` }}
/>
) : (
<div className="absolute inset-y-0 w-1/3 rounded-full bg-[var(--accent)] animate-[indeterminate_1.4s_ease-in-out_infinite]" />
)}
<style>{`
@keyframes indeterminate {
0% { left: -33%; }
100% { left: 100%; }
}
`}</style>
</div>
);
}
);
GenerationProgress.displayName = 'GenerationProgress';
export { GenerationProgress };

View File

@ -0,0 +1,90 @@
import * as React from 'react';
import { cn } from '../utils/cn';
export interface ImageGridImage {
src: string;
alt?: string;
}
export interface ImageGridProps extends React.HTMLAttributes<HTMLDivElement> {
images: ImageGridImage[];
columns?: 1 | 2 | 3 | 4;
onImageClick?: (index: number) => void;
loading?: boolean;
}
const columnClasses = {
1: 'grid-cols-1',
2: 'grid-cols-2',
3: 'grid-cols-3',
4: 'grid-cols-4',
} as const;
const ImageGrid = React.forwardRef<HTMLDivElement, ImageGridProps>(
({ images, columns = 2, onImageClick, loading, className, ...props }, ref) => {
const [loadedImages, setLoadedImages] = React.useState<Set<number>>(new Set());
const handleImageLoad = React.useCallback((index: number) => {
setLoadedImages((prev) => new Set(prev).add(index));
}, []);
if (loading) {
return (
<div
ref={ref}
className={cn('grid gap-2', columnClasses[columns], className)}
{...props}
>
{Array.from({ length: columns * 2 }).map((_, i) => (
<div
key={i}
className="aspect-square rounded-lg bg-[var(--surface-200)] animate-pulse"
/>
))}
</div>
);
}
if (images.length === 0) {
return null;
}
return (
<div
ref={ref}
className={cn('grid gap-2', columnClasses[columns], className)}
{...props}
>
{images.map((image, index) => (
<button
key={index}
type="button"
onClick={() => onImageClick?.(index)}
disabled={!onImageClick}
className={cn(
'relative aspect-square rounded-lg overflow-hidden bg-[var(--surface-200)]',
'focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2 focus:ring-offset-[var(--background)]',
onImageClick && 'cursor-pointer hover:opacity-90 transition-opacity'
)}
>
{!loadedImages.has(index) && (
<div className="absolute inset-0 animate-pulse bg-[var(--surface-300)]" />
)}
<img
src={image.src}
alt={image.alt || `Generated image ${index + 1}`}
className={cn(
'w-full h-full object-cover transition-opacity duration-300',
loadedImages.has(index) ? 'opacity-100' : 'opacity-0'
)}
onLoad={() => handleImageLoad(index)}
/>
</button>
))}
</div>
);
}
);
ImageGrid.displayName = 'ImageGrid';
export { ImageGrid };

View File

@ -0,0 +1,144 @@
import { useState } from 'react';
import { Trash2, Image, Video, ExternalLink } from 'lucide-react';
export interface MediaItem {
path: string;
url: string;
contentType: string;
size: number;
createdAt: string;
}
export interface MediaLibraryProps {
/** Media items to display */
items: MediaItem[];
/** Called when a media item is deleted */
onDelete?: (path: string) => void;
/** Called when a media item is selected */
onSelect?: (item: MediaItem) => void;
/** Whether delete operations are in progress */
isDeleting?: boolean;
/** Show empty state */
emptyMessage?: string;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function isImage(contentType: string): boolean {
return contentType.startsWith('image/');
}
function isVideo(contentType: string): boolean {
return contentType.startsWith('video/');
}
export function MediaLibrary({
items,
onDelete,
onSelect,
isDeleting = false,
emptyMessage = 'No media files yet. Upload or generate some!',
}: MediaLibraryProps) {
const [selectedPath, setSelectedPath] = useState<string | null>(null);
if (items.length === 0) {
return (
<div className="text-center py-12 text-[var(--text-muted)]">
<Image className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">{emptyMessage}</p>
</div>
);
}
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{items.map((item) => (
<div
key={item.path}
className={`
group relative rounded-lg overflow-hidden border transition-colors cursor-pointer
${selectedPath === item.path
? 'border-[var(--accent)] ring-2 ring-[var(--accent)]/20'
: 'border-[var(--border-muted)] hover:border-[var(--border-default)]'}
`}
onClick={() => {
setSelectedPath(item.path);
onSelect?.(item);
}}
>
{/* Preview */}
<div className="aspect-square bg-[var(--surface-200)] flex items-center justify-center overflow-hidden">
{isImage(item.contentType) ? (
<img
src={item.url}
alt={item.path.split('/').pop() || ''}
className="w-full h-full object-cover"
loading="lazy"
/>
) : isVideo(item.contentType) ? (
<video
src={item.url}
className="w-full h-full object-cover"
muted
playsInline
onMouseOver={(e) => (e.target as HTMLVideoElement).play()}
onMouseOut={(e) => { const v = e.target as HTMLVideoElement; v.pause(); v.currentTime = 0; }}
/>
) : (
<div className="text-[var(--text-muted)]">
<Image className="h-8 w-8" />
</div>
)}
</div>
{/* Info bar */}
<div className="p-2 bg-[var(--surface-100)]">
<p className="text-xs text-[var(--text-primary)] truncate">
{item.path.split('/').pop()}
</p>
<p className="text-xs text-[var(--text-muted)]">
{formatSize(item.size)}
</p>
</div>
{/* Overlay actions */}
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-1.5 rounded bg-black/60 text-white hover:bg-black/80"
title="Open in new tab"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
{onDelete && (
<button
onClick={(e) => { e.stopPropagation(); onDelete(item.path); }}
disabled={isDeleting}
className="p-1.5 rounded bg-black/60 text-white hover:bg-red-600"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Type badge */}
<div className="absolute top-2 left-2">
{isVideo(item.contentType) ? (
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-black/60 text-white text-xs">
<Video className="h-3 w-3" /> Video
</span>
) : null}
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,143 @@
import { useCallback, useState, useRef } from 'react';
import { Upload, Loader2, Check } from 'lucide-react';
export interface MediaUploaderProps {
/** Called when upload completes successfully */
onUploadComplete?: (result: { url: string; path: string }) => void;
/** Called on error */
onError?: (error: string) => void;
/** Upload function from useMediaUpload hook */
upload: (file: File) => Promise<{ url: string; path: string }>;
/** Whether an upload is currently in progress */
isUploading?: boolean;
/** Upload progress percentage (0-100) */
progress?: number;
/** Accepted file types (e.g., "image/*,video/*") */
accept?: string;
/** Max file size in MB */
maxSizeMB?: number;
}
export function MediaUploader({
onUploadComplete,
onError,
upload,
isUploading = false,
progress = 0,
accept = 'image/*,video/*',
maxSizeMB = 50,
}: MediaUploaderProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [lastResult, setLastResult] = useState<{ url: string; path: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFile = useCallback(async (file: File) => {
// Validate file type (accept prop only restricts file picker, not drag-and-drop)
if (accept) {
const acceptedTypes = accept.split(',').map((t) => t.trim());
const isAccepted = acceptedTypes.some((type) => {
if (type.endsWith('/*')) return file.type.startsWith(type.replace('/*', '/'));
return file.type === type;
});
if (!isAccepted) {
onError?.(`File type "${file.type}" is not accepted. Allowed: ${accept}`);
return;
}
}
if (file.size > maxSizeMB * 1024 * 1024) {
onError?.(`File too large. Maximum size is ${maxSizeMB}MB.`);
return;
}
try {
const result = await upload(file);
setLastResult(result);
onUploadComplete?.(result);
} catch (err) {
onError?.(err instanceof Error ? err.message : 'Upload failed');
}
}, [upload, accept, maxSizeMB, onUploadComplete, onError]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
}, [handleFile]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback(() => {
setIsDragOver(false);
}, []);
const handleClick = () => fileInputRef.current?.click();
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
if (fileInputRef.current) fileInputRef.current.value = '';
};
return (
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={isUploading ? undefined : handleClick}
className={`
relative border-2 border-dashed rounded-lg p-8 text-center transition-colors
${isDragOver
? 'border-[var(--accent)] bg-[var(--accent)]/5'
: 'border-[var(--border-muted)] hover:border-[var(--border-default)]'}
${isUploading ? 'pointer-events-none opacity-75' : 'cursor-pointer'}
`}
>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleInputChange}
className="hidden"
/>
{isUploading ? (
<div className="space-y-3">
<Loader2 className="h-8 w-8 animate-spin text-[var(--accent)] mx-auto" />
<p className="text-sm text-[var(--text-muted)]">Uploading... {progress}%</p>
<div className="w-full max-w-xs mx-auto h-2 rounded-full bg-[var(--surface-200)] overflow-hidden">
<div
className="h-full rounded-full bg-[var(--accent)] transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
) : lastResult ? (
<div className="space-y-2">
<Check className="h-8 w-8 text-green-500 mx-auto" />
<p className="text-sm text-[var(--text-primary)]">Upload complete</p>
<button
onClick={(e) => { e.stopPropagation(); setLastResult(null); }}
className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] underline"
>
Upload another
</button>
</div>
) : (
<div className="space-y-2">
<Upload className="h-8 w-8 text-[var(--text-muted)] mx-auto" />
<p className="text-sm text-[var(--text-primary)]">
Drop a file here or click to browse
</p>
<p className="text-xs text-[var(--text-muted)]">
Max {maxSizeMB}MB
</p>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,70 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../utils/cn';
const providerBadgeVariants = cva(
'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium',
{
variants: {
provider: {
gemini: 'bg-blue-500/15 text-blue-400 border border-blue-500/30',
laozhang: 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30',
default: 'bg-[var(--surface-200)] text-[var(--text-secondary)] border border-[var(--border)]',
},
},
defaultVariants: {
provider: 'default',
},
}
);
type ProviderVariant = 'gemini' | 'laozhang' | 'default';
export interface ProviderBadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
Omit<VariantProps<typeof providerBadgeVariants>, 'provider'> {
provider: string;
latency?: number;
}
const providerIcons: Record<string, string> = {
gemini: '✦',
laozhang: '◆',
};
const providerLabels: Record<string, string> = {
gemini: 'Gemini',
laozhang: 'LaoZhang',
};
function getProviderVariant(provider: string): ProviderVariant {
const normalized = provider.toLowerCase();
if (normalized.includes('gemini')) return 'gemini';
if (normalized.includes('laozhang')) return 'laozhang';
return 'default';
}
const ProviderBadge = React.forwardRef<HTMLSpanElement, ProviderBadgeProps>(
({ provider, latency, className, ...props }, ref) => {
const variant = getProviderVariant(provider);
const icon = providerIcons[variant] || '●';
const label = providerLabels[variant] || provider;
return (
<span
ref={ref}
className={cn(providerBadgeVariants({ provider: variant }), className)}
{...props}
>
<span className="opacity-70">{icon}</span>
<span>{label}</span>
{latency !== undefined && (
<span className="opacity-60 tabular-nums">{latency}ms</span>
)}
</span>
);
}
);
ProviderBadge.displayName = 'ProviderBadge';
export { ProviderBadge, providerBadgeVariants };

View File

@ -0,0 +1,35 @@
import * as React from 'react';
import { cn } from '../utils/cn';
export interface StreamingTextProps extends React.HTMLAttributes<HTMLSpanElement> {
text: string;
isStreaming: boolean;
}
const StreamingText = React.forwardRef<HTMLSpanElement, StreamingTextProps>(
({ text, isStreaming, className, ...props }, ref) => {
return (
<span ref={ref} className={cn('whitespace-pre-wrap', className)} {...props}>
{text}
{isStreaming && (
<span
className="inline-block w-[2px] h-[1.1em] ml-0.5 bg-current align-text-bottom animate-[blink_1s_step-end_infinite]"
style={{
// Inline keyframes for cursor blink
animation: 'blink 1s step-end infinite',
}}
/>
)}
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</span>
);
}
);
StreamingText.displayName = 'StreamingText';
export { StreamingText };

View File

@ -0,0 +1,103 @@
'use client';
import { useRef, useState } from 'react';
import { cn } from '../utils/cn';
export interface VideoPlayerProps {
/** Video source URL or base64 data URI. */
src: string;
/** MIME type for the source. */
mimeType?: string;
/** Alt text / accessible label. */
alt?: string;
/** Additional class names for the container. */
className?: string;
/** Aspect ratio CSS class (default: aspect-video). */
aspectRatio?: string;
}
/**
* VideoPlayer renders an HTML5 video with controls, loading state,
* and error handling. Supports both URL and base64 data URIs.
*/
export function VideoPlayer({
src,
mimeType = 'video/mp4',
alt = 'Generated video',
className,
aspectRatio = 'aspect-video',
}: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
return (
<div
className={cn(
'relative rounded-lg overflow-hidden bg-[var(--surface)]',
aspectRatio,
className,
)}
>
{isLoading && !error && (
<div className="absolute inset-0 flex items-center justify-center bg-[var(--surface)]">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[var(--text-muted)] border-t-[var(--accent)]" />
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-[var(--surface)]">
<p className="text-sm text-[var(--error)]">{error}</p>
</div>
)}
<video
ref={videoRef}
className={cn('h-full w-full object-contain', isLoading && 'invisible')}
controls
playsInline
aria-label={alt}
onLoadedData={() => setIsLoading(false)}
onError={() => {
setIsLoading(false);
setError('Failed to load video');
}}
>
<source src={src} type={mimeType} />
</video>
</div>
);
}
export interface VideoGridProps {
/** Videos to display. */
videos: Array<{ src: string; mimeType?: string; alt?: string }>;
/** Additional class names. */
className?: string;
}
/**
* VideoGrid displays one or more videos in a responsive grid.
*/
export function VideoGrid({ videos, className }: VideoGridProps) {
if (videos.length === 0) return null;
return (
<div
className={cn(
'grid gap-4',
videos.length === 1 ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-2',
className,
)}
>
{videos.map((video, i) => (
<VideoPlayer
key={`video-${i}`}
src={video.src}
mimeType={video.mimeType}
alt={video.alt}
/>
))}
</div>
);
}

View File

@ -0,0 +1,103 @@
'use client';
import { useState, useCallback } from 'react';
export interface FieldErrors {
[field: string]: string;
}
export interface UseFormErrorsReturn {
/** Current field errors */
errors: FieldErrors;
/** Set error for a specific field */
setFieldError: (field: string, message: string) => void;
/** Clear error for a specific field */
clearFieldError: (field: string) => void;
/** Set multiple field errors at once */
setErrors: (errors: FieldErrors) => void;
/** Clear all errors */
clearErrors: () => void;
/** Check if a field has an error */
hasError: (field: string) => boolean;
/** Get error message for a field */
getError: (field: string) => string | undefined;
/** Check if any errors exist */
hasAnyErrors: boolean;
}
/**
* Hook for managing form field errors.
* Works well with ApiClientError.getFieldErrors().
*
* @example
* const { errors, setErrors, clearErrors, hasError, getError } = useFormErrors();
*
* const handleSubmit = async (data) => {
* clearErrors();
* try {
* await api.post('/users', data);
* } catch (error) {
* if (isApiClientError(error) && error.isValidationError()) {
* setErrors(error.getFieldErrors());
* }
* }
* };
*
* return (
* <FormField
* name="email"
* label="Email"
* error={getError('email')}
* />
* );
*/
export function useFormErrors(initialErrors: FieldErrors = {}): UseFormErrorsReturn {
const [errors, setErrorsState] = useState<FieldErrors>(initialErrors);
const setFieldError = useCallback((field: string, message: string) => {
setErrorsState((prev) => ({ ...prev, [field]: message }));
}, []);
const clearFieldError = useCallback((field: string) => {
setErrorsState((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}, []);
const setErrors = useCallback((newErrors: FieldErrors) => {
setErrorsState(newErrors);
}, []);
const clearErrors = useCallback(() => {
setErrorsState({});
}, []);
const hasError = useCallback(
(field: string) => {
return !!errors[field];
},
[errors]
);
const getError = useCallback(
(field: string) => {
return errors[field];
},
[errors]
);
const hasAnyErrors = Object.keys(errors).length > 0;
return {
errors,
setFieldError,
clearFieldError,
setErrors,
clearErrors,
hasError,
getError,
hasAnyErrors,
};
}

Some files were not shown because too many files have changed in this diff Show More