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