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

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

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

4
.gitignore vendored
View File

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

View File

@ -37,6 +37,7 @@ When discussing code: "add to **platform**" = edit rdev; "add to **skeleton**" =
| **Cookbook tree system (commands)** | [services/cookbook-trees.md](.claude/guides/services/cookbook-trees.md) |
| **Slackpath reference architectures** | [services/cookbook-trees.md](.claude/guides/services/cookbook-trees.md#slackpath-trees-reference-architectures) |
| **Write cookbook trees** | [cookbook-trees/SKILL.md](.claude/skills/cookbook-trees/SKILL.md) |
| **Build/maintain skeleton packages** | [skeleton-craftsman/SKILL.md](.claude/skills/skeleton-craftsman/SKILL.md) |
| **Build orchestration** | [services/build-orchestration.md](.claude/guides/services/build-orchestration.md) |
| **Build event streaming** | [services/build-streaming.md](.claude/guides/services/build-streaming.md) |
| **Resource provisioning plan** | [services/resource-provisioning-plan.md](.claude/guides/services/resource-provisioning-plan.md) |
@ -55,10 +56,13 @@ When discussing code: "add to **platform**" = edit rdev; "add to **skeleton**" =
| **Woodpecker CI v3 pipelines** | [ops/woodpecker-v3.md](.claude/guides/ops/woodpecker-v3.md) |
| **Traefik v3 ingress & middleware** | [ops/traefik-v3.md](.claude/guides/ops/traefik-v3.md) |
| **Zot container registry** | [ops/zot-registry.md](.claude/guides/ops/zot-registry.md) |
| **cert-manager / TLS certificates** | [ops/cert-manager.md](.claude/guides/ops/cert-manager.md) |
| **Structured logging** | `internal/logging/` - field constants, context propagation, redaction |
## Critical Rules
- **Frustration = systemic fix:** When the user says they're tired of repeating something, stop what you're doing and find or create a systemic fix in `.claude/**/*` or `CLAUDE.md` — don't just apologize and do the same thing again.
- **AI credentials are provisioned:** rdev injects `LAOZHANG_API_KEY` and `GEMINI_API_KEY` as env vars into every deployed component (`component_deploy.go:fetchProjectCredentials`). Skeleton code reads them with `os.Getenv()`. Never treat AI packages as needing external setup.
- **Root cause fixes:** When diagnosing failures in generated projects, NEVER patch the project directly. Find the systemic root cause in: (1) **platform** - rdev handlers/services that create resources, (2) **skeleton** - templates that ship in generated projects, or (3) **cookbook** - test scripts with wrong assumptions. Fix the source, not the symptom. Every project-specific fix is technical debt that will recur.
- **LLM vs rdev:** LLMs generate code; rdev executes deterministic operations (git, lint, deploy). Never rely on LLMs for runbook tasks.
- **Pod git ops:** Git operations run inside pods via `PodGitOperations` (kubectl exec), never locally.

View File

@ -34,6 +34,11 @@ type Config struct {
// Internal API token for service-to-service callbacks
InternalToken string
// Citadel logging integration
CitadelURL string // e.g., "https://citadel-staging.orchard9.ai"
CitadelAPIKey string // API key for Citadel (starts with ck_live_ or ck_dev_)
CitadelPlatformTenantID string // Tenant ID for the rdev-platform environment
// Infrastructure adapters (threesix.ai) - fallback values if not in credential store
GiteaURL string
GiteaToken string
@ -107,6 +112,11 @@ func loadConfig() Config {
// Internal API token for service-to-service callbacks (e.g., SDLC callbacks)
InternalToken: os.Getenv("INTERNAL_TOKEN"),
// Citadel logging integration
CitadelURL: os.Getenv("CITADEL_URL"), // e.g., "https://citadel-staging.orchard9.ai"
CitadelAPIKey: os.Getenv("CITADEL_API_KEY"), // API key for Citadel
CitadelPlatformTenantID: os.Getenv("CITADEL_PLATFORM_TENANT_ID"), // rdev-platform tenant ID
// Infrastructure adapters (fallback if not in credential store)
GiteaURL: envutil.GetEnv("GITEA_URL", "https://git.threesix.ai"),
GiteaToken: os.Getenv("GITEA_TOKEN"),

View File

@ -7,6 +7,7 @@ import (
"strings"
"time"
citadeladapter "github.com/orchard9/rdev/internal/adapter/citadel"
"github.com/orchard9/rdev/internal/adapter/cloudflare"
"github.com/orchard9/rdev/internal/adapter/cockroach"
"github.com/orchard9/rdev/internal/adapter/codeagent"
@ -96,6 +97,16 @@ func main() {
// Load infrastructure config from credential store (falls back to env vars)
infraCfg := loadInfraConfig(context.Background(), credentialStore, cfg, logger)
// Initialize Citadel client (optional - for log environment provisioning and audit shipping)
var citadelClient *citadeladapter.Client
if cfg.CitadelURL != "" && cfg.CitadelAPIKey != "" {
citadelClient = citadeladapter.NewClient(citadeladapter.Config{
URL: cfg.CitadelURL,
APIKey: cfg.CitadelAPIKey,
}, logger)
logger.Info("citadel client initialized", "url", cfg.CitadelURL)
}
// Create adapters (dependency injection)
namespace := envutil.GetEnv("K8S_NAMESPACE", "rdev")
@ -113,7 +124,16 @@ func main() {
}
}
auditLogger := postgres.NewAuditLogger(database.DB)
var auditLogger port.AuditLogger
var auditShipper *citadeladapter.AuditShipper
pgAuditLogger := postgres.NewAuditLogger(database.DB)
if citadelClient != nil && cfg.CitadelPlatformTenantID != "" {
auditShipper = citadeladapter.NewAuditShipper(pgAuditLogger, citadelClient, cfg.CitadelPlatformTenantID, logger)
auditLogger = auditShipper
logger.Info("audit logger wrapped with citadel shipper", "tenant_id", cfg.CitadelPlatformTenantID)
} else {
auditLogger = pgAuditLogger
}
rateLimiter := postgres.NewRateLimiter(database.DB)
stopRateLimitCleanup := rateLimiter.StartCleanupWorker(context.Background(), 5*time.Minute)
commandQueue := postgres.NewCommandQueueRepository(database.DB)
@ -459,6 +479,9 @@ func main() {
if registryClient != nil {
projectInfraService = projectInfraService.WithRegistryProvider(registryClient)
}
if citadelClient != nil {
projectInfraService = projectInfraService.WithCitadelClient(citadelClient)
}
// Create domain service adapter for infrastructure handler
domainServiceAdapter := handlers.NewDomainServiceAdapter(projectInfraService)
@ -761,6 +784,9 @@ func main() {
}
queueProcessor.Stop()
webhookDispatcher.Stop()
if auditShipper != nil {
auditShipper.Close()
}
projectRepo.StopWatching()
stopRateLimitCleanup()
closeProvisioner(dbProvisioner, "database", logger)

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

@ -0,0 +1,241 @@
// Package main provides a CLI tool for rendering skeleton templates locally.
//
// This is used for testing templates without needing Gitea/rdev infrastructure.
// The tool renders the monorepo skeleton plus all component types to a local directory.
//
// Usage:
//
// go run ./cmd/render-skeleton -output ./examples/full-monorepo
package main
import (
"flag"
"fmt"
"maps"
"os"
"path/filepath"
"strings"
"github.com/orchard9/rdev/internal/adapter/templates"
)
// Component defines a component to render.
type Component struct {
Type string
Name string
Port string
DestDir string // "services", "workers", "apps", or "cli"
}
// defaultComponents are the components to render in a full example project.
var defaultComponents = []Component{
{Type: "service", Name: "example-api", Port: "8001", DestDir: "services"},
{Type: "worker", Name: "example-worker", Port: "", DestDir: "workers"},
{Type: "app-astro", Name: "example-astro", Port: "4321", DestDir: "apps"},
{Type: "app-react", Name: "example-react", Port: "5173", DestDir: "apps"},
{Type: "app-nextjs", Name: "example-nextjs", Port: "3000", DestDir: "apps"},
{Type: "cli", Name: "example-cli", Port: "", DestDir: "cli"},
}
func main() {
outputDir := flag.String("output", "", "Output directory for rendered skeleton")
projectName := flag.String("project", "test-project", "Project name")
goModule := flag.String("module", "git.threesix.ai/threesix/test-project", "Go module path")
domain := flag.String("domain", "test.threesix.ai", "Domain for the project")
flag.Parse()
if *outputDir == "" {
fmt.Fprintln(os.Stderr, "Usage: render-skeleton -output <dir> [-project <name>] [-module <path>] [-domain <domain>]")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Example:")
fmt.Fprintln(os.Stderr, " go run ./cmd/render-skeleton -output ./examples/full-monorepo")
os.Exit(1)
}
// Base variables for the project
vars := map[string]string{
"PROJECT_NAME": *projectName,
"GO_MODULE": *goModule,
"DOMAIN": *domain,
"DESCRIPTION": "Test project for skeleton verification",
"GIT_URL": "https://" + strings.TrimPrefix(*goModule, "git."),
}
fmt.Printf("Rendering skeleton to %s\n", *outputDir)
fmt.Printf(" PROJECT_NAME: %s\n", vars["PROJECT_NAME"])
fmt.Printf(" GO_MODULE: %s\n", vars["GO_MODULE"])
fmt.Printf(" DOMAIN: %s\n", vars["DOMAIN"])
fmt.Println()
// Clean output directory if it exists (but preserve .git if present)
if err := cleanOutputDir(*outputDir); err != nil {
fmt.Fprintf(os.Stderr, "Error cleaning output directory: %v\n", err)
os.Exit(1)
}
// Render skeleton
fmt.Println("Rendering skeleton...")
if err := templates.RenderSkeletonToDir(*outputDir, vars); err != nil {
fmt.Fprintf(os.Stderr, "Error rendering skeleton: %v\n", err)
os.Exit(1)
}
// Render all components
for _, c := range defaultComponents {
fmt.Printf("Rendering component: %s (%s)\n", c.Name, c.Type)
// Component-specific variables
componentVars := copyVars(vars)
componentVars["COMPONENT_NAME"] = c.Name
componentVars["PORT"] = c.Port
// For frontend apps, inject the primary service name/port for API proxy
if strings.HasPrefix(c.Type, "app-") {
if svc := findFirstService(defaultComponents); svc != nil {
componentVars["SERVICE_NAME"] = svc.Name
componentVars["SERVICE_PORT"] = svc.Port
}
}
// Determine destination path
destPath := filepath.Join(c.DestDir, c.Name)
if err := templates.RenderComponentToDir(*outputDir, c.Type, destPath, componentVars); err != nil {
fmt.Fprintf(os.Stderr, "Error rendering component %s: %v\n", c.Name, err)
os.Exit(1)
}
}
// Update monorepo files
fmt.Println("\nUpdating monorepo files...")
if err := updateGoWork(*outputDir, defaultComponents); err != nil {
fmt.Fprintf(os.Stderr, "Error updating go.work: %v\n", err)
os.Exit(1)
}
if err := updateProcfile(*outputDir, defaultComponents); err != nil {
fmt.Fprintf(os.Stderr, "Error updating Procfile: %v\n", err)
os.Exit(1)
}
// Remove .gitkeep files from directories that now have content
if err := removeGitkeeps(*outputDir); err != nil {
fmt.Fprintf(os.Stderr, "Error removing .gitkeep files: %v\n", err)
os.Exit(1)
}
fmt.Println("\nDone!")
fmt.Printf("Rendered %d components to %s\n", len(defaultComponents), *outputDir)
fmt.Println("\nNext steps:")
fmt.Println(" cd " + *outputDir)
fmt.Println(" go work sync")
fmt.Println(" go build ./...")
fmt.Println(" pnpm install")
fmt.Println(" pnpm -r typecheck")
}
// cleanOutputDir removes existing content but preserves .git directory.
func cleanOutputDir(outputDir string) error {
entries, err := os.ReadDir(outputDir)
if os.IsNotExist(err) {
return os.MkdirAll(outputDir, 0755)
}
if err != nil {
return err
}
for _, entry := range entries {
if entry.Name() == ".git" {
continue // preserve .git
}
path := filepath.Join(outputDir, entry.Name())
if err := os.RemoveAll(path); err != nil {
return fmt.Errorf("failed to remove %s: %w", path, err)
}
}
return nil
}
// copyVars creates a copy of a variables map.
func copyVars(vars map[string]string) map[string]string {
return maps.Clone(vars)
}
// updateGoWork adds component modules to go.work.
func updateGoWork(outputDir string, components []Component) error {
var sb strings.Builder
sb.WriteString("go 1.25\n\n")
sb.WriteString("use ./pkg\n")
for _, c := range components {
// Only Go components get added to go.work
switch c.Type {
case "service", "worker", "cli":
fmt.Fprintf(&sb, "use ./%s/%s\n", c.DestDir, c.Name)
}
}
return os.WriteFile(filepath.Join(outputDir, "go.work"), []byte(sb.String()), 0644)
}
// updateProcfile adds component processes to Procfile.
func updateProcfile(outputDir string, components []Component) error {
var lines []string
lines = append(lines, "# Local development processes")
lines = append(lines, "")
for _, c := range components {
var cmd string
switch c.Type {
case "service":
cmd = fmt.Sprintf("%s: cd %s/%s && make dev", c.Name, c.DestDir, c.Name)
case "worker":
cmd = fmt.Sprintf("%s: cd %s/%s && make dev", c.Name, c.DestDir, c.Name)
case "cli":
// CLIs don't run as processes
continue
case "app-astro", "app-react":
cmd = fmt.Sprintf("%s: cd %s/%s && pnpm dev", c.Name, c.DestDir, c.Name)
case "app-nextjs":
cmd = fmt.Sprintf("%s: cd %s/%s && pnpm dev", c.Name, c.DestDir, c.Name)
default:
continue
}
lines = append(lines, cmd)
}
lines = append(lines, "")
content := strings.Join(lines, "\n")
return os.WriteFile(filepath.Join(outputDir, "Procfile"), []byte(content), 0644)
}
// findFirstService returns the first service component, or nil if none.
func findFirstService(components []Component) *Component {
for i := range components {
if components[i].Type == "service" {
return &components[i]
}
}
return nil
}
// removeGitkeeps removes .gitkeep files from directories that have other content.
func removeGitkeeps(outputDir string) error {
keepDirs := []string{"services", "workers", "apps", "cli", "packages"}
for _, dir := range keepDirs {
gitkeepPath := filepath.Join(outputDir, dir, ".gitkeep")
dirPath := filepath.Join(outputDir, dir)
entries, err := os.ReadDir(dirPath)
if err != nil {
continue // directory might not exist
}
// If there's more than just .gitkeep, remove it
if len(entries) > 1 {
_ = os.Remove(gitkeepPath) // Ignore error - file may not exist
}
}
return nil
}

View File

@ -95,7 +95,7 @@ api_call() {
# Returns: 0 on success, 1 on failure, 2 on timeout
wait_for_build() {
local task_id="$1"
local max_attempts="${2:-60}" # 5 minutes default (5s * 60)
local max_attempts="${2:-120}" # 10 minutes default (5s * 120)
local poll_interval="${3:-5}"
local attempt=0
@ -155,7 +155,7 @@ wait_for_build() {
# the pipeline has already failed.
wait_for_pipeline() {
local project_id="$1"
local max_attempts="${2:-60}" # 5 minutes default
local max_attempts="${2:-120}" # 10 minutes default
local poll_interval="${3:-5}"
local attempt=0
local tracked_pipeline="" # Track specific pipeline once found

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,208 @@
name: genkit-test
description: "Validate AI generation packages with a chat UI and API backend"
version: 1
vars:
project_name: ""
service_name: "ai-gateway"
app_name: "chat-ui"
feature_slug: "ai-chat"
steps:
# --- Infrastructure ---
create-project:
action: api
method: POST
endpoint: /project
body:
name: "{{ .vars.project_name }}"
description: "Genkit validation: AI chat with text and image generation"
outputs:
- project_id: .data.name
- domain: .data.domain
add-service:
description: Add AI gateway service
depends_on: [create-project]
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
body:
type: service
name: "{{ .vars.service_name }}"
add-ui:
description: Add React chat UI
depends_on: [create-project]
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
body:
type: app-react
name: "{{ .vars.app_name }}"
wait-init:
depends_on: [add-service, add-ui]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
max_attempts: 120
poll_interval: 5
# --- SDLC: Build AI Endpoints ---
create-feature:
depends_on: [wait-init]
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features"
body:
slug: "{{ .vars.feature_slug }}"
title: "AI Generation Endpoints"
implement-ai:
description: "Agent implements text and image generation endpoints"
depends_on: [create-feature]
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds"
body:
prompt: |
/implement-feature {{ .vars.feature_slug }} --requirements '
Use pkg/textgen and pkg/mediagen to create AI generation endpoints.
Environment variables available: LAOZHANG_API_KEY, GEMINI_API_KEY
Endpoints to implement:
1. GET /health/providers - Check AI provider connectivity
- Initialize textgen.Manager with ProductionConfig
- Return provider names and health status
2. POST /chat - Text generation
Request: {"message": "string", "system_prompt": "string (optional)"}
- Use textgen.Manager.GenerateText()
- Return: {"response": "string", "provider": "string"}
3. POST /generate-image - Image generation
Request: {"prompt": "string"}
- Use mediagen.Manager.GenerateImage()
- Return: {"image_base64": "string", "provider": "string"}
Use ProductionConfig for both managers (LaoZhang primary, Gemini fallback).
Handle errors gracefully with proper HTTP status codes.
'
auto_commit: true
auto_push: true
git_clone_url: "https://git.threesix.ai/jordan/{{ .outputs.create-project.project_id }}.git"
outputs:
- build_id: .data.task_id
wait-build:
description: Wait for agent code generation
depends_on: [implement-ai]
action: wait_build
build_id: "{{ .outputs.implement-ai.build_id }}"
max_attempts: 120
poll_interval: 5
wait-deploy:
depends_on: [wait-build]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
max_attempts: 120
poll_interval: 5
# --- Verification ---
verify-service-health:
description: "Verify the AI service is running"
depends_on: [wait-deploy]
action: shell
command: |
DOMAIN="{{ .outputs.create-project.domain }}"
SERVICE_NAME="{{ .vars.service_name }}"
HEALTH=$(curl -s "https://$DOMAIN/api/$SERVICE_NAME/health" | jq -r '.data.status // .status // empty')
if [ "$HEALTH" == "healthy" ] || [ "$HEALTH" == "ok" ]; then
echo "Service healthy"
exit 0
else
echo "Service not healthy: $HEALTH"
curl -s "https://$DOMAIN/api/$SERVICE_NAME/health" | jq .
exit 1
fi
verify-provider-health:
description: "Verify AI providers are accessible"
depends_on: [verify-service-health]
on_error: continue
action: shell
command: |
DOMAIN="{{ .outputs.create-project.domain }}"
SERVICE_NAME="{{ .vars.service_name }}"
echo "Checking provider health..."
RESP=$(curl -s "https://$DOMAIN/api/$SERVICE_NAME/health/providers")
echo "$RESP" | jq .
# Check if we got any provider info (success even if some providers are down)
if echo "$RESP" | jq -e '.data // .providers // .' > /dev/null 2>&1; then
echo "Provider health endpoint working"
exit 0
else
echo "Provider health check failed"
exit 1
fi
verify-text-generation:
description: "Test text generation endpoint"
depends_on: [verify-provider-health]
on_error: continue
action: shell
command: |
DOMAIN="{{ .outputs.create-project.domain }}"
SERVICE_NAME="{{ .vars.service_name }}"
echo "Testing text generation..."
RESP=$(curl -s -X POST "https://$DOMAIN/api/$SERVICE_NAME/chat" \
-H "Content-Type: application/json" \
-d '{"message": "What is 2+2? Reply with just the number."}')
echo "$RESP" | jq .
# Check if we got a response
RESPONSE=$(echo "$RESP" | jq -r '.response // .data.response // .text // empty')
if [ -n "$RESPONSE" ]; then
echo "Text generation working: $RESPONSE"
exit 0
else
echo "Text generation failed"
exit 1
fi
verify-image-generation:
description: "Test image generation endpoint"
depends_on: [verify-text-generation]
on_error: continue
action: shell
command: |
DOMAIN="{{ .outputs.create-project.domain }}"
SERVICE_NAME="{{ .vars.service_name }}"
echo "Testing image generation..."
RESP=$(curl -s -X POST "https://$DOMAIN/api/$SERVICE_NAME/generate-image" \
-H "Content-Type: application/json" \
-d '{"prompt": "a simple red circle on white background"}')
# Check if we got base64 image data
IMAGE=$(echo "$RESP" | jq -r '.image_base64 // .data.image_base64 // .image // empty')
if [ -n "$IMAGE" ] && [ ${#IMAGE} -gt 100 ]; then
echo "Image generation working (got ${#IMAGE} chars of base64)"
exit 0
else
echo "Image generation failed or returned empty"
echo "$RESP" | jq .
exit 1
fi
teardown:
- action: api
method: DELETE
endpoint: "/project/{{ .outputs.create-project.project_id }}"

View File

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

View File

@ -0,0 +1,27 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: citadel-agent-config
namespace: observability
labels:
app.kubernetes.io/name: citadel-agent
app.kubernetes.io/part-of: citadel
data:
# Agent ships logs via HTTP to partner-hosted Citadel
CITADEL_HTTP: "true"
CITADEL_HTTP_URL: "https://citadel-staging.orchard9.ai"
# Log routing: agent reads these k8s labels to determine target environment
# citadel.io/environment → target Citadel tenant ID
# citadel.io/service → service name tag
CITADEL_ROUTE_LABEL_ENVIRONMENT: "citadel.io/environment"
CITADEL_ROUTE_LABEL_SERVICE: "citadel.io/service"
# Namespaces to collect logs from
# rdev namespace = platform components (rdev-api, rdev-worker, claudebox)
# projects namespace = generated project pods
CITADEL_NAMESPACES: "rdev,projects,observability"
# Default environment for pods without citadel.io/environment label
# Platform pods (rdev-api, rdev-worker) route here
CITADEL_DEFAULT_ENVIRONMENT: "rdev-platform"

View File

@ -0,0 +1,92 @@
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: citadel-agent
namespace: observability
labels:
app.kubernetes.io/name: citadel-agent
app.kubernetes.io/part-of: citadel
spec:
selector:
matchLabels:
app.kubernetes.io/name: citadel-agent
updateStrategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
template:
metadata:
labels:
app.kubernetes.io/name: citadel-agent
app.kubernetes.io/part-of: citadel
# Label this pod so it routes its OWN logs to rdev-platform
citadel.io/environment: rdev-platform
citadel.io/service: citadel-agent
spec:
serviceAccountName: citadel-agent
tolerations:
# Run on all nodes including control plane
- operator: Exists
containers:
- name: agent
image: gcr.io/orchard9/citadel-agent:latest
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
envFrom:
- configMapRef:
name: citadel-agent-config
env:
- name: CITADEL_API_KEY
valueFrom:
secretKeyRef:
name: citadel-agent
key: api-key
- name: CITADEL_TENANT_ID
valueFrom:
secretKeyRef:
name: citadel-agent
key: tenant-id
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
volumeMounts:
# Container log files on the node
- name: varlog
mountPath: /var/log
readOnly: true
# Container runtime data (for resolving container IDs to pod metadata)
- name: containers
mountPath: /var/lib/docker/containers
readOnly: true
# Persistent state (checkpoint offsets survive agent restarts)
- name: agent-state
mountPath: /var/lib/citadel-agent
livenessProbe:
httpGet:
path: /healthz
port: 9090
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /readyz
port: 9090
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: varlog
hostPath:
path: /var/log
- name: containers
hostPath:
path: /var/lib/docker/containers
- name: agent-state
hostPath:
path: /var/lib/citadel-agent
type: DirectoryOrCreate

View File

@ -0,0 +1,13 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: observability
resources:
- namespace.yaml
- serviceaccount.yaml
- daemonset.yaml
- configmap.yaml
# NOTE: secret.yaml contains real keys and is gitignored.
# Copy from secret.yaml.example and fill in real values before deploying.
- secret.yaml

View File

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

View File

@ -0,0 +1,11 @@
apiVersion: v1
kind: Secret
metadata:
name: citadel-agent
namespace: observability
type: Opaque
stringData:
# API key for Citadel (get from: citadel api-key create --name "rdev-agent" --environment live)
api-key: "ck_live_REPLACE_ME"
# Tenant ID for rdev-platform environment (get from: citadel env create rdev-platform)
tenant-id: "REPLACE_ME"

View File

@ -0,0 +1,37 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: citadel-agent
namespace: observability
labels:
app.kubernetes.io/name: citadel-agent
app.kubernetes.io/part-of: citadel
---
# ClusterRole to read pod metadata for log enrichment
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: citadel-agent
labels:
app.kubernetes.io/name: citadel-agent
app.kubernetes.io/part-of: citadel
rules:
- apiGroups: [""]
resources: ["pods", "namespaces"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: citadel-agent
labels:
app.kubernetes.io/name: citadel-agent
app.kubernetes.io/part-of: citadel
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: citadel-agent
subjects:
- kind: ServiceAccount
name: citadel-agent
namespace: observability

View File

@ -19,6 +19,9 @@ spec:
app.kubernetes.io/name: claudebox
app.kubernetes.io/part-of: rdev
rdev.orchard9.ai/role: worker
# Citadel agent routes these logs to the rdev-platform environment
citadel.io/environment: rdev-platform
citadel.io/service: claudebox
spec:
containers:
- name: claudebox

View File

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

View File

@ -20,6 +20,9 @@ spec:
app: rdev-api
app.kubernetes.io/name: rdev-api
app.kubernetes.io/part-of: rdev
# Citadel agent routes these logs to the rdev-platform environment
citadel.io/environment: rdev-platform
citadel.io/service: rdev-api
spec:
serviceAccountName: rdev-api
containers:

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

@ -0,0 +1,186 @@
# Citadel Integration Plan
rdev integrates with a **partner-hosted Citadel** instance at `citadel-staging.orchard9.ai` for centralized log aggregation, querying, and alerting across both the platform and generated projects.
## Environment Architecture
```
Organization: orchard9 (on citadel-staging.orchard9.ai)
├── Environment: "rdev-platform"
│ ├── rdev-api logs (stdout via agent)
│ ├── rdev-worker logs (stdout via agent)
│ ├── claudebox logs (stdout via agent)
│ └── audit events (shipped from AuditLogger)
├── Environment: "<project-slug>" (auto-created per project)
│ └── Project pod logs (stdout via agent, routed by k8s labels)
├── Tenant Group: "platform" → rdev-platform
└── Tenant Group: "projects" → all project-* environments
```
Each project gets its own Citadel environment, matching rdev's isolation model (each project gets its own DB, Redis, DNS, registry namespace).
## Integration Points
### 1. Agent DaemonSet (Log Collection)
**What:** A `citadel-agent` DaemonSet on every k3s node collects container stdout/stderr and ships to Citadel via HTTPS.
**Why:** rdev already outputs structured JSON slog to stdout. Zero code changes needed.
**Routing:** Agent reads k8s labels to route logs to the correct Citadel environment:
- `citadel.io/environment` label → determines target environment
- `citadel.io/service` label → tags the service name in Citadel
**Manifests:** `deployments/k8s/base/citadel-agent/`
### 2. Citadel Client Adapter (API Integration)
**What:** Go HTTP client for Citadel's API, following rdev's hexagonal architecture (port interface + adapter).
**Why:** Needed for auto-provisioning environments and shipping audit logs.
**Files:**
- `internal/port/citadel.go` — Port interface
- `internal/adapter/citadel/client.go` — HTTP client implementation
- `internal/adapter/citadel/audit_shipper.go` — Audit log shipping
### 3. Project Provisioning Step
**What:** When `ProjectInfraService.CreateProject()` runs, a new step creates a Citadel environment for the project.
**Why:** Each project needs its own isolated log environment. Auto-provisioning eliminates manual setup.
**Where in the flow:**
```
CreateProject()
1. Generate slug
2. Create project in DB
3. Create git repo
4. Create DNS
5. Activate CI
6. Seed template
7. Provision DB + cache
8. *** Create Citadel environment *** ← NEW
9. Create initial deployment
10. Trigger CI build
```
**Rollback:** On project deletion, the Citadel environment is deleted too.
### 4. Skeleton Template Labels
**What:** Add `citadel.io/*` labels to the skeleton's k8s deployment templates.
**Why:** The agent uses these labels to route project logs to the correct Citadel environment.
**Template vars:** `{{CITADEL_TENANT_ID}}` injected during provisioning.
### 5. Audit Log Shipping
**What:** Hook into rdev's existing `AuditLogger` to also ship audit events to Citadel.
**Why:** Unified search across application logs and security events.
**Pattern:** Wrap the existing PostgreSQL `AuditLogger` with a multi-writer that sends to both PostgreSQL and Citadel.
## Configuration
### Environment Variables (rdev-api)
| Variable | Description | Example |
|----------|-------------|---------|
| `CITADEL_URL` | Partner Citadel instance URL | `https://citadel-staging.orchard9.ai` |
| `CITADEL_API_KEY` | API key for environment management | `ck_live_...` |
| `CITADEL_TENANT_ID` | Platform environment tenant ID | `uuid` |
| `CITADEL_ENABLED` | Enable/disable Citadel integration | `true` |
### Secrets (k8s)
```yaml
# deployments/k8s/base/citadel-agent/secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: citadel-agent
namespace: observability
type: Opaque
stringData:
api-key: "ck_live_..."
tenant-id: "..."
```
## Queries (What You Get)
### Platform Operations
```bash
# All errors across platform
citadel query "level:error" --tenant rdev-platform --last 1h
# Track a project lifecycle
citadel query "project_id:my-project" --tenant rdev-platform --last 24h
# Build failures
citadel query "component:build level:error" --tenant rdev-platform
# Saga failures
citadel query "component:saga result:failure" --tenant rdev-platform
# Slow API requests
citadel query "duration_ms:>5000 component:http" --tenant rdev-platform
# Audit trail
citadel query "action:project.create" --tenant rdev-platform --last 7d
```
### Generated Projects
```bash
# Query a specific project's logs
citadel query "level:error" --tenant <project-slug> --last 1h
# All project errors at once
citadel query "level:error" --group projects --last 1h
# Correlate platform + project
citadel query "project_id:my-project" --last 24h # org-wide
```
## Implementation Order
| Phase | Task | Effort |
|-------|------|--------|
| 1 | Write Citadel client adapter (`internal/adapter/citadel/`) | 2h |
| 1 | Write port interface (`internal/port/citadel.go`) | 30m |
| 2 | Deploy agent DaemonSet (`deployments/k8s/base/citadel-agent/`) | 1h |
| 2 | Add citadel labels to existing rdev-api/worker manifests | 15m |
| 3 | Add Citadel env creation to `ProjectInfraService.CreateProject()` | 2h |
| 3 | Add Citadel env deletion to `ProjectInfraService.DeleteProject()` | 30m |
| 3 | Add migration for `citadel_tenant_id` column on projects table | 15m |
| 4 | Add `citadel.io/*` labels to skeleton k8s templates | 30m |
| 4 | Add `CITADEL_TENANT_ID` to template variables | 15m |
| 5 | Implement audit log shipper | 2h |
| 5 | Wire into main.go startup | 30m |
## Files Created/Modified
### New Files
- `internal/port/citadel.go`
- `internal/adapter/citadel/client.go`
- `internal/adapter/citadel/audit_shipper.go`
- `deployments/k8s/base/citadel-agent/kustomization.yaml`
- `deployments/k8s/base/citadel-agent/daemonset.yaml`
- `deployments/k8s/base/citadel-agent/serviceaccount.yaml`
- `deployments/k8s/base/citadel-agent/configmap.yaml`
- `internal/db/migrations/024_citadel_tenant_id.sql`
### Modified Files
- `deployments/k8s/base/kustomization.yaml` — include citadel-agent
- `deployments/k8s/base/rdev-api.yaml` — add citadel labels + env vars
- `internal/service/project_infra.go` — add CitadelClient field
- `internal/service/project_infra_crud.go` — add provisioning + cleanup steps
- `internal/adapter/templates/templates/skeleton/` — k8s template labels
- `cmd/rdev-api/main.go` — wire Citadel adapter

45
examples/README.md Normal file
View File

@ -0,0 +1,45 @@
# Examples
This directory contains example projects that are rendered from rdev's skeleton templates.
## full-monorepo
A fully rendered monorepo skeleton with all component types:
| Component | Type | Purpose |
|-----------|------|---------|
| `services/example-api` | service | Go REST API |
| `workers/example-worker` | worker | Go background worker |
| `apps/example-astro` | app-astro | Astro landing page |
| `apps/example-react` | app-react | React SPA |
| `apps/example-nextjs` | app-nextjs | Next.js dashboard |
| `cli/example-cli` | cli | Go CLI tool |
### Purpose
1. **Template testing**: Ensures templates render correctly and compile
2. **IDE debugging**: Step through generated code with full syntax highlighting
3. **Documentation**: Shows what rendered projects look like
### Regenerating
To regenerate after template changes:
```bash
./scripts/verify-skeleton.sh --update
```
### Verification
To verify templates are in sync:
```bash
./scripts/verify-skeleton.sh # Full verification (Go + TypeScript)
./scripts/verify-skeleton.sh --quick # Skip TypeScript
```
## How This Works
1. `cmd/render-skeleton/main.go` - CLI tool that renders templates using the actual `templates.Provider`
2. `internal/adapter/templates/provider.go` - Has `RenderSkeletonToDir` and `RenderComponentToDir` functions
3. `scripts/verify-skeleton.sh` - CI script that checks sync and attempts builds

4
go.mod
View File

@ -3,6 +3,7 @@ module github.com/orchard9/rdev
go 1.25.0
require (
cloud.google.com/go/storage v1.59.2
code.gitea.io/sdk/gitea v0.22.1
github.com/bdpiprava/scalar-go v0.13.0
github.com/go-chi/chi/v5 v5.1.0
@ -18,6 +19,7 @@ require (
go.opentelemetry.io/otel/sdk v1.39.0
go.opentelemetry.io/otel/trace v1.39.0
go.woodpecker-ci.org/woodpecker/v3 v3.13.0
google.golang.org/api v0.265.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.35.0
k8s.io/apimachinery v0.35.0
@ -32,7 +34,6 @@ require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.5.3 // indirect
cloud.google.com/go/monitoring v1.24.3 // indirect
cloud.google.com/go/storage v1.59.2 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
@ -95,7 +96,6 @@ require (
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/api v0.265.0 // indirect
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect

17
go.sum
View File

@ -10,10 +10,16 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
cloud.google.com/go/storage v1.59.2 h1:gmOAuG1opU8YvycMNpP+DvHfT9BfzzK5Cy+arP+Nocw=
cloud.google.com/go/storage v1.59.2/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA=
code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
@ -22,6 +28,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
@ -52,8 +60,11 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cu
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM=
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs=
github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo=
github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@ -86,6 +97,8 @@ github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7O
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
@ -179,6 +192,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNl
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
@ -237,8 +252,6 @@ google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=

View File

@ -0,0 +1,161 @@
package citadel
import (
"context"
"log/slog"
"sync"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// AuditShipper wraps an existing AuditLogger and also ships audit events to Citadel.
// It buffers events and flushes them in batches for efficiency.
type AuditShipper struct {
inner port.AuditLogger
client port.CitadelClient
tenantID string // rdev-platform tenant ID
logger *slog.Logger
mu sync.Mutex
buffer []map[string]any
done chan struct{}
}
// NewAuditShipper wraps an existing AuditLogger with Citadel shipping.
// tenantID is the Citadel tenant ID for the rdev-platform environment.
func NewAuditShipper(inner port.AuditLogger, client port.CitadelClient, tenantID string, logger *slog.Logger) *AuditShipper {
if logger == nil {
logger = slog.Default()
}
s := &AuditShipper{
inner: inner,
client: client,
tenantID: tenantID,
logger: logger.With("component", "audit_shipper"),
buffer: make([]map[string]any, 0, 64),
done: make(chan struct{}),
}
go s.flushLoop()
return s
}
// LogCommandStart records the start of a command and ships to Citadel.
func (s *AuditShipper) LogCommandStart(ctx context.Context, entry *domain.AuditLogEntry) error {
// Always write to primary store first
if err := s.inner.LogCommandStart(ctx, entry); err != nil {
return err
}
// Buffer for Citadel (best-effort, don't block)
s.enqueue(map[string]any{
"message": "audit: command started",
"level": "info",
"service": "rdev-platform",
"event_type": "audit",
"audit_action": "command_start",
"command_id": entry.CommandID,
"command_type": string(entry.CommandType),
"project_id": entry.ProjectID,
"api_key_id": entry.APIKeyID,
"client_ip": entry.ClientIP,
"timestamp": entry.StartedAt.Format(time.RFC3339Nano),
})
return nil
}
// LogCommandEnd records the completion of a command and ships to Citadel.
func (s *AuditShipper) LogCommandEnd(ctx context.Context, commandID string, result *domain.AuditResult) error {
// Always write to primary store first
if err := s.inner.LogCommandEnd(ctx, commandID, result); err != nil {
return err
}
// Buffer for Citadel (best-effort)
s.enqueue(map[string]any{
"message": "audit: command completed",
"level": auditStatusToLevel(result.Status),
"service": "rdev-platform",
"event_type": "audit",
"audit_action": "command_end",
"command_id": commandID,
"status": string(result.Status),
"exit_code": result.ExitCode,
"duration_ms": result.DurationMs,
"error_message": result.ErrorMessage,
"output_size_bytes": result.OutputSizeBytes,
"timestamp": time.Now().Format(time.RFC3339Nano),
})
return nil
}
// List delegates to the inner AuditLogger.
func (s *AuditShipper) List(ctx context.Context, filters domain.AuditFilters) ([]domain.AuditLogEntry, error) {
return s.inner.List(ctx, filters)
}
// Get delegates to the inner AuditLogger.
func (s *AuditShipper) Get(ctx context.Context, commandID string) (*domain.AuditLogEntry, error) {
return s.inner.Get(ctx, commandID)
}
// Close flushes remaining events and stops the background goroutine.
func (s *AuditShipper) Close() {
close(s.done)
s.flush()
}
func (s *AuditShipper) enqueue(event map[string]any) {
s.mu.Lock()
defer s.mu.Unlock()
s.buffer = append(s.buffer, event)
}
func (s *AuditShipper) flushLoop() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.flush()
case <-s.done:
return
}
}
}
func (s *AuditShipper) flush() {
s.mu.Lock()
if len(s.buffer) == 0 {
s.mu.Unlock()
return
}
events := s.buffer
s.buffer = make([]map[string]any, 0, 64)
s.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.client.IngestBatch(ctx, s.tenantID, events); err != nil {
s.logger.Warn("failed to ship audit events to citadel",
"error", err,
"event_count", len(events),
)
}
}
func auditStatusToLevel(status domain.AuditStatus) string {
switch status {
case domain.AuditStatusError:
return "error"
case domain.AuditStatusCancelled:
return "warn"
default:
return "info"
}
}

View File

@ -0,0 +1,244 @@
// Package citadel provides an HTTP client adapter for the Citadel logging platform.
//
// This adapter communicates with a partner-hosted Citadel instance
// (e.g., citadel-staging.orchard9.ai) to manage environments and ship logs.
package citadel
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"github.com/orchard9/rdev/internal/port"
)
// Client implements port.CitadelClient via HTTP.
type Client struct {
baseURL string
apiKey string
httpClient *http.Client
logger *slog.Logger
}
// Ensure Client implements port.CitadelClient.
var _ port.CitadelClient = (*Client)(nil)
// Config holds configuration for the Citadel client.
type Config struct {
// URL is the base URL of the Citadel instance (e.g., "https://citadel-staging.orchard9.ai").
URL string
// APIKey is the API key for authentication (starts with "ck_live_" or "ck_dev_").
APIKey string
}
// NewClient creates a new Citadel HTTP client.
func NewClient(cfg Config, logger *slog.Logger) *Client {
if logger == nil {
logger = slog.Default()
}
return &Client{
baseURL: cfg.URL,
apiKey: cfg.APIKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
logger: logger.With("component", "citadel_client"),
}
}
// CreateEnvironment creates a new Citadel environment.
func (c *Client) CreateEnvironment(ctx context.Context, name string) (*port.CitadelEnvironment, error) {
body := map[string]string{"name": name}
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/environments", bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("create environment request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusConflict {
// Environment already exists — fetch and return it
c.logger.Info("citadel environment already exists, fetching", "name", name)
return c.GetEnvironment(ctx, name)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, c.readError(resp)
}
var result struct {
TenantID string `json:"tenant_id"`
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
c.logger.Info("citadel environment created", "name", name, "tenant_id", result.TenantID)
return &port.CitadelEnvironment{
TenantID: result.TenantID,
Name: result.Name,
}, nil
}
// DeleteEnvironment removes a Citadel environment.
func (c *Client) DeleteEnvironment(ctx context.Context, tenantID string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL+"/api/v1/environments/"+tenantID, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("delete environment request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// 404 is fine — environment may already be gone
if resp.StatusCode == http.StatusNotFound {
return nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return c.readError(resp)
}
c.logger.Info("citadel environment deleted", "tenant_id", tenantID)
return nil
}
// GetEnvironment returns an environment by name.
func (c *Client) GetEnvironment(ctx context.Context, name string) (*port.CitadelEnvironment, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/environments?name="+name, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("get environment request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, c.readError(resp)
}
var result struct {
TenantID string `json:"tenant_id"`
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &port.CitadelEnvironment{
TenantID: result.TenantID,
Name: result.Name,
}, nil
}
// IngestEvent sends a single log event to Citadel.
func (c *Client) IngestEvent(ctx context.Context, tenantID string, event map[string]any) error {
data, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("marshal event: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/ingest/event", bytes.NewReader(data))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
req.Header.Set("X-Tenant-ID", tenantID)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("ingest event request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return c.readError(resp)
}
return nil
}
// IngestBatch sends a batch of log events to Citadel.
func (c *Client) IngestBatch(ctx context.Context, tenantID string, events []map[string]any) error {
data, err := json.Marshal(events)
if err != nil {
return fmt.Errorf("marshal events: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/ingest", bytes.NewReader(data))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
req.Header.Set("X-Tenant-ID", tenantID)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("ingest batch request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return c.readError(resp)
}
return nil
}
// Healthy returns true if the Citadel instance is reachable.
func (c *Client) Healthy(ctx context.Context) bool {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil)
if err != nil {
return false
}
resp, err := c.httpClient.Do(req)
if err != nil {
return false
}
defer func() { _ = resp.Body.Close() }()
return resp.StatusCode == http.StatusOK
}
func (c *Client) setHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
func (c *Client) readError(resp *http.Response) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("citadel API error (HTTP %d): %s", resp.StatusCode, string(body))
}

View File

@ -156,6 +156,10 @@ func (d *Deployer) buildDeployment(spec domain.DeploySpec, ns string, replicas i
if spec.ComponentPath != "" {
labels["component"] = sanitizeLabelValue(spec.ComponentPath)
}
// Apply extra labels (e.g., citadel.io/environment for log routing)
for k, v := range spec.ExtraLabels {
labels[k] = v
}
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{

View File

@ -0,0 +1,341 @@
package templates
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// availableComponentTemplates lists all supported component templates.
var availableComponentTemplates = []port.ComponentTemplateInfo{
{
Type: "service",
Description: "Go API service using pkg/ shared packages",
Stack: "go",
DefaultPort: 8080,
DestDir: "services",
},
{
Type: "worker",
Description: "Go background worker for async job processing",
Stack: "go",
DefaultPort: 0, // Workers don't expose ports
DestDir: "workers",
},
{
Type: "app-astro",
Description: "Astro landing page with Tailwind CSS",
Stack: "astro",
DefaultPort: 4321,
DestDir: "apps",
},
{
Type: "app-react",
Description: "React SPA with Vite, TypeScript, and Tailwind",
Stack: "react",
DefaultPort: 5173,
DestDir: "apps",
},
{
Type: "app-nextjs",
Description: "Next.js 14 dashboard with App Router and design system",
Stack: "nextjs",
DefaultPort: 3000,
DestDir: "apps",
},
{
Type: "cli",
Description: "Go CLI tool using Cobra",
Stack: "go",
DefaultPort: 0, // CLIs don't expose ports
DestDir: "cli",
},
}
// GetComponentTemplate returns info about a specific component template.
func (p *Provider) GetComponentTemplate(ctx context.Context, componentType string) (*port.ComponentTemplateInfo, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
for _, t := range availableComponentTemplates {
if t.Type == componentType {
result := t
files, err := listComponentTemplateFiles(componentType)
if err == nil {
result.Files = files
}
return &result, nil
}
}
return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType)
}
// ListComponentTemplates returns available component templates.
// If componentType is empty, returns all templates; otherwise filters by type.
func (p *Provider) ListComponentTemplates(ctx context.Context, componentType string) ([]port.ComponentTemplateInfo, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
var result []port.ComponentTemplateInfo
for _, t := range availableComponentTemplates {
if componentType != "" && t.Type != componentType {
continue
}
info := t
files, err := listComponentTemplateFiles(t.Type)
if err == nil {
info.Files = files
}
result = append(result, info)
}
return result, nil
}
// GetComponentFiles returns the files for a component template with variables interpolated.
func (p *Provider) GetComponentFiles(ctx context.Context, componentType string, destPath string, vars map[string]string) ([]port.ComponentFile, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// Validate component type exists
found := false
for _, t := range availableComponentTemplates {
if t.Type == componentType {
found = true
break
}
}
if !found {
return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType)
}
templateDir := "templates/components/" + componentType
var files []port.ComponentFile
err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
// Read file content
content, err := templatesFS.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read component template file %s: %w", path, err)
}
// Interpolate variables
interpolated := interpolateVars(string(content), vars)
// Calculate relative path from component template root
relPath, err := filepath.Rel(templateDir, path)
if err != nil {
return fmt.Errorf("failed to get relative path: %w", err)
}
// Strip .tmpl extension
relPath = strings.TrimSuffix(relPath, ".tmpl")
// Prepend destination path
fullPath := filepath.Join(destPath, relPath)
files = append(files, port.ComponentFile{
Path: fullPath,
Content: interpolated,
})
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to collect component template files: %w", err)
}
if len(files) == 0 {
return nil, fmt.Errorf("component template %s contains no files", componentType)
}
p.logger.Debug("prepared component files",
"component_type", componentType,
"dest_path", destPath,
"file_count", len(files),
)
return files, nil
}
// listComponentTemplateFiles returns the list of files in a component template.
func listComponentTemplateFiles(componentType string) ([]string, error) {
templateDir := "templates/components/" + componentType
var files []string
err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
relPath, err := filepath.Rel(templateDir, path)
if err != nil {
return err
}
// Strip .tmpl extension for display
relPath = strings.TrimSuffix(relPath, ".tmpl")
files = append(files, relPath)
return nil
})
return files, err
}
// GetComponentWoodpeckerStep returns the .woodpecker.step.yml content for a component.
// This is the CI step that should be inserted into the main .woodpecker.yml file.
func (p *Provider) GetComponentWoodpeckerStep(ctx context.Context, componentType string, vars map[string]string) (string, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
stepPath := "templates/components/" + componentType + "/.woodpecker.step.yml.tmpl"
content, err := templatesFS.ReadFile(stepPath)
if err != nil {
return "", fmt.Errorf("failed to read woodpecker step template: %w", err)
}
return interpolateVars(string(content), vars), nil
}
// RenderSkeletonToDir renders the monorepo skeleton template to a local directory.
// This is used for testing templates locally without needing Gitea.
func RenderSkeletonToDir(outputDir string, vars map[string]string) error {
templateDir := "templates/skeleton"
return fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
// Read file content
content, err := templatesFS.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read template file %s: %w", path, err)
}
// Interpolate variables
interpolated := interpolateVars(string(content), vars)
// Calculate relative path from template root
relPath, err := filepath.Rel(templateDir, path)
if err != nil {
return fmt.Errorf("failed to get relative path: %w", err)
}
// Strip .tmpl extension
relPath = strings.TrimSuffix(relPath, ".tmpl")
// Create output path
outPath := filepath.Join(outputDir, relPath)
// Create parent directories
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return fmt.Errorf("failed to create directory for %s: %w", outPath, err)
}
// Write file
if err := os.WriteFile(outPath, []byte(interpolated), 0644); err != nil {
return fmt.Errorf("failed to write file %s: %w", outPath, err)
}
return nil
})
}
// RenderComponentToDir renders a component template to a local directory.
// The destPath is relative to outputDir (e.g., "services/my-api").
func RenderComponentToDir(outputDir, componentType, destPath string, vars map[string]string) error {
// Validate component type exists
found := false
for _, t := range availableComponentTemplates {
if t.Type == componentType {
found = true
break
}
}
if !found {
return fmt.Errorf("unknown component type: %s", componentType)
}
templateDir := "templates/components/" + componentType
return fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
// Skip .woodpecker.step.yml.tmpl - it's for CI insertion, not file creation
if strings.HasSuffix(path, ".woodpecker.step.yml.tmpl") {
return nil
}
// Read file content
content, err := templatesFS.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read component template file %s: %w", path, err)
}
// Interpolate variables
interpolated := interpolateVars(string(content), vars)
// Calculate relative path from component template root
relPath, err := filepath.Rel(templateDir, path)
if err != nil {
return fmt.Errorf("failed to get relative path: %w", err)
}
// Strip .tmpl extension
relPath = strings.TrimSuffix(relPath, ".tmpl")
// Create output path under destPath
outPath := filepath.Join(outputDir, destPath, relPath)
// Create parent directories
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return fmt.Errorf("failed to create directory for %s: %w", outPath, err)
}
// Write file
if err := os.WriteFile(outPath, []byte(interpolated), 0644); err != nil {
return fmt.Errorf("failed to write file %s: %w", outPath, err)
}
return nil
})
}

View File

@ -43,52 +43,6 @@ var skeletonTemplate = port.TemplateInfo{
Stack: "monorepo",
}
// availableComponentTemplates lists all supported component templates.
var availableComponentTemplates = []port.ComponentTemplateInfo{
{
Type: "service",
Description: "Go API service using pkg/ shared packages",
Stack: "go",
DefaultPort: 8080,
DestDir: "services",
},
{
Type: "worker",
Description: "Go background worker for async job processing",
Stack: "go",
DefaultPort: 0, // Workers don't expose ports
DestDir: "workers",
},
{
Type: "app-astro",
Description: "Astro landing page with Tailwind CSS",
Stack: "astro",
DefaultPort: 4321,
DestDir: "apps",
},
{
Type: "app-react",
Description: "React SPA with Vite, TypeScript, and Tailwind",
Stack: "react",
DefaultPort: 5173,
DestDir: "apps",
},
{
Type: "app-nextjs",
Description: "Next.js 14 dashboard with App Router and design system",
Stack: "nextjs",
DefaultPort: 3000,
DestDir: "apps",
},
{
Type: "cli",
Description: "Go CLI tool using Cobra",
Stack: "go",
DefaultPort: 0, // CLIs don't expose ports
DestDir: "cli",
},
}
// templateNameRegex validates template names (alphanumeric, dash only).
var templateNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
@ -323,172 +277,3 @@ func (p *Provider) GetSkeleton(ctx context.Context) (*port.TemplateInfo, error)
}
return &result, nil
}
// GetComponentTemplate returns info about a specific component template.
func (p *Provider) GetComponentTemplate(ctx context.Context, componentType string) (*port.ComponentTemplateInfo, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
for _, t := range availableComponentTemplates {
if t.Type == componentType {
result := t
files, err := listComponentTemplateFiles(componentType)
if err == nil {
result.Files = files
}
return &result, nil
}
}
return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType)
}
// ListComponentTemplates returns available component templates.
// If componentType is empty, returns all templates; otherwise filters by type.
func (p *Provider) ListComponentTemplates(ctx context.Context, componentType string) ([]port.ComponentTemplateInfo, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
var result []port.ComponentTemplateInfo
for _, t := range availableComponentTemplates {
if componentType != "" && t.Type != componentType {
continue
}
info := t
files, err := listComponentTemplateFiles(t.Type)
if err == nil {
info.Files = files
}
result = append(result, info)
}
return result, nil
}
// GetComponentFiles returns the files for a component template with variables interpolated.
func (p *Provider) GetComponentFiles(ctx context.Context, componentType string, destPath string, vars map[string]string) ([]port.ComponentFile, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// Validate component type exists
found := false
for _, t := range availableComponentTemplates {
if t.Type == componentType {
found = true
break
}
}
if !found {
return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType)
}
templateDir := "templates/components/" + componentType
var files []port.ComponentFile
err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
// Read file content
content, err := templatesFS.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read component template file %s: %w", path, err)
}
// Interpolate variables
interpolated := interpolateVars(string(content), vars)
// Calculate relative path from component template root
relPath, err := filepath.Rel(templateDir, path)
if err != nil {
return fmt.Errorf("failed to get relative path: %w", err)
}
// Strip .tmpl extension
relPath = strings.TrimSuffix(relPath, ".tmpl")
// Prepend destination path
fullPath := filepath.Join(destPath, relPath)
files = append(files, port.ComponentFile{
Path: fullPath,
Content: interpolated,
})
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to collect component template files: %w", err)
}
if len(files) == 0 {
return nil, fmt.Errorf("component template %s contains no files", componentType)
}
p.logger.Debug("prepared component files",
"component_type", componentType,
"dest_path", destPath,
"file_count", len(files),
)
return files, nil
}
// listComponentTemplateFiles returns the list of files in a component template.
func listComponentTemplateFiles(componentType string) ([]string, error) {
templateDir := "templates/components/" + componentType
var files []string
err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
relPath, err := filepath.Rel(templateDir, path)
if err != nil {
return err
}
// Strip .tmpl extension for display
relPath = strings.TrimSuffix(relPath, ".tmpl")
files = append(files, relPath)
return nil
})
return files, err
}
// GetComponentWoodpeckerStep returns the .woodpecker.step.yml content for a component.
// This is the CI step that should be inserted into the main .woodpecker.yml file.
func (p *Provider) GetComponentWoodpeckerStep(ctx context.Context, componentType string, vars map[string]string) (string, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
stepPath := "templates/components/" + componentType + "/.woodpecker.step.yml.tmpl"
content, err := templatesFS.ReadFile(stepPath)
if err != nil {
return "", fmt.Errorf("failed to read woodpecker step template: %w", err)
}
return interpolateVars(string(content), vars), nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,190 @@
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import { useAuth } from '@{{PROJECT_NAME}}/auth';
import { useChat } from '@{{PROJECT_NAME}}/realtime';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
ChatBubble,
ChatInput,
Badge,
ProviderBadge,
} from '@{{PROJECT_NAME}}/ui';
interface TimelineMessage {
id: string;
content: string;
role: 'user' | 'assistant' | 'system';
timestamp: Date;
provider?: string;
isStreaming?: boolean;
}
export function ChatPage() {
const { user, getToken } = useAuth();
const messagesEndRef = useRef<HTMLDivElement>(null);
// API base URL from environment
const apiBaseUrl = import.meta.env.VITE_API_URL || '';
const authHeaders = useMemo(() => {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : undefined;
}, [getToken]);
const {
messages,
aiMessages,
streamingMessages,
sendMessage,
connectionState,
onlineUsers,
} = useChat({
endpoint: `${apiBaseUrl}/api/{{SERVICE_NAME}}/chat/messages`,
sseEndpoint: `${apiBaseUrl}/api/{{SERVICE_NAME}}/events`,
channel: 'channel:general',
userId: user?.id || 'anonymous',
userName: user?.name || user?.email || 'Anonymous',
headers: authHeaders,
});
// Track send errors for user feedback
const [sendError, setSendError] = useState<string | null>(null);
// Merge user messages + AI messages into a single sorted timeline
const timeline = useMemo<TimelineMessage[]>(() => {
const combined: TimelineMessage[] = [];
for (const msg of messages) {
combined.push({
id: msg.id,
content: msg.content,
role: msg.userId === user?.id ? 'user' : 'assistant',
timestamp: new Date(msg.timestamp),
});
}
for (const msg of aiMessages) {
combined.push({
id: msg.id,
content: msg.content,
role: 'assistant',
timestamp: new Date(msg.timestamp),
provider: msg.provider,
});
}
// Add in-progress streaming messages
for (const [, stream] of streamingMessages) {
combined.push({
id: stream.streamId,
content: stream.content,
role: 'assistant',
timestamp: new Date(stream.timestamp),
isStreaming: true,
});
}
combined.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
return combined;
}, [messages, aiMessages, streamingMessages, user?.id]);
// Handle sending a message (wraps async sendMessage for ChatInput)
const handleSendMessage = useCallback((content: string) => {
sendMessage(content).catch(() => {
setSendError('Failed to send message. Please try again.');
setTimeout(() => setSendError(null), 3000);
});
}, [sendMessage]);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [timeline]);
const connectionBadge = () => {
switch (connectionState) {
case 'connected':
return <Badge variant="success">Connected</Badge>;
case 'connecting':
return <Badge variant="warning">Connecting...</Badge>;
case 'disconnected':
return <Badge variant="error">Disconnected</Badge>;
case 'error':
return <Badge variant="error">Error</Badge>;
default:
return null;
}
};
return (
<div className="h-[calc(100vh-8rem)] flex flex-col">
<Card className="flex-1 flex flex-col">
<CardHeader className="flex-shrink-0 flex flex-row items-center justify-between">
<div>
<CardTitle>AI Chat</CardTitle>
<CardDescription>
Chat with AI in real-time
</CardDescription>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-[var(--text-muted)]">
{onlineUsers.length} online
</span>
{connectionBadge()}
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0">
{/* Messages area */}
<div className="flex-1 overflow-y-auto space-y-4 pr-2 mb-4">
{timeline.length === 0 ? (
<div className="h-full flex items-center justify-center">
<p className="text-[var(--text-muted)] text-sm">
No messages yet. Start the conversation!
</p>
</div>
) : (
timeline.map((msg) => (
<div key={msg.id}>
<ChatBubble
role={msg.role}
content={msg.content}
timestamp={msg.timestamp}
isStreaming={msg.isStreaming}
/>
{msg.provider && (
<div className={msg.role === 'user' ? 'text-right' : 'text-left'}>
<ProviderBadge provider={msg.provider} className="mt-1" />
</div>
)}
</div>
))
)}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div className="flex-shrink-0 space-y-2">
{sendError && (
<div className="px-3 py-2 text-sm text-[var(--error)] bg-[var(--error)]/10 rounded-lg">
{sendError}
</div>
)}
<ChatInput
onSubmit={handleSendMessage}
disabled={connectionState !== 'connected'}
placeholder={
connectionState === 'connected'
? 'Type a message... (Cmd+Enter to send)'
: 'Connecting...'
}
/>
</div>
</CardContent>
</Card>
</div>
);
}

