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
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:
parent
7249575dea
commit
a8c8a0a14d
4
.gitignore
vendored
4
.gitignore
vendored
@ -43,3 +43,7 @@ tmp/
|
||||
/rdev-api
|
||||
/claudebox-sidecar
|
||||
/sdlc
|
||||
/render-skeleton
|
||||
|
||||
# Rendered example monorepo (regenerated from templates)
|
||||
examples/full-monorepo/
|
||||
|
||||
@ -37,6 +37,7 @@ When discussing code: "add to **platform**" = edit rdev; "add to **skeleton**" =
|
||||
| **Cookbook tree system (commands)** | [services/cookbook-trees.md](.claude/guides/services/cookbook-trees.md) |
|
||||
| **Slackpath reference architectures** | [services/cookbook-trees.md](.claude/guides/services/cookbook-trees.md#slackpath-trees-reference-architectures) |
|
||||
| **Write cookbook trees** | [cookbook-trees/SKILL.md](.claude/skills/cookbook-trees/SKILL.md) |
|
||||
| **Build/maintain skeleton packages** | [skeleton-craftsman/SKILL.md](.claude/skills/skeleton-craftsman/SKILL.md) |
|
||||
| **Build orchestration** | [services/build-orchestration.md](.claude/guides/services/build-orchestration.md) |
|
||||
| **Build event streaming** | [services/build-streaming.md](.claude/guides/services/build-streaming.md) |
|
||||
| **Resource provisioning plan** | [services/resource-provisioning-plan.md](.claude/guides/services/resource-provisioning-plan.md) |
|
||||
@ -55,10 +56,13 @@ When discussing code: "add to **platform**" = edit rdev; "add to **skeleton**" =
|
||||
| **Woodpecker CI v3 pipelines** | [ops/woodpecker-v3.md](.claude/guides/ops/woodpecker-v3.md) |
|
||||
| **Traefik v3 ingress & middleware** | [ops/traefik-v3.md](.claude/guides/ops/traefik-v3.md) |
|
||||
| **Zot container registry** | [ops/zot-registry.md](.claude/guides/ops/zot-registry.md) |
|
||||
| **cert-manager / TLS certificates** | [ops/cert-manager.md](.claude/guides/ops/cert-manager.md) |
|
||||
| **Structured logging** | `internal/logging/` - field constants, context propagation, redaction |
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- **Frustration = systemic fix:** When the user says they're tired of repeating something, stop what you're doing and find or create a systemic fix in `.claude/**/*` or `CLAUDE.md` — don't just apologize and do the same thing again.
|
||||
- **AI credentials are provisioned:** rdev injects `LAOZHANG_API_KEY` and `GEMINI_API_KEY` as env vars into every deployed component (`component_deploy.go:fetchProjectCredentials`). Skeleton code reads them with `os.Getenv()`. Never treat AI packages as needing external setup.
|
||||
- **Root cause fixes:** When diagnosing failures in generated projects, NEVER patch the project directly. Find the systemic root cause in: (1) **platform** - rdev handlers/services that create resources, (2) **skeleton** - templates that ship in generated projects, or (3) **cookbook** - test scripts with wrong assumptions. Fix the source, not the symptom. Every project-specific fix is technical debt that will recur.
|
||||
- **LLM vs rdev:** LLMs generate code; rdev executes deterministic operations (git, lint, deploy). Never rely on LLMs for runbook tasks.
|
||||
- **Pod git ops:** Git operations run inside pods via `PodGitOperations` (kubectl exec), never locally.
|
||||
|
||||
@ -34,6 +34,11 @@ type Config struct {
|
||||
// Internal API token for service-to-service callbacks
|
||||
InternalToken string
|
||||
|
||||
// Citadel logging integration
|
||||
CitadelURL string // e.g., "https://citadel-staging.orchard9.ai"
|
||||
CitadelAPIKey string // API key for Citadel (starts with ck_live_ or ck_dev_)
|
||||
CitadelPlatformTenantID string // Tenant ID for the rdev-platform environment
|
||||
|
||||
// Infrastructure adapters (threesix.ai) - fallback values if not in credential store
|
||||
GiteaURL string
|
||||
GiteaToken string
|
||||
@ -107,6 +112,11 @@ func loadConfig() Config {
|
||||
// Internal API token for service-to-service callbacks (e.g., SDLC callbacks)
|
||||
InternalToken: os.Getenv("INTERNAL_TOKEN"),
|
||||
|
||||
// Citadel logging integration
|
||||
CitadelURL: os.Getenv("CITADEL_URL"), // e.g., "https://citadel-staging.orchard9.ai"
|
||||
CitadelAPIKey: os.Getenv("CITADEL_API_KEY"), // API key for Citadel
|
||||
CitadelPlatformTenantID: os.Getenv("CITADEL_PLATFORM_TENANT_ID"), // rdev-platform tenant ID
|
||||
|
||||
// Infrastructure adapters (fallback if not in credential store)
|
||||
GiteaURL: envutil.GetEnv("GITEA_URL", "https://git.threesix.ai"),
|
||||
GiteaToken: os.Getenv("GITEA_TOKEN"),
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
citadeladapter "github.com/orchard9/rdev/internal/adapter/citadel"
|
||||
"github.com/orchard9/rdev/internal/adapter/cloudflare"
|
||||
"github.com/orchard9/rdev/internal/adapter/cockroach"
|
||||
"github.com/orchard9/rdev/internal/adapter/codeagent"
|
||||
@ -96,6 +97,16 @@ func main() {
|
||||
// Load infrastructure config from credential store (falls back to env vars)
|
||||
infraCfg := loadInfraConfig(context.Background(), credentialStore, cfg, logger)
|
||||
|
||||
// Initialize Citadel client (optional - for log environment provisioning and audit shipping)
|
||||
var citadelClient *citadeladapter.Client
|
||||
if cfg.CitadelURL != "" && cfg.CitadelAPIKey != "" {
|
||||
citadelClient = citadeladapter.NewClient(citadeladapter.Config{
|
||||
URL: cfg.CitadelURL,
|
||||
APIKey: cfg.CitadelAPIKey,
|
||||
}, logger)
|
||||
logger.Info("citadel client initialized", "url", cfg.CitadelURL)
|
||||
}
|
||||
|
||||
// Create adapters (dependency injection)
|
||||
namespace := envutil.GetEnv("K8S_NAMESPACE", "rdev")
|
||||
|
||||
@ -113,7 +124,16 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
auditLogger := postgres.NewAuditLogger(database.DB)
|
||||
var auditLogger port.AuditLogger
|
||||
var auditShipper *citadeladapter.AuditShipper
|
||||
pgAuditLogger := postgres.NewAuditLogger(database.DB)
|
||||
if citadelClient != nil && cfg.CitadelPlatformTenantID != "" {
|
||||
auditShipper = citadeladapter.NewAuditShipper(pgAuditLogger, citadelClient, cfg.CitadelPlatformTenantID, logger)
|
||||
auditLogger = auditShipper
|
||||
logger.Info("audit logger wrapped with citadel shipper", "tenant_id", cfg.CitadelPlatformTenantID)
|
||||
} else {
|
||||
auditLogger = pgAuditLogger
|
||||
}
|
||||
rateLimiter := postgres.NewRateLimiter(database.DB)
|
||||
stopRateLimitCleanup := rateLimiter.StartCleanupWorker(context.Background(), 5*time.Minute)
|
||||
commandQueue := postgres.NewCommandQueueRepository(database.DB)
|
||||
@ -459,6 +479,9 @@ func main() {
|
||||
if registryClient != nil {
|
||||
projectInfraService = projectInfraService.WithRegistryProvider(registryClient)
|
||||
}
|
||||
if citadelClient != nil {
|
||||
projectInfraService = projectInfraService.WithCitadelClient(citadelClient)
|
||||
}
|
||||
|
||||
// Create domain service adapter for infrastructure handler
|
||||
domainServiceAdapter := handlers.NewDomainServiceAdapter(projectInfraService)
|
||||
@ -761,6 +784,9 @@ func main() {
|
||||
}
|
||||
queueProcessor.Stop()
|
||||
webhookDispatcher.Stop()
|
||||
if auditShipper != nil {
|
||||
auditShipper.Close()
|
||||
}
|
||||
projectRepo.StopWatching()
|
||||
stopRateLimitCleanup()
|
||||
closeProvisioner(dbProvisioner, "database", logger)
|
||||
|
||||
241
cmd/render-skeleton/main.go
Normal file
241
cmd/render-skeleton/main.go
Normal 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
|
||||
}
|
||||
@ -95,7 +95,7 @@ api_call() {
|
||||
# Returns: 0 on success, 1 on failure, 2 on timeout
|
||||
wait_for_build() {
|
||||
local task_id="$1"
|
||||
local max_attempts="${2:-60}" # 5 minutes default (5s * 60)
|
||||
local max_attempts="${2:-120}" # 10 minutes default (5s * 120)
|
||||
local poll_interval="${3:-5}"
|
||||
local attempt=0
|
||||
|
||||
@ -155,7 +155,7 @@ wait_for_build() {
|
||||
# the pipeline has already failed.
|
||||
wait_for_pipeline() {
|
||||
local project_id="$1"
|
||||
local max_attempts="${2:-60}" # 5 minutes default
|
||||
local max_attempts="${2:-120}" # 10 minutes default
|
||||
local poll_interval="${3:-5}"
|
||||
local attempt=0
|
||||
local tracked_pipeline="" # Track specific pipeline once found
|
||||
|
||||
@ -120,7 +120,7 @@ execute_wait_pipeline_step() {
|
||||
|
||||
local project_id max_attempts poll_interval
|
||||
project_id=$(echo "$step_json" | jq -r '.project_id')
|
||||
max_attempts=$(echo "$step_json" | jq -r '.max_attempts // 60')
|
||||
max_attempts=$(echo "$step_json" | jq -r '.max_attempts // 120')
|
||||
poll_interval=$(echo "$step_json" | jq -r '.poll_interval // 5')
|
||||
|
||||
wait_for_pipeline "$project_id" "$max_attempts" "$poll_interval"
|
||||
|
||||
@ -77,7 +77,7 @@ steps:
|
||||
depends_on: [spec-feature]
|
||||
action: wait_build
|
||||
build_id: "{{ .outputs.spec-feature.build_id }}"
|
||||
max_attempts: 60
|
||||
max_attempts: 120
|
||||
poll_interval: 5
|
||||
|
||||
implement-backend:
|
||||
|
||||
@ -62,7 +62,7 @@ steps:
|
||||
depends_on: [verify-components]
|
||||
action: wait_pipeline
|
||||
project_id: "{{ .outputs.create-project.project_id }}"
|
||||
max_attempts: 60
|
||||
max_attempts: 120
|
||||
poll_interval: 5
|
||||
on_error: continue
|
||||
|
||||
|
||||
@ -39,7 +39,7 @@ steps:
|
||||
depends_on: [add-service]
|
||||
action: wait_pipeline
|
||||
project_id: "{{ .outputs.create-project.project_id }}"
|
||||
max_attempts: 60
|
||||
max_attempts: 120
|
||||
|
||||
# --- Phase 2: Evolve (Add Feature) ---
|
||||
create-feature:
|
||||
@ -71,7 +71,7 @@ steps:
|
||||
depends_on: [generate-spec]
|
||||
action: wait_build
|
||||
build_id: "{{ .outputs.generate-spec.build_id }}"
|
||||
max_attempts: 60
|
||||
max_attempts: 120
|
||||
poll_interval: 5
|
||||
|
||||
check-artifact:
|
||||
|
||||
@ -136,7 +136,7 @@ steps:
|
||||
depends_on: [wait-deploy-2]
|
||||
action: wait_site
|
||||
domain: "{{ .vars.domain }}"
|
||||
max_attempts: 60
|
||||
max_attempts: 120
|
||||
|
||||
verify-complete:
|
||||
description: "Print success summary"
|
||||
|
||||
@ -86,7 +86,7 @@ steps:
|
||||
depends_on: [wait-components]
|
||||
action: wait_site
|
||||
domain: "{{ .outputs.create-project.domain }}"
|
||||
max_attempts: 60
|
||||
max_attempts: 120
|
||||
on_error: continue
|
||||
|
||||
# ============================================================
|
||||
|
||||
@ -39,7 +39,7 @@ steps:
|
||||
depends_on: [add-service]
|
||||
action: wait_pipeline
|
||||
project_id: "{{ .outputs.create-project.project_id }}"
|
||||
max_attempts: 60
|
||||
max_attempts: 120
|
||||
|
||||
# --- Phase 2: SDLC Process (Spec & Design) ---
|
||||
create-feature:
|
||||
@ -71,7 +71,7 @@ steps:
|
||||
depends_on: [generate-spec]
|
||||
action: wait_build
|
||||
build_id: "{{ .outputs.generate-spec.build_id }}"
|
||||
max_attempts: 60
|
||||
max_attempts: 120
|
||||
poll_interval: 5
|
||||
|
||||
approve-spec:
|
||||
@ -101,7 +101,7 @@ steps:
|
||||
depends_on: [generate-design]
|
||||
action: wait_build
|
||||
build_id: "{{ .outputs.generate-design.build_id }}"
|
||||
max_attempts: 60
|
||||
max_attempts: 120
|
||||
poll_interval: 5
|
||||
|
||||
approve-design:
|
||||
@ -152,7 +152,7 @@ steps:
|
||||
depends_on: [wait-implementation]
|
||||
action: wait_pipeline
|
||||
project_id: "{{ .outputs.create-project.project_id }}"
|
||||
max_attempts: 60
|
||||
max_attempts: 120
|
||||
|
||||
# --- Phase 4: Verification ---
|
||||
verify-crud:
|
||||
|
||||
208
cookbooks/trees/genkit-test.yaml
Normal file
208
cookbooks/trees/genkit-test.yaml
Normal 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 }}"
|
||||
@ -38,7 +38,7 @@ steps:
|
||||
depends_on: [add-component]
|
||||
action: wait_pipeline
|
||||
project_id: "{{ .outputs.create-project.project_id }}"
|
||||
max_attempts: 60
|
||||
max_attempts: 120
|
||||
poll_interval: 5
|
||||
on_error: continue
|
||||
|
||||
|
||||
27
deployments/k8s/base/citadel-agent/configmap.yaml
Normal file
27
deployments/k8s/base/citadel-agent/configmap.yaml
Normal 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"
|
||||
92
deployments/k8s/base/citadel-agent/daemonset.yaml
Normal file
92
deployments/k8s/base/citadel-agent/daemonset.yaml
Normal 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
|
||||
13
deployments/k8s/base/citadel-agent/kustomization.yaml
Normal file
13
deployments/k8s/base/citadel-agent/kustomization.yaml
Normal 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
|
||||
6
deployments/k8s/base/citadel-agent/namespace.yaml
Normal file
6
deployments/k8s/base/citadel-agent/namespace.yaml
Normal file
@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: observability
|
||||
labels:
|
||||
app.kubernetes.io/part-of: citadel
|
||||
11
deployments/k8s/base/citadel-agent/secret.yaml.example
Normal file
11
deployments/k8s/base/citadel-agent/secret.yaml.example
Normal 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"
|
||||
37
deployments/k8s/base/citadel-agent/serviceaccount.yaml
Normal file
37
deployments/k8s/base/citadel-agent/serviceaccount.yaml
Normal 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
|
||||
@ -19,6 +19,9 @@ spec:
|
||||
app.kubernetes.io/name: claudebox
|
||||
app.kubernetes.io/part-of: rdev
|
||||
rdev.orchard9.ai/role: worker
|
||||
# Citadel agent routes these logs to the rdev-platform environment
|
||||
citadel.io/environment: rdev-platform
|
||||
citadel.io/service: claudebox
|
||||
spec:
|
||||
containers:
|
||||
- name: claudebox
|
||||
|
||||
@ -36,3 +36,6 @@ resources:
|
||||
# Wildcard TLS for session preview URLs
|
||||
- preview-cert.yaml
|
||||
|
||||
# Citadel log agent (ships container logs to partner-hosted Citadel)
|
||||
- citadel-agent/
|
||||
|
||||
|
||||
@ -20,6 +20,9 @@ spec:
|
||||
app: rdev-api
|
||||
app.kubernetes.io/name: rdev-api
|
||||
app.kubernetes.io/part-of: rdev
|
||||
# Citadel agent routes these logs to the rdev-platform environment
|
||||
citadel.io/environment: rdev-platform
|
||||
citadel.io/service: rdev-api
|
||||
spec:
|
||||
serviceAccountName: rdev-api
|
||||
containers:
|
||||
|
||||
186
docs/citadel-integration.md
Normal file
186
docs/citadel-integration.md
Normal 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
45
examples/README.md
Normal 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
4
go.mod
@ -3,6 +3,7 @@ module github.com/orchard9/rdev
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
cloud.google.com/go/storage v1.59.2
|
||||
code.gitea.io/sdk/gitea v0.22.1
|
||||
github.com/bdpiprava/scalar-go v0.13.0
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
@ -18,6 +19,7 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.39.0
|
||||
go.opentelemetry.io/otel/trace v1.39.0
|
||||
go.woodpecker-ci.org/woodpecker/v3 v3.13.0
|
||||
google.golang.org/api v0.265.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
k8s.io/api v0.35.0
|
||||
k8s.io/apimachinery v0.35.0
|
||||
@ -32,7 +34,6 @@ require (
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.3 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.3 // indirect
|
||||
cloud.google.com/go/storage v1.59.2 // indirect
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
|
||||
@ -95,7 +96,6 @@ require (
|
||||
golang.org/x/term v0.39.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/api v0.265.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
|
||||
17
go.sum
17
go.sum
@ -10,10 +10,16 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
|
||||
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
|
||||
cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
|
||||
cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/storage v1.59.2 h1:gmOAuG1opU8YvycMNpP+DvHfT9BfzzK5Cy+arP+Nocw=
|
||||
cloud.google.com/go/storage v1.59.2/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
|
||||
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
|
||||
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
||||
code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA=
|
||||
code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||
@ -22,6 +28,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
@ -52,8 +60,11 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cu
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM=
|
||||
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
@ -86,6 +97,8 @@ github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7O
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
@ -179,6 +192,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNl
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
@ -237,8 +252,6 @@ google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb
|
||||
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
|
||||
161
internal/adapter/citadel/audit_shipper.go
Normal file
161
internal/adapter/citadel/audit_shipper.go
Normal 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"
|
||||
}
|
||||
}
|
||||
244
internal/adapter/citadel/client.go
Normal file
244
internal/adapter/citadel/client.go
Normal 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))
|
||||
}
|
||||
@ -156,6 +156,10 @@ func (d *Deployer) buildDeployment(spec domain.DeploySpec, ns string, replicas i
|
||||
if spec.ComponentPath != "" {
|
||||
labels["component"] = sanitizeLabelValue(spec.ComponentPath)
|
||||
}
|
||||
// Apply extra labels (e.g., citadel.io/environment for log routing)
|
||||
for k, v := range spec.ExtraLabels {
|
||||
labels[k] = v
|
||||
}
|
||||
|
||||
return &appsv1.Deployment{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
|
||||
341
internal/adapter/templates/components.go
Normal file
341
internal/adapter/templates/components.go
Normal 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
|
||||
})
|
||||
}
|
||||
@ -43,52 +43,6 @@ var skeletonTemplate = port.TemplateInfo{
|
||||
Stack: "monorepo",
|
||||
}
|
||||
|
||||
// availableComponentTemplates lists all supported component templates.
|
||||
var availableComponentTemplates = []port.ComponentTemplateInfo{
|
||||
{
|
||||
Type: "service",
|
||||
Description: "Go API service using pkg/ shared packages",
|
||||
Stack: "go",
|
||||
DefaultPort: 8080,
|
||||
DestDir: "services",
|
||||
},
|
||||
{
|
||||
Type: "worker",
|
||||
Description: "Go background worker for async job processing",
|
||||
Stack: "go",
|
||||
DefaultPort: 0, // Workers don't expose ports
|
||||
DestDir: "workers",
|
||||
},
|
||||
{
|
||||
Type: "app-astro",
|
||||
Description: "Astro landing page with Tailwind CSS",
|
||||
Stack: "astro",
|
||||
DefaultPort: 4321,
|
||||
DestDir: "apps",
|
||||
},
|
||||
{
|
||||
Type: "app-react",
|
||||
Description: "React SPA with Vite, TypeScript, and Tailwind",
|
||||
Stack: "react",
|
||||
DefaultPort: 5173,
|
||||
DestDir: "apps",
|
||||
},
|
||||
{
|
||||
Type: "app-nextjs",
|
||||
Description: "Next.js 14 dashboard with App Router and design system",
|
||||
Stack: "nextjs",
|
||||
DefaultPort: 3000,
|
||||
DestDir: "apps",
|
||||
},
|
||||
{
|
||||
Type: "cli",
|
||||
Description: "Go CLI tool using Cobra",
|
||||
Stack: "go",
|
||||
DefaultPort: 0, // CLIs don't expose ports
|
||||
DestDir: "cli",
|
||||
},
|
||||
}
|
||||
|
||||
// templateNameRegex validates template names (alphanumeric, dash only).
|
||||
var templateNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
|
||||
@ -323,172 +277,3 @@ func (p *Provider) GetSkeleton(ctx context.Context) (*port.TemplateInfo, error)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetComponentTemplate returns info about a specific component template.
|
||||
func (p *Provider) GetComponentTemplate(ctx context.Context, componentType string) (*port.ComponentTemplateInfo, error) {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
for _, t := range availableComponentTemplates {
|
||||
if t.Type == componentType {
|
||||
result := t
|
||||
files, err := listComponentTemplateFiles(componentType)
|
||||
if err == nil {
|
||||
result.Files = files
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType)
|
||||
}
|
||||
|
||||
// ListComponentTemplates returns available component templates.
|
||||
// If componentType is empty, returns all templates; otherwise filters by type.
|
||||
func (p *Provider) ListComponentTemplates(ctx context.Context, componentType string) ([]port.ComponentTemplateInfo, error) {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
var result []port.ComponentTemplateInfo
|
||||
for _, t := range availableComponentTemplates {
|
||||
if componentType != "" && t.Type != componentType {
|
||||
continue
|
||||
}
|
||||
info := t
|
||||
files, err := listComponentTemplateFiles(t.Type)
|
||||
if err == nil {
|
||||
info.Files = files
|
||||
}
|
||||
result = append(result, info)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetComponentFiles returns the files for a component template with variables interpolated.
|
||||
func (p *Provider) GetComponentFiles(ctx context.Context, componentType string, destPath string, vars map[string]string) ([]port.ComponentFile, error) {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Validate component type exists
|
||||
found := false
|
||||
for _, t := range availableComponentTemplates {
|
||||
if t.Type == componentType {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType)
|
||||
}
|
||||
|
||||
templateDir := "templates/components/" + componentType
|
||||
var files []port.ComponentFile
|
||||
|
||||
err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read file content
|
||||
content, err := templatesFS.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read component template file %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Interpolate variables
|
||||
interpolated := interpolateVars(string(content), vars)
|
||||
|
||||
// Calculate relative path from component template root
|
||||
relPath, err := filepath.Rel(templateDir, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get relative path: %w", err)
|
||||
}
|
||||
|
||||
// Strip .tmpl extension
|
||||
relPath = strings.TrimSuffix(relPath, ".tmpl")
|
||||
|
||||
// Prepend destination path
|
||||
fullPath := filepath.Join(destPath, relPath)
|
||||
|
||||
files = append(files, port.ComponentFile{
|
||||
Path: fullPath,
|
||||
Content: interpolated,
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to collect component template files: %w", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return nil, fmt.Errorf("component template %s contains no files", componentType)
|
||||
}
|
||||
|
||||
p.logger.Debug("prepared component files",
|
||||
"component_type", componentType,
|
||||
"dest_path", destPath,
|
||||
"file_count", len(files),
|
||||
)
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// listComponentTemplateFiles returns the list of files in a component template.
|
||||
func listComponentTemplateFiles(componentType string) ([]string, error) {
|
||||
templateDir := "templates/components/" + componentType
|
||||
var files []string
|
||||
|
||||
err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
relPath, err := filepath.Rel(templateDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Strip .tmpl extension for display
|
||||
relPath = strings.TrimSuffix(relPath, ".tmpl")
|
||||
files = append(files, relPath)
|
||||
return nil
|
||||
})
|
||||
|
||||
return files, err
|
||||
}
|
||||
|
||||
// GetComponentWoodpeckerStep returns the .woodpecker.step.yml content for a component.
|
||||
// This is the CI step that should be inserted into the main .woodpecker.yml file.
|
||||
func (p *Provider) GetComponentWoodpeckerStep(ctx context.Context, componentType string, vars map[string]string) (string, error) {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
stepPath := "templates/components/" + componentType + "/.woodpecker.step.yml.tmpl"
|
||||
content, err := templatesFS.ReadFile(stepPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read woodpecker step template: %w", err)
|
||||
}
|
||||
|
||||
return interpolateVars(string(content), vars), nil
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Enable React strict mode for better development experience
|
||||
reactStrictMode: true,
|
||||
|
||||
@ -11,9 +11,13 @@
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@{{PROJECT_NAME}}/logger": "workspace:*",
|
||||
"@{{PROJECT_NAME}}/ui": "workspace:*",
|
||||
"@{{PROJECT_NAME}}/ai-client": "workspace:*",
|
||||
"@{{PROJECT_NAME}}/api-client": "workspace:*",
|
||||
"@{{PROJECT_NAME}}/auth": "workspace:*",
|
||||
"@{{PROJECT_NAME}}/layout": "workspace:*",
|
||||
"@{{PROJECT_NAME}}/logger": "workspace:*",
|
||||
"@{{PROJECT_NAME}}/realtime": "workspace:*",
|
||||
"@{{PROJECT_NAME}}/ui": "workspace:*",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.23.1"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export default {
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
@ -1,4 +1,5 @@
|
||||
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth, ProtectedRoute } from '@{{PROJECT_NAME}}/auth';
|
||||
import { DashboardShell, Sidebar, Header, type NavItem } from '@{{PROJECT_NAME}}/layout';
|
||||
import {
|
||||
Button,
|
||||
@ -9,13 +10,24 @@ import {
|
||||
CardContent,
|
||||
Badge,
|
||||
Home,
|
||||
ImageIcon,
|
||||
Users,
|
||||
Settings,
|
||||
BarChart3,
|
||||
MessageSquare,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
} from '@{{PROJECT_NAME}}/ui';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { ChatPage } from './pages/ChatPage';
|
||||
import { GeneratePage } from './pages/GeneratePage';
|
||||
import { MediaPage } from './pages/MediaPage';
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: 'Dashboard', href: '/', icon: Home },
|
||||
{ label: 'Chat', href: '/chat', icon: MessageSquare },
|
||||
{ label: 'Generate', href: '/generate', icon: Sparkles },
|
||||
{ label: 'Media', href: '/media', icon: ImageIcon },
|
||||
{ label: 'Analytics', href: '/analytics', icon: BarChart3 },
|
||||
{ label: 'Users', href: '/users', icon: Users, badge: '12' },
|
||||
{ label: 'Settings', href: '/settings', icon: Settings },
|
||||
@ -23,6 +35,9 @@ const navItems: NavItem[] = [
|
||||
|
||||
const pageTitles: Record<string, string> = {
|
||||
'/': 'Dashboard',
|
||||
'/chat': 'Chat',
|
||||
'/generate': 'Generate',
|
||||
'/media': 'Media',
|
||||
'/analytics': 'Analytics',
|
||||
'/users': 'Users',
|
||||
'/settings': 'Settings',
|
||||
@ -195,6 +210,14 @@ function AnalyticsPage() {
|
||||
}
|
||||
|
||||
function SettingsPage() {
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
@ -218,6 +241,22 @@ function SettingsPage() {
|
||||
</CardContent>
|
||||
</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>
|
||||
<CardHeader>
|
||||
<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 navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const itemsWithActive = navItems.map((item) => ({
|
||||
...item,
|
||||
@ -259,7 +310,7 @@ function App() {
|
||||
onNavigate={(href) => navigate(href)}
|
||||
footer={
|
||||
<div className="text-sm text-[var(--text-muted)]">
|
||||
v0.0.1
|
||||
{user?.email || 'v0.0.1'}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@ -274,6 +325,9 @@ function App() {
|
||||
>
|
||||
<Routes>
|
||||
<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="/analytics" element={<AnalyticsPage />} />
|
||||
<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;
|
||||
|
||||
@ -7,7 +7,12 @@ import './lib/logger';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
@ -12,3 +13,5 @@ export default {
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@ -6,6 +6,27 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: {{PORT}},
|
||||
proxy: {
|
||||
// SSE events endpoint — must disable buffering for streaming
|
||||
'/api/{{SERVICE_NAME}}/events': {
|
||||
target: 'http://localhost:{{SERVICE_PORT}}',
|
||||
changeOrigin: true,
|
||||
// Disable response buffering so SSE events stream immediately
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyRes', (proxyRes) => {
|
||||
// Prevent Vite from buffering SSE responses
|
||||
if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
|
||||
proxyRes.headers['cache-control'] = 'no-cache';
|
||||
proxyRes.headers['x-accel-buffering'] = 'no';
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:{{SERVICE_PORT}}',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
port: {{PORT}},
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
.PHONY: build run test lint fmt docker-build clean
|
||||
.PHONY: build run dev test lint fmt docker-build clean
|
||||
|
||||
SERVICE := {{COMPONENT_NAME}}
|
||||
BINARY := bin/$(SERVICE)
|
||||
@ -12,6 +12,10 @@ build:
|
||||
run:
|
||||
go run ./cmd/server
|
||||
|
||||
# Run the service in development mode (alias for run)
|
||||
dev:
|
||||
go run ./cmd/server
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
@ -2,14 +2,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"{{GO_MODULE}}/pkg/app"
|
||||
"{{GO_MODULE}}/pkg/database"
|
||||
"{{GO_MODULE}}/pkg/gemini"
|
||||
"{{GO_MODULE}}/pkg/laozhang"
|
||||
"{{GO_MODULE}}/pkg/logging"
|
||||
"{{GO_MODULE}}/pkg/mediagen"
|
||||
mediagenAdapters "{{GO_MODULE}}/pkg/mediagen/adapters"
|
||||
"{{GO_MODULE}}/pkg/generation"
|
||||
"{{GO_MODULE}}/pkg/queue"
|
||||
"{{GO_MODULE}}/pkg/realtime"
|
||||
"{{GO_MODULE}}/pkg/storage"
|
||||
"{{GO_MODULE}}/pkg/textgen"
|
||||
textgenAdapters "{{GO_MODULE}}/pkg/textgen/adapters"
|
||||
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/memory"
|
||||
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api"
|
||||
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/config"
|
||||
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service"
|
||||
)
|
||||
|
||||
@ -30,21 +46,247 @@ func main() {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Load config
|
||||
cfg := config.Load()
|
||||
|
||||
// Create logger
|
||||
logger := logging.Default()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create SSE hub for async event delivery (generation progress, chat, etc.)
|
||||
sseHub := realtime.NewSSEHub(logger.Logger)
|
||||
|
||||
// Initialize storage backend (before queue, since standalone queue handlers use it).
|
||||
// GCS_BUCKET set = production (GCS). Otherwise = dev (in-memory).
|
||||
port := fmt.Sprintf("%d", {{PORT}})
|
||||
var mediaStore storage.Store
|
||||
if bucket := os.Getenv("GCS_BUCKET"); bucket != "" {
|
||||
gcsStore, err := storage.NewGCSStore(bucket, os.Getenv("GCS_SERVICE_ACCOUNT_JSON"), logger.Logger)
|
||||
if err != nil {
|
||||
logger.Error("failed to create GCS store", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = gcsStore.Close() }()
|
||||
mediaStore = gcsStore
|
||||
logger.Info("storage initialized (GCS)", "bucket", bucket)
|
||||
} else {
|
||||
memStore := storage.NewMemoryStore("http://localhost:" + port + "/storage")
|
||||
mediaStore = memStore
|
||||
logger.Info("storage initialized (in-memory dev mode)")
|
||||
}
|
||||
|
||||
// Select queue backend based on DATABASE_URL availability.
|
||||
// With DATABASE_URL: DB queue + separate worker process (production)
|
||||
// Without DATABASE_URL: in-memory queue + in-process handlers (development)
|
||||
var jobQueue queue.Producer
|
||||
if cfg.Database.URL != "" {
|
||||
jobQueue = setupDBQueue(ctx, cfg, sseHub, logger)
|
||||
} else {
|
||||
logger.Info("DATABASE_URL not set — running in standalone mode (in-memory queue + in-process AI)")
|
||||
jobQueue = setupStandaloneQueue(ctx, mediaStore, sseHub, logger)
|
||||
}
|
||||
|
||||
// Create adapters (repositories)
|
||||
exampleRepo := memory.NewExampleRepository()
|
||||
userRepo := memory.NewUserRepository()
|
||||
|
||||
// Create services (business logic)
|
||||
exampleService := service.NewExampleService(exampleRepo, logger)
|
||||
authService := service.NewAuthService(userRepo, cfg.JWTSecret, logger)
|
||||
|
||||
// Create application
|
||||
application := app.New("{{COMPONENT_NAME}}", app.WithDefaultPort({{PORT}}))
|
||||
|
||||
// Mount in-memory storage HTTP handler for dev mode
|
||||
if memStore, ok := mediaStore.(*storage.MemoryStore); ok {
|
||||
application.Router().Handle("/storage/*", memStore)
|
||||
}
|
||||
|
||||
// Register routes with dependency injection
|
||||
api.RegisterRoutes(application, exampleService)
|
||||
api.RegisterRoutes(application, &api.Dependencies{
|
||||
ExampleService: exampleService,
|
||||
AuthService: authService,
|
||||
Queue: jobQueue,
|
||||
SSEHub: sseHub,
|
||||
Store: mediaStore,
|
||||
})
|
||||
|
||||
// Start server
|
||||
application.Run()
|
||||
}
|
||||
|
||||
// setupDBQueue initializes the production queue backend with database + optional Redis.
|
||||
func setupDBQueue(ctx context.Context, cfg *config.Config, sseHub *realtime.SSEHub, logger *logging.Logger) queue.Producer {
|
||||
pool, err := database.Connect(ctx, cfg.Database.URL, database.Options{
|
||||
MaxOpenConns: cfg.Database.MaxOpenConns,
|
||||
MaxIdleConns: cfg.Database.MaxIdleConns,
|
||||
ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("failed to connect to database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Note: pool is not deferred here since it's needed for the lifetime of the process.
|
||||
// The OS reclaims resources on exit.
|
||||
logger.Info("connected to database")
|
||||
|
||||
if err := queue.RunMigrations(ctx, pool); err != nil {
|
||||
logger.Error("failed to run queue migrations", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("queue migrations complete")
|
||||
|
||||
jobQueue := queue.NewQueue(pool.DB, logger)
|
||||
|
||||
// Start Redis SSE subscriber if configured.
|
||||
if cfg.RedisURL != "" {
|
||||
opts, err := redis.ParseURL(cfg.RedisURL)
|
||||
if err != nil {
|
||||
logger.Error("failed to parse REDIS_URL", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
redisClient := redis.NewClient(opts)
|
||||
if err := redisClient.Ping(ctx).Err(); err != nil {
|
||||
logger.Error("failed to connect to Redis", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("connected to Redis")
|
||||
|
||||
go func() {
|
||||
if err := realtime.RunSSESubscriber(ctx, redisClient, sseHub, logger.Logger); err != nil {
|
||||
logger.Error("SSE Redis subscriber stopped", "error", err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
logger.Warn("REDIS_URL not set — SSE events from worker will not be delivered")
|
||||
}
|
||||
|
||||
return jobQueue
|
||||
}
|
||||
|
||||
// setupStandaloneQueue initializes an in-memory queue with in-process AI handlers.
|
||||
// This mode requires no database or Redis — everything runs in a single process.
|
||||
func setupStandaloneQueue(ctx context.Context, store storage.Store, sseHub *realtime.SSEHub, logger *logging.Logger) queue.Producer {
|
||||
memQueue := queue.NewMemoryQueue(logger.Logger)
|
||||
|
||||
// LocalPublisher delivers events directly to the SSE hub (no Redis needed).
|
||||
pub := realtime.NewLocalPublisher(sseHub)
|
||||
|
||||
// Initialize AI providers
|
||||
mediagenManager := initMediagen(ctx, logger)
|
||||
textgenManager := initTextgen(ctx, logger)
|
||||
|
||||
// Register job handlers (same handlers the worker uses).
|
||||
if mediagenManager != nil {
|
||||
memQueue.RegisterHandler("generate_image", generation.ImageHandler(mediagenManager, store, pub, logger))
|
||||
memQueue.RegisterHandler("generate_video", generation.VideoHandler(mediagenManager, store, pub, logger))
|
||||
}
|
||||
if textgenManager != nil {
|
||||
memQueue.RegisterHandler("generate_text", generation.TextHandler(textgenManager, pub, logger))
|
||||
memQueue.RegisterHandler("ai_chat_response", generation.ChatResponseHandler(textgenManager, pub, logger))
|
||||
}
|
||||
|
||||
return memQueue
|
||||
}
|
||||
|
||||
// initMediagen creates a mediagen manager from available AI provider credentials.
|
||||
func initMediagen(ctx context.Context, logger *logging.Logger) *mediagen.Manager {
|
||||
var laozhangMediaProvider *mediagenAdapters.LaoZhangProvider
|
||||
var geminiMediaProvider *mediagenAdapters.GeminiProvider
|
||||
|
||||
if apiKey := os.Getenv("LAOZHANG_API_KEY"); apiKey != "" {
|
||||
client, err := laozhang.NewClient(laozhang.Config{
|
||||
APIKey: apiKey,
|
||||
VideoTimeout: 5 * time.Minute,
|
||||
Logger: logger.Logger,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("failed to create LaoZhang client", "error", err)
|
||||
} else {
|
||||
laozhangMediaProvider = mediagenAdapters.NewLaoZhangProvider(client)
|
||||
logger.Info("LaoZhang media provider initialized")
|
||||
}
|
||||
}
|
||||
|
||||
if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
|
||||
client, err := gemini.NewClient(ctx, gemini.Config{
|
||||
APIKey: apiKey,
|
||||
Logger: logger.Logger,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("failed to create Gemini client", "error", err)
|
||||
} else {
|
||||
geminiMediaProvider = mediagenAdapters.NewGeminiProvider(client)
|
||||
logger.Info("Gemini media provider initialized")
|
||||
}
|
||||
}
|
||||
|
||||
if laozhangMediaProvider == nil && geminiMediaProvider == nil {
|
||||
logger.Warn("no media generation providers available (set LAOZHANG_API_KEY or GEMINI_API_KEY)")
|
||||
return nil
|
||||
}
|
||||
|
||||
mgCfg := mediagen.ProductionConfig(mediagen.ProviderSet{
|
||||
LaoZhang: laozhangMediaProvider,
|
||||
Gemini: geminiMediaProvider,
|
||||
}, mediagen.WithLogger(logger.Logger))
|
||||
if laozhangMediaProvider != nil {
|
||||
mgCfg.VideoProviders = append(mgCfg.VideoProviders, laozhangMediaProvider)
|
||||
}
|
||||
if geminiMediaProvider != nil {
|
||||
mgCfg.VideoProviders = append(mgCfg.VideoProviders, geminiMediaProvider)
|
||||
}
|
||||
|
||||
mgr, err := mediagen.NewManager(mgCfg)
|
||||
if err != nil {
|
||||
logger.Warn("failed to create mediagen manager", "error", err)
|
||||
return nil
|
||||
}
|
||||
logger.Info("mediagen manager initialized (image + video)")
|
||||
return mgr
|
||||
}
|
||||
|
||||
// initTextgen creates a textgen manager from available AI provider credentials.
|
||||
func initTextgen(ctx context.Context, logger *logging.Logger) *textgen.Manager {
|
||||
var textProviders []textgen.TextGenerator
|
||||
|
||||
if apiKey := os.Getenv("LAOZHANG_API_KEY"); apiKey != "" {
|
||||
client, err := laozhang.NewClient(laozhang.Config{
|
||||
APIKey: apiKey,
|
||||
Logger: logger.Logger,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("failed to create LaoZhang text client", "error", err)
|
||||
} else {
|
||||
textProviders = append(textProviders, textgenAdapters.NewLaoZhangTextProvider(client, ""))
|
||||
}
|
||||
}
|
||||
|
||||
if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
|
||||
provider, err := textgenAdapters.NewGeminiTextProvider(ctx, textgenAdapters.GeminiTextConfig{
|
||||
APIKey: apiKey,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("failed to create Gemini text provider", "error", err)
|
||||
} else {
|
||||
textProviders = append(textProviders, provider)
|
||||
}
|
||||
}
|
||||
|
||||
if len(textProviders) == 0 {
|
||||
logger.Warn("no text generation providers available")
|
||||
return nil
|
||||
}
|
||||
|
||||
tgCfg := textgen.ProductionConfig(textgen.ProviderSet{}, textgen.WithLogger(logger.Logger))
|
||||
tgCfg.Providers = textProviders
|
||||
|
||||
mgr, err := textgen.NewManager(tgCfg)
|
||||
if err != nil {
|
||||
logger.Warn("failed to create textgen manager", "error", err)
|
||||
return nil
|
||||
}
|
||||
logger.Info("textgen manager initialized")
|
||||
return mgr
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -4,6 +4,9 @@ package api
|
||||
import (
|
||||
"{{GO_MODULE}}/pkg/app"
|
||||
"{{GO_MODULE}}/pkg/auth"
|
||||
"{{GO_MODULE}}/pkg/queue"
|
||||
"{{GO_MODULE}}/pkg/realtime"
|
||||
"{{GO_MODULE}}/pkg/storage"
|
||||
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api/handlers"
|
||||
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/config"
|
||||
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service"
|
||||
@ -14,23 +17,52 @@ import (
|
||||
// This allows the monorepo to expose multiple services under a single domain:
|
||||
// - https://domain/api/{{COMPONENT_NAME}}/health
|
||||
// - https://domain/api/{{COMPONENT_NAME}}/examples
|
||||
func RegisterRoutes(application *app.App, exampleService *service.ExampleService) {
|
||||
// - https://domain/api/{{COMPONENT_NAME}}/events?channel=user:123 (SSE)
|
||||
func RegisterRoutes(application *app.App, deps *Dependencies) {
|
||||
logger := application.Logger()
|
||||
cfg := config.Load()
|
||||
|
||||
// Initialize handlers with injected services
|
||||
healthHandler := handlers.NewHealth(logger)
|
||||
exampleHandler := handlers.NewExample(exampleService, logger)
|
||||
exampleHandler := handlers.NewExample(deps.ExampleService, logger)
|
||||
authHandler := handlers.NewAuth(deps.AuthService, logger)
|
||||
generateHandler := handlers.NewGenerate(deps.Queue, deps.SSEHub, logger)
|
||||
chatHandler := handlers.NewChat(deps.Queue, deps.SSEHub, logger)
|
||||
mediaHandler := handlers.NewMedia(deps.Store, logger)
|
||||
|
||||
// Build and mount OpenAPI spec
|
||||
spec := NewServiceSpec()
|
||||
application.EnableDocs(spec)
|
||||
|
||||
// JWT validator for protected routes
|
||||
jwtValidator := auth.NewJWTValidator(auth.JWTConfig{
|
||||
Secret: []byte(cfg.JWTSecret),
|
||||
Issuer: "{{PROJECT_NAME}}",
|
||||
})
|
||||
|
||||
// Register API routes under /api/{service-name} to match ingress path routing.
|
||||
// The ingress routes /api/{{COMPONENT_NAME}}/* to this service.
|
||||
application.Route("/api/{{COMPONENT_NAME}}", func(r app.Router) {
|
||||
r.Get("/health", healthHandler.Check)
|
||||
|
||||
// ----- Auth routes -----
|
||||
// Public auth routes
|
||||
r.Post("/auth/login", app.Wrap(authHandler.Login))
|
||||
r.Post("/auth/logout", app.Wrap(authHandler.Logout))
|
||||
|
||||
// Protected auth routes
|
||||
r.Group(func(r app.Router) {
|
||||
r.Use(auth.Middleware(auth.MiddlewareConfig{
|
||||
Validator: jwtValidator,
|
||||
}))
|
||||
r.Get("/auth/me", app.Wrap(authHandler.Me))
|
||||
})
|
||||
|
||||
// ----- SSE Events -----
|
||||
// Server-Sent Events for async job updates (generation progress, etc.)
|
||||
r.Mount("/events", generateHandler.Events())
|
||||
|
||||
// ----- Example routes -----
|
||||
// Public routes (no auth required)
|
||||
r.Get("/examples", app.Wrap(exampleHandler.List))
|
||||
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
|
||||
@ -39,10 +71,7 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
|
||||
r.Group(func(r app.Router) {
|
||||
if cfg.AuthEnabled {
|
||||
r.Use(auth.Middleware(auth.MiddlewareConfig{
|
||||
Validator: auth.NewJWTValidator(auth.JWTConfig{
|
||||
Secret: []byte(cfg.JWTSecret),
|
||||
Issuer: "{{PROJECT_NAME}}",
|
||||
}),
|
||||
Validator: jwtValidator,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -50,5 +79,34 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
|
||||
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update))
|
||||
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
|
||||
})
|
||||
|
||||
// ----- Chat + Generate + Media routes (auth required) -----
|
||||
// Auth is required because SSE events are delivered to user:<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
|
||||
}
|
||||
|
||||
@ -18,6 +18,9 @@ type Config struct {
|
||||
// Auth
|
||||
AuthEnabled bool
|
||||
JWTSecret string
|
||||
|
||||
// Redis for cross-process SSE event delivery
|
||||
RedisURL string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables.
|
||||
@ -30,5 +33,6 @@ func Load() *Config {
|
||||
|
||||
AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
RedisURL: os.Getenv("REDIS_URL"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
.PHONY: build run test lint fmt docker-build clean
|
||||
.PHONY: build run dev test lint fmt docker-build clean
|
||||
|
||||
WORKER := {{COMPONENT_NAME}}
|
||||
BINARY := bin/$(WORKER)
|
||||
@ -12,6 +12,10 @@ build:
|
||||
run:
|
||||
go run ./cmd/worker
|
||||
|
||||
# Run the worker in development mode (alias for run)
|
||||
dev:
|
||||
go run ./cmd/worker
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
@ -3,22 +3,28 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"{{GO_MODULE}}/pkg/database"
|
||||
"{{GO_MODULE}}/pkg/gemini"
|
||||
"{{GO_MODULE}}/pkg/laozhang"
|
||||
"{{GO_MODULE}}/pkg/logging"
|
||||
"{{GO_MODULE}}/pkg/mediagen"
|
||||
mediagenAdapters "{{GO_MODULE}}/pkg/mediagen/adapters"
|
||||
"{{GO_MODULE}}/pkg/queue"
|
||||
"{{GO_MODULE}}/pkg/realtime"
|
||||
"{{GO_MODULE}}/pkg/storage"
|
||||
"{{GO_MODULE}}/pkg/textgen"
|
||||
textgenAdapters "{{GO_MODULE}}/pkg/textgen/adapters"
|
||||
"{{GO_MODULE}}/workers/{{COMPONENT_NAME}}/internal/config"
|
||||
"{{GO_MODULE}}/workers/{{COMPONENT_NAME}}/internal/handlers"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
func main() {
|
||||
// Initialize logger first (with defaults) so we can log config errors
|
||||
logger := logging.New(logging.Config{
|
||||
@ -60,24 +66,156 @@ func main() {
|
||||
defer pool.Close()
|
||||
logger.Info("connected to database", "url", pool.URL)
|
||||
|
||||
// Run migrations
|
||||
database.MustRunMigrations(ctx, pool, migrationsFS, "migrations")
|
||||
logger.Info("migrations complete")
|
||||
// Run queue migrations (idempotent — safe for both service and worker)
|
||||
if err := queue.RunMigrations(ctx, pool); err != nil {
|
||||
logger.Error("failed to run queue migrations", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("queue migrations complete")
|
||||
|
||||
// Initialize queue
|
||||
jobQueue := queue.NewPostgresQueue(pool.DB, logger)
|
||||
jobQueue := queue.NewQueue(pool.DB, logger)
|
||||
|
||||
// Initialize Redis for SSE event publishing
|
||||
if cfg.RedisURL == "" {
|
||||
logger.Error("REDIS_URL is required for worker to publish SSE events")
|
||||
os.Exit(1)
|
||||
}
|
||||
redisOpts, err := redis.ParseURL(cfg.RedisURL)
|
||||
if err != nil {
|
||||
logger.Error("failed to parse REDIS_URL", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
redisClient := redis.NewClient(redisOpts)
|
||||
if err := redisClient.Ping(ctx).Err(); err != nil {
|
||||
logger.Error("failed to connect to Redis", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("connected to Redis")
|
||||
|
||||
ssePub := realtime.NewSSEPublisher(redisClient, logger.Logger)
|
||||
|
||||
// Initialize AI providers
|
||||
// LaoZhang client (primary provider — pay-per-use, OpenAI-compatible)
|
||||
var laozhangClient *laozhang.Client
|
||||
if apiKey := os.Getenv("LAOZHANG_API_KEY"); apiKey != "" {
|
||||
laozhangClient, err = laozhang.NewClient(laozhang.Config{
|
||||
APIKey: apiKey,
|
||||
VideoTimeout: 5 * time.Minute,
|
||||
Logger: logger.Logger,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("failed to create LaoZhang client", "error", err)
|
||||
} else {
|
||||
logger.Info("LaoZhang client initialized")
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini client for media generation
|
||||
var geminiClient *gemini.Client
|
||||
if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
|
||||
geminiClient, err = gemini.NewClient(ctx, gemini.Config{
|
||||
APIKey: apiKey,
|
||||
Logger: logger.Logger,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("failed to create Gemini client", "error", err)
|
||||
} else {
|
||||
logger.Info("Gemini client initialized")
|
||||
}
|
||||
}
|
||||
|
||||
// Create mediagen manager (image + video)
|
||||
var mediagenManager *mediagen.Manager
|
||||
{
|
||||
var laozhangMediaProvider *mediagenAdapters.LaoZhangProvider
|
||||
var geminiMediaProvider *mediagenAdapters.GeminiProvider
|
||||
if laozhangClient != nil {
|
||||
laozhangMediaProvider = mediagenAdapters.NewLaoZhangProvider(laozhangClient)
|
||||
}
|
||||
if geminiClient != nil {
|
||||
geminiMediaProvider = mediagenAdapters.NewGeminiProvider(geminiClient)
|
||||
}
|
||||
|
||||
if geminiMediaProvider != nil || laozhangMediaProvider != nil {
|
||||
mgCfg := mediagen.ProductionConfig(mediagen.ProviderSet{
|
||||
LaoZhang: laozhangMediaProvider,
|
||||
Gemini: geminiMediaProvider,
|
||||
}, mediagen.WithLogger(logger.Logger))
|
||||
if laozhangMediaProvider != nil {
|
||||
mgCfg.VideoProviders = append(mgCfg.VideoProviders, laozhangMediaProvider)
|
||||
}
|
||||
if geminiMediaProvider != nil {
|
||||
mgCfg.VideoProviders = append(mgCfg.VideoProviders, geminiMediaProvider)
|
||||
}
|
||||
mediagenManager, err = mediagen.NewManager(mgCfg)
|
||||
if err != nil {
|
||||
logger.Warn("failed to create mediagen manager", "error", err)
|
||||
} else {
|
||||
logger.Info("mediagen manager initialized (image + video)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create textgen manager (text + streaming)
|
||||
var textgenManager *textgen.Manager
|
||||
{
|
||||
var textProviders []textgen.TextGenerator
|
||||
if laozhangClient != nil {
|
||||
textProviders = append(textProviders, textgenAdapters.NewLaoZhangTextProvider(laozhangClient, ""))
|
||||
}
|
||||
if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
|
||||
geminiTextProvider, err := textgenAdapters.NewGeminiTextProvider(ctx, textgenAdapters.GeminiTextConfig{
|
||||
APIKey: apiKey,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("failed to create gemini text provider", "error", err)
|
||||
} else {
|
||||
textProviders = append(textProviders, geminiTextProvider)
|
||||
}
|
||||
}
|
||||
if len(textProviders) > 0 {
|
||||
textgenCfg := textgen.ProductionConfig(textgen.ProviderSet{}, textgen.WithLogger(logger.Logger))
|
||||
textgenCfg.Providers = textProviders
|
||||
textgenManager, err = textgen.NewManager(textgenCfg)
|
||||
if err != nil {
|
||||
logger.Warn("failed to create textgen manager", "error", err)
|
||||
} else {
|
||||
logger.Info("textgen manager initialized")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize and start handler
|
||||
handler := handlers.New(logger, jobQueue, handlers.Config{
|
||||
PollInterval: cfg.Worker.PollInterval,
|
||||
StaleJobTimeout: cfg.Worker.StaleJobTimeout,
|
||||
JobTimeout: cfg.Worker.JobTimeout,
|
||||
PollInterval: cfg.Worker.PollInterval,
|
||||
StaleJobTimeout: cfg.Worker.StaleJobTimeout,
|
||||
JobTimeout: cfg.Worker.JobTimeout,
|
||||
})
|
||||
|
||||
// Initialize storage backend for persisting generated media.
|
||||
// GCS_BUCKET is injected by the platform; if absent, store is nil (media not persisted).
|
||||
var mediaStore storage.Store
|
||||
if bucket := os.Getenv("GCS_BUCKET"); bucket != "" {
|
||||
gcsStore, err := storage.NewGCSStore(bucket, os.Getenv("GCS_SERVICE_ACCOUNT_JSON"), logger.Logger)
|
||||
if err != nil {
|
||||
logger.Warn("failed to create GCS store, generated media will not be persisted", "error", err)
|
||||
} else {
|
||||
defer func() { _ = gcsStore.Close() }()
|
||||
mediaStore = gcsStore
|
||||
logger.Info("storage initialized (GCS)", "bucket", bucket)
|
||||
}
|
||||
}
|
||||
|
||||
// Register job handlers
|
||||
// TODO: Register your job handlers here
|
||||
// handler.RegisterHandler("send_email", emailHandler)
|
||||
// handler.RegisterHandler("process_image", imageHandler)
|
||||
if mediagenManager != nil {
|
||||
handler.RegisterHandler("generate_image", handlers.ImageHandler(mediagenManager, mediaStore, ssePub, logger))
|
||||
handler.RegisterHandler("generate_video", handlers.VideoHandler(mediagenManager, mediaStore, ssePub, logger))
|
||||
}
|
||||
if textgenManager != nil {
|
||||
handler.RegisterHandler("generate_text", handlers.TextHandler(textgenManager, ssePub, logger))
|
||||
handler.RegisterHandler("ai_chat_response", handlers.ChatResponseHandler(textgenManager, ssePub, logger))
|
||||
}
|
||||
|
||||
// Setup signal handling
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
@ -98,7 +236,6 @@ func main() {
|
||||
cancel()
|
||||
|
||||
// Give in-flight jobs time to complete (grace period)
|
||||
// This allows handlers to notice context cancellation and finish cleanly.
|
||||
const shutdownGracePeriod = 5 * time.Second
|
||||
time.Sleep(shutdownGracePeriod)
|
||||
|
||||
@ -106,7 +243,7 @@ func main() {
|
||||
}
|
||||
|
||||
// runStaleJobRecovery periodically requeues jobs that have been running too long.
|
||||
func runStaleJobRecovery(ctx context.Context, q *queue.PostgresQueue, timeout time.Duration, logger *logging.Logger) {
|
||||
func runStaleJobRecovery(ctx context.Context, q *queue.DBQueue, timeout time.Duration, logger *logging.Logger) {
|
||||
const staleCheckInterval = time.Minute
|
||||
ticker := time.NewTicker(staleCheckInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
@ -15,6 +16,9 @@ type Config struct {
|
||||
Database config.DatabaseConfig
|
||||
Logging config.LoggingConfig
|
||||
Worker WorkerConfig
|
||||
|
||||
// Redis for publishing SSE events to the service
|
||||
RedisURL string
|
||||
}
|
||||
|
||||
// WorkerConfig holds worker-specific settings.
|
||||
@ -62,5 +66,6 @@ func Load() (*Config, error) {
|
||||
StaleJobTimeout: viper.GetDuration("WORKER_STALE_JOB_TIMEOUT"),
|
||||
JobTimeout: viper.GetDuration("WORKER_JOB_TIMEOUT"),
|
||||
},
|
||||
RedisURL: os.Getenv("REDIS_URL"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -1,40 +1,42 @@
|
||||
---
|
||||
name: realtime-specialist
|
||||
description: WebSocket and real-time communication patterns for {{PROJECT_NAME}} - connection management, room-based broadcasting, Redis pub/sub scaling
|
||||
description: SSE and real-time communication patterns for {{PROJECT_NAME}} - HTTP2 POST for input, SSE for output, Redis pub/sub for scaling
|
||||
color: cyan
|
||||
---
|
||||
|
||||
# Realtime Specialist
|
||||
|
||||
You design and implement real-time communication features for {{PROJECT_NAME}} using pkg/realtime. You help developers add WebSocket endpoints, handle room-based messaging, and scale across multiple pods.
|
||||
You design and implement real-time communication features for {{PROJECT_NAME}} using HTTP2 + SSE. You help developers add event streams, handle channel-based messaging, and scale across multiple pods.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- **NO WEBSOCKETS. EVER.** All real-time communication uses HTTP2 + SSE.
|
||||
- **User → Server:** HTTP2 POST/PUT/DELETE. Standard REST endpoints.
|
||||
- **Server → User:** SSE only. One-way event stream.
|
||||
- **Event flow:** `server → redis → redis listeners → SSE hub → user`
|
||||
|
||||
## When to Use
|
||||
|
||||
- Adding WebSocket endpoints to a service
|
||||
- Implementing chat or notification features
|
||||
- Broadcasting messages to connected clients
|
||||
- Adding SSE endpoints to a service
|
||||
- Implementing chat, notifications, or progress features
|
||||
- Broadcasting events to connected clients
|
||||
- Scaling real-time features across multiple pods
|
||||
- Handling client reconnection and presence
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Redis Pub/Sub │
|
||||
└─────────────┬───────────┬───────────┘
|
||||
│ │
|
||||
┌───────────────────────┼───────────┼───────────────────────┐
|
||||
│ │ │ │
|
||||
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
|
||||
│ Pod A │ │ Pod B │ │ Pod C │
|
||||
│ │ │ │ │ │
|
||||
│ ┌───────┐ │ │ ┌───────┐ │ │ ┌───────┐ │
|
||||
│ │ Hub │ │ │ │ Hub │ │ │ │ Hub │ │
|
||||
│ └───┬───┘ │ │ └───┬───┘ │ │ └───┬───┘ │
|
||||
│ │ │ │ │ │ │ │ │
|
||||
│ ┌───▼───┐ │ │ ┌───▼───┐ │ │ ┌───▼───┐ │
|
||||
│ │Clients│ │ │ │Clients│ │ │ │Clients│ │
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
┌─────────────┐ HTTP2 POST ┌─────────────┐ publish ┌─────────────┐
|
||||
│ Browser │ ───────────────▶│ API │ ────────────▶│ Redis │
|
||||
│ │ │ Handler │ │ Pub/Sub │
|
||||
│ │ └─────────────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
│ │ subscribe
|
||||
│ │ │
|
||||
│ │ ┌──────▼──────┐
|
||||
│ │ SSE stream ┌─────────────┐ notify │ Redis │
|
||||
│ │ ◀───────────────│ SSE Hub │ ◀────────────│ Listener │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@ -45,15 +47,14 @@ You design and implement real-time communication features for {{PROJECT_NAME}} u
|
||||
func main() {
|
||||
logger := logging.NewDevelopment()
|
||||
|
||||
// Create hub
|
||||
hub := realtime.NewHub(logger)
|
||||
go hub.Run(ctx)
|
||||
// Create SSE hub
|
||||
sseHub := realtime.NewSSEHub(logger)
|
||||
|
||||
// Create handler (no Redis needed for single pod)
|
||||
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{})
|
||||
// Create handler
|
||||
sseHandler := realtime.NewSSEHandler(sseHub, logger)
|
||||
|
||||
// Mount on router
|
||||
r.Mount("/ws", wsHandler.Routes())
|
||||
r.Mount("/api/events", sseHandler.Routes())
|
||||
}
|
||||
```
|
||||
|
||||
@ -63,160 +64,212 @@ func main() {
|
||||
func main() {
|
||||
logger := logging.NewProduction()
|
||||
|
||||
// Create hub
|
||||
hub := realtime.NewHub(logger)
|
||||
go hub.Run(ctx)
|
||||
// Create SSE hub
|
||||
sseHub := realtime.NewSSEHub(logger)
|
||||
|
||||
// Create Redis broadcaster for cross-pod messaging
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: os.Getenv("REDIS_URL")})
|
||||
broadcaster := realtime.NewRedisBroadcaster(redisClient, hub, logger)
|
||||
broadcaster := realtime.NewRedisBroadcaster(redisClient, sseHub, logger)
|
||||
go broadcaster.Run(ctx)
|
||||
|
||||
// Create handler with broadcaster
|
||||
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{
|
||||
Broadcaster: broadcaster,
|
||||
})
|
||||
sseHandler := realtime.NewSSEHandler(sseHub, logger)
|
||||
|
||||
r.Mount("/ws", wsHandler.Routes())
|
||||
r.Mount("/api/events", sseHandler.Routes())
|
||||
}
|
||||
```
|
||||
|
||||
## Message Protocol
|
||||
## Channel Types
|
||||
|
||||
Messages use JSON format:
|
||||
| Pattern | Use For | Example |
|
||||
|---------|---------|---------|
|
||||
| `user:<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
|
||||
{
|
||||
"id": "uuid",
|
||||
"type": "chat",
|
||||
"room": "general",
|
||||
"from": "client-id",
|
||||
"data": { "text": "Hello world" },
|
||||
"timestamp": "2024-01-15T10:30:00Z"
|
||||
"timestamp": "2024-01-15T10:30:00Z",
|
||||
"userId": "u_abc123",
|
||||
"content": "Hello world"
|
||||
}
|
||||
```
|
||||
|
||||
### Message Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `chat` | User-generated chat message |
|
||||
| `presence` | User online/offline/away status |
|
||||
| `notification` | System notification to user |
|
||||
| `system` | Broadcast from server |
|
||||
| `error` | Error response to client |
|
||||
| `ping` / `pong` | Application-level keepalive |
|
||||
|
||||
## Patterns
|
||||
|
||||
### Room-Based Chat
|
||||
### Chat Room
|
||||
|
||||
```go
|
||||
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{
|
||||
OnConnect: func(conn realtime.Connection) {
|
||||
// Notify room of new member
|
||||
msg, _ := realtime.SystemMessage("presence", realtime.PresenceData{
|
||||
Status: realtime.PresenceOnline,
|
||||
UserID: conn.UserID(),
|
||||
})
|
||||
hub.Broadcast(msg)
|
||||
},
|
||||
OnDisconnect: func(conn realtime.Connection) {
|
||||
msg, _ := realtime.SystemMessage("presence", realtime.PresenceData{
|
||||
Status: realtime.PresenceOffline,
|
||||
UserID: conn.UserID(),
|
||||
})
|
||||
hub.Broadcast(msg)
|
||||
},
|
||||
})
|
||||
**Client sends message (HTTP POST):**
|
||||
|
||||
// Connect: ws://host/ws/room-name
|
||||
```typescript
|
||||
// POST /api/chat/messages
|
||||
await fetch('/api/chat/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
channel: 'general',
|
||||
content: 'Hello world',
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### Message Filtering
|
||||
**Server handles POST, publishes to Redis:**
|
||||
|
||||
```go
|
||||
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{
|
||||
OnMessage: func(conn realtime.Connection, msg *realtime.Message) *realtime.Message {
|
||||
// Filter profanity
|
||||
if containsProfanity(msg.Data) {
|
||||
return nil // Suppress message
|
||||
}
|
||||
|
||||
// Add server metadata
|
||||
msg.From = conn.UserID() // Use user ID instead of connection ID
|
||||
|
||||
return msg
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Authenticated Connections
|
||||
|
||||
```go
|
||||
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{
|
||||
AuthRequired: true, // Requires valid JWT
|
||||
})
|
||||
|
||||
// Client connects with token:
|
||||
// ws://host/ws?token=<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) {
|
||||
func (h *ChatHandler) PostMessage(w http.ResponseWriter, r *http.Request) error {
|
||||
var req struct {
|
||||
Room string `json:"room"`
|
||||
Text string `json:"text"`
|
||||
Channel string `json:"channel"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := app.Bind(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
// ... decode request ...
|
||||
|
||||
msg := &realtime.Message{
|
||||
Type: realtime.MessageTypeChat,
|
||||
Room: req.Room,
|
||||
Data: json.RawMessage(`{"text":"` + req.Text + `"}`),
|
||||
user := auth.GetUser(r.Context())
|
||||
|
||||
// Publish to Redis (reaches all pods)
|
||||
h.broadcaster.Publish(r.Context(), &realtime.Event{
|
||||
Type: "chat",
|
||||
Channel: req.Channel,
|
||||
UserID: user.ID,
|
||||
UserName: user.Name,
|
||||
Content: req.Content,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
})
|
||||
|
||||
// Publish via broadcaster (reaches all pods)
|
||||
if h.broadcaster != nil {
|
||||
h.broadcaster.Publish(r.Context(), msg)
|
||||
} else {
|
||||
h.hub.Broadcast(msg)
|
||||
return httpresponse.NoContent(w, r)
|
||||
}
|
||||
```
|
||||
|
||||
**Client receives via SSE:**
|
||||
|
||||
```typescript
|
||||
useEventChannel(`channel:general`, {
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'chat') {
|
||||
addMessage(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Async Job Progress
|
||||
|
||||
**Client initiates job (HTTP POST):**
|
||||
|
||||
```typescript
|
||||
const { jobId } = await api.post('/generate/video', {
|
||||
prompt: 'A cat playing piano',
|
||||
aspectRatio: '16:9',
|
||||
});
|
||||
```
|
||||
|
||||
**Client listens for progress (SSE):**
|
||||
|
||||
```typescript
|
||||
useEventChannel(`user:${userId}`, {
|
||||
onEvent: (event) => {
|
||||
if (event.jobId !== jobId) return;
|
||||
switch (event.type) {
|
||||
case 'generation_progress':
|
||||
setProgress(event.progress);
|
||||
break;
|
||||
case 'generation_complete':
|
||||
setResult(event.result);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Worker sends progress events:**
|
||||
|
||||
```go
|
||||
func (w *Worker) ProcessJob(ctx context.Context, job *domain.Job) error {
|
||||
// Send progress
|
||||
w.hub.SendToUser(job.UserID, &realtime.Event{
|
||||
Type: "generation_progress",
|
||||
JobID: job.ID,
|
||||
Progress: 50,
|
||||
Message: "Processing...",
|
||||
})
|
||||
|
||||
// ... do work ...
|
||||
|
||||
// Send complete
|
||||
w.hub.SendToUser(job.UserID, &realtime.Event{
|
||||
Type: "generation_complete",
|
||||
JobID: job.ID,
|
||||
Result: result,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Presence
|
||||
|
||||
**Client connects, server broadcasts presence:**
|
||||
|
||||
```go
|
||||
// In SSE connection handler
|
||||
func (h *SSEHandler) onConnect(userID string, channel string) {
|
||||
h.broadcaster.Publish(ctx, &realtime.Event{
|
||||
Type: "presence",
|
||||
Channel: channel,
|
||||
UserID: userID,
|
||||
Status: "online",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SSEHandler) onDisconnect(userID string, channel string) {
|
||||
h.broadcaster.Publish(ctx, &realtime.Event{
|
||||
Type: "presence",
|
||||
Channel: channel,
|
||||
UserID: userID,
|
||||
Status: "offline",
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Client Reconnection
|
||||
|
||||
Clients should implement reconnection with exponential backoff:
|
||||
SSE clients should implement reconnection with exponential backoff:
|
||||
|
||||
```javascript
|
||||
class RealtimeClient {
|
||||
connect() {
|
||||
this.ws = new WebSocket(`${this.url}?last_id=${this.lastMessageId}`);
|
||||
this.ws.onclose = () => this.scheduleReconnect();
|
||||
this.ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
this.lastMessageId = msg.id;
|
||||
this.onMessage(msg);
|
||||
};
|
||||
}
|
||||
```typescript
|
||||
function useEventChannel(channel: string, config: Config) {
|
||||
const [retries, setRetries] = useState(0);
|
||||
|
||||
scheduleReconnect() {
|
||||
const delay = Math.min(1000 * Math.pow(2, this.retries), 30000);
|
||||
setTimeout(() => this.connect(), delay);
|
||||
this.retries++;
|
||||
}
|
||||
const connect = useCallback(() => {
|
||||
const eventSource = new EventSource(`/api/events?channel=${channel}`);
|
||||
|
||||
eventSource.onopen = () => setRetries(0);
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close();
|
||||
const delay = Math.min(1000 * Math.pow(2, retries), 30000);
|
||||
setTimeout(connect, delay);
|
||||
setRetries(r => r + 1);
|
||||
};
|
||||
|
||||
eventSource.onmessage = (e) => {
|
||||
config.onEvent(JSON.parse(e.data));
|
||||
};
|
||||
}, [channel, retries]);
|
||||
}
|
||||
```
|
||||
|
||||
## Scaling Considerations
|
||||
|
||||
### Redis Channel Strategy
|
||||
|
||||
- One channel per room: `realtime:channel:{channelId}`
|
||||
- One channel per user: `realtime:user:{userId}`
|
||||
- Pattern subscription: `realtime:*`
|
||||
|
||||
### Connection Limits
|
||||
|
||||
Set reasonable limits per pod:
|
||||
@ -224,26 +277,20 @@ Set reasonable limits per pod:
|
||||
```go
|
||||
const maxConnectionsPerPod = 10000
|
||||
|
||||
func (h *Handler) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *SSEHandler) HandleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
if h.hub.ConnectionCount() >= maxConnectionsPerPod {
|
||||
http.Error(w, "server at capacity", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
// ... continue upgrade ...
|
||||
// ... continue ...
|
||||
}
|
||||
```
|
||||
|
||||
### Redis Channel Strategy
|
||||
|
||||
- One channel per room: `realtime:room:{roomId}`
|
||||
- Global channel for broadcasts: `realtime:global`
|
||||
- Pattern subscription: `realtime:room:*`
|
||||
|
||||
### Memory Considerations
|
||||
|
||||
Each connection uses ~10KB for buffers. Plan accordingly:
|
||||
- 10,000 connections ≈ 100MB
|
||||
- 100,000 connections ≈ 1GB
|
||||
Each SSE connection uses ~5KB for buffers. Plan accordingly:
|
||||
- 10,000 connections ≈ 50MB
|
||||
- 100,000 connections ≈ 500MB
|
||||
|
||||
## Monitoring
|
||||
|
||||
@ -251,50 +298,26 @@ Track these metrics:
|
||||
|
||||
| Metric | Description |
|
||||
|--------|-------------|
|
||||
| `realtime_connections_total` | Total active connections |
|
||||
| `realtime_rooms_total` | Number of active rooms |
|
||||
| `realtime_messages_sent` | Messages sent per second |
|
||||
| `realtime_messages_received` | Messages received per second |
|
||||
| `realtime_redis_publish_errors` | Failed Redis publishes |
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Client Errors
|
||||
|
||||
```go
|
||||
OnMessage: func(conn realtime.Connection, msg *realtime.Message) *realtime.Message {
|
||||
if err := validate(msg); err != nil {
|
||||
errMsg, _ := realtime.SystemMessage(realtime.MessageTypeError, map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
conn.Send(errMsg)
|
||||
return nil // Don't broadcast invalid message
|
||||
}
|
||||
return msg
|
||||
}
|
||||
```
|
||||
|
||||
### Redis Failures
|
||||
|
||||
RedisBroadcaster degrades gracefully:
|
||||
- If publish fails, message still broadcasts locally
|
||||
- Subscriber reconnects automatically on disconnect
|
||||
- Log warnings for monitoring
|
||||
| `sse_connections_total` | Total active SSE connections |
|
||||
| `sse_channels_total` | Number of active channels |
|
||||
| `sse_events_sent` | Events sent per second |
|
||||
| `redis_publish_errors` | Failed Redis publishes |
|
||||
|
||||
## Do
|
||||
|
||||
1. ALWAYS use room-based broadcasting for multi-tenant apps
|
||||
2. SET connection limits per pod
|
||||
3. IMPLEMENT client reconnection with backoff
|
||||
4. USE Redis for multi-pod deployments
|
||||
5. AUTHENTICATE WebSocket connections in production
|
||||
6. MONITOR connection count and message rates
|
||||
1. USE HTTP POST for all client→server messages
|
||||
2. USE SSE for all server→client events
|
||||
3. USE Redis pub/sub for multi-pod deployments
|
||||
4. SET connection limits per pod
|
||||
5. IMPLEMENT client reconnection with backoff
|
||||
6. AUTHENTICATE SSE connections in production
|
||||
7. DOCUMENT all channels in `docs/channels.md`
|
||||
|
||||
## Do Not
|
||||
|
||||
1. STORE large payloads in messages (send IDs, fetch data separately)
|
||||
2. BROADCAST without rate limiting
|
||||
3. RELY on message ordering (out-of-order is possible)
|
||||
4. SKIP ping/pong (connections will time out)
|
||||
5. USE synchronous operations in message handlers (blocks hub)
|
||||
1. USE WebSocket for anything — SSE only
|
||||
2. STORE large payloads in events (send IDs, fetch data separately)
|
||||
3. BROADCAST without rate limiting
|
||||
4. SIMULATE progress with fake timers
|
||||
5. SKIP ping/pong (connections will time out)
|
||||
6. TRUST client-provided user IDs (extract from auth token)
|
||||
|
||||
@ -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 |
|
||||
@ -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 });
|
||||
```
|
||||
@ -17,8 +17,9 @@ coverage.html
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# Go workspace file (local only)
|
||||
# Go workspace files (local only)
|
||||
go.work.sum
|
||||
*.go.sum
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@ -34,6 +35,7 @@ go.work.sum
|
||||
# Node
|
||||
node_modules/
|
||||
.npm/
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Shared packages
|
||||
packages/*/node_modules/
|
||||
|
||||
@ -10,6 +10,8 @@
|
||||
| **Build a feature** | [feature-development.md](.claude/guides/feature-development.md) |
|
||||
| **Backend API patterns** | [backend/api-patterns.md](.claude/guides/backend/api-patterns.md) |
|
||||
| **Frontend design system** | [frontend/design-system.md](.claude/guides/frontend/design-system.md) |
|
||||
| **Event channels** | [events.md](.claude/guides/events.md) |
|
||||
| **Media pipeline** | [media.md](.claude/guides/media.md) |
|
||||
| **Deploy** | [ops/deploying.md](.claude/guides/ops/deploying.md) |
|
||||
|
||||
## Quick Reference
|
||||
@ -36,6 +38,13 @@
|
||||
- **OpenAPI first:** Document endpoints in `spec.go` using `openapi.*` helpers. Mount with `application.EnableDocs(spec)`.
|
||||
- **CSS variables:** All UI components use CSS custom properties (`var(--background)`, `var(--accent)`, etc.). Never hardcode colors.
|
||||
- **Monorepo imports:** Go packages from `{{GO_MODULE}}/pkg/*`, TypeScript from `@{{PROJECT_NAME}}/*`.
|
||||
- **NO WEBSOCKETS. EVER.** All real-time communication uses HTTP2 + SSE. User → server is HTTP2 POST. Server → user is SSE. This includes chat, notifications, progress, everything.
|
||||
- **Event flow:** `POST → Service (enqueue) → Queue → Worker (generate) → Redis pub/sub → Service SSE subscriber → User`. Service is thin, worker does AI work.
|
||||
- **Channel naming:** `user:<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
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
@ -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"]
|
||||
}
|
||||
@ -5,6 +5,12 @@
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "../scripts/generate-client.sh",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import type { ApiError } from './types';
|
||||
import { ApiClientError } from './errors';
|
||||
|
||||
/**
|
||||
* API Client Configuration
|
||||
*/
|
||||
@ -6,7 +9,85 @@ export interface ClientConfig {
|
||||
apiKey?: string;
|
||||
bearerToken?: string;
|
||||
headers?: Record<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 newUser = await client.post('/users', { name: 'John' });
|
||||
*
|
||||
* @example
|
||||
* // Handling validation errors
|
||||
* try {
|
||||
* await client.post('/users', { email: 'invalid' });
|
||||
* } catch (error) {
|
||||
* if (isApiClientError(error) && error.isValidationError()) {
|
||||
* const fieldErrors = error.getFieldErrors();
|
||||
* console.log(fieldErrors.email); // "email must be a valid email address"
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export function createClient(config: ClientConfig) {
|
||||
const { baseUrl, apiKey, bearerToken, headers = {}, onError } = config;
|
||||
const { baseUrl, apiKey, bearerToken, headers = {}, onError, onAuthError } = config;
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
@ -65,10 +157,19 @@ export function createClient(config: ClientConfig) {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`API error: ${response.status}`);
|
||||
const apiError = await parseErrorResponse(response);
|
||||
const error = new ApiClientError(apiError, response);
|
||||
|
||||
// Call auth error handler for 401s
|
||||
if (error.isAuthError() && onAuthError) {
|
||||
onAuthError(error);
|
||||
}
|
||||
|
||||
// Call general error handler
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
@ -77,19 +178,22 @@ export function createClient(config: ClientConfig) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
const json = await response.json();
|
||||
|
||||
// Handle wrapped response format { data: { ... } }
|
||||
if (json.data !== undefined) {
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
return json as T;
|
||||
}
|
||||
|
||||
return {
|
||||
get: <T>(path: string, params?: Record<string, string | number | boolean | undefined>) =>
|
||||
request<T>('GET', path, { params }),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>('POST', path, { body }),
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>('PUT', path, { body }),
|
||||
patch: <T>(path: string, body?: unknown) =>
|
||||
request<T>('PATCH', path, { body }),
|
||||
delete: <T>(path: string) =>
|
||||
request<T>('DELETE', path),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, { body }),
|
||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, { body }),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, { body }),
|
||||
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
export * from './client';
|
||||
export * from './types';
|
||||
export * from './errors';
|
||||
// Note: schema.d.ts is generated by running `pnpm generate`
|
||||
// export type { paths, components, operations } from './schema';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -5,6 +5,12 @@
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc",
|
||||
|
||||
@ -34,8 +34,6 @@ export interface AuthProviderProps {
|
||||
loginUrl?: string;
|
||||
/** API endpoint for logout */
|
||||
logoutUrl?: string;
|
||||
/** API endpoint for fetching current user */
|
||||
userUrl?: string;
|
||||
/** Custom login handler */
|
||||
onLogin?: (credentials: LoginCredentials) => Promise<{ token: string; user: User }>;
|
||||
/** Custom logout handler */
|
||||
@ -68,7 +66,6 @@ export function AuthProvider({
|
||||
children,
|
||||
loginUrl = '/api/auth/login',
|
||||
logoutUrl = '/api/auth/logout',
|
||||
userUrl = '/api/auth/me',
|
||||
onLogin,
|
||||
onLogout,
|
||||
storage = 'localStorage',
|
||||
@ -140,8 +137,9 @@ export function AuthProvider({
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.message || 'Login failed');
|
||||
const errBody = await response.json().catch(() => ({}));
|
||||
const errMsg = errBody.error?.message || errBody.message || 'Login failed';
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@ -5,6 +5,12 @@
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc",
|
||||
|
||||
@ -5,6 +5,12 @@
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc"
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
@ -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"]
|
||||
}
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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
Loading…
Reference in New Issue
Block a user