View File

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

View File

@ -0,0 +1,109 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@{{PROJECT_NAME}}/auth';
import {
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
FormField,
useFormErrors,
Alert,
AlertDescription,
Loader2,
} from '@{{PROJECT_NAME}}/ui';
import { isApiClientError } from '@{{PROJECT_NAME}}/api-client';
export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { login, isLoading } = useAuth();
const { setErrors, clearErrors, getError } = useFormErrors();
const [generalError, setGeneralError] = useState<string | null>(null);
// Get the redirect path from location state, default to dashboard
const from = (location.state as { from?: string })?.from || '/';
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
clearErrors();
setGeneralError(null);
const formData = new FormData(e.currentTarget);
const email = formData.get('email') as string;
const password = formData.get('password') as string;
try {
await login({ email, password });
navigate(from, { replace: true });
} catch (error) {
if (isApiClientError(error)) {
if (error.isValidationError()) {
setErrors(error.getFieldErrors());
} else {
setGeneralError(error.message);
}
} else {
setGeneralError('An unexpected error occurred. Please try again.');
}
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)] p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Welcome back</CardTitle>
<CardDescription>Sign in to your {{PROJECT_NAME}} account</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{generalError && (
<Alert variant="destructive">
<AlertDescription>{generalError}</AlertDescription>
</Alert>
)}
<FormField
label="Email"
name="email"
type="email"
placeholder="you@example.com"
error={getError('email')}
required
autoComplete="email"
autoFocus
/>
<FormField
label="Password"
name="password"
type="password"
placeholder="Enter your password"
error={getError('password')}
required
autoComplete="current-password"
/>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign in
</Button>
<p className="text-sm text-center text-[var(--text-muted)]">
Demo accounts: test@example.com / password123
<br />
or admin@example.com / admin123
</p>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@ -0,0 +1,129 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useAuth } from '@{{PROJECT_NAME}}/auth';
import { useMediaUpload } from '@{{PROJECT_NAME}}/realtime';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
MediaUploader,
MediaLibrary,
type MediaItem,
Badge,
} from '@{{PROJECT_NAME}}/ui';
export function MediaPage() {
const { getToken } = useAuth();
const [items, setItems] = useState<MediaItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [deleteError, setDeleteError] = useState<string | null>(null);
const apiPrefix = import.meta.env.VITE_API_URL || '';
const authHeaders = useMemo(() => {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : undefined;
}, [getToken]);
const mediaUpload = useMediaUpload({
apiPrefix,
serviceName: '{{SERVICE_NAME}}',
headers: authHeaders,
});
const [fetchError, setFetchError] = useState<string | null>(null);
const fetchMedia = useCallback(async () => {
setFetchError(null);
try {
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/media`, {
headers: { ...authHeaders },
});
if (!res.ok) {
setFetchError(`Failed to load media (${res.status})`);
return;
}
const data = await res.json();
setItems(data.items || []);
} catch (err) {
setFetchError(err instanceof Error ? err.message : 'Failed to load media');
} finally {
setIsLoading(false);
}
}, [apiPrefix, authHeaders]);
useEffect(() => {
fetchMedia();
}, [fetchMedia]);
const handleUploadComplete = useCallback(() => {
fetchMedia();
}, [fetchMedia]);
const handleDelete = useCallback(async (path: string) => {
setDeleteError(null);
try {
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/media/${path}`, {
method: 'DELETE',
headers: { ...authHeaders },
});
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
setItems((prev) => prev.filter((item) => item.path !== path));
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Delete failed');
}
}, [apiPrefix, authHeaders]);
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Upload Media</CardTitle>
<CardDescription>
Upload images and videos to your media library
</CardDescription>
</CardHeader>
<CardContent>
<MediaUploader
upload={mediaUpload.upload}
isUploading={mediaUpload.isUploading}
progress={mediaUpload.progress}
onUploadComplete={handleUploadComplete}
onError={(err) => console.error('Upload error:', err)}
/>
{mediaUpload.error && (
<p className="mt-2 text-sm text-[var(--error)]">{mediaUpload.error}</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Media Library</CardTitle>
<CardDescription>
Your uploaded and generated media files
</CardDescription>
</div>
{items.length > 0 && (
<Badge variant="outline">{items.length} files</Badge>
)}
</CardHeader>
<CardContent>
{(deleteError || fetchError) && (
<p className="mb-4 text-sm text-[var(--error)]">{deleteError || fetchError}</p>
)}
{isLoading ? (
<div className="text-center py-8 text-[var(--text-muted)]">Loading...</div>
) : (
<MediaLibrary
items={items}
onDelete={handleDelete}
/>
)}
</CardContent>
</Card>
</div>
);
}

View File

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

View File

@ -6,6 +6,27 @@ export default defineConfig({
plugins: [react()],
server: {
port: {{PORT}},
proxy: {
// SSE events endpoint — must disable buffering for streaming
'/api/{{SERVICE_NAME}}/events': {
target: 'http://localhost:{{SERVICE_PORT}}',
changeOrigin: true,
// Disable response buffering so SSE events stream immediately
configure: (proxy) => {
proxy.on('proxyRes', (proxyRes) => {
// Prevent Vite from buffering SSE responses
if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
proxyRes.headers['cache-control'] = 'no-cache';
proxyRes.headers['x-accel-buffering'] = 'no';
}
});
},
},
'/api': {
target: 'http://localhost:{{SERVICE_PORT}}',
changeOrigin: true,
},
},
},
preview: {
port: {{PORT}},

View File

@ -1,4 +1,4 @@
.PHONY: build run test lint fmt docker-build clean
.PHONY: build run dev test lint fmt docker-build clean
SERVICE := {{COMPONENT_NAME}}
BINARY := bin/$(SERVICE)
@ -12,6 +12,10 @@ build:
run:
go run ./cmd/server
# Run the service in development mode (alias for run)
dev:
go run ./cmd/server
# Run tests
test:
go test -v ./...

View File

@ -2,14 +2,30 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"time"
"github.com/redis/go-redis/v9"
"{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/database"
"{{GO_MODULE}}/pkg/gemini"
"{{GO_MODULE}}/pkg/laozhang"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/pkg/mediagen"
mediagenAdapters "{{GO_MODULE}}/pkg/mediagen/adapters"
"{{GO_MODULE}}/pkg/generation"
"{{GO_MODULE}}/pkg/queue"
"{{GO_MODULE}}/pkg/realtime"
"{{GO_MODULE}}/pkg/storage"
"{{GO_MODULE}}/pkg/textgen"
textgenAdapters "{{GO_MODULE}}/pkg/textgen/adapters"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/memory"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/config"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service"
)
@ -30,21 +46,247 @@ func main() {
os.Exit(0)
}
// Load config
cfg := config.Load()
// Create logger
logger := logging.Default()
ctx := context.Background()
// Create SSE hub for async event delivery (generation progress, chat, etc.)
sseHub := realtime.NewSSEHub(logger.Logger)
// Initialize storage backend (before queue, since standalone queue handlers use it).
// GCS_BUCKET set = production (GCS). Otherwise = dev (in-memory).
port := fmt.Sprintf("%d", {{PORT}})
var mediaStore storage.Store
if bucket := os.Getenv("GCS_BUCKET"); bucket != "" {
gcsStore, err := storage.NewGCSStore(bucket, os.Getenv("GCS_SERVICE_ACCOUNT_JSON"), logger.Logger)
if err != nil {
logger.Error("failed to create GCS store", "error", err)
os.Exit(1)
}
defer func() { _ = gcsStore.Close() }()
mediaStore = gcsStore
logger.Info("storage initialized (GCS)", "bucket", bucket)
} else {
memStore := storage.NewMemoryStore("http://localhost:" + port + "/storage")
mediaStore = memStore
logger.Info("storage initialized (in-memory dev mode)")
}
// Select queue backend based on DATABASE_URL availability.
// With DATABASE_URL: DB queue + separate worker process (production)
// Without DATABASE_URL: in-memory queue + in-process handlers (development)
var jobQueue queue.Producer
if cfg.Database.URL != "" {
jobQueue = setupDBQueue(ctx, cfg, sseHub, logger)
} else {
logger.Info("DATABASE_URL not set — running in standalone mode (in-memory queue + in-process AI)")
jobQueue = setupStandaloneQueue(ctx, mediaStore, sseHub, logger)
}
// Create adapters (repositories)
exampleRepo := memory.NewExampleRepository()
userRepo := memory.NewUserRepository()
// Create services (business logic)
exampleService := service.NewExampleService(exampleRepo, logger)
authService := service.NewAuthService(userRepo, cfg.JWTSecret, logger)
// Create application
application := app.New("{{COMPONENT_NAME}}", app.WithDefaultPort({{PORT}}))
// Mount in-memory storage HTTP handler for dev mode
if memStore, ok := mediaStore.(*storage.MemoryStore); ok {
application.Router().Handle("/storage/*", memStore)
}
// Register routes with dependency injection
api.RegisterRoutes(application, exampleService)
api.RegisterRoutes(application, &api.Dependencies{
ExampleService: exampleService,
AuthService: authService,
Queue: jobQueue,
SSEHub: sseHub,
Store: mediaStore,
})
// Start server
application.Run()
}
// setupDBQueue initializes the production queue backend with database + optional Redis.
func setupDBQueue(ctx context.Context, cfg *config.Config, sseHub *realtime.SSEHub, logger *logging.Logger) queue.Producer {
pool, err := database.Connect(ctx, cfg.Database.URL, database.Options{
MaxOpenConns: cfg.Database.MaxOpenConns,
MaxIdleConns: cfg.Database.MaxIdleConns,
ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
})
if err != nil {
logger.Error("failed to connect to database", "error", err)
os.Exit(1)
}
// Note: pool is not deferred here since it's needed for the lifetime of the process.
// The OS reclaims resources on exit.
logger.Info("connected to database")
if err := queue.RunMigrations(ctx, pool); err != nil {
logger.Error("failed to run queue migrations", "error", err)
os.Exit(1)
}
logger.Info("queue migrations complete")
jobQueue := queue.NewQueue(pool.DB, logger)
// Start Redis SSE subscriber if configured.
if cfg.RedisURL != "" {
opts, err := redis.ParseURL(cfg.RedisURL)
if err != nil {
logger.Error("failed to parse REDIS_URL", "error", err)
os.Exit(1)
}
redisClient := redis.NewClient(opts)
if err := redisClient.Ping(ctx).Err(); err != nil {
logger.Error("failed to connect to Redis", "error", err)
os.Exit(1)
}
logger.Info("connected to Redis")
go func() {
if err := realtime.RunSSESubscriber(ctx, redisClient, sseHub, logger.Logger); err != nil {
logger.Error("SSE Redis subscriber stopped", "error", err)
}
}()
} else {
logger.Warn("REDIS_URL not set — SSE events from worker will not be delivered")
}
return jobQueue
}
// setupStandaloneQueue initializes an in-memory queue with in-process AI handlers.
// This mode requires no database or Redis — everything runs in a single process.
func setupStandaloneQueue(ctx context.Context, store storage.Store, sseHub *realtime.SSEHub, logger *logging.Logger) queue.Producer {
memQueue := queue.NewMemoryQueue(logger.Logger)
// LocalPublisher delivers events directly to the SSE hub (no Redis needed).
pub := realtime.NewLocalPublisher(sseHub)
// Initialize AI providers
mediagenManager := initMediagen(ctx, logger)
textgenManager := initTextgen(ctx, logger)
// Register job handlers (same handlers the worker uses).
if mediagenManager != nil {
memQueue.RegisterHandler("generate_image", generation.ImageHandler(mediagenManager, store, pub, logger))
memQueue.RegisterHandler("generate_video", generation.VideoHandler(mediagenManager, store, pub, logger))
}
if textgenManager != nil {
memQueue.RegisterHandler("generate_text", generation.TextHandler(textgenManager, pub, logger))
memQueue.RegisterHandler("ai_chat_response", generation.ChatResponseHandler(textgenManager, pub, logger))
}
return memQueue
}
// initMediagen creates a mediagen manager from available AI provider credentials.
func initMediagen(ctx context.Context, logger *logging.Logger) *mediagen.Manager {
var laozhangMediaProvider *mediagenAdapters.LaoZhangProvider
var geminiMediaProvider *mediagenAdapters.GeminiProvider
if apiKey := os.Getenv("LAOZHANG_API_KEY"); apiKey != "" {
client, err := laozhang.NewClient(laozhang.Config{
APIKey: apiKey,
VideoTimeout: 5 * time.Minute,
Logger: logger.Logger,
})
if err != nil {
logger.Warn("failed to create LaoZhang client", "error", err)
} else {
laozhangMediaProvider = mediagenAdapters.NewLaoZhangProvider(client)
logger.Info("LaoZhang media provider initialized")
}
}
if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
client, err := gemini.NewClient(ctx, gemini.Config{
APIKey: apiKey,
Logger: logger.Logger,
})
if err != nil {
logger.Warn("failed to create Gemini client", "error", err)
} else {
geminiMediaProvider = mediagenAdapters.NewGeminiProvider(client)
logger.Info("Gemini media provider initialized")
}
}
if laozhangMediaProvider == nil && geminiMediaProvider == nil {
logger.Warn("no media generation providers available (set LAOZHANG_API_KEY or GEMINI_API_KEY)")
return nil
}
mgCfg := mediagen.ProductionConfig(mediagen.ProviderSet{
LaoZhang: laozhangMediaProvider,
Gemini: geminiMediaProvider,
}, mediagen.WithLogger(logger.Logger))
if laozhangMediaProvider != nil {
mgCfg.VideoProviders = append(mgCfg.VideoProviders, laozhangMediaProvider)
}
if geminiMediaProvider != nil {
mgCfg.VideoProviders = append(mgCfg.VideoProviders, geminiMediaProvider)
}
mgr, err := mediagen.NewManager(mgCfg)
if err != nil {
logger.Warn("failed to create mediagen manager", "error", err)
return nil
}
logger.Info("mediagen manager initialized (image + video)")
return mgr
}
// initTextgen creates a textgen manager from available AI provider credentials.
func initTextgen(ctx context.Context, logger *logging.Logger) *textgen.Manager {
var textProviders []textgen.TextGenerator
if apiKey := os.Getenv("LAOZHANG_API_KEY"); apiKey != "" {
client, err := laozhang.NewClient(laozhang.Config{
APIKey: apiKey,
Logger: logger.Logger,
})
if err != nil {
logger.Warn("failed to create LaoZhang text client", "error", err)
} else {
textProviders = append(textProviders, textgenAdapters.NewLaoZhangTextProvider(client, ""))
}
}
if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
provider, err := textgenAdapters.NewGeminiTextProvider(ctx, textgenAdapters.GeminiTextConfig{
APIKey: apiKey,
})
if err != nil {
logger.Warn("failed to create Gemini text provider", "error", err)
} else {
textProviders = append(textProviders, provider)
}
}
if len(textProviders) == 0 {
logger.Warn("no text generation providers available")
return nil
}
tgCfg := textgen.ProductionConfig(textgen.ProviderSet{}, textgen.WithLogger(logger.Logger))
tgCfg.Providers = textProviders
mgr, err := textgen.NewManager(tgCfg)
if err != nil {
logger.Warn("failed to create textgen manager", "error", err)
return nil
}
logger.Info("textgen manager initialized")
return mgr
}

View File

@ -0,0 +1,92 @@
package memory
import (
"context"
"sync"
"{{GO_MODULE}}/pkg/auth"
)
// userEntry stores a user with their password for demo purposes.
type userEntry struct {
user *auth.User
password string
}
// UserRepository is an in-memory user store for demo/testing purposes.
// Pre-populated with demo users.
type UserRepository struct {
mu sync.RWMutex
users map[string]*userEntry // keyed by email
}
// NewUserRepository creates a new in-memory user repository with demo users.
func NewUserRepository() *UserRepository {
repo := &UserRepository{
users: make(map[string]*userEntry),
}
// Add demo users
repo.users["test@example.com"] = &userEntry{
user: &auth.User{
ID: "usr_test_001",
Email: "test@example.com",
Roles: []string{"user"},
Metadata: map[string]any{
"name": "Test User",
},
},
password: "password123",
}
repo.users["admin@example.com"] = &userEntry{
user: &auth.User{
ID: "usr_admin_001",
Email: "admin@example.com",
Roles: []string{"admin", "user"},
Metadata: map[string]any{
"name": "Admin User",
},
},
password: "admin123",
}
return repo
}
// FindByEmail returns a user by email address.
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*auth.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
entry, ok := r.users[email]
if !ok {
return nil, nil
}
return entry.user, nil
}
// FindByID returns a user by ID.
func (r *UserRepository) FindByID(ctx context.Context, id string) (*auth.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, entry := range r.users {
if entry.user.ID == id {
return entry.user, nil
}
}
return nil, nil
}
// ValidatePassword checks if the password matches for a user.
func (r *UserRepository) ValidatePassword(ctx context.Context, user *auth.User, password string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
entry, ok := r.users[user.Email]
if !ok {
return false
}
return entry.password == password
}

View File

@ -0,0 +1,127 @@
package handlers
import (
"errors"
"net/http"
"{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/auth"
"{{GO_MODULE}}/pkg/httperror"
"{{GO_MODULE}}/pkg/httpresponse"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service"
)
// Auth handles authentication HTTP requests.
type Auth struct {
svc *service.AuthService
logger *logging.Logger
}
// NewAuth creates a new Auth handler with injected dependencies.
func NewAuth(svc *service.AuthService, logger *logging.Logger) *Auth {
return &Auth{
svc: svc,
logger: logger.WithComponent("AuthHandler"),
}
}
// LoginRequest is the request body for login.
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=1"`
}
// LoginResponse is the response for successful login.
type LoginResponse struct {
Token string `json:"token"`
User UserResponse `json:"user"`
}
// UserResponse is the user data returned in auth responses.
type UserResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name,omitempty"`
Roles []string `json:"roles,omitempty"`
}
// toUserResponse converts an auth.User to UserResponse.
func toUserResponse(u *auth.User) UserResponse {
name := ""
if u.Metadata != nil {
if n, ok := u.Metadata["name"].(string); ok {
name = n
}
}
return UserResponse{
ID: u.ID,
Email: u.Email,
Name: name,
Roles: u.Roles,
}
}
// Login authenticates a user and returns a JWT token.
//
// POST /api/{service}/auth/login
func (h *Auth) Login(w http.ResponseWriter, r *http.Request) error {
var req LoginRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
output, err := h.svc.Login(r.Context(), service.LoginInput{
Email: req.Email,
Password: req.Password,
})
if err != nil {
if errors.Is(err, service.ErrInvalidCredentials) {
return httperror.Unauthorized("invalid email or password")
}
return err
}
httpresponse.OK(w, r, LoginResponse{
Token: output.Token,
User: toUserResponse(output.User),
})
return nil
}
// Me returns the current authenticated user.
//
// GET /api/{service}/auth/me
func (h *Auth) Me(w http.ResponseWriter, r *http.Request) error {
user, err := auth.GetUserOrError(r.Context())
if err != nil {
return httperror.Unauthorized("not authenticated")
}
// Optionally refresh user data from repository
freshUser, err := h.svc.GetCurrentUser(r.Context(), user.ID)
if err != nil {
if errors.Is(err, service.ErrUserNotFound) {
return httperror.Unauthorized("user not found")
}
return err
}
httpresponse.OK(w, r, toUserResponse(freshUser))
return nil
}
// Logout handles user logout.
// This is a stateless operation since we use JWTs.
//
// POST /api/{service}/auth/logout
func (h *Auth) Logout(w http.ResponseWriter, r *http.Request) error {
// With JWT-based auth, logout is handled client-side by discarding the token.
// This endpoint exists for API completeness and could be extended to:
// - Add the token to a blacklist
// - Clear server-side sessions if using hybrid auth
// - Log the logout event
httpresponse.NoContent(w)
return nil
}

View File

@ -0,0 +1,94 @@
package handlers
import (
"net/http"
"time"
"github.com/google/uuid"
"{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/auth"
"{{GO_MODULE}}/pkg/httpresponse"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/pkg/queue"
"{{GO_MODULE}}/pkg/realtime"
)
// Chat handles HTTP requests for chat messaging with AI responses.
// User messages are broadcast immediately via SSE.
// AI responses are enqueued and processed by the worker with streaming chunks.
type Chat struct {
queue queue.Producer
sseHub *realtime.SSEHub
logger *logging.Logger
}
// NewChat creates a new Chat handler.
func NewChat(q queue.Producer, hub *realtime.SSEHub, logger *logging.Logger) *Chat {
return &Chat{
queue: q,
sseHub: hub,
logger: logger.WithComponent("ChatHandler"),
}
}
// SendMessageRequest is the request body for sending a chat message.
type SendMessageRequest struct {
Content string `json:"content" validate:"required,min=1,max=5000"`
}
// SendMessage broadcasts a chat message to a channel via SSE
// and enqueues an AI response job for the worker.
func (h *Chat) SendMessage(w http.ResponseWriter, r *http.Request) error {
var req SendMessageRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
// Get user info
userID := "anonymous"
userName := "Anonymous"
if user := auth.GetUser(r.Context()); user != nil {
userID = user.ID
if name, ok := user.Metadata["name"].(string); ok && name != "" {
userName = name
} else if user.Email != "" {
userName = user.Email
}
}
msgID := uuid.New().String()
now := time.Now().UTC()
// Broadcast user message to channel:general immediately (synchronous — users
// see their own messages instantly without waiting for the queue)
h.sseHub.SendToChannel("channel:general", &realtime.SSEEvent{
Type: "chat",
Timestamp: now,
JobID: msgID,
Message: req.Content,
Result: map[string]any{
"id": msgID,
"content": req.Content,
"userId": userID,
"userName": userName,
"timestamp": now.Format(time.RFC3339),
},
})
// Enqueue AI response job — worker streams chunks via Redis → SSE
if _, err := h.queue.Enqueue(r.Context(), "ai_chat_response", map[string]any{
"content": req.Content,
"userID": userID,
"channel": "channel:general",
}); err != nil {
h.logger.Error("failed to enqueue AI chat response", "error", err)
// Don't fail the request — user message was already delivered
}
httpresponse.OK(w, r, map[string]string{
"id": msgID,
"status": "sent",
})
return nil
}

View File

@ -0,0 +1,188 @@
package handlers
import (
"net/http"
"{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/auth"
"{{GO_MODULE}}/pkg/httperror"
"{{GO_MODULE}}/pkg/httpresponse"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/pkg/queue"
"{{GO_MODULE}}/pkg/realtime"
)
// Generate handles HTTP requests for AI generation endpoints.
// All generation is async: validate request, enqueue job, return 202 with job ID.
// The worker processes jobs and sends results via Redis → SSE.
type Generate struct {
queue queue.Producer
sseHub *realtime.SSEHub
logger *logging.Logger
}
// NewGenerate creates a new Generate handler with injected dependencies.
func NewGenerate(q queue.Producer, hub *realtime.SSEHub, logger *logging.Logger) *Generate {
return &Generate{
queue: q,
sseHub: hub,
logger: logger.WithComponent("GenerateHandler"),
}
}
// ---------------------------------------------------------------------------
// Image generation (async - returns job ID, results via SSE)
// ---------------------------------------------------------------------------
// GenerateImageRequest is the request body for image generation.
type GenerateImageRequest struct {
Prompt string `json:"prompt" validate:"required,min=1,max=2000"`
Count int `json:"count"`
AspectRatio string `json:"aspectRatio"`
}
// GenerateAccepted is the immediate HTTP response with the job ID.
type GenerateAccepted struct {
JobID string `json:"jobId"`
}
// GenerateImage queues an image generation job.
// Returns immediately with job ID. Results come via SSE events:
// - generation_started: Job accepted
// - generation_progress: Progress updates
// - generation_complete: Images available
// - generation_failed: Error occurred
//
// Client should subscribe to SSE channel `user:<userId>` before calling.
func (h *Generate) GenerateImage(w http.ResponseWriter, r *http.Request) error {
var req GenerateImageRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
// Set defaults
if req.Count == 0 {
req.Count = 1
}
if req.Count > 4 {
req.Count = 4
}
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("authentication required")
}
jobID, err := h.queue.Enqueue(r.Context(), "generate_image", map[string]any{
"prompt": req.Prompt,
"count": req.Count,
"aspectRatio": req.AspectRatio,
"userID": user.ID,
})
if err != nil {
h.logger.Error("failed to enqueue image job", "error", err)
return httperror.Internal("failed to queue image generation")
}
h.logger.Info("image generation queued", "jobId", jobID, "userID", user.ID)
httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
return nil
}
// ---------------------------------------------------------------------------
// Video generation (async - takes 2-5 minutes)
// ---------------------------------------------------------------------------
// GenerateVideoRequest is the request body for video generation.
type GenerateVideoRequest struct {
Prompt string `json:"prompt" validate:"required,min=1,max=2000"`
AspectRatio string `json:"aspectRatio"`
Duration string `json:"duration"`
}
// GenerateVideo queues a video generation job.
// Returns immediately with job ID. Results come via SSE events.
func (h *Generate) GenerateVideo(w http.ResponseWriter, r *http.Request) error {
var req GenerateVideoRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
// Validate video aspect ratio (Veo only supports 16:9 and 9:16)
if req.AspectRatio != "" && req.AspectRatio != "16:9" && req.AspectRatio != "9:16" {
return httperror.BadRequest("video only supports 16:9 and 9:16 aspect ratios")
}
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("authentication required")
}
jobID, err := h.queue.Enqueue(r.Context(), "generate_video", map[string]any{
"prompt": req.Prompt,
"aspectRatio": req.AspectRatio,
"duration": req.Duration,
"userID": user.ID,
})
if err != nil {
h.logger.Error("failed to enqueue video job", "error", err)
return httperror.Internal("failed to queue video generation")
}
h.logger.Info("video generation queued", "jobId", jobID, "userID", user.ID)
httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
return nil
}
// ---------------------------------------------------------------------------
// Text generation (async - returns job ID, results via SSE with streaming chunks)
// ---------------------------------------------------------------------------
// GenerateTextRequest is the request body for text generation.
type GenerateTextRequest struct {
Prompt string `json:"prompt" validate:"required,min=1,max=5000"`
SystemPrompt string `json:"systemPrompt"`
MaxTokens int `json:"maxTokens"`
}
// GenerateText queues a text generation job.
// Returns immediately with job ID. Chunks come via SSE as ai_chat_chunk events.
func (h *Generate) GenerateText(w http.ResponseWriter, r *http.Request) error {
var req GenerateTextRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("authentication required")
}
jobID, err := h.queue.Enqueue(r.Context(), "generate_text", map[string]any{
"prompt": req.Prompt,
"systemPrompt": req.SystemPrompt,
"maxTokens": req.MaxTokens,
"userID": user.ID,
})
if err != nil {
h.logger.Error("failed to enqueue text job", "error", err)
return httperror.Internal("failed to queue text generation")
}
h.logger.Info("text generation queued", "jobId", jobID, "userID", user.ID)
httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
return nil
}
// ---------------------------------------------------------------------------
// SSE Events endpoint
// ---------------------------------------------------------------------------
// Events returns the SSE handler for event subscriptions.
// Mount at /api/events.
func (h *Generate) Events() http.Handler {
return realtime.NewSSEHandler(h.sseHub, h.logger.Logger)
}

View File

@ -0,0 +1,161 @@
package handlers
import (
"fmt"
"net/http"
"strings"
"{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/auth"
"{{GO_MODULE}}/pkg/httperror"
"{{GO_MODULE}}/pkg/httpresponse"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/pkg/storage"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// Media handles media upload and library operations.
type Media struct {
store storage.Store
logger *logging.Logger
}
// NewMedia creates a new media handler.
func NewMedia(store storage.Store, logger *logging.Logger) *Media {
return &Media{store: store, logger: logger.WithComponent("MediaHandler")}
}
// Routes returns the media subrouter.
func (h *Media) Routes() http.Handler {
r := chi.NewRouter()
r.Post("/upload/init", app.Wrap(h.InitUpload))
r.Post("/upload/complete", app.Wrap(h.CompleteUpload))
r.Get("/", app.Wrap(h.List))
r.Delete("/*", app.Wrap(h.Delete))
return r
}
// initUploadRequest is the request body for POST /media/upload/init.
type initUploadRequest struct {
Filename string `json:"filename" validate:"required"`
ContentType string `json:"contentType" validate:"required"`
}
// InitUpload returns a presigned URL for direct client-to-storage upload.
func (h *Media) InitUpload(w http.ResponseWriter, r *http.Request) error {
var req initUploadRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
user := auth.GetUser(r.Context())
userID := "anonymous"
if user != nil {
userID = user.ID
}
// Build object path: media/{userID}/{uuid}/{filename}
objectPath := fmt.Sprintf("media/%s/%s/%s", userID, uuid.New().String(), req.Filename)
presigned, err := h.store.UploadPresigned(r.Context(), objectPath, req.ContentType)
if err != nil {
h.logger.Error("failed to create presigned upload", "error", err)
return httperror.Internal("failed to create upload URL")
}
httpresponse.OK(w, r, map[string]any{
"uploadURL": presigned.URL,
"objectPath": objectPath,
"headers": presigned.Headers,
"method": presigned.Method,
"expires": presigned.Expires,
})
return nil
}
// completeUploadRequest is the request body for POST /media/upload/complete.
type completeUploadRequest struct {
ObjectPath string `json:"objectPath" validate:"required"`
}
// CompleteUpload confirms an upload is done and returns the final URL.
func (h *Media) CompleteUpload(w http.ResponseWriter, r *http.Request) error {
var req completeUploadRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
url, err := h.store.GetURL(r.Context(), req.ObjectPath)
if err != nil {
h.logger.Error("failed to get object URL", "error", err, "path", req.ObjectPath)
return httperror.Internal("failed to confirm upload")
}
httpresponse.OK(w, r, map[string]any{
"url": url,
"path": req.ObjectPath,
})
return nil
}
// List returns the user's media objects.
func (h *Media) List(w http.ResponseWriter, r *http.Request) error {
user := auth.GetUser(r.Context())
userID := "anonymous"
if user != nil {
userID = user.ID
}
prefix := fmt.Sprintf("media/%s/", userID)
// Allow filtering by sub-prefix (e.g., ?prefix=images)
if subPrefix := r.URL.Query().Get("prefix"); subPrefix != "" {
prefix = fmt.Sprintf("media/%s/%s", userID, subPrefix)
}
objects, err := h.store.List(r.Context(), prefix)
if err != nil {
h.logger.Error("failed to list media", "error", err)
return httperror.Internal("failed to list media")
}
if objects == nil {
objects = []storage.MediaObject{}
}
httpresponse.OK(w, r, map[string]any{
"items": objects,
"count": len(objects),
})
return nil
}
// Delete removes a media object.
// Users can only delete objects under their own media/{userID}/ prefix.
func (h *Media) Delete(w http.ResponseWriter, r *http.Request) error {
// Extract path from URL (everything after /media/)
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
return httperror.BadRequest("path is required")
}
// Verify the path belongs to the authenticated user
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("authentication required")
}
expectedPrefix := fmt.Sprintf("media/%s/", user.ID)
if !strings.HasPrefix(path, expectedPrefix) {
return httperror.Forbidden("cannot delete another user's media")
}
if err := h.store.Delete(r.Context(), path); err != nil {
h.logger.Error("failed to delete media", "error", err, "path", path)
return httperror.Internal("failed to delete media")
}
httpresponse.OK(w, r, map[string]any{"deleted": path})
return nil
}

View File

@ -4,6 +4,9 @@ package api
import (
"{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/auth"
"{{GO_MODULE}}/pkg/queue"
"{{GO_MODULE}}/pkg/realtime"
"{{GO_MODULE}}/pkg/storage"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api/handlers"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/config"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service"
@ -14,23 +17,52 @@ import (
// This allows the monorepo to expose multiple services under a single domain:
// - https://domain/api/{{COMPONENT_NAME}}/health
// - https://domain/api/{{COMPONENT_NAME}}/examples
func RegisterRoutes(application *app.App, exampleService *service.ExampleService) {
// - https://domain/api/{{COMPONENT_NAME}}/events?channel=user:123 (SSE)
func RegisterRoutes(application *app.App, deps *Dependencies) {
logger := application.Logger()
cfg := config.Load()
// Initialize handlers with injected services
healthHandler := handlers.NewHealth(logger)
exampleHandler := handlers.NewExample(exampleService, logger)
exampleHandler := handlers.NewExample(deps.ExampleService, logger)
authHandler := handlers.NewAuth(deps.AuthService, logger)
generateHandler := handlers.NewGenerate(deps.Queue, deps.SSEHub, logger)
chatHandler := handlers.NewChat(deps.Queue, deps.SSEHub, logger)
mediaHandler := handlers.NewMedia(deps.Store, logger)
// Build and mount OpenAPI spec
spec := NewServiceSpec()
application.EnableDocs(spec)
// JWT validator for protected routes
jwtValidator := auth.NewJWTValidator(auth.JWTConfig{
Secret: []byte(cfg.JWTSecret),
Issuer: "{{PROJECT_NAME}}",
})
// Register API routes under /api/{service-name} to match ingress path routing.
// The ingress routes /api/{{COMPONENT_NAME}}/* to this service.
application.Route("/api/{{COMPONENT_NAME}}", func(r app.Router) {
r.Get("/health", healthHandler.Check)
// ----- Auth routes -----
// Public auth routes
r.Post("/auth/login", app.Wrap(authHandler.Login))
r.Post("/auth/logout", app.Wrap(authHandler.Logout))
// Protected auth routes
r.Group(func(r app.Router) {
r.Use(auth.Middleware(auth.MiddlewareConfig{
Validator: jwtValidator,
}))
r.Get("/auth/me", app.Wrap(authHandler.Me))
})
// ----- SSE Events -----
// Server-Sent Events for async job updates (generation progress, etc.)
r.Mount("/events", generateHandler.Events())
// ----- Example routes -----
// Public routes (no auth required)
r.Get("/examples", app.Wrap(exampleHandler.List))
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
@ -39,10 +71,7 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
r.Group(func(r app.Router) {
if cfg.AuthEnabled {
r.Use(auth.Middleware(auth.MiddlewareConfig{
Validator: auth.NewJWTValidator(auth.JWTConfig{
Secret: []byte(cfg.JWTSecret),
Issuer: "{{PROJECT_NAME}}",
}),
Validator: jwtValidator,
}))
}
@ -50,5 +79,34 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update))
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
})
// ----- Chat + Generate + Media routes (auth required) -----
// Auth is required because SSE events are delivered to user:<userId> channels.
// Without a real user identity, events go to user:anonymous and never reach the client.
r.Group(func(r app.Router) {
r.Use(auth.Middleware(auth.MiddlewareConfig{
Validator: jwtValidator,
}))
// Chat messaging
r.Post("/chat/messages", app.Wrap(chatHandler.SendMessage))
// Media generation (all queue-based, returns 202)
r.Post("/generate/image", app.Wrap(generateHandler.GenerateImage))
r.Post("/generate/video", app.Wrap(generateHandler.GenerateVideo))
r.Post("/generate/text", app.Wrap(generateHandler.GenerateText))
// Media library (upload, list, delete)
r.Mount("/media", mediaHandler.Routes())
})
})
}
// Dependencies holds all service dependencies for route registration.
type Dependencies struct {
ExampleService *service.ExampleService
AuthService *service.AuthService
Queue queue.Producer
SSEHub *realtime.SSEHub
Store storage.Store
}

View File

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

View File

@ -0,0 +1,23 @@
package port
import (
"context"
"{{GO_MODULE}}/pkg/auth"
)
// UserRepository defines the interface for user lookup operations.
// Used by AuthService for authentication.
type UserRepository interface {
// FindByEmail returns a user by email address.
// Returns nil if not found (no error).
FindByEmail(ctx context.Context, email string) (*auth.User, error)
// FindByID returns a user by ID.
// Returns nil if not found (no error).
FindByID(ctx context.Context, id string) (*auth.User, error)
// ValidatePassword checks if the password matches for a user.
// Returns true if valid, false otherwise.
ValidatePassword(ctx context.Context, user *auth.User, password string) bool
}

View File

@ -0,0 +1,97 @@
package service
import (
"context"
"errors"
"time"
"{{GO_MODULE}}/pkg/auth"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port"
)
// Auth errors.
var (
ErrInvalidCredentials = errors.New("invalid email or password")
ErrUserNotFound = errors.New("user not found")
)
// AuthService handles authentication logic.
type AuthService struct {
userRepo port.UserRepository
jwtSecret []byte
issuer string
logger *logging.Logger
}
// NewAuthService creates a new auth service.
func NewAuthService(userRepo port.UserRepository, jwtSecret string, logger *logging.Logger) *AuthService {
return &AuthService{
userRepo: userRepo,
jwtSecret: []byte(jwtSecret),
issuer: "{{PROJECT_NAME}}",
logger: logger.WithService("AuthService"),
}
}
// LoginInput contains the data needed to log in.
type LoginInput struct {
Email string
Password string
}
// LoginOutput contains the login result.
type LoginOutput struct {
Token string
User *auth.User
}
// Login authenticates a user and returns a JWT token.
func (s *AuthService) Login(ctx context.Context, input LoginInput) (*LoginOutput, error) {
// Find user by email
user, err := s.userRepo.FindByEmail(ctx, input.Email)
if err != nil {
return nil, err
}
if user == nil {
s.logger.Warn("login attempt for unknown email", "email", input.Email)
return nil, ErrInvalidCredentials
}
// Validate password
if !s.userRepo.ValidatePassword(ctx, user, input.Password) {
s.logger.Warn("invalid password attempt", "email", input.Email)
return nil, ErrInvalidCredentials
}
// Generate JWT token
token, err := auth.GenerateTokenWithIssuer(
s.jwtSecret,
user,
24*time.Hour, // 24 hour expiration
s.issuer,
s.issuer, // audience = issuer for simplicity
)
if err != nil {
return nil, err
}
s.logger.Info("user logged in", "user_id", user.ID, "email", user.Email)
return &LoginOutput{
Token: token,
User: user,
}, nil
}
// GetCurrentUser returns the user for the given ID.
func (s *AuthService) GetCurrentUser(ctx context.Context, userID string) (*auth.User, error) {
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, ErrUserNotFound
}
return user, nil
}

View File

@ -1,4 +1,4 @@
.PHONY: build run test lint fmt docker-build clean
.PHONY: build run dev test lint fmt docker-build clean
WORKER := {{COMPONENT_NAME}}
BINARY := bin/$(WORKER)
@ -12,6 +12,10 @@ build:
run:
go run ./cmd/worker
# Run the worker in development mode (alias for run)
dev:
go run ./cmd/worker
# Run tests
test:
go test -v ./...

View File

@ -3,22 +3,28 @@ package main
import (
"context"
"embed"
"os"
"os/signal"
"syscall"
"time"
"github.com/redis/go-redis/v9"
"{{GO_MODULE}}/pkg/database"
"{{GO_MODULE}}/pkg/gemini"
"{{GO_MODULE}}/pkg/laozhang"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/pkg/mediagen"
mediagenAdapters "{{GO_MODULE}}/pkg/mediagen/adapters"
"{{GO_MODULE}}/pkg/queue"
"{{GO_MODULE}}/pkg/realtime"
"{{GO_MODULE}}/pkg/storage"
"{{GO_MODULE}}/pkg/textgen"
textgenAdapters "{{GO_MODULE}}/pkg/textgen/adapters"
"{{GO_MODULE}}/workers/{{COMPONENT_NAME}}/internal/config"
"{{GO_MODULE}}/workers/{{COMPONENT_NAME}}/internal/handlers"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
func main() {
// Initialize logger first (with defaults) so we can log config errors
logger := logging.New(logging.Config{
@ -60,24 +66,156 @@ func main() {
defer pool.Close()
logger.Info("connected to database", "url", pool.URL)
// Run migrations
database.MustRunMigrations(ctx, pool, migrationsFS, "migrations")
logger.Info("migrations complete")
// Run queue migrations (idempotent — safe for both service and worker)
if err := queue.RunMigrations(ctx, pool); err != nil {
logger.Error("failed to run queue migrations", "error", err)
os.Exit(1)
}
logger.Info("queue migrations complete")
// Initialize queue
jobQueue := queue.NewPostgresQueue(pool.DB, logger)
jobQueue := queue.NewQueue(pool.DB, logger)
// Initialize Redis for SSE event publishing
if cfg.RedisURL == "" {
logger.Error("REDIS_URL is required for worker to publish SSE events")
os.Exit(1)
}
redisOpts, err := redis.ParseURL(cfg.RedisURL)
if err != nil {
logger.Error("failed to parse REDIS_URL", "error", err)
os.Exit(1)
}
redisClient := redis.NewClient(redisOpts)
if err := redisClient.Ping(ctx).Err(); err != nil {
logger.Error("failed to connect to Redis", "error", err)
os.Exit(1)
}
logger.Info("connected to Redis")
ssePub := realtime.NewSSEPublisher(redisClient, logger.Logger)
// Initialize AI providers
// LaoZhang client (primary provider — pay-per-use, OpenAI-compatible)
var laozhangClient *laozhang.Client
if apiKey := os.Getenv("LAOZHANG_API_KEY"); apiKey != "" {
laozhangClient, err = laozhang.NewClient(laozhang.Config{
APIKey: apiKey,
VideoTimeout: 5 * time.Minute,
Logger: logger.Logger,
})
if err != nil {
logger.Warn("failed to create LaoZhang client", "error", err)
} else {
logger.Info("LaoZhang client initialized")
}
}
// Gemini client for media generation
var geminiClient *gemini.Client
if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
geminiClient, err = gemini.NewClient(ctx, gemini.Config{
APIKey: apiKey,
Logger: logger.Logger,
})
if err != nil {
logger.Warn("failed to create Gemini client", "error", err)
} else {
logger.Info("Gemini client initialized")
}
}
// Create mediagen manager (image + video)
var mediagenManager *mediagen.Manager
{
var laozhangMediaProvider *mediagenAdapters.LaoZhangProvider
var geminiMediaProvider *mediagenAdapters.GeminiProvider
if laozhangClient != nil {
laozhangMediaProvider = mediagenAdapters.NewLaoZhangProvider(laozhangClient)
}
if geminiClient != nil {
geminiMediaProvider = mediagenAdapters.NewGeminiProvider(geminiClient)
}
if geminiMediaProvider != nil || laozhangMediaProvider != nil {
mgCfg := mediagen.ProductionConfig(mediagen.ProviderSet{
LaoZhang: laozhangMediaProvider,
Gemini: geminiMediaProvider,
}, mediagen.WithLogger(logger.Logger))
if laozhangMediaProvider != nil {
mgCfg.VideoProviders = append(mgCfg.VideoProviders, laozhangMediaProvider)
}
if geminiMediaProvider != nil {
mgCfg.VideoProviders = append(mgCfg.VideoProviders, geminiMediaProvider)
}
mediagenManager, err = mediagen.NewManager(mgCfg)
if err != nil {
logger.Warn("failed to create mediagen manager", "error", err)
} else {
logger.Info("mediagen manager initialized (image + video)")
}
}
}
// Create textgen manager (text + streaming)
var textgenManager *textgen.Manager
{
var textProviders []textgen.TextGenerator
if laozhangClient != nil {
textProviders = append(textProviders, textgenAdapters.NewLaoZhangTextProvider(laozhangClient, ""))
}
if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
geminiTextProvider, err := textgenAdapters.NewGeminiTextProvider(ctx, textgenAdapters.GeminiTextConfig{
APIKey: apiKey,
})
if err != nil {
logger.Warn("failed to create gemini text provider", "error", err)
} else {
textProviders = append(textProviders, geminiTextProvider)
}
}
if len(textProviders) > 0 {
textgenCfg := textgen.ProductionConfig(textgen.ProviderSet{}, textgen.WithLogger(logger.Logger))
textgenCfg.Providers = textProviders
textgenManager, err = textgen.NewManager(textgenCfg)
if err != nil {
logger.Warn("failed to create textgen manager", "error", err)
} else {
logger.Info("textgen manager initialized")
}
}
}
// Initialize and start handler
handler := handlers.New(logger, jobQueue, handlers.Config{
PollInterval: cfg.Worker.PollInterval,
StaleJobTimeout: cfg.Worker.StaleJobTimeout,
JobTimeout: cfg.Worker.JobTimeout,
PollInterval: cfg.Worker.PollInterval,
StaleJobTimeout: cfg.Worker.StaleJobTimeout,
JobTimeout: cfg.Worker.JobTimeout,
})
// Initialize storage backend for persisting generated media.
// GCS_BUCKET is injected by the platform; if absent, store is nil (media not persisted).
var mediaStore storage.Store
if bucket := os.Getenv("GCS_BUCKET"); bucket != "" {
gcsStore, err := storage.NewGCSStore(bucket, os.Getenv("GCS_SERVICE_ACCOUNT_JSON"), logger.Logger)
if err != nil {
logger.Warn("failed to create GCS store, generated media will not be persisted", "error", err)
} else {
defer func() { _ = gcsStore.Close() }()
mediaStore = gcsStore
logger.Info("storage initialized (GCS)", "bucket", bucket)
}
}
// Register job handlers
// TODO: Register your job handlers here
// handler.RegisterHandler("send_email", emailHandler)
// handler.RegisterHandler("process_image", imageHandler)
if mediagenManager != nil {
handler.RegisterHandler("generate_image", handlers.ImageHandler(mediagenManager, mediaStore, ssePub, logger))
handler.RegisterHandler("generate_video", handlers.VideoHandler(mediagenManager, mediaStore, ssePub, logger))
}
if textgenManager != nil {
handler.RegisterHandler("generate_text", handlers.TextHandler(textgenManager, ssePub, logger))
handler.RegisterHandler("ai_chat_response", handlers.ChatResponseHandler(textgenManager, ssePub, logger))
}
// Setup signal handling
sigCh := make(chan os.Signal, 1)
@ -98,7 +236,6 @@ func main() {
cancel()
// Give in-flight jobs time to complete (grace period)
// This allows handlers to notice context cancellation and finish cleanly.
const shutdownGracePeriod = 5 * time.Second
time.Sleep(shutdownGracePeriod)
@ -106,7 +243,7 @@ func main() {
}
// runStaleJobRecovery periodically requeues jobs that have been running too long.
func runStaleJobRecovery(ctx context.Context, q *queue.PostgresQueue, timeout time.Duration, logger *logging.Logger) {
func runStaleJobRecovery(ctx context.Context, q *queue.DBQueue, timeout time.Duration, logger *logging.Logger) {
const staleCheckInterval = time.Minute
ticker := time.NewTicker(staleCheckInterval)
defer ticker.Stop()

View File

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

View File

@ -0,0 +1,33 @@
// Package handlers re-exports generation job handlers from the shared package.
// The worker registers these handlers to process queue jobs.
package handlers
import (
"{{GO_MODULE}}/pkg/generation"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/pkg/mediagen"
"{{GO_MODULE}}/pkg/queue"
"{{GO_MODULE}}/pkg/realtime"
"{{GO_MODULE}}/pkg/storage"
"{{GO_MODULE}}/pkg/textgen"
)
// ImageHandler returns a queue.Handler that processes image generation jobs.
func ImageHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler {
return generation.ImageHandler(mg, store, pub, logger)
}
// VideoHandler returns a queue.Handler that processes video generation jobs.
func VideoHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler {
return generation.VideoHandler(mg, store, pub, logger)
}
// TextHandler returns a queue.Handler that processes text generation jobs with streaming.
func TextHandler(tg *textgen.Manager, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler {
return generation.TextHandler(tg, pub, logger)
}
// ChatResponseHandler returns a queue.Handler that generates AI chat responses.
func ChatResponseHandler(tg *textgen.Manager, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler {
return generation.ChatResponseHandler(tg, pub, logger)
}

View File

@ -1,40 +1,42 @@
---
name: realtime-specialist
description: WebSocket and real-time communication patterns for {{PROJECT_NAME}} - connection management, room-based broadcasting, Redis pub/sub scaling
description: SSE and real-time communication patterns for {{PROJECT_NAME}} - HTTP2 POST for input, SSE for output, Redis pub/sub for scaling
color: cyan
---
# Realtime Specialist
You design and implement real-time communication features for {{PROJECT_NAME}} using pkg/realtime. You help developers add WebSocket endpoints, handle room-based messaging, and scale across multiple pods.
You design and implement real-time communication features for {{PROJECT_NAME}} using HTTP2 + SSE. You help developers add event streams, handle channel-based messaging, and scale across multiple pods.
## Critical Rules
- **NO WEBSOCKETS. EVER.** All real-time communication uses HTTP2 + SSE.
- **User → Server:** HTTP2 POST/PUT/DELETE. Standard REST endpoints.
- **Server → User:** SSE only. One-way event stream.
- **Event flow:** `server → redis → redis listeners → SSE hub → user`
## When to Use
- Adding WebSocket endpoints to a service
- Implementing chat or notification features
- Broadcasting messages to connected clients
- Adding SSE endpoints to a service
- Implementing chat, notifications, or progress features
- Broadcasting events to connected clients
- Scaling real-time features across multiple pods
- Handling client reconnection and presence
## Architecture Overview
```
┌─────────────────────────────────────┐
│ Redis Pub/Sub │
└─────────────┬───────────┬───────────┘
│ │
┌───────────────────────┼───────────┼───────────────────────┐
│ │ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ Pod A │ │ Pod B │ │ Pod C │
│ │ │ │ │ │
│ ┌───────┐ │ │ ┌───────┐ │ │ ┌───────┐ │
│ │ Hub │ │ │ │ Hub │ │ │ │ Hub │ │
│ └───┬───┘ │ │ └───┬───┘ │ │ └───┬───┘ │
│ │ │ │ │ │ │ │ │
│ ┌───▼───┐ │ │ ┌───▼───┐ │ │ ┌───▼───┐ │
│ │Clients│ │ │ │Clients│ │ │ │Clients│ │
└─────────┘ └─────────┘ └─────────┘
┌─────────────┐ HTTP2 POST ┌─────────────┐ publish ┌─────────────┐
│ Browser │ ───────────────▶│ API │ ────────────▶│ Redis │
│ │ │ Handler │ │ Pub/Sub │
│ │ └─────────────┘ └──────┬──────┘
│ │ │
│ │ subscribe
│ │ │
│ │ ┌──────▼──────┐
│ │ SSE stream ┌─────────────┐ notify │ Redis │
│ │ ◀───────────────│ SSE Hub │ ◀────────────│ Listener │
└─────────────┘ └─────────────┘ └─────────────┘
```
## Quick Start
@ -45,15 +47,14 @@ You design and implement real-time communication features for {{PROJECT_NAME}} u
func main() {
logger := logging.NewDevelopment()
// Create hub
hub := realtime.NewHub(logger)
go hub.Run(ctx)
// Create SSE hub
sseHub := realtime.NewSSEHub(logger)
// Create handler (no Redis needed for single pod)
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{})
// Create handler
sseHandler := realtime.NewSSEHandler(sseHub, logger)
// Mount on router
r.Mount("/ws", wsHandler.Routes())
r.Mount("/api/events", sseHandler.Routes())
}
```
@ -63,160 +64,212 @@ func main() {
func main() {
logger := logging.NewProduction()
// Create hub
hub := realtime.NewHub(logger)
go hub.Run(ctx)
// Create SSE hub
sseHub := realtime.NewSSEHub(logger)
// Create Redis broadcaster for cross-pod messaging
redisClient := redis.NewClient(&redis.Options{Addr: os.Getenv("REDIS_URL")})
broadcaster := realtime.NewRedisBroadcaster(redisClient, hub, logger)
broadcaster := realtime.NewRedisBroadcaster(redisClient, sseHub, logger)
go broadcaster.Run(ctx)
// Create handler with broadcaster
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{
Broadcaster: broadcaster,
})
sseHandler := realtime.NewSSEHandler(sseHub, logger)
r.Mount("/ws", wsHandler.Routes())
r.Mount("/api/events", sseHandler.Routes())
}
```
## Message Protocol
## Channel Types
Messages use JSON format:
| Pattern | Use For | Example |
|---------|---------|---------|
| `user:<id>` | Private events for one user | `user:u_abc123` |
| `channel:<id>` | Shared events for a room/topic | `channel:general` |
## Event Structure
Every event MUST follow this structure:
```json
{
"id": "uuid",
"type": "chat",
"room": "general",
"from": "client-id",
"data": { "text": "Hello world" },
"timestamp": "2024-01-15T10:30:00Z"
"timestamp": "2024-01-15T10:30:00Z",
"userId": "u_abc123",
"content": "Hello world"
}
```
### Message Types
| Type | Description |
|------|-------------|
| `chat` | User-generated chat message |
| `presence` | User online/offline/away status |
| `notification` | System notification to user |
| `system` | Broadcast from server |
| `error` | Error response to client |
| `ping` / `pong` | Application-level keepalive |
## Patterns
### Room-Based Chat
### Chat Room
```go
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{
OnConnect: func(conn realtime.Connection) {
// Notify room of new member
msg, _ := realtime.SystemMessage("presence", realtime.PresenceData{
Status: realtime.PresenceOnline,
UserID: conn.UserID(),
})
hub.Broadcast(msg)
},
OnDisconnect: func(conn realtime.Connection) {
msg, _ := realtime.SystemMessage("presence", realtime.PresenceData{
Status: realtime.PresenceOffline,
UserID: conn.UserID(),
})
hub.Broadcast(msg)
},
})
**Client sends message (HTTP POST):**
// Connect: ws://host/ws/room-name
```typescript
// POST /api/chat/messages
await fetch('/api/chat/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channel: 'general',
content: 'Hello world',
}),
});
```
### Message Filtering
**Server handles POST, publishes to Redis:**
```go
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{
OnMessage: func(conn realtime.Connection, msg *realtime.Message) *realtime.Message {
// Filter profanity
if containsProfanity(msg.Data) {
return nil // Suppress message
}
// Add server metadata
msg.From = conn.UserID() // Use user ID instead of connection ID
return msg
},
})
```
### Authenticated Connections
```go
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{
AuthRequired: true, // Requires valid JWT
})
// Client connects with token:
// ws://host/ws?token=<jwt>
// OR
// ws://host/ws with Authorization header
```
### Sending from HTTP Handlers
```go
// Broadcast to a room from REST endpoint
func (h *ChatHandler) PostMessage(w http.ResponseWriter, r *http.Request) {
func (h *ChatHandler) PostMessage(w http.ResponseWriter, r *http.Request) error {
var req struct {
Room string `json:"room"`
Text string `json:"text"`
Channel string `json:"channel"`
Content string `json:"content"`
}
if err := app.Bind(r, &req); err != nil {
return err
}
// ... decode request ...
msg := &realtime.Message{
Type: realtime.MessageTypeChat,
Room: req.Room,
Data: json.RawMessage(`{"text":"` + req.Text + `"}`),
user := auth.GetUser(r.Context())
// Publish to Redis (reaches all pods)
h.broadcaster.Publish(r.Context(), &realtime.Event{
Type: "chat",
Channel: req.Channel,
UserID: user.ID,
UserName: user.Name,
Content: req.Content,
Timestamp: time.Now().UTC(),
}
})
// Publish via broadcaster (reaches all pods)
if h.broadcaster != nil {
h.broadcaster.Publish(r.Context(), msg)
} else {
h.hub.Broadcast(msg)
return httpresponse.NoContent(w, r)
}
```
**Client receives via SSE:**
```typescript
useEventChannel(`channel:general`, {
onEvent: (event) => {
if (event.type === 'chat') {
addMessage(event);
}
}
});
```
### Async Job Progress
**Client initiates job (HTTP POST):**
```typescript
const { jobId } = await api.post('/generate/video', {
prompt: 'A cat playing piano',
aspectRatio: '16:9',
});
```
**Client listens for progress (SSE):**
```typescript
useEventChannel(`user:${userId}`, {
onEvent: (event) => {
if (event.jobId !== jobId) return;
switch (event.type) {
case 'generation_progress':
setProgress(event.progress);
break;
case 'generation_complete':
setResult(event.result);
break;
}
}
});
```
**Worker sends progress events:**
```go
func (w *Worker) ProcessJob(ctx context.Context, job *domain.Job) error {
// Send progress
w.hub.SendToUser(job.UserID, &realtime.Event{
Type: "generation_progress",
JobID: job.ID,
Progress: 50,
Message: "Processing...",
})
// ... do work ...
// Send complete
w.hub.SendToUser(job.UserID, &realtime.Event{
Type: "generation_complete",
JobID: job.ID,
Result: result,
})
return nil
}
```
### Presence
**Client connects, server broadcasts presence:**
```go
// In SSE connection handler
func (h *SSEHandler) onConnect(userID string, channel string) {
h.broadcaster.Publish(ctx, &realtime.Event{
Type: "presence",
Channel: channel,
UserID: userID,
Status: "online",
})
}
func (h *SSEHandler) onDisconnect(userID string, channel string) {
h.broadcaster.Publish(ctx, &realtime.Event{
Type: "presence",
Channel: channel,
UserID: userID,
Status: "offline",
})
}
```
## Client Reconnection
Clients should implement reconnection with exponential backoff:
SSE clients should implement reconnection with exponential backoff:
```javascript
class RealtimeClient {
connect() {
this.ws = new WebSocket(`${this.url}?last_id=${this.lastMessageId}`);
this.ws.onclose = () => this.scheduleReconnect();
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
this.lastMessageId = msg.id;
this.onMessage(msg);
};
}
```typescript
function useEventChannel(channel: string, config: Config) {
const [retries, setRetries] = useState(0);
scheduleReconnect() {
const delay = Math.min(1000 * Math.pow(2, this.retries), 30000);
setTimeout(() => this.connect(), delay);
this.retries++;
}
const connect = useCallback(() => {
const eventSource = new EventSource(`/api/events?channel=${channel}`);
eventSource.onopen = () => setRetries(0);
eventSource.onerror = () => {
eventSource.close();
const delay = Math.min(1000 * Math.pow(2, retries), 30000);
setTimeout(connect, delay);
setRetries(r => r + 1);
};
eventSource.onmessage = (e) => {
config.onEvent(JSON.parse(e.data));
};
}, [channel, retries]);
}
```
## Scaling Considerations
### Redis Channel Strategy
- One channel per room: `realtime:channel:{channelId}`
- One channel per user: `realtime:user:{userId}`
- Pattern subscription: `realtime:*`
### Connection Limits
Set reasonable limits per pod:
@ -224,26 +277,20 @@ Set reasonable limits per pod:
```go
const maxConnectionsPerPod = 10000
func (h *Handler) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
func (h *SSEHandler) HandleSSE(w http.ResponseWriter, r *http.Request) {
if h.hub.ConnectionCount() >= maxConnectionsPerPod {
http.Error(w, "server at capacity", http.StatusServiceUnavailable)
return
}
// ... continue upgrade ...
// ... continue ...
}
```
### Redis Channel Strategy
- One channel per room: `realtime:room:{roomId}`
- Global channel for broadcasts: `realtime:global`
- Pattern subscription: `realtime:room:*`
### Memory Considerations
Each connection uses ~10KB for buffers. Plan accordingly:
- 10,000 connections ≈ 100MB
- 100,000 connections ≈ 1GB
Each SSE connection uses ~5KB for buffers. Plan accordingly:
- 10,000 connections ≈ 50MB
- 100,000 connections ≈ 500MB
## Monitoring
@ -251,50 +298,26 @@ Track these metrics:
| Metric | Description |
|--------|-------------|
| `realtime_connections_total` | Total active connections |
| `realtime_rooms_total` | Number of active rooms |
| `realtime_messages_sent` | Messages sent per second |
| `realtime_messages_received` | Messages received per second |
| `realtime_redis_publish_errors` | Failed Redis publishes |
## Error Handling
### Client Errors
```go
OnMessage: func(conn realtime.Connection, msg *realtime.Message) *realtime.Message {
if err := validate(msg); err != nil {
errMsg, _ := realtime.SystemMessage(realtime.MessageTypeError, map[string]string{
"error": err.Error(),
})
conn.Send(errMsg)
return nil // Don't broadcast invalid message
}
return msg
}
```
### Redis Failures
RedisBroadcaster degrades gracefully:
- If publish fails, message still broadcasts locally
- Subscriber reconnects automatically on disconnect
- Log warnings for monitoring
| `sse_connections_total` | Total active SSE connections |
| `sse_channels_total` | Number of active channels |
| `sse_events_sent` | Events sent per second |
| `redis_publish_errors` | Failed Redis publishes |
## Do
1. ALWAYS use room-based broadcasting for multi-tenant apps
2. SET connection limits per pod
3. IMPLEMENT client reconnection with backoff
4. USE Redis for multi-pod deployments
5. AUTHENTICATE WebSocket connections in production
6. MONITOR connection count and message rates
1. USE HTTP POST for all client→server messages
2. USE SSE for all server→client events
3. USE Redis pub/sub for multi-pod deployments
4. SET connection limits per pod
5. IMPLEMENT client reconnection with backoff
6. AUTHENTICATE SSE connections in production
7. DOCUMENT all channels in `docs/channels.md`
## Do Not
1. STORE large payloads in messages (send IDs, fetch data separately)
2. BROADCAST without rate limiting
3. RELY on message ordering (out-of-order is possible)
4. SKIP ping/pong (connections will time out)
5. USE synchronous operations in message handlers (blocks hub)
1. USE WebSocket for anything — SSE only
2. STORE large payloads in events (send IDs, fetch data separately)
3. BROADCAST without rate limiting
4. SIMULATE progress with fake timers
5. SKIP ping/pong (connections will time out)
6. TRUST client-provided user IDs (extract from auth token)

View File

@ -0,0 +1,164 @@
# Event Channels
## Critical Rules
- **NO WEBSOCKETS. EVER.** All real-time communication uses HTTP2 + SSE.
- **User → Server:** HTTP2 POST/PUT/DELETE. Standard REST endpoints.
- **Server → User:** SSE only. One-way event stream.
- **Event flow:** `POST → Service (enqueue) → Queue → Worker (generate) → Redis pub/sub → Service SSE subscriber → SSE Hub → User`
- **This applies to EVERYTHING:** Chat, notifications, progress updates, generation — all SSE.
- **Channel format is non-negotiable:** `user:<id>` or `channel:<id>`. No exceptions.
- **All events are JSON** with `type` as the first field.
- **Document every channel** in this file before using it.
- **Service is thin:** Validates, enqueues, returns 202. No AI work in the service.
- **Worker does all AI work:** Initializes providers, processes jobs, publishes events via Redis.
## Architecture
```
┌─────────────┐ POST /generate/* ┌─────────────┐ enqueue ┌─────────────┐
│ Browser │ ──────────────────────▶│ Service │ ────────────▶│ CRDB │
│ │ { jobId } (202) │ (thin) │ │ Queue │
│ │ ◀──────────────────────│ │ └──────┬──────┘
│ │ │ │ │
│ │ │ │ dequeue│
│ │ │ │ ▼
│ │ │ │ ┌──────────────┐
│ │ │ │ │ Worker │
│ │ │ │ │ (AI work) │
│ │ │ │ └──────┬───────┘
│ │ │ │ │
│ │ │ │ publish│(SSE events)
│ │ │ │ ▼
│ │ │ │ ┌──────────────┐
│ │ SSE stream │ SSE Hub │ subscribe │ Redis │
│ │ ◀──────────────────────│ ◀──────────│──────────────│ Pub/Sub │
└─────────────┘ └─────────────┘ └──────────────┘
```
**Generation flow:**
1. User sends `POST /api/generate/image` with prompt
2. Service validates, enqueues job in CRDB, returns `202 {jobId}`
3. Worker dequeues job, calls AI provider (LaoZhang/Gemini)
4. Worker publishes progress/result SSE events to Redis
5. Service's SSE subscriber receives events from Redis
6. SSE Hub delivers to the user's connected SSE stream
**Chat flow:**
1. User sends `POST /api/chat/messages`
2. Service broadcasts user message to `channel:general` via SSE Hub (immediate)
3. Service enqueues `ai_chat_response` job
4. Worker dequeues, streams AI response tokens via Redis pub/sub
5. Service's SSE subscriber delivers `ai_chat_chunk` events to channel
## Channel Types
| Pattern | Use For | Example |
|---------|---------|---------|
| `user:<id>` | Private events for one user | `user:u_abc123` |
| `channel:<id>` | Shared events for a room/topic | `channel:general` |
## Event Structure
Every event MUST follow this structure:
```typescript
interface Event {
type: string; // REQUIRED: event type identifier
timestamp: string; // REQUIRED: ISO 8601
jobId?: string; // Job correlation ID
progress?: number; // 0-100 percentage
message?: string; // Human-readable status
result?: any; // Payload (type-specific)
error?: string; // Error message
}
```
## Standard Event Types
### User Channel Events
#### Media Generation Events
| Event | Trigger | Payload |
|-------|---------|---------|
| `generation_started` | Worker picks up job | `{ jobId, message }` |
| `generation_progress` | Progress update | `{ jobId, progress, message }` |
| `generation_complete` | Generation done | `{ jobId, progress: 100, result }` |
| `generation_failed` | Error occurred | `{ jobId, error }` |
#### Text Generation Events (streaming)
| Event | Trigger | Payload |
|-------|---------|---------|
| `ai_chat_chunk` | Token generated | `{ jobId, result: { streamId, text, done, provider? } }` |
#### Media Upload Events
| Event | Trigger | Payload |
|-------|---------|---------|
| `upload_started` | Upload job begins | `{ jobId }` |
| `upload_progress` | Chunk uploaded | `{ jobId, progress, bytesUploaded }` |
| `upload_complete` | Processing done | `{ jobId, result: { original, optimized, thumbnail } }` |
| `upload_failed` | Error occurred | `{ jobId, error }` |
### Room Channel Events
| Event | Trigger | Payload |
|-------|---------|---------|
| `chat` | User sends message | `{ result: { id, content, userId, userName, timestamp } }` |
| `ai_chat_chunk` | AI streaming chunk | `{ result: { streamId, text, done, provider? } }` |
| `ai_chat` | AI response complete | `{ result: { id, content, provider, timestamp } }` |
| `presence` | User joins/leaves | `{ status, userId, userName }` |
| `typing` | User typing indicator | `{ userId, isTyping }` |
## Implementation Pattern
### Backend (Go) — Service Handler
```go
// Enqueue generation job (service is thin — no AI work)
jobID, err := h.queue.Enqueue(r.Context(), "generate_image", map[string]any{
"prompt": req.Prompt,
"userID": userID,
})
httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
```
### Backend (Go) — Worker Job Handler
```go
// Worker publishes events via Redis SSE publisher
pub.SendToUser(userID, &realtime.SSEEvent{
Type: realtime.EventGenerationComplete,
JobID: jobID,
Progress: 100,
Result: result,
})
```
### Frontend (TypeScript)
```typescript
// Subscribe to user channel for generation events
const { status, progress, result } = useMediaGeneration<ImageResult>({
endpoint: '/api/generate/image',
userId: currentUser.id,
});
// Subscribe to room channel for chat events
const { messages, aiMessages, streamingMessages } = useChat({
endpoint: '/api/chat/messages',
channel: 'channel:general',
userId: currentUser.id,
});
```
## Active Channels
<!-- Document all channels used in this project below -->
| Channel | Events | Purpose |
|---------|--------|---------|
| `user:<userId>` | `generation_*`, `ai_chat_chunk` | Async job results, text streaming |
| `channel:<room>` | `chat`, `ai_chat_chunk`, `presence` | Real-time chat |

View File

@ -0,0 +1,200 @@
# Media Pipeline
## Critical Rules
- **ALL media operations are async jobs.** Upload, process, generate - everything goes through the job queue.
- **NEVER wait synchronously.** POST returns a job ID immediately. Results come via SSE.
- **NEVER simulate progress.** Real progress comes from real events. Fake progress is a lie.
- **Storage is opaque.** Backend returns URLs. Frontend never constructs storage paths.
- **GCS in production, MemoryStore in dev.** When `GCS_BUCKET` env var is set, storage uses GCS. Otherwise, an in-memory store serves files at `/storage/`.
## Architecture Overview
```
┌─────────────┐ POST /generate/* ┌──────────────┐ enqueue ┌──────────────┐
│ Frontend │ ─────────────────────▶│ Service │ ───────────▶│ CRDB │
│ │ { jobId } (202) │ (thin) │ │ Queue │
│ │ ◀─────────────────────│ │ └──────┬───────┘
│ │ │ │ │
│ │ │ │ dequeue│
│ │ │ │ ▼
│ │ │ │ ┌──────────────┐
│ │ │ │ │ Worker │
│ │ │ │ │ (AI work) │
│ │ │ │ └──────┬───────┘
│ │ │ │ │
│ │ SSE stream │ SSE Hub │ Redis sub │ persist to
│ │ ◀─────────────────────│ ◀──────────│─────────────│◀── storage
└─────────────┘ └──────────────┘ │ (GCS)
└─────┘
```
## Storage
### Backend: `pkg/storage/`
The storage package provides a `Store` interface with two implementations:
| Implementation | When | Env Vars |
|---|---|---|
| `GCSStore` | `GCS_BUCKET` is set (production, deployed) | `GCS_BUCKET`, `GCS_SERVICE_ACCOUNT_JSON` |
| `MemoryStore` | No `GCS_BUCKET` (local dev, standalone) | None |
```go
type Store interface {
Upload(ctx context.Context, path string, data []byte, contentType string) (string, error)
UploadPresigned(ctx context.Context, path string, contentType string) (*PresignedUpload, error)
GetURL(ctx context.Context, path string) (string, error)
Delete(ctx context.Context, path string) error
List(ctx context.Context, prefix string) ([]MediaObject, error)
}
```
### Initialization (service main.go)
Storage is initialized early in `main()` — before the queue, since standalone queue handlers need it:
```go
var mediaStore storage.Store
if bucket := os.Getenv("GCS_BUCKET"); bucket != "" {
mediaStore, _ = storage.NewGCSStore(bucket, os.Getenv("GCS_SERVICE_ACCOUNT_JSON"), logger)
} else {
memStore := storage.NewMemoryStore("http://localhost:" + port + "/storage")
mediaStore = memStore
// Mount memStore.ServeHTTP at /storage/* for dev mode
}
```
### Object Path Convention
All media is stored under `media/{userID}/`:
- Generated images: `media/{userID}/images/{jobID}_{index}.png`
- Generated videos: `media/{userID}/videos/{jobID}_{index}.mp4`
- Uploads: `media/{userID}/{uuid}/{filename}`
### Generation Auto-Persist
Image and video generation handlers accept a `storage.Store`. When non-nil, generated results are automatically persisted and SSE events contain permanent URLs instead of temporary provider URLs or base64.
```go
generation.ImageHandler(mediagenManager, store, pub, logger)
generation.VideoHandler(mediagenManager, store, pub, logger)
```
## Upload Flow (Presigned URL)
```
Frontend Backend Storage (GCS/Memory)
│ │ │
│ POST /media/upload/init │ │
│ {filename, contentType} │ │
│──────────────────────────▶│ │
│ │ UploadPresigned() │
│ │─────────────────────────────▶│
│ {uploadURL, objectPath} │ │
│◀──────────────────────────│ │
│ │ │
│ PUT uploadURL (file body) │ │
│─────────────────────────────────────────────────────────▶│
│ 200 OK │ │
│◀─────────────────────────────────────────────────────────│
│ │ │
│ POST /media/upload/complete│ │
│ {objectPath} │ │
│──────────────────────────▶│ GetURL() │
│ │─────────────────────────────▶│
│ {url, path} │ │
│◀──────────────────────────│ │
```
### Frontend Hook
```typescript
import { useMediaUpload } from '@project/realtime';
const { upload, isUploading, progress, error, reset } = useMediaUpload({
apiPrefix: '',
serviceName: 'example-api',
headers: { Authorization: `Bearer ${token}` },
});
// Upload a file
const result = await upload(file); // { url, path }
```
## Media Library
### Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/media/upload/init` | Get presigned upload URL |
| `POST` | `/media/upload/complete` | Confirm upload, get final URL |
| `GET` | `/media` | List user's media (optional `?prefix=images`) |
| `DELETE` | `/media/{path...}` | Delete a media object |
All endpoints require authentication.
### Frontend Components
```tsx
import { MediaUploader, MediaLibrary } from '@project/ui';
// Upload drop zone
<MediaUploader
upload={mediaUpload.upload}
isUploading={mediaUpload.isUploading}
progress={mediaUpload.progress}
onUploadComplete={() => refetchMedia()}
/>
// Media grid with preview
<MediaLibrary
items={items}
onDelete={(path) => deleteMedia(path)}
/>
```
## Event Reference
| Event | When | Payload |
|-------|------|---------|
| `generation_started` | Generation begins | `{ jobId }` |
| `generation_progress` | Progress update | `{ jobId, progress, message }` |
| `generation_complete` | Generation done (URLs are persistent) | `{ jobId, result }` |
| `generation_failed` | Error occurred | `{ jobId, error }` |
| `upload_started` | Upload job begins | `{ jobId }` |
| `upload_progress` | Chunk uploaded | `{ jobId, progress }` |
| `upload_complete` | Upload done | `{ jobId, result: { url, path } }` |
| `upload_failed` | Error occurred | `{ jobId, error }` |
## Common Mistakes
### DON'T: Construct storage URLs
```typescript
// WRONG
const url = `/storage/uploads/${userId}/${fileId}.jpg`;
```
### DO: Use URLs from backend
```typescript
// CORRECT
const url = media.url; // Backend provides complete URL
```
### DON'T: Proxy uploads through the backend
```typescript
// WRONG - wastes backend bandwidth
await fetch('/api/upload', { body: file });
```
### DO: Upload directly to storage via presigned URL
```typescript
// CORRECT - frontend uploads directly to GCS
const { uploadURL } = await initUpload(file);
await fetch(uploadURL, { method: 'PUT', body: file });
```

View File

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

View File

@ -10,6 +10,8 @@
| **Build a feature** | [feature-development.md](.claude/guides/feature-development.md) |
| **Backend API patterns** | [backend/api-patterns.md](.claude/guides/backend/api-patterns.md) |
| **Frontend design system** | [frontend/design-system.md](.claude/guides/frontend/design-system.md) |
| **Event channels** | [events.md](.claude/guides/events.md) |
| **Media pipeline** | [media.md](.claude/guides/media.md) |
| **Deploy** | [ops/deploying.md](.claude/guides/ops/deploying.md) |
## Quick Reference
@ -36,6 +38,13 @@
- **OpenAPI first:** Document endpoints in `spec.go` using `openapi.*` helpers. Mount with `application.EnableDocs(spec)`.
- **CSS variables:** All UI components use CSS custom properties (`var(--background)`, `var(--accent)`, etc.). Never hardcode colors.
- **Monorepo imports:** Go packages from `{{GO_MODULE}}/pkg/*`, TypeScript from `@{{PROJECT_NAME}}/*`.
- **NO WEBSOCKETS. EVER.** All real-time communication uses HTTP2 + SSE. User → server is HTTP2 POST. Server → user is SSE. This includes chat, notifications, progress, everything.
- **Event flow:** `POST → Service (enqueue) → Queue → Worker (generate) → Redis pub/sub → Service SSE subscriber → User`. Service is thin, worker does AI work.
- **Channel naming:** `user:<id>` = events for a specific user. `channel:<id>` = events for a topic/room/resource. Document all channels in `./docs/channels.md`.
- **Media uploads:** POST returns job ID immediately. Progress and result come via SSE events. Never wait synchronously.
- **Media generation:** Same pattern - POST queues job, returns ID, results via SSE. Video takes 2-5 min; never block HTTP. Text generation streams `ai_chat_chunk` events token-by-token.
- **Media storage:** Backend returns complete URLs. Never construct storage paths in frontend. Variants (thumbnail, optimized) auto-generated.
- **No fake progress:** Never simulate progress with timers. Real progress comes from real events.
## Architecture

View File

@ -0,0 +1,22 @@
{
"name": "@{{PROJECT_NAME}}/ai-client",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"devDependencies": {
"typescript": "^5.5.3"
}
}

View File

@ -0,0 +1,44 @@
// Types
export type {
TokenUsage,
AIResponseMeta,
AIError,
ErrorCode,
} from './types';
export { createAIError, ErrorCodes } from './types';
// Text generation
export type {
MessageRole,
TextGenMessage,
TextGenRequest,
TextGenResponse,
TextGenChunk,
TextGenOptions,
} from './textgen';
export { streamText, generateText } from './textgen';
// Image generation
export type {
ImageSize,
AspectRatio,
ImageGenRequest,
GeneratedImage,
ImageGenResponse,
GenerationProgress,
MediaGenOptions,
} from './mediagen';
export { generateImage, generateImageWithProgress } from './mediagen';
// Video generation
export type {
VideoDuration,
VideoGenRequest,
GeneratedVideo,
VideoGenResponse,
} from './mediagen';
export { generateVideo, generateVideoWithProgress } from './mediagen';

View File

@ -0,0 +1,384 @@
/**
* Media generation types and utilities.
*
* IMPORTANT: For generation, use @{{PROJECT_NAME}}/realtime's useMediaGeneration hook.
* That hook implements the correct async pattern:
* 1. POST returns job ID immediately
* 2. Progress and results come via SSE events
* 3. Never blocks waiting for generation
*
* @example
* ```tsx
* import { useMediaGeneration } from '@{{PROJECT_NAME}}/realtime';
*
* const { status, progress, result, generate } = useMediaGeneration({
* endpoint: '/api/generate/video',
* userId: user.id,
* });
*
* await generate({ prompt: 'A cat playing piano' });
* // Results arrive via SSE - no blocking
* ```
*
* The synchronous functions in this file are DEPRECATED.
* Only use them for simple image generation where blocking is acceptable.
*/
import { AIResponseMeta, createAIError, ErrorCodes } from './types';
/** Image size presets. */
export type ImageSize = '256x256' | '512x512' | '1024x1024' | '1024x1792' | '1792x1024';
/** Aspect ratio presets. */
export type AspectRatio = '1:1' | '16:9' | '9:16' | '4:3' | '3:4';
/** Video duration presets. */
export type VideoDuration = '5s' | '10s';
// ---------------------------------------------------------------------------
// Image types
// ---------------------------------------------------------------------------
/** Request for image generation. */
export interface ImageGenRequest {
prompt: string;
model?: string;
size?: ImageSize;
aspectRatio?: AspectRatio;
count?: number;
negativePrompt?: string;
style?: string;
}
/** A single generated image. */
export interface GeneratedImage {
/** Base64-encoded image data or URL. */
data: string;
/** Whether data is a URL (true) or base64 (false). */
isUrl: boolean;
/** Provider that generated this image. */
provider: string;
/** Generation seed if available. */
seed?: number;
/** Revised prompt if the provider modified it. */
revisedPrompt?: string;
}
/** Response from image generation. */
export interface ImageGenResponse extends AIResponseMeta {
images: GeneratedImage[];
}
// ---------------------------------------------------------------------------
// Video types
// ---------------------------------------------------------------------------
/** Request for video generation. */
export interface VideoGenRequest {
prompt: string;
model?: string;
aspectRatio?: AspectRatio;
duration?: VideoDuration;
}
/** A single generated video. */
export interface GeneratedVideo {
/** Base64-encoded video data or URL. */
data: string;
/** Whether data is a URL (true) or base64 (false). */
isUrl: boolean;
/** MIME type (e.g. "video/mp4"). */
mimeType: string;
/** Provider that generated this video. */
provider: string;
/** Generation seed if available. */
seed?: number;
}
/** Response from video generation. */
export interface VideoGenResponse extends AIResponseMeta {
videos: GeneratedVideo[];
}
// ---------------------------------------------------------------------------
// Shared types
// ---------------------------------------------------------------------------
/** Progress update during generation. */
export interface GenerationProgress {
percent: number;
stage: string;
}
/** Options for API calls. */
export interface MediaGenOptions {
timeout?: number;
headers?: Record<string, string>;
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Parse image array from backend response. */
function parseImages(data: Record<string, unknown>): GeneratedImage[] {
const images = data.images as Record<string, unknown>[] | undefined;
if (!images) return [];
return images.map((img) => ({
data: (img.data as string) || (img.url as string) || '',
isUrl: !!img.isUrl || !!img.url,
provider: (img.provider as string) || (data.provider as string) || '',
seed: img.seed as number | undefined,
revisedPrompt: img.revised_prompt as string | undefined,
}));
}
/** Parse video array from backend response. */
function parseVideos(data: Record<string, unknown>): GeneratedVideo[] {
const videos = data.videos as Record<string, unknown>[] | undefined;
if (!videos) return [];
return videos.map((v) => ({
data: (v.data as string) || (v.url as string) || '',
isUrl: !!v.isUrl || !!v.url,
mimeType: (v.mimeType as string) || 'video/mp4',
provider: (v.provider as string) || (data.provider as string) || '',
seed: v.seed as number | undefined,
}));
}
/** Handle HTTP errors from generation endpoints. */
function handleHttpError(status: number, errorData: Record<string, unknown>): never {
throw createAIError(
(errorData.message as string) || `HTTP ${status}`,
status === 429 ? ErrorCodes.RATE_LIMITED : ErrorCodes.PROVIDER_ERROR,
undefined,
status === 429 || status >= 500,
);
}
/**
* Simulate progress ticks during a synchronous fetch.
* Returns a cleanup function.
*/
function simulateProgress(
onProgress: (p: GenerationProgress) => void,
stage: string,
maxPercent: number,
intervalMs: number,
): () => void {
let current = 10;
const id = setInterval(() => {
if (current < maxPercent) {
current += Math.random() * 8 + 2;
current = Math.min(current, maxPercent);
onProgress({ percent: Math.round(current), stage });
}
}, intervalMs);
return () => clearInterval(id);
}
// ---------------------------------------------------------------------------
// Image generation
// ---------------------------------------------------------------------------
/**
* Generate images from a text prompt.
*
* @param endpoint - The image generation API endpoint URL
* @param request - The generation request
* @param options - Additional options
* @returns Promise resolving to generated images
*
* @example
* ```ts
* const response = await generateImage('/api/my-api/generate/image', {
* prompt: 'A sunset over mountains',
* count: 2,
* });
* ```
*/
export async function generateImage(
endpoint: string,
request: ImageGenRequest,
options: MediaGenOptions = {},
): Promise<ImageGenResponse> {
const startTime = Date.now();
const controller = new AbortController();
const timeoutId = options.timeout
? setTimeout(() => controller.abort(), options.timeout)
: undefined;
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options.headers },
body: JSON.stringify(request),
signal: controller.signal,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
handleHttpError(response.status, errorData);
}
const data = await response.json();
return {
images: parseImages(data),
provider: (data.provider as string) || '',
model: data.model as string | undefined,
latencyMs: Date.now() - startTime,
};
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw createAIError('Request timed out', ErrorCodes.TIMEOUT, undefined, true);
}
throw error;
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
/**
* Generate images with progress feedback.
*
* The backend generates images synchronously. Progress is simulated
* to give the user visual feedback while waiting.
*
* @param endpoint - The image generation API endpoint URL
* @param request - The generation request
* @param onProgress - Callback for progress updates
* @param options - Additional options
* @returns Promise resolving to generated images
*
* @example
* ```ts
* const response = await generateImageWithProgress(
* '/api/my-api/generate/image',
* { prompt: 'A detailed fantasy landscape', count: 4 },
* (progress) => setProgress(progress.percent),
* );
* ```
*/
export async function generateImageWithProgress(
endpoint: string,
request: ImageGenRequest,
onProgress: (progress: GenerationProgress) => void,
options: MediaGenOptions = {},
): Promise<ImageGenResponse> {
onProgress({ percent: 0, stage: 'Starting generation...' });
const stopSimulation = simulateProgress(onProgress, 'Generating images...', 85, 800);
try {
const result = await generateImage(endpoint, request, options);
onProgress({ percent: 100, stage: 'Complete' });
return result;
} catch (error) {
onProgress({ percent: 0, stage: 'Failed' });
throw error;
} finally {
stopSimulation();
}
}
// ---------------------------------------------------------------------------
// Video generation
// ---------------------------------------------------------------------------
/**
* @deprecated Use useMediaGeneration from @{{PROJECT_NAME}}/realtime instead.
* Video generation takes 2-5 minutes. Blocking HTTP requests is wrong.
*
* Correct pattern:
* ```tsx
* import { useMediaGeneration } from '@{{PROJECT_NAME}}/realtime';
* const { generate, progress, result } = useMediaGeneration({
* endpoint: '/api/generate/video',
* userId: user.id,
* });
* await generate({ prompt: '...' }); // Returns immediately
* // Result arrives via SSE
* ```
*/
export async function generateVideo(
endpoint: string,
request: VideoGenRequest,
options: MediaGenOptions = {},
): Promise<VideoGenResponse> {
const startTime = Date.now();
const controller = new AbortController();
// Video generation can take minutes; default timeout is 5 min
const timeout = options.timeout || 300_000;
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options.headers },
body: JSON.stringify(request),
signal: controller.signal,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
handleHttpError(response.status, errorData);
}
const data = await response.json();
return {
videos: parseVideos(data),
provider: (data.provider as string) || '',
model: data.model as string | undefined,
latencyMs: Date.now() - startTime,
};
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw createAIError('Video generation timed out', ErrorCodes.TIMEOUT, undefined, true);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
/**
* @deprecated Use useMediaGeneration from @{{PROJECT_NAME}}/realtime instead.
* This function uses FAKE simulated progress. Real progress comes from SSE events.
*
* Correct pattern:
* ```tsx
* import { useMediaGeneration } from '@{{PROJECT_NAME}}/realtime';
* const { generate, progress, result } = useMediaGeneration({
* endpoint: '/api/generate/video',
* userId: user.id,
* });
* await generate({ prompt: '...' });
* // progress updates automatically from real SSE events
* ```
*/
export async function generateVideoWithProgress(
endpoint: string,
request: VideoGenRequest,
onProgress: (progress: GenerationProgress) => void,
options: MediaGenOptions = {},
): Promise<VideoGenResponse> {
onProgress({ percent: 0, stage: 'Starting video generation...' });
// Video takes longer; simulate progress more slowly
const stopSimulation = simulateProgress(onProgress, 'Rendering video...', 80, 2000);
try {
const result = await generateVideo(endpoint, request, options);
onProgress({ percent: 100, stage: 'Complete' });
return result;
} catch (error) {
onProgress({ percent: 0, stage: 'Failed' });
throw error;
} finally {
stopSimulation();
}
}

View File

@ -0,0 +1,226 @@
/**
* Text generation client for calling textgen API endpoints.
*/
import { TokenUsage, AIResponseMeta, createAIError, ErrorCodes } from './types';
/** Role for chat messages. */
export type MessageRole = 'system' | 'user' | 'assistant';
/** A single message in a conversation. */
export interface TextGenMessage {
role: MessageRole;
content: string;
}
/** Request for text generation. */
export interface TextGenRequest {
messages: TextGenMessage[];
model?: string;
maxTokens?: number;
temperature?: number;
stream?: boolean;
}
/** Response from text generation. */
export interface TextGenResponse extends AIResponseMeta {
text: string;
usage?: TokenUsage;
finishReason?: 'stop' | 'length' | 'content_filter';
}
/** Chunk received during streaming. */
export interface TextGenChunk {
text: string;
done: boolean;
}
/** Options for API calls. */
export interface TextGenOptions {
timeout?: number;
headers?: Record<string, string>;
}
/**
* Stream text generation with Server-Sent Events.
*
* @param endpoint - The textgen API endpoint URL
* @param request - The generation request
* @param onChunk - Callback for each text chunk received
* @param onDone - Callback when generation completes
* @param onError - Callback for errors
* @param options - Additional options
* @returns Abort function to cancel the stream
*
* @example
* ```ts
* const abort = streamText(
* '/api/textgen',
* { messages: [{ role: 'user', content: 'Hello!' }] },
* (chunk) => console.log(chunk),
* (response) => console.log('Done:', response),
* (error) => console.error(error)
* );
*
* // Cancel if needed
* abort();
* ```
*/
export function streamText(
endpoint: string,
request: TextGenRequest,
onChunk: (chunk: string) => void,
onDone: (response: TextGenResponse) => void,
onError: (error: Error) => void,
options: TextGenOptions = {}
): () => void {
const controller = new AbortController();
const startTime = Date.now();
const streamRequest = { ...request, stream: true };
fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
body: JSON.stringify(streamRequest),
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw createAIError(
errorData.message || `HTTP ${response.status}`,
response.status === 429 ? ErrorCodes.RATE_LIMITED : ErrorCodes.PROVIDER_ERROR,
undefined,
response.status === 429 || response.status >= 500
);
}
const reader = response.body?.getReader();
if (!reader) {
throw createAIError('No response body', ErrorCodes.NETWORK_ERROR);
}
const decoder = new TextDecoder();
let fullText = '';
let provider = '';
let usage: TokenUsage | undefined;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
if (parsed.text) {
fullText += parsed.text;
onChunk(parsed.text);
}
if (parsed.provider) provider = parsed.provider;
if (parsed.usage) usage = parsed.usage;
} catch {
// Skip invalid JSON lines
}
}
}
}
onDone({
text: fullText,
provider,
usage,
latencyMs: Date.now() - startTime,
});
})
.catch((error) => {
if (error.name === 'AbortError') return;
onError(error);
});
return () => controller.abort();
}
/**
* Generate text (non-streaming).
*
* @param endpoint - The textgen API endpoint URL
* @param request - The generation request
* @param options - Additional options
* @returns Promise resolving to the generation response
*
* @example
* ```ts
* const response = await generateText('/api/textgen', {
* messages: [
* { role: 'system', content: 'You are a helpful assistant.' },
* { role: 'user', content: 'What is 2 + 2?' }
* ],
* temperature: 0.7,
* });
* console.log(response.text);
* ```
*/
export async function generateText(
endpoint: string,
request: TextGenRequest,
options: TextGenOptions = {}
): Promise<TextGenResponse> {
const startTime = Date.now();
const nonStreamRequest = { ...request, stream: false };
const controller = new AbortController();
const timeoutId = options.timeout
? setTimeout(() => controller.abort(), options.timeout)
: undefined;
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
body: JSON.stringify(nonStreamRequest),
signal: controller.signal,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw createAIError(
errorData.message || `HTTP ${response.status}`,
response.status === 429 ? ErrorCodes.RATE_LIMITED : ErrorCodes.PROVIDER_ERROR,
undefined,
response.status === 429 || response.status >= 500
);
}
const data = await response.json();
return {
text: data.text || '',
provider: data.provider || '',
model: data.model,
usage: data.usage,
finishReason: data.finish_reason,
latencyMs: Date.now() - startTime,
};
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw createAIError('Request timed out', ErrorCodes.TIMEOUT, undefined, true);
}
throw error;
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}

View File

@ -0,0 +1,51 @@
/**
* Shared types for AI client operations.
*/
/** Token usage information from AI providers. */
export interface TokenUsage {
promptTokens: number;
completionTokens: number;
totalTokens?: number;
}
/** Base response metadata from any AI operation. */
export interface AIResponseMeta {
provider: string;
model?: string;
latencyMs: number;
}
/** Error from AI operations. */
export interface AIError extends Error {
code: string;
provider?: string;
retryable: boolean;
}
/** Create a typed AI error. */
export function createAIError(
message: string,
code: string,
provider?: string,
retryable = false
): AIError {
const error = new Error(message) as AIError;
error.code = code;
error.provider = provider;
error.retryable = retryable;
return error;
}
/** Common error codes. */
export const ErrorCodes = {
NETWORK_ERROR: 'NETWORK_ERROR',
TIMEOUT: 'TIMEOUT',
RATE_LIMITED: 'RATE_LIMITED',
INVALID_REQUEST: 'INVALID_REQUEST',
PROVIDER_ERROR: 'PROVIDER_ERROR',
QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
CONTENT_FILTERED: 'CONTENT_FILTERED',
} as const;
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

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

View File

@ -1,3 +1,6 @@
import type { ApiError } from './types';
import { ApiClientError } from './errors';
/**
* API Client Configuration
*/
@ -6,7 +9,85 @@ export interface ClientConfig {
apiKey?: string;
bearerToken?: string;
headers?: Record<string, string>;
onError?: (error: Error) => void;
/** Called on any error (after parsing) */
onError?: (error: ApiClientError) => void;
/** Called specifically on auth errors (401) - useful for triggering logout */
onAuthError?: (error: ApiClientError) => void;
}
/**
* Parse error response body into ApiError structure.
*/
async function parseErrorResponse(response: Response): Promise<ApiError> {
try {
const body = await response.json();
// Handle wrapped response format { error: { ... } }
if (body.error && typeof body.error === 'object') {
return {
status: response.status,
code: body.error.code || 'UNKNOWN_ERROR',
message: body.error.message || response.statusText,
details: body.error.details,
};
}
// Handle direct error format { code, message, details }
if (body.code && body.message) {
return {
status: response.status,
code: body.code,
message: body.message,
details: body.details,
};
}
// Handle simple message format { message: "..." }
if (body.message) {
return {
status: response.status,
code: codeFromStatus(response.status),
message: body.message,
details: body.details,
};
}
// Fallback
return {
status: response.status,
code: codeFromStatus(response.status),
message: response.statusText || 'An error occurred',
};
} catch {
// Failed to parse JSON
return {
status: response.status,
code: codeFromStatus(response.status),
message: response.statusText || 'An error occurred',
};
}
}
/**
* Map HTTP status to error code.
*/
function codeFromStatus(status: number): string {
switch (status) {
case 400:
return 'BAD_REQUEST';
case 401:
return 'UNAUTHORIZED';
case 403:
return 'FORBIDDEN';
case 404:
return 'NOT_FOUND';
case 409:
return 'CONFLICT';
case 422:
return 'UNPROCESSABLE_ENTITY';
case 429:
return 'TOO_MANY_REQUESTS';
case 503:
return 'SERVICE_UNAVAILABLE';
default:
return status >= 500 ? 'INTERNAL_ERROR' : 'UNKNOWN_ERROR';
}
}
/**
@ -20,9 +101,20 @@ export interface ClientConfig {
*
* const users = await client.get('/users');
* const newUser = await client.post('/users', { name: 'John' });
*
* @example
* // Handling validation errors
* try {
* await client.post('/users', { email: 'invalid' });
* } catch (error) {
* if (isApiClientError(error) && error.isValidationError()) {
* const fieldErrors = error.getFieldErrors();
* console.log(fieldErrors.email); // "email must be a valid email address"
* }
* }
*/
export function createClient(config: ClientConfig) {
const { baseUrl, apiKey, bearerToken, headers = {}, onError } = config;
const { baseUrl, apiKey, bearerToken, headers = {}, onError, onAuthError } = config;
async function request<T>(
method: string,
@ -65,10 +157,19 @@ export function createClient(config: ClientConfig) {
});
if (!response.ok) {
const error = new Error(`API error: ${response.status}`);
const apiError = await parseErrorResponse(response);
const error = new ApiClientError(apiError, response);
// Call auth error handler for 401s
if (error.isAuthError() && onAuthError) {
onAuthError(error);
}
// Call general error handler
if (onError) {
onError(error);
}
throw error;
}
@ -77,19 +178,22 @@ export function createClient(config: ClientConfig) {
return undefined as T;
}
return response.json();
const json = await response.json();
// Handle wrapped response format { data: { ... } }
if (json.data !== undefined) {
return json.data as T;
}
return json as T;
}
return {
get: <T>(path: string, params?: Record<string, string | number | boolean | undefined>) =>
request<T>('GET', path, { params }),
post: <T>(path: string, body?: unknown) =>
request<T>('POST', path, { body }),
put: <T>(path: string, body?: unknown) =>
request<T>('PUT', path, { body }),
patch: <T>(path: string, body?: unknown) =>
request<T>('PATCH', path, { body }),
delete: <T>(path: string) =>
request<T>('DELETE', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, { body }),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, { body }),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, { body }),
delete: <T>(path: string) => request<T>('DELETE', path),
};
}

View File

@ -0,0 +1,114 @@
import type { ApiError, ValidationDetail, ErrorCodeType } from './types';
/**
* API client error with typed error information.
* Provides convenient methods for accessing validation errors.
*/
export class ApiClientError extends Error {
/** HTTP status code */
readonly status: number;
/** Machine-readable error code */
readonly code: string;
/** Optional validation details or other error details */
readonly details?: ValidationDetail[] | Record<string, unknown>;
/** The original response */
readonly response?: Response;
constructor(apiError: ApiError, response?: Response) {
super(apiError.message);
this.name = 'ApiClientError';
this.status = apiError.status;
this.code = apiError.code;
this.details = apiError.details;
this.response = response;
// Maintains proper stack trace in V8 environments
if ('captureStackTrace' in Error && typeof Error.captureStackTrace === 'function') {
Error.captureStackTrace(this, ApiClientError);
}
}
/**
* Check if this is a validation error.
*/
isValidationError(): boolean {
return this.code === 'VALIDATION_ERROR' || this.code === 'BAD_REQUEST';
}
/**
* Check if this is an authentication error.
*/
isAuthError(): boolean {
return this.status === 401 || this.code === 'UNAUTHORIZED';
}
/**
* Check if this is a permission error.
*/
isForbiddenError(): boolean {
return this.status === 403 || this.code === 'FORBIDDEN';
}
/**
* Check if this is a not found error.
*/
isNotFoundError(): boolean {
return this.status === 404 || this.code === 'NOT_FOUND';
}
/**
* Check if the error has a specific code.
*/
hasCode(code: ErrorCodeType | string): boolean {
return this.code === code;
}
/**
* Get validation errors as an array.
* Returns empty array if no validation details are present.
*/
getValidationErrors(): ValidationDetail[] {
if (!this.details) return [];
if (Array.isArray(this.details)) return this.details;
return [];
}
/**
* Get validation errors mapped by field name.
* Useful for displaying errors next to form fields.
*
* @example
* const errors = error.getFieldErrors();
* // { email: 'email is required', password: 'password must be at least 8 characters' }
*/
getFieldErrors(): Record<string, string> {
const errors: Record<string, string> = {};
for (const detail of this.getValidationErrors()) {
errors[detail.field] = detail.message;
}
return errors;
}
/**
* Get the error message for a specific field.
* Returns undefined if no error for that field.
*/
getFieldError(field: string): string | undefined {
const errors = this.getValidationErrors();
return errors.find((e) => e.field === field)?.message;
}
/**
* Check if a specific field has an error.
*/
hasFieldError(field: string): boolean {
return this.getFieldError(field) !== undefined;
}
}
/**
* Type guard for ApiClientError.
*/
export function isApiClientError(error: unknown): error is ApiClientError {
return error instanceof ApiClientError;
}

View File

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

View File

@ -0,0 +1,67 @@
/**
* Validation error detail from the API.
* Matches the Go httpvalidation.ValidationDetail struct.
*/
export interface ValidationDetail {
/** The field name that failed validation */
field: string;
/** Human-readable error message */
message: string;
}
/**
* Standard API error response structure.
* Matches the Go httperror.HTTPError struct.
*/
export interface ApiError {
/** HTTP status code */
status: number;
/** Machine-readable error code (e.g., "VALIDATION_ERROR", "NOT_FOUND") */
code: string;
/** Human-readable error message */
message: string;
/** Optional additional details (validation errors, etc.) */
details?: ValidationDetail[] | Record<string, unknown>;
}
/**
* Standard API response wrapper.
*/
export interface ApiResponse<T> {
data?: T;
error?: ApiError;
}
/**
* Common error codes returned by the API.
*/
export const ErrorCode = {
BAD_REQUEST: 'BAD_REQUEST',
VALIDATION_ERROR: 'VALIDATION_ERROR',
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
NOT_FOUND: 'NOT_FOUND',
CONFLICT: 'CONFLICT',
UNPROCESSABLE_ENTITY: 'UNPROCESSABLE_ENTITY',
TOO_MANY_REQUESTS: 'TOO_MANY_REQUESTS',
INTERNAL_ERROR: 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
} as const;
export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode];
/**
* Auth-related types for login/user responses.
*/
export interface AuthUser {
id: string;
email: string;
name?: string;
roles?: string[];
scopes?: string[];
}
export interface LoginResponse {
token: string;
user: AuthUser;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,28 @@
{
"name": "@{{PROJECT_NAME}}/realtime",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"typescript": "^5.5.3"
}
}

View File

@ -0,0 +1,5 @@
export * from './types';
export { useEventChannel, type ChannelEvent, type SSEState, type UseEventChannelConfig } from './useEventChannel';
export { useMediaGeneration, type GenerationStatus, type UseMediaGenerationConfig, type UseMediaGenerationResult } from './useMediaGeneration';
export { useChat, type UseChatConfig, type UseChatResult, type ChatMessage } from './useChat';
export { useMediaUpload, type UploadProgress, type UploadResult, type UseMediaUploadConfig, type UseMediaUploadResult } from './useMediaUpload';

View File

@ -0,0 +1,79 @@
/**
* SSE event types matching Go realtime constants.
*/
export type EventType =
| 'chat'
| 'ai_chat'
| 'ai_chat_chunk'
| 'presence'
| 'notification'
| 'system'
| 'error'
| 'generation_started'
| 'generation_progress'
| 'generation_complete'
| 'generation_failed'
| 'upload_started'
| 'upload_progress'
| 'upload_complete'
| 'upload_failed';
/**
* Chat message data payload.
*/
export interface ChatData {
content: string;
userId: string;
userName?: string;
}
/**
* AI response data payload.
*/
export interface AIResponseData {
content: string;
provider: string;
}
/**
* AI chunk data payload (for streaming).
*/
export interface AIChunkData {
streamId: string;
text: string;
done: boolean;
provider?: string;
}
/**
* Presence message data payload.
*/
export interface PresenceData {
userId: string;
userName?: string;
status: PresenceStatus;
}
/**
* Presence status values.
*/
export type PresenceStatus = 'online' | 'offline' | 'away';
/**
* Generation result payload.
*/
export interface GenerationResult {
url: string;
provider: string;
latencyMs: number;
}
/**
* Upload result payload.
*/
export interface UploadResult {
id: string;
original: string;
optimized: string;
thumbnail: string;
}

View File

@ -0,0 +1,296 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import { useEventChannel, type ChannelEvent } from './useEventChannel';
/**
* Chat message from a user.
*/
export interface ChatMessage {
id: string;
content: string;
userId: string;
userName?: string;
timestamp: string;
}
/**
* AI response message.
*/
export interface AIMessage {
id: string;
content: string;
provider: string;
timestamp: string;
}
/**
* In-progress AI streaming message.
*/
export interface AIStreamingMessage {
streamId: string;
content: string;
isStreaming: boolean;
timestamp: string;
}
/**
* Online user in the room.
*/
export interface OnlineUser {
id: string;
name?: string;
status: 'online' | 'away' | 'offline';
}
/**
* Configuration for the chat hook.
*/
export interface UseChatConfig {
/** API endpoint for sending messages (e.g., '/api/chat/messages') */
endpoint: string;
/** SSE endpoint for events (default: '/api/events') */
sseEndpoint?: string;
/** Channel to subscribe to (e.g., 'channel:room123') */
channel: string;
/** Current user ID */
userId: string;
/** Current user display name */
userName?: string;
/** Additional headers for POST requests */
headers?: Record<string, string>;
}
/**
* Result from the chat hook.
*/
export interface UseChatResult {
/** List of chat messages */
messages: ChatMessage[];
/** List of AI responses */
aiMessages: AIMessage[];
/** Map of in-progress AI streams */
streamingMessages: Map<string, AIStreamingMessage>;
/** List of online users */
onlineUsers: OnlineUser[];
/** SSE connection state */
connectionState: 'connecting' | 'connected' | 'disconnected' | 'error';
/** Send a chat message */
sendMessage: (content: string) => Promise<void>;
/** Clear all messages */
clearMessages: () => void;
}
/** Maximum message IDs to track for deduplication */
const MAX_SEEN_MESSAGES = 500;
/**
* Chat hook using HTTP2 + SSE pattern.
*
* - Send messages: HTTP POST to endpoint
* - Receive messages: SSE subscription to channel
*
* @example
* ```tsx
* const {
* messages,
* aiMessages,
* sendMessage,
* connectionState,
* } = useChat({
* endpoint: '/api/chat/messages',
* channel: 'channel:room123',
* userId: currentUser.id,
* userName: currentUser.name,
* });
*
* // Send a message (HTTP POST)
* await sendMessage('Hello, world!');
*
* // Messages arrive via SSE automatically
* return (
* <div>
* {messages.map(msg => (
* <ChatBubble key={msg.id} {...msg} />
* ))}
* </div>
* );
* ```
*/
export function useChat(config: UseChatConfig): UseChatResult {
const {
endpoint,
sseEndpoint = '/api/events',
channel,
userId,
userName,
headers,
} = config;
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [aiMessages, setAIMessages] = useState<AIMessage[]>([]);
const [streamingMessages, setStreamingMessages] = useState<Map<string, AIStreamingMessage>>(new Map());
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
const seenMessageIds = useRef(new Set<string>());
// Handle incoming SSE events
const handleEvent = useCallback((event: ChannelEvent) => {
const eventId = event.id as string | undefined;
switch (event.type) {
case 'chat': {
// Deduplicate
if (eventId && seenMessageIds.current.has(eventId)) return;
if (eventId) {
seenMessageIds.current.add(eventId);
if (seenMessageIds.current.size > MAX_SEEN_MESSAGES) {
const arr = Array.from(seenMessageIds.current);
seenMessageIds.current = new Set(arr.slice(-MAX_SEEN_MESSAGES));
}
}
// Chat data may be at top level or nested in event.result (SSEEvent envelope)
const payload = (event.result as Record<string, unknown>) || event;
const msg: ChatMessage = {
id: (payload.id as string) || eventId || crypto.randomUUID(),
content: (payload.content as string) || (event.message as string) || '',
userId: (payload.userId as string) || 'unknown',
userName: payload.userName as string | undefined,
timestamp: (payload.timestamp as string) || event.timestamp || new Date().toISOString(),
};
setMessages((prev) => [...prev, msg]);
break;
}
case 'ai_chat_chunk': {
// Chunk data may be at top level or nested in event.result (SSEEvent envelope)
const chunkPayload = (event.result as Record<string, unknown>) || event;
const streamId = (chunkPayload.streamId as string) || (event.streamId as string);
const text = (chunkPayload.text as string) || (event.text as string) || '';
const done = (chunkPayload.done as boolean) || (event.done as boolean) || false;
setStreamingMessages((prev) => {
const updated = new Map(prev);
const existing = updated.get(streamId);
if (done) {
// Move completed stream to aiMessages
const finalContent = (existing?.content || '') + text;
if (finalContent) {
const provider = (chunkPayload.provider as string) || '';
setAIMessages((prevAI) => [...prevAI, {
id: streamId || crypto.randomUUID(),
content: finalContent,
provider,
timestamp: existing?.timestamp || new Date().toISOString(),
}]);
}
updated.delete(streamId);
} else {
updated.set(streamId, {
streamId,
content: (existing?.content || '') + text,
isStreaming: true,
timestamp: existing?.timestamp || new Date().toISOString(),
});
}
return updated;
});
break;
}
case 'ai_chat': {
// AI chat data may be at top level or nested in event.result (SSEEvent envelope)
const aiPayload = (event.result as Record<string, unknown>) || event;
const aiId = (aiPayload.id as string) || event.jobId || eventId || crypto.randomUUID();
// Deduplicate
if (seenMessageIds.current.has(aiId)) return;
seenMessageIds.current.add(aiId);
if (seenMessageIds.current.size > MAX_SEEN_MESSAGES) {
const arr = Array.from(seenMessageIds.current);
seenMessageIds.current = new Set(arr.slice(-MAX_SEEN_MESSAGES));
}
const aiMsg: AIMessage = {
id: aiId,
content: (aiPayload.content as string) || (event.message as string) || '',
provider: (aiPayload.provider as string) || '',
timestamp: (aiPayload.timestamp as string) || event.timestamp || new Date().toISOString(),
};
setAIMessages((prev) => [...prev, aiMsg]);
break;
}
case 'presence': {
const presenceUserId = event.userId as string;
const status = event.status as 'online' | 'away' | 'offline';
const name = event.userName as string | undefined;
setOnlineUsers((prev) => {
if (status === 'offline') {
return prev.filter((u) => u.id !== presenceUserId);
}
const existing = prev.find((u) => u.id === presenceUserId);
if (existing) {
return prev.map((u) =>
u.id === presenceUserId ? { ...u, status, name } : u
);
}
return [...prev, { id: presenceUserId, name, status }];
});
break;
}
}
}, []);
// Subscribe to SSE channel
const { state: sseState } = useEventChannel({
endpoint: sseEndpoint,
channel,
onEvent: handleEvent,
});
// Send message via HTTP POST
const sendMessage = useCallback(
async (content: string) => {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: JSON.stringify({
content,
userId,
userName,
channel,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error((error as { message?: string }).message || `HTTP ${response.status}`);
}
},
[endpoint, headers, userId, userName, channel]
);
const clearMessages = useCallback(() => {
setMessages([]);
setAIMessages([]);
setStreamingMessages(new Map());
seenMessageIds.current.clear();
}, []);
return {
messages,
aiMessages,
streamingMessages,
onlineUsers,
connectionState: sseState,
sendMessage,
clearMessages,
};
}

View File

@ -0,0 +1,164 @@
'use client';
import { useEffect, useRef, useCallback, useState } from 'react';
/**
* Event from SSE channel.
*/
export interface ChannelEvent {
type: string;
timestamp: string;
jobId?: string;
progress?: number;
message?: string;
result?: unknown;
error?: string;
[key: string]: unknown;
}
/**
* Connection state for SSE.
*/
export type SSEState = 'connecting' | 'connected' | 'disconnected' | 'error';
/**
* Configuration for useEventChannel hook.
*/
export interface UseEventChannelConfig {
/** SSE endpoint URL (e.g., '/api/events') */
endpoint: string;
/** Channel to subscribe to (e.g., 'user:123') */
channel: string;
/** Handler for incoming events */
onEvent?: (event: ChannelEvent) => void;
/** Handler for connection state changes */
onStateChange?: (state: SSEState) => void;
/** Auto-reconnect on disconnect (default: true) */
autoReconnect?: boolean;
/** Reconnect delay in ms (default: 3000) */
reconnectDelay?: number;
}
/**
* Hook for subscribing to SSE event channels.
*
* Connects to an SSE endpoint and receives events for a specific channel.
* Automatically reconnects on disconnect.
*
* @example
* ```tsx
* const { state } = useEventChannel({
* endpoint: '/api/events',
* channel: `user:${userId}`,
* onEvent: (event) => {
* switch (event.type) {
* case 'generation_complete':
* setResult(event.result);
* break;
* case 'generation_progress':
* setProgress(event.progress);
* break;
* }
* },
* });
* ```
*/
export function useEventChannel(config: UseEventChannelConfig) {
const {
endpoint,
channel,
onEvent,
onStateChange,
autoReconnect = true,
reconnectDelay = 3000,
} = config;
const [state, setState] = useState<SSEState>('disconnected');
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Refs for callbacks to avoid stale closures
const onEventRef = useRef(onEvent);
const onStateChangeRef = useRef(onStateChange);
useEffect(() => {
onEventRef.current = onEvent;
onStateChangeRef.current = onStateChange;
});
const updateState = useCallback((newState: SSEState) => {
setState(newState);
onStateChangeRef.current?.(newState);
}, []);
const connect = useCallback(() => {
// Close existing connection
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
// Build URL with channel parameter
const url = new URL(endpoint, window.location.origin);
url.searchParams.set('channel', channel);
updateState('connecting');
const eventSource = new EventSource(url.toString());
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
updateState('connected');
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as ChannelEvent;
onEventRef.current?.(data);
} catch {
console.error('[SSE] Failed to parse event:', event.data);
}
};
eventSource.onerror = () => {
updateState('error');
eventSource.close();
eventSourceRef.current = null;
// Reconnect if enabled
if (autoReconnect) {
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, reconnectDelay);
}
};
}, [endpoint, channel, autoReconnect, reconnectDelay, updateState]);
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
updateState('disconnected');
}, [updateState]);
// Connect on mount, disconnect on unmount
useEffect(() => {
connect();
return () => {
disconnect();
};
}, [connect, disconnect]);
return {
/** Current connection state */
state,
/** Manually reconnect */
reconnect: connect,
/** Manually disconnect */
disconnect,
};
}

View File

@ -0,0 +1,241 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import { useEventChannel, type ChannelEvent, type SSEState } from './useEventChannel';
/**
* Generation status.
*/
export type GenerationStatus = 'idle' | 'pending' | 'generating' | 'complete' | 'failed';
/**
* Configuration for media generation hook.
*/
export interface UseMediaGenerationConfig {
/** API endpoint for starting the generation job */
endpoint: string;
/** SSE endpoint for events (default: '/api/events') */
sseEndpoint?: string;
/** User ID for subscribing to user channel */
userId: string;
/** Additional headers for the POST request */
headers?: Record<string, string>;
}
/**
* Result from the media generation hook.
*/
export interface UseMediaGenerationResult<T> {
/** Current generation status */
status: GenerationStatus;
/** SSE connection state */
sseState: SSEState;
/** Progress percentage (0-100) */
progress: number;
/** Current stage message */
message: string;
/** Generated result (available when status is 'complete') */
result: T | null;
/** Error message (available when status is 'failed') */
error: string | null;
/** Job ID of the current generation */
jobId: string | null;
/** Start a new generation */
generate: (request: Record<string, unknown>) => Promise<void>;
/** Reset state for a new generation */
reset: () => void;
}
/**
* Hook for async media generation with real-time progress via SSE events.
*
* This hook implements the correct async pattern:
* 1. POST to endpoint returns job ID immediately
* 2. Progress and results come via SSE events
* 3. Never blocks waiting for generation
*
* @example
* ```tsx
* const {
* status,
* progress,
* message,
* result,
* error,
* generate,
* } = useMediaGeneration<VideoResult>({
* endpoint: '/api/generate/video',
* userId: currentUser.id,
* });
*
* // Start generation
* await generate({ prompt: 'A cat playing piano', aspectRatio: '16:9' });
*
* // UI automatically updates as SSE events arrive
* return (
* <div>
* <p>Status: {status}</p>
* <p>Progress: {progress}%</p>
* <p>{message}</p>
* {result && <video src={result.url} />}
* {error && <p className="error">{error}</p>}
* </div>
* );
* ```
*/
export function useMediaGeneration<T = unknown>(
config: UseMediaGenerationConfig
): UseMediaGenerationResult<T> {
const {
endpoint,
sseEndpoint = '/api/events',
userId,
headers,
} = config;
const [status, setStatus] = useState<GenerationStatus>('idle');
const [progress, setProgress] = useState(0);
const [message, setMessage] = useState('');
const [result, setResult] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [jobId, setJobId] = useState<string | null>(null);
const jobIdRef = useRef<string | null>(null);
// Handle incoming SSE events
const handleEvent = useCallback((event: ChannelEvent) => {
// Only handle events for our job
if (!jobIdRef.current || event.jobId !== jobIdRef.current) return;
switch (event.type) {
case 'generation_started':
setStatus('generating');
setProgress(0);
setMessage('Starting generation...');
break;
case 'generation_progress':
setProgress(event.progress ?? 0);
setMessage(event.message ?? 'Generating...');
break;
case 'generation_complete':
setStatus('complete');
setProgress(100);
setMessage('Complete');
setResult(event.result as T);
break;
case 'generation_failed':
setStatus('failed');
setError(event.error ?? 'Generation failed');
break;
case 'upload_started':
setStatus('generating');
setProgress(0);
setMessage('Starting upload...');
break;
case 'upload_progress':
setProgress(event.progress ?? 0);
setMessage('Uploading...');
break;
case 'upload_complete':
setStatus('complete');
setProgress(100);
setMessage('Complete');
setResult(event.result as T);
break;
case 'upload_failed':
setStatus('failed');
setError(event.error ?? 'Upload failed');
break;
}
}, []);
// Subscribe to SSE events for user channel
const { state: sseState } = useEventChannel({
endpoint: sseEndpoint,
channel: `user:${userId}`,
onEvent: handleEvent,
});
const generate = useCallback(
async (request: Record<string, unknown>) => {
// Reset state
setStatus('pending');
setProgress(0);
setMessage('Submitting job...');
setResult(null);
setError(null);
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: JSON.stringify(request),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
(errorData as { message?: string }).message || `HTTP ${response.status}`
);
}
// Handle both envelope-wrapped ({ data: { jobId } }) and direct ({ jobId }) responses
const json = (await response.json()) as
| { data: { jobId: string }; meta?: unknown }
| { jobId: string };
const resolvedJobId =
'data' in json && json.data?.jobId
? json.data.jobId
: (json as { jobId: string }).jobId;
if (!resolvedJobId) {
throw new Error('No job ID returned from server');
}
// Store job ID and transition to generating immediately.
// The 202 response confirms the job was accepted — don't wait for
// the generation_started SSE event to leave the 'pending' state.
jobIdRef.current = resolvedJobId;
setJobId(resolvedJobId);
setStatus('generating');
setMessage('Generation in progress...');
} catch (err) {
setStatus('failed');
setError(err instanceof Error ? err.message : 'Failed to start generation');
}
},
[endpoint, headers]
);
const reset = useCallback(() => {
jobIdRef.current = null;
setJobId(null);
setStatus('idle');
setProgress(0);
setMessage('');
setResult(null);
setError(null);
}, []);
return {
status,
sseState,
progress,
message,
result,
error,
jobId,
generate,
reset,
};
}

View File

@ -0,0 +1,141 @@
import { useState, useCallback } from 'react';
export interface UploadProgress {
loaded: number;
total: number;
percent: number;
}
export interface UploadResult {
url: string;
path: string;
}
export interface UseMediaUploadConfig {
/** Base URL for the API (e.g., "" or "http://localhost:8001") */
apiPrefix?: string;
/** Service name for API path (e.g., "example-api") */
serviceName: string;
/** Auth headers to include in API calls */
headers?: Record<string, string>;
/** Called on progress updates during the upload to GCS */
onProgress?: (progress: UploadProgress) => void;
}
export interface UseMediaUploadResult {
/** Upload a file. Returns the final URL on success. */
upload: (file: File) => Promise<UploadResult>;
/** Whether an upload is in progress */
isUploading: boolean;
/** Current upload progress (0-100) */
progress: number;
/** Error message if upload failed */
error: string | null;
/** Reset state */
reset: () => void;
}
export function useMediaUpload(config: UseMediaUploadConfig): UseMediaUploadResult {
const [isUploading, setIsUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const reset = useCallback(() => {
setIsUploading(false);
setProgress(0);
setError(null);
}, []);
const upload = useCallback(async (file: File): Promise<UploadResult> => {
setIsUploading(true);
setProgress(0);
setError(null);
try {
// 1. Get presigned upload URL from backend
const initRes = await fetch(
`${config.apiPrefix || ''}/api/${config.serviceName}/media/upload/init`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...config.headers,
},
body: JSON.stringify({
filename: file.name,
contentType: file.type,
}),
}
);
if (!initRes.ok) {
throw new Error(`Upload init failed: ${initRes.status}`);
}
const { uploadURL, objectPath, headers: uploadHeaders } = await initRes.json();
// 2. Upload directly to storage using XHR (for progress tracking)
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', uploadURL);
xhr.timeout = 5 * 60 * 1000; // 5 minutes for large uploads
// Set headers from presigned response
if (uploadHeaders) {
Object.entries(uploadHeaders).forEach(([key, value]) => {
xhr.setRequestHeader(key, value as string);
});
}
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
setProgress(pct);
config.onProgress?.({ loaded: e.loaded, total: e.total, percent: pct });
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Upload failed: ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error('Upload failed: network error'));
xhr.ontimeout = () => reject(new Error('Upload timed out'));
xhr.send(file);
});
// 3. Confirm upload with backend
const completeRes = await fetch(
`${config.apiPrefix || ''}/api/${config.serviceName}/media/upload/complete`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...config.headers,
},
body: JSON.stringify({ objectPath }),
}
);
if (!completeRes.ok) {
throw new Error(`Upload complete failed: ${completeRes.status}`);
}
const result = await completeRes.json();
setProgress(100);
setIsUploading(false);
return { url: result.url, path: result.path };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Upload failed';
setError(msg);
setIsUploading(false);
throw err;
}
}, [config]);
return { upload, isUploading, progress, error, reset };
}

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -0,0 +1,75 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../utils/cn';
const chatBubbleVariants = cva(
'relative max-w-[85%] rounded-2xl px-4 py-3 text-sm leading-relaxed',
{
variants: {
role: {
user: 'ml-auto bg-[var(--accent)] text-[var(--accent-foreground)] rounded-br-sm',
assistant: 'mr-auto bg-[var(--surface-200)] text-[var(--text-primary)] rounded-bl-sm',
system: 'mx-auto bg-[var(--surface-100)] text-[var(--text-secondary)] text-xs italic max-w-[90%]',
},
},
defaultVariants: {
role: 'assistant',
},
}
);
export interface ChatBubbleProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'role'>,
VariantProps<typeof chatBubbleVariants> {
content: string;
timestamp?: Date;
avatar?: string;
isStreaming?: boolean;
}
const ChatBubble = React.forwardRef<HTMLDivElement, ChatBubbleProps>(
({ className, role, content, timestamp, avatar, isStreaming, ...props }, ref) => {
const isUser = role === 'user';
return (
<div
ref={ref}
className={cn(
'flex items-end gap-2',
isUser ? 'flex-row-reverse' : 'flex-row',
className
)}
{...props}
>
{avatar && role !== 'system' && (
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-[var(--surface-300)] overflow-hidden">
<img src={avatar} alt="" className="w-full h-full object-cover" />
</div>
)}
<div className="flex flex-col gap-1">
<div className={cn(chatBubbleVariants({ role }))}>
<span className="whitespace-pre-wrap">{content}</span>
{isStreaming && (
<span className="inline-block w-2 h-4 ml-1 bg-current animate-pulse" />
)}
</div>
{timestamp && (
<span
className={cn(
'text-xs text-[var(--text-muted)]',
isUser ? 'text-right' : 'text-left'
)}
>
{timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
</div>
);
}
);
ChatBubble.displayName = 'ChatBubble';
export { ChatBubble, chatBubbleVariants };

View File

@ -0,0 +1,118 @@
import * as React from 'react';
import { cn } from '../utils/cn';
import { Send } from 'lucide-react';
export interface ChatInputProps {
placeholder?: string;
maxLength?: number;
disabled?: boolean;
/** Called when the user submits a message. Return false to indicate failure. */
onSubmit: (text: string) => boolean | void;
/** Called when send fails (onSubmit returns false). */
onSendError?: (text: string) => void;
className?: string;
}
const ChatInput = React.forwardRef<HTMLDivElement, ChatInputProps>(
({ placeholder = 'Type a message...', maxLength = 4000, disabled, onSubmit, onSendError, className }, ref) => {
const [value, setValue] = React.useState('');
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const adjustHeight = React.useCallback(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
}
}, []);
React.useEffect(() => {
adjustHeight();
}, [value, adjustHeight]);
const handleSubmit = React.useCallback(() => {
const trimmed = value.trim();
if (trimmed && !disabled) {
const result = onSubmit(trimmed);
if (result === false) {
// Send failed - notify via callback
onSendError?.(trimmed);
return;
}
setValue('');
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
}
}, [value, disabled, onSubmit, onSendError]);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit]
);
const charCount = value.length;
const isNearLimit = charCount > maxLength * 0.9;
const isOverLimit = charCount > maxLength;
return (
<div
ref={ref}
className={cn(
'relative flex items-end gap-2 rounded-xl border border-[var(--border)] bg-[var(--surface-100)] p-2 transition-colors focus-within:border-[var(--accent)]',
disabled && 'opacity-50 cursor-not-allowed',
className
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
className={cn(
'flex-1 resize-none bg-transparent text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:outline-none disabled:cursor-not-allowed',
'min-h-[24px] max-h-[200px] py-1 px-2'
)}
/>
<div className="flex items-center gap-2 flex-shrink-0">
{isNearLimit && (
<span
className={cn(
'text-xs tabular-nums',
isOverLimit ? 'text-[var(--error)]' : 'text-[var(--text-muted)]'
)}
>
{charCount}/{maxLength}
</span>
)}
<button
type="button"
onClick={handleSubmit}
disabled={disabled || !value.trim() || isOverLimit}
className={cn(
'flex items-center justify-center w-8 h-8 rounded-lg transition-colors',
'bg-[var(--accent)] text-[var(--accent-foreground)]',
'hover:bg-[var(--accent-hover)]',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-[var(--accent)]'
)}
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
);
}
);
ChatInput.displayName = 'ChatInput';
export { ChatInput };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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