initial agent-mgr: app builder platform MVP
Go API server + Preact UI + Claude Code adapter. - App-centric model (ideas, not repos) - AgentProvider interface for multi-agent support - K8s pod lifecycle for sandboxed agent sessions - Gitea integration (create repos, push branches) - WebSocket streaming for live session output - Woodpecker CI/CD pipelines (kaniko build + kubectl deploy)
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
bin/
|
||||
ui/node_modules/
|
||||
ui/dist/index.js
|
||||
*.secret.tfvars
|
||||
.env
|
||||
38
.woodpecker/build.yaml
Normal file
38
.woodpecker/build.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
variables:
|
||||
- &kaniko_settings
|
||||
registry: git.asp.now
|
||||
username:
|
||||
from_secret: gitea_user
|
||||
password:
|
||||
from_secret: gitea_token
|
||||
|
||||
when:
|
||||
- event: [push, tag]
|
||||
branch: main
|
||||
|
||||
steps:
|
||||
- name: build-agent-mgr
|
||||
image: woodpeckerci/plugin-kaniko
|
||||
settings:
|
||||
<<: *kaniko_settings
|
||||
repo: git.asp.now/platform/agent-mgr
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
dockerfile: docker/Dockerfile
|
||||
context: .
|
||||
cache: true
|
||||
cache_repo: git.asp.now/platform/agent-mgr/cache
|
||||
|
||||
- name: build-claude-runner
|
||||
image: woodpeckerci/plugin-kaniko
|
||||
settings:
|
||||
<<: *kaniko_settings
|
||||
repo: git.asp.now/platform/claude-code-runner
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
dockerfile: docker/Dockerfile.claude-runner
|
||||
context: docker/
|
||||
cache: true
|
||||
cache_repo: git.asp.now/platform/claude-code-runner/cache
|
||||
16
.woodpecker/deploy.yaml
Normal file
16
.woodpecker/deploy.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
when:
|
||||
- event: [push, tag]
|
||||
branch: main
|
||||
|
||||
steps:
|
||||
- name: deploy
|
||||
image: bitnami/kubectl:latest
|
||||
commands:
|
||||
- kubectl -n agent-mgr set image deployment/agent-mgr agent-mgr=git.asp.now/platform/agent-mgr:${CI_COMMIT_SHA:0:8}
|
||||
- kubectl -n agent-mgr rollout status deployment/agent-mgr --timeout=120s
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: woodpecker-deployer
|
||||
32
Makefile
Normal file
32
Makefile
Normal file
@@ -0,0 +1,32 @@
|
||||
.PHONY: build build-ui build-go run clean docker docker-runner
|
||||
|
||||
# Build UI, then Go binary
|
||||
build: build-ui build-go
|
||||
|
||||
build-ui:
|
||||
cd ui && npm ci --ignore-scripts && npm run build
|
||||
|
||||
build-go:
|
||||
go build -o bin/agent-mgr ./cmd/agent-mgr
|
||||
|
||||
run: build
|
||||
./bin/agent-mgr
|
||||
|
||||
clean:
|
||||
rm -rf bin/ ui/dist/index.js ui/node_modules
|
||||
|
||||
# Docker images
|
||||
docker:
|
||||
docker build -f docker/Dockerfile -t agent-mgr .
|
||||
|
||||
docker-runner:
|
||||
docker build -f docker/Dockerfile.claude-runner -t claude-code-runner docker/
|
||||
|
||||
# Development: run with local kubeconfig
|
||||
dev: build-ui
|
||||
KUBECONFIG=~/.kube/n2d-1-config \
|
||||
DATABASE_URL=postgres://localhost:5432/agent_mgr_db?sslmode=disable \
|
||||
GITEA_URL=https://git.asp.now \
|
||||
GITEA_TOKEN= \
|
||||
GITEA_BOT_USER=agent-mgr-bot \
|
||||
go run ./cmd/agent-mgr
|
||||
129
cmd/agent-mgr/main.go
Normal file
129
cmd/agent-mgr/main.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/agentsphere/agent-mgr/internal/api"
|
||||
"github.com/agentsphere/agent-mgr/internal/app"
|
||||
"github.com/agentsphere/agent-mgr/internal/events"
|
||||
"github.com/agentsphere/agent-mgr/internal/gitea"
|
||||
"github.com/agentsphere/agent-mgr/internal/provider"
|
||||
"github.com/agentsphere/agent-mgr/internal/provider/claudecode"
|
||||
"github.com/agentsphere/agent-mgr/internal/store"
|
||||
"github.com/agentsphere/agent-mgr/ui"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
// Config from env
|
||||
listenAddr := envOr("LISTEN_ADDR", ":8080")
|
||||
databaseURL := envOr("DATABASE_URL", "postgres://localhost:5432/agent_mgr_db?sslmode=disable")
|
||||
giteaURL := envOr("GITEA_URL", "https://git.asp.now")
|
||||
giteaToken := envOr("GITEA_TOKEN", "")
|
||||
giteaBotUser := envOr("GITEA_BOT_USER", "agent-mgr-bot")
|
||||
valkeyAddr := envOr("VALKEY_ADDR", "valkey.valkey.svc.cluster.local:6379")
|
||||
valkeyPassword := envOr("VALKEY_PASSWORD", "")
|
||||
|
||||
// Postgres
|
||||
db, err := store.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
log.Error("failed to connect to postgres", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Migrate(ctx); err != nil {
|
||||
log.Error("failed to run migrations", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Kubernetes client
|
||||
restConfig, err := kubeConfig()
|
||||
if err != nil {
|
||||
log.Error("failed to get kube config", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
k8s, err := kubernetes.NewForConfig(restConfig)
|
||||
if err != nil {
|
||||
log.Error("failed to create kube client", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Providers
|
||||
registry := provider.NewRegistry()
|
||||
registry.Register(claudecode.New(k8s, restConfig))
|
||||
|
||||
// Gitea
|
||||
giteaClient := gitea.New(giteaURL, giteaToken, giteaBotUser)
|
||||
|
||||
// Events
|
||||
bus := events.NewBus(valkeyAddr, valkeyPassword)
|
||||
defer bus.Close()
|
||||
|
||||
// App service
|
||||
appSvc := app.NewService(db, giteaClient, registry, log)
|
||||
|
||||
// Static UI files
|
||||
var staticFS fs.FS
|
||||
uiFS := ui.StaticFS()
|
||||
if uiFS != nil {
|
||||
staticFS = uiFS
|
||||
}
|
||||
|
||||
// HTTP server
|
||||
srv := api.NewServer(db, appSvc, registry, bus, log)
|
||||
httpSrv := &http.Server{
|
||||
Addr: listenAddr,
|
||||
Handler: srv.Handler(staticFS),
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Info("starting server", "addr", listenAddr)
|
||||
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Error("server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Info("shutting down...")
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutdownCancel()
|
||||
httpSrv.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
func kubeConfig() (*rest.Config, error) {
|
||||
// In-cluster first
|
||||
cfg, err := rest.InClusterConfig()
|
||||
if err == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
// Fallback to kubeconfig
|
||||
kubeconfigPath := envOr("KUBECONFIG", os.Getenv("HOME")+"/.kube/config")
|
||||
return clientcmd.BuildConfigFromFlags("", kubeconfigPath)
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
23
docker/Dockerfile
Normal file
23
docker/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# Stage 1: Build UI
|
||||
FROM node:22-slim AS ui-builder
|
||||
WORKDIR /ui
|
||||
COPY ui/package.json ui/package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY ui/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Go binary
|
||||
FROM golang:1.23-alpine AS go-builder
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
COPY --from=ui-builder /ui/dist ./ui/dist
|
||||
RUN CGO_ENABLED=0 go build -o /agent-mgr ./cmd/agent-mgr
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=go-builder /agent-mgr /agent-mgr
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/agent-mgr"]
|
||||
17
docker/Dockerfile.claude-runner
Normal file
17
docker/Dockerfile.claude-runner
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM node:22-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
RUN useradd -m -s /bin/bash agent
|
||||
USER agent
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY --chown=agent:agent entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
16
docker/entrypoint.sh
Normal file
16
docker/entrypoint.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
cd /workspace
|
||||
|
||||
# Run claude interactively, streaming JSON output
|
||||
claude --output-format stream-json "$@"
|
||||
EXIT_CODE=$?
|
||||
|
||||
# After claude exits, push whatever it did
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
git add -A
|
||||
git commit -m "agent session ${SESSION_ID:-unknown}"
|
||||
git push origin "agent/${SESSION_ID:-unknown}"
|
||||
fi
|
||||
|
||||
exit $EXIT_CODE
|
||||
62
go.mod
Normal file
62
go.mod
Normal file
@@ -0,0 +1,62 @@
|
||||
module github.com/agentsphere/agent-mgr
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
k8s.io/api v0.35.1
|
||||
k8s.io/apimachinery v0.35.1
|
||||
k8s.io/client-go v0.35.1
|
||||
nhooyr.io/websocket v1.8.17
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/moby/spdystream v0.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.37.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
166
go.sum
Normal file
166
go.sum
Normal file
@@ -0,0 +1,166 @@
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
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/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
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/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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
|
||||
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q=
|
||||
k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM=
|
||||
k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU=
|
||||
k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
|
||||
k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM=
|
||||
k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA=
|
||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
|
||||
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
105
internal/api/apps.go
Normal file
105
internal/api/apps.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/agentsphere/agent-mgr/internal/store"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type createAppRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Config json.RawMessage `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) createApp(w http.ResponseWriter, r *http.Request) {
|
||||
var req createAppRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Name == "" && req.Description == "" {
|
||||
http.Error(w, `{"error":"name or description required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
// Auto-generate name from first few words of description
|
||||
name := req.Description
|
||||
if len(name) > 40 {
|
||||
name = name[:40]
|
||||
}
|
||||
req.Name = name
|
||||
}
|
||||
|
||||
app, sess, err := s.app.CreateApp(r.Context(), req.Name, req.Description, req.Provider, req.Config)
|
||||
if err != nil {
|
||||
s.log.Error("create app failed", "err", err)
|
||||
http.Error(w, `{"error":"failed to create app"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{"app": app}
|
||||
if sess != nil {
|
||||
resp["session"] = sess
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (s *Server) listApps(w http.ResponseWriter, r *http.Request) {
|
||||
apps, err := s.store.ListApps(r.Context())
|
||||
if err != nil {
|
||||
s.log.Error("list apps failed", "err", err)
|
||||
http.Error(w, `{"error":"failed to list apps"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if apps == nil {
|
||||
apps = []store.App{}
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"apps": apps})
|
||||
}
|
||||
|
||||
func (s *Server) getApp(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := uuid.Parse(chi.URLParam(r, "appID"))
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid app id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
app, err := s.store.GetApp(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to get app"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if app == nil {
|
||||
http.Error(w, `{"error":"app not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
sessions, _ := s.store.ListSessionsByApp(r.Context(), id)
|
||||
if sessions == nil {
|
||||
sessions = []store.Session{}
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]any{"app": app, "sessions": sessions})
|
||||
}
|
||||
|
||||
func (s *Server) deleteApp(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := uuid.Parse(chi.URLParam(r, "appID"))
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid app id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.DeleteApp(r.Context(), id); err != nil {
|
||||
http.Error(w, `{"error":"failed to delete app"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
77
internal/api/router.go
Normal file
77
internal/api/router.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/agentsphere/agent-mgr/internal/app"
|
||||
"github.com/agentsphere/agent-mgr/internal/events"
|
||||
"github.com/agentsphere/agent-mgr/internal/provider"
|
||||
"github.com/agentsphere/agent-mgr/internal/store"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
store *store.Store
|
||||
app *app.Service
|
||||
registry *provider.Registry
|
||||
events *events.Bus
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func NewServer(st *store.Store, appSvc *app.Service, reg *provider.Registry, bus *events.Bus, log *slog.Logger) *Server {
|
||||
return &Server{store: st, app: appSvc, registry: reg, events: bus, log: log}
|
||||
}
|
||||
|
||||
func (s *Server) Handler(staticFS fs.FS) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Compress(5))
|
||||
|
||||
// API routes
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Use(middleware.SetHeader("Content-Type", "application/json"))
|
||||
|
||||
r.Post("/apps", s.createApp)
|
||||
r.Get("/apps", s.listApps)
|
||||
r.Get("/apps/{appID}", s.getApp)
|
||||
r.Delete("/apps/{appID}", s.deleteApp)
|
||||
|
||||
r.Post("/apps/{appID}/sessions", s.createSession)
|
||||
r.Get("/apps/{appID}/sessions", s.listSessions)
|
||||
|
||||
r.Get("/sessions/{sessionID}", s.getSession)
|
||||
r.Post("/sessions/{sessionID}/stop", s.stopSession)
|
||||
r.Post("/sessions/{sessionID}/message", s.sendMessage)
|
||||
r.Get("/sessions/{sessionID}/ws", s.streamSession)
|
||||
|
||||
r.Get("/providers", s.listProviders)
|
||||
|
||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
})
|
||||
|
||||
// Serve SPA for all other routes
|
||||
if staticFS != nil {
|
||||
fileServer := http.FileServer(http.FS(staticFS))
|
||||
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Try to serve the file; if not found, serve index.html (SPA routing)
|
||||
path := r.URL.Path
|
||||
f, err := staticFS.Open(path[1:]) // strip leading /
|
||||
if err != nil {
|
||||
// Serve index.html for SPA client-side routing
|
||||
r.URL.Path = "/"
|
||||
} else {
|
||||
f.Close()
|
||||
}
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
136
internal/api/sessions.go
Normal file
136
internal/api/sessions.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/agentsphere/agent-mgr/internal/store"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type createSessionRequest struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Config json.RawMessage `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
type sendMessageRequest struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (s *Server) createSession(w http.ResponseWriter, r *http.Request) {
|
||||
appID, err := uuid.Parse(chi.URLParam(r, "appID"))
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid app id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req createSessionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Prompt == "" {
|
||||
http.Error(w, `{"error":"prompt required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
app, err := s.store.GetApp(r.Context(), appID)
|
||||
if err != nil || app == nil {
|
||||
http.Error(w, `{"error":"app not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := s.app.StartSession(r.Context(), app, req.Prompt, req.Provider, req.Config)
|
||||
if err != nil {
|
||||
s.log.Error("create session failed", "err", err)
|
||||
http.Error(w, `{"error":"failed to create session"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]any{"session": sess})
|
||||
}
|
||||
|
||||
func (s *Server) listSessions(w http.ResponseWriter, r *http.Request) {
|
||||
appID, err := uuid.Parse(chi.URLParam(r, "appID"))
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid app id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := s.store.ListSessionsByApp(r.Context(), appID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to list sessions"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if sessions == nil {
|
||||
sessions = []store.Session{}
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]any{"sessions": sessions})
|
||||
}
|
||||
|
||||
func (s *Server) getSession(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := uuid.Parse(chi.URLParam(r, "sessionID"))
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid session id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := s.store.GetSession(r.Context(), id)
|
||||
if err != nil || sess == nil {
|
||||
http.Error(w, `{"error":"session not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]any{"session": sess})
|
||||
}
|
||||
|
||||
func (s *Server) stopSession(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := uuid.Parse(chi.URLParam(r, "sessionID"))
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid session id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.app.StopSession(r.Context(), id); err != nil {
|
||||
s.log.Error("stop session failed", "err", err)
|
||||
http.Error(w, `{"error":"failed to stop session"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]any{"status": "stopped"})
|
||||
}
|
||||
|
||||
func (s *Server) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := uuid.Parse(chi.URLParam(r, "sessionID"))
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid session id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req sendMessageRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Message == "" {
|
||||
http.Error(w, `{"error":"message required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.app.SendMessage(r.Context(), id, req.Message); err != nil {
|
||||
s.log.Error("send message failed", "err", err)
|
||||
http.Error(w, `{"error":"failed to send message"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]any{"status": "sent"})
|
||||
}
|
||||
|
||||
func (s *Server) listProviders(w http.ResponseWriter, r *http.Request) {
|
||||
infos := s.registry.List()
|
||||
json.NewEncoder(w).Encode(map[string]any{"providers": infos})
|
||||
}
|
||||
74
internal/api/ws.go
Normal file
74
internal/api/ws.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"nhooyr.io/websocket"
|
||||
|
||||
"github.com/agentsphere/agent-mgr/internal/provider"
|
||||
"github.com/agentsphere/agent-mgr/internal/provider/claudecode"
|
||||
)
|
||||
|
||||
func (s *Server) streamSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessID, err := uuid.Parse(chi.URLParam(r, "sessionID"))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid session id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := s.store.GetSession(r.Context(), sessID)
|
||||
if err != nil || sess == nil {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
p, err := s.registry.Get(sess.Provider)
|
||||
if err != nil {
|
||||
http.Error(w, "provider not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
handle := &provider.SessionHandle{SessionID: sess.ID, PodName: sess.PodName}
|
||||
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
InsecureSkipVerify: true, // Traefik handles TLS/origin
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Error("websocket accept failed", "err", err)
|
||||
return
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
ctx := conn.CloseRead(r.Context())
|
||||
|
||||
stream, err := p.StreamOutput(ctx, handle)
|
||||
if err != nil {
|
||||
s.log.Error("stream output failed", "err", err, "pod", sess.PodName)
|
||||
conn.Close(websocket.StatusInternalError, "failed to stream logs")
|
||||
return
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
tracker := claudecode.NewProgressTracker()
|
||||
scanner := bufio.NewScanner(stream)
|
||||
scanner.Buffer(make([]byte, 64*1024), 64*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
tracker.ProcessLine(line)
|
||||
|
||||
if err := conn.Write(ctx, websocket.MessageText, []byte(line)); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil && err != io.EOF {
|
||||
s.log.Warn("stream scanner error", "err", err)
|
||||
}
|
||||
|
||||
conn.Close(websocket.StatusNormalClosure, "stream ended")
|
||||
}
|
||||
170
internal/app/service.go
Normal file
170
internal/app/service.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/agentsphere/agent-mgr/internal/gitea"
|
||||
"github.com/agentsphere/agent-mgr/internal/provider"
|
||||
"github.com/agentsphere/agent-mgr/internal/store"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store *store.Store
|
||||
gitea *gitea.Client
|
||||
registry *provider.Registry
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func NewService(s *store.Store, g *gitea.Client, r *provider.Registry, log *slog.Logger) *Service {
|
||||
return &Service{store: s, gitea: g, registry: r, log: log}
|
||||
}
|
||||
|
||||
// CreateApp creates an app record, a Gitea repo, and starts the first build session.
|
||||
func (svc *Service) CreateApp(ctx context.Context, name, description, providerName string, providerConfig json.RawMessage) (*store.App, *store.Session, error) {
|
||||
repoName := slugify(name)
|
||||
|
||||
app, err := svc.store.CreateApp(ctx, name, description)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create app: %w", err)
|
||||
}
|
||||
|
||||
repo, err := svc.gitea.CreateRepo(ctx, repoName, description)
|
||||
if err != nil {
|
||||
_ = svc.store.DeleteApp(ctx, app.ID)
|
||||
return nil, nil, fmt.Errorf("create gitea repo: %w", err)
|
||||
}
|
||||
|
||||
if err := svc.store.UpdateAppRepo(ctx, app.ID, repo.FullName[:strings.Index(repo.FullName, "/")], repo.Name); err != nil {
|
||||
return nil, nil, fmt.Errorf("update app repo: %w", err)
|
||||
}
|
||||
app.RepoOwner = repo.FullName[:strings.Index(repo.FullName, "/")]
|
||||
app.RepoName = repo.Name
|
||||
|
||||
sess, err := svc.StartSession(ctx, app, description, providerName, providerConfig)
|
||||
if err != nil {
|
||||
return app, nil, fmt.Errorf("start initial session: %w", err)
|
||||
}
|
||||
|
||||
return app, sess, nil
|
||||
}
|
||||
|
||||
// StartSession creates a new session on an existing app and spawns the agent pod.
|
||||
func (svc *Service) StartSession(ctx context.Context, app *store.App, prompt, providerName string, providerConfig json.RawMessage) (*store.Session, error) {
|
||||
if providerName == "" {
|
||||
providerName = "claude-code"
|
||||
}
|
||||
|
||||
p, err := svc.registry.Get(providerName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sessID := uuid.New()
|
||||
branch := fmt.Sprintf("agent/%s", sessID.String()[:8])
|
||||
|
||||
configBytes := providerConfig
|
||||
if configBytes == nil {
|
||||
configBytes = json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
sess, err := svc.store.CreateSession(ctx, app.ID, providerName, prompt, branch, configBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
// Override the auto-generated ID with our pre-generated one for branch consistency
|
||||
// (the DB generates its own UUID, but the branch name uses sessID)
|
||||
// We'll use the DB-generated ID going forward
|
||||
branch = fmt.Sprintf("agent/%s", sess.ID.String()[:8])
|
||||
_ = svc.store.UpdateSessionStatus(ctx, sess.ID, "pending")
|
||||
sess.Branch = branch
|
||||
|
||||
cloneURL := svc.gitea.AuthCloneURL(app.RepoOwner, app.RepoName)
|
||||
|
||||
systemPrompt := fmt.Sprintf("You are building an app called %q. The user's idea: %s\nCreate a complete, working application. When done, ensure all files are committed.", app.Name, app.Description)
|
||||
fullPrompt := prompt
|
||||
if prompt == app.Description {
|
||||
fullPrompt = systemPrompt
|
||||
}
|
||||
|
||||
handle, err := p.CreateSession(ctx, provider.SessionConfig{
|
||||
SessionID: sess.ID,
|
||||
AppName: app.Name,
|
||||
RepoClone: cloneURL,
|
||||
Branch: branch,
|
||||
Prompt: fullPrompt,
|
||||
Provider: configBytes,
|
||||
})
|
||||
if err != nil {
|
||||
_ = svc.store.UpdateSessionStatus(ctx, sess.ID, "failed")
|
||||
return nil, fmt.Errorf("create agent session: %w", err)
|
||||
}
|
||||
|
||||
_ = svc.store.UpdateSessionPod(ctx, sess.ID, handle.PodName)
|
||||
_ = svc.store.UpdateSessionStatus(ctx, sess.ID, "running")
|
||||
_ = svc.store.UpdateAppStatus(ctx, app.ID, "building")
|
||||
|
||||
sess.PodName = handle.PodName
|
||||
sess.Status = "running"
|
||||
|
||||
svc.log.Info("session started", "app", app.Name, "session", sess.ID, "pod", handle.PodName)
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (svc *Service) StopSession(ctx context.Context, sessID uuid.UUID) error {
|
||||
sess, err := svc.store.GetSession(ctx, sessID)
|
||||
if err != nil || sess == nil {
|
||||
return fmt.Errorf("session not found: %w", err)
|
||||
}
|
||||
|
||||
p, err := svc.registry.Get(sess.Provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
handle := &provider.SessionHandle{SessionID: sess.ID, PodName: sess.PodName}
|
||||
if err := p.StopSession(ctx, handle); err != nil {
|
||||
svc.log.Warn("failed to stop pod", "pod", sess.PodName, "err", err)
|
||||
}
|
||||
|
||||
_ = svc.store.UpdateSessionStatus(ctx, sessID, "stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) SendMessage(ctx context.Context, sessID uuid.UUID, msg string) error {
|
||||
sess, err := svc.store.GetSession(ctx, sessID)
|
||||
if err != nil || sess == nil {
|
||||
return fmt.Errorf("session not found")
|
||||
}
|
||||
if sess.Status != "running" {
|
||||
return fmt.Errorf("session not running (status: %s)", sess.Status)
|
||||
}
|
||||
|
||||
p, err := svc.registry.Get(sess.Provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
handle := &provider.SessionHandle{SessionID: sess.ID, PodName: sess.PodName}
|
||||
return p.SendMessage(ctx, handle, msg)
|
||||
}
|
||||
|
||||
var nonAlphaNum = regexp.MustCompile(`[^a-z0-9-]+`)
|
||||
|
||||
func slugify(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = nonAlphaNum.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
if len(s) > 50 {
|
||||
s = s[:50]
|
||||
}
|
||||
if s == "" {
|
||||
s = "app"
|
||||
}
|
||||
return s
|
||||
}
|
||||
71
internal/events/valkey.go
Normal file
71
internal/events/valkey.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const channel = "agent-mgr:events"
|
||||
|
||||
type Bus struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Type string `json:"type"` // "session.started", "session.completed", "session.failed", "session.stopped", "app.status"
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
AppID string `json:"app_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func NewBus(addr, password string) *Bus {
|
||||
return &Bus{
|
||||
rdb: redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: password,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus) Publish(ctx context.Context, evt Event) error {
|
||||
data, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal event: %w", err)
|
||||
}
|
||||
return b.rdb.Publish(ctx, channel, data).Err()
|
||||
}
|
||||
|
||||
func (b *Bus) Subscribe(ctx context.Context) (<-chan Event, error) {
|
||||
sub := b.rdb.Subscribe(ctx, channel)
|
||||
ch := make(chan Event, 64)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
defer sub.Close()
|
||||
for {
|
||||
msg, err := sub.ReceiveMessage(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var evt Event
|
||||
if err := json.Unmarshal([]byte(msg.Payload), &evt); err != nil {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case ch <- evt:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (b *Bus) Close() error {
|
||||
return b.rdb.Close()
|
||||
}
|
||||
142
internal/gitea/client.go
Normal file
142
internal/gitea/client.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
botUser string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL, token, botUser string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
botUser: botUser,
|
||||
http: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Empty bool `json:"empty"`
|
||||
}
|
||||
|
||||
type Branch struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateRepo(ctx context.Context, name, description string) (*Repo, error) {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": name,
|
||||
"description": description,
|
||||
"auto_init": true,
|
||||
"default_branch": "main",
|
||||
"private": false,
|
||||
})
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/user/repos", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create repo: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("create repo: status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
|
||||
var repo Repo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil {
|
||||
return nil, fmt.Errorf("decode repo: %w", err)
|
||||
}
|
||||
return &repo, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListRepos(ctx context.Context) ([]Repo, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/user/repos?limit=50", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list repos: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("list repos: status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
|
||||
var repos []Repo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
|
||||
return nil, fmt.Errorf("decode repos: %w", err)
|
||||
}
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListBranches(ctx context.Context, owner, repo string) ([]Branch, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/branches", c.baseURL, owner, repo)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list branches: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("list branches: status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
|
||||
var branches []Branch
|
||||
if err := json.NewDecoder(resp.Body).Decode(&branches); err != nil {
|
||||
return nil, fmt.Errorf("decode branches: %w", err)
|
||||
}
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
// AuthCloneURL returns a clone URL with embedded bot credentials.
|
||||
func (c *Client) AuthCloneURL(owner, repo string) string {
|
||||
// Strip protocol from base URL
|
||||
base := c.baseURL
|
||||
if len(base) > 8 && base[:8] == "https://" {
|
||||
base = base[8:]
|
||||
} else if len(base) > 7 && base[:7] == "http://" {
|
||||
base = base[7:]
|
||||
}
|
||||
return fmt.Sprintf("https://%s:%s@%s/%s/%s.git", c.botUser, c.token, base, owner, repo)
|
||||
}
|
||||
|
||||
func (c *Client) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
127
internal/provider/claudecode/adapter.go
Normal file
127
internal/provider/claudecode/adapter.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package claudecode
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/agentsphere/agent-mgr/internal/provider"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/remotecommand"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
MaxTurns int `json:"max_turns,omitempty"`
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
client kubernetes.Interface
|
||||
restConfig *rest.Config
|
||||
}
|
||||
|
||||
func New(client kubernetes.Interface, restConfig *rest.Config) *Provider {
|
||||
return &Provider{client: client, restConfig: restConfig}
|
||||
}
|
||||
|
||||
func (p *Provider) Info() provider.Info {
|
||||
return provider.Info{
|
||||
Name: "claude-code",
|
||||
DisplayName: "Claude Code",
|
||||
Description: "Anthropic Claude Code CLI agent — builds full applications from natural language",
|
||||
Capabilities: []provider.Capability{
|
||||
{Name: "create-app", Description: "Create new applications from scratch"},
|
||||
{Name: "edit-code", Description: "Modify existing codebases"},
|
||||
{Name: "interactive", Description: "Supports follow-up messages during a session"},
|
||||
},
|
||||
ConfigSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string", "description": "Claude model to use", "default": ""},
|
||||
"max_turns": {"type": "integer", "description": "Maximum agentic turns", "default": 0}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) CreateSession(ctx context.Context, cfg provider.SessionConfig) (*provider.SessionHandle, error) {
|
||||
var opts *Config
|
||||
if len(cfg.Provider) > 0 {
|
||||
opts = &Config{}
|
||||
if err := json.Unmarshal(cfg.Provider, opts); err != nil {
|
||||
return nil, fmt.Errorf("parse claude-code config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
pod := buildPod(cfg, opts)
|
||||
created, err := p.client.CoreV1().Pods(Namespace).Create(ctx, pod, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pod: %w", err)
|
||||
}
|
||||
|
||||
return &provider.SessionHandle{
|
||||
SessionID: cfg.SessionID,
|
||||
PodName: created.Name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) StopSession(ctx context.Context, handle *provider.SessionHandle) error {
|
||||
return p.client.CoreV1().Pods(Namespace).Delete(ctx, handle.PodName, metav1.DeleteOptions{})
|
||||
}
|
||||
|
||||
func (p *Provider) SendMessage(ctx context.Context, handle *provider.SessionHandle, msg string) error {
|
||||
req := p.client.CoreV1().RESTClient().Post().
|
||||
Resource("pods").
|
||||
Name(handle.PodName).
|
||||
Namespace(Namespace).
|
||||
SubResource("attach").
|
||||
VersionedParams(&corev1.PodAttachOptions{
|
||||
Container: "claude",
|
||||
Stdin: true,
|
||||
Stdout: false,
|
||||
Stderr: false,
|
||||
}, scheme.ParameterCodec)
|
||||
|
||||
exec, err := remotecommand.NewSPDYExecutor(p.restConfig, "POST", req.URL())
|
||||
if err != nil {
|
||||
return fmt.Errorf("create attach executor: %w", err)
|
||||
}
|
||||
|
||||
return exec.StreamWithContext(ctx, remotecommand.StreamOptions{
|
||||
Stdin: bytes.NewReader([]byte(msg + "\n")),
|
||||
})
|
||||
}
|
||||
|
||||
func (p *Provider) StreamOutput(ctx context.Context, handle *provider.SessionHandle) (io.ReadCloser, error) {
|
||||
req := p.client.CoreV1().Pods(Namespace).GetLogs(handle.PodName, &corev1.PodLogOptions{
|
||||
Container: "claude",
|
||||
Follow: true,
|
||||
})
|
||||
return req.Stream(ctx)
|
||||
}
|
||||
|
||||
func (p *Provider) GetStatus(ctx context.Context, handle *provider.SessionHandle) (provider.Status, error) {
|
||||
pod, err := p.client.CoreV1().Pods(Namespace).Get(ctx, handle.PodName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return provider.StatusFailed, fmt.Errorf("get pod: %w", err)
|
||||
}
|
||||
|
||||
switch pod.Status.Phase {
|
||||
case corev1.PodPending:
|
||||
return provider.StatusPending, nil
|
||||
case corev1.PodRunning:
|
||||
return provider.StatusRunning, nil
|
||||
case corev1.PodSucceeded:
|
||||
return provider.StatusCompleted, nil
|
||||
case corev1.PodFailed:
|
||||
return provider.StatusFailed, nil
|
||||
default:
|
||||
return provider.StatusFailed, nil
|
||||
}
|
||||
}
|
||||
122
internal/provider/claudecode/pod.go
Normal file
122
internal/provider/claudecode/pod.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package claudecode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/agentsphere/agent-mgr/internal/provider"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
Namespace = "agent-mgr"
|
||||
RunnerImage = "git.asp.now/platform/claude-code-runner:latest"
|
||||
GitImage = "alpine/git:latest"
|
||||
)
|
||||
|
||||
func buildPod(cfg provider.SessionConfig, opts *Config) *corev1.Pod {
|
||||
podName := fmt.Sprintf("agent-%s", cfg.SessionID.String()[:8])
|
||||
branch := cfg.Branch
|
||||
|
||||
cloneScript := fmt.Sprintf(`
|
||||
set -eu
|
||||
git clone %s /workspace
|
||||
cd /workspace
|
||||
git checkout -b %s
|
||||
git config user.name "agent-mgr-bot"
|
||||
git config user.email "bot@asp.now"
|
||||
`, cfg.RepoClone, branch)
|
||||
|
||||
claudeArgs := []string{"--output-format", "stream-json", "--permission-mode", "auto-accept-only"}
|
||||
if opts != nil {
|
||||
if opts.Model != "" {
|
||||
claudeArgs = append(claudeArgs, "--model", opts.Model)
|
||||
}
|
||||
if opts.MaxTurns > 0 {
|
||||
claudeArgs = append(claudeArgs, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns))
|
||||
}
|
||||
}
|
||||
|
||||
return &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: podName,
|
||||
Namespace: Namespace,
|
||||
Labels: map[string]string{
|
||||
"app": "agent-session",
|
||||
"session-id": cfg.SessionID.String(),
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
RestartPolicy: corev1.RestartPolicyNever,
|
||||
ServiceAccountName: "agent-runner",
|
||||
InitContainers: []corev1.Container{
|
||||
{
|
||||
Name: "git-clone",
|
||||
Image: GitImage,
|
||||
Command: []string{"sh", "-c", cloneScript},
|
||||
VolumeMounts: []corev1.VolumeMount{
|
||||
{Name: "workspace", MountPath: "/workspace"},
|
||||
},
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Requests: corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("50m"),
|
||||
corev1.ResourceMemory: resource.MustParse("64Mi"),
|
||||
},
|
||||
Limits: corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("200m"),
|
||||
corev1.ResourceMemory: resource.MustParse("128Mi"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "claude",
|
||||
Image: RunnerImage,
|
||||
Args: claudeArgs,
|
||||
Stdin: true,
|
||||
TTY: false,
|
||||
WorkingDir: "/workspace",
|
||||
Env: []corev1.EnvVar{
|
||||
{
|
||||
Name: "ANTHROPIC_API_KEY",
|
||||
ValueFrom: &corev1.EnvVarSource{
|
||||
SecretKeyRef: &corev1.SecretKeySelector{
|
||||
LocalObjectReference: corev1.LocalObjectReference{Name: "agent-mgr-secrets"},
|
||||
Key: "anthropic-api-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
{Name: "SESSION_ID", Value: cfg.SessionID.String()},
|
||||
},
|
||||
VolumeMounts: []corev1.VolumeMount{
|
||||
{Name: "workspace", MountPath: "/workspace"},
|
||||
},
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Requests: corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("200m"),
|
||||
corev1.ResourceMemory: resource.MustParse("256Mi"),
|
||||
},
|
||||
Limits: corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("500m"),
|
||||
corev1.ResourceMemory: resource.MustParse("512Mi"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Volumes: []corev1.Volume{
|
||||
{
|
||||
Name: "workspace",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
EmptyDir: &corev1.EmptyDirVolumeSource{
|
||||
SizeLimit: resourcePtr(resource.MustParse("1Gi")),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func resourcePtr(q resource.Quantity) *resource.Quantity { return &q }
|
||||
75
internal/provider/claudecode/progress.go
Normal file
75
internal/provider/claudecode/progress.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package claudecode
|
||||
|
||||
import "strings"
|
||||
|
||||
type Milestone struct {
|
||||
Label string `json:"label"`
|
||||
Status string `json:"status"` // "done", "active", "pending"
|
||||
}
|
||||
|
||||
type ProgressTracker struct {
|
||||
filesSeen map[string]bool
|
||||
milestones []Milestone
|
||||
}
|
||||
|
||||
func NewProgressTracker() *ProgressTracker {
|
||||
return &ProgressTracker{
|
||||
filesSeen: make(map[string]bool),
|
||||
milestones: []Milestone{
|
||||
{Label: "Setting up project", Status: "active"},
|
||||
{Label: "Creating data models", Status: "pending"},
|
||||
{Label: "Building UI components", Status: "pending"},
|
||||
{Label: "Adding styling and polish", Status: "pending"},
|
||||
{Label: "Final checks", Status: "pending"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (pt *ProgressTracker) Milestones() []Milestone {
|
||||
cp := make([]Milestone, len(pt.milestones))
|
||||
copy(cp, pt.milestones)
|
||||
return cp
|
||||
}
|
||||
|
||||
func (pt *ProgressTracker) ProcessLine(line string) {
|
||||
lower := strings.ToLower(line)
|
||||
|
||||
if strings.Contains(lower, "\"tool\":\"write\"") || strings.Contains(lower, "\"tool\":\"create\"") {
|
||||
pt.advanceTo(0)
|
||||
}
|
||||
|
||||
if containsAny(lower, "model", "schema", "database", "migration", "struct", "type ") {
|
||||
pt.advanceTo(1)
|
||||
}
|
||||
|
||||
if containsAny(lower, "component", "page", "route", "template", "html", "tsx", "jsx", "vue") {
|
||||
pt.advanceTo(2)
|
||||
}
|
||||
|
||||
if containsAny(lower, "css", "style", "tailwind", "theme", "color", "font", "layout") {
|
||||
pt.advanceTo(3)
|
||||
}
|
||||
|
||||
if containsAny(lower, "test", "readme", "done", "complete", "finish", "final") {
|
||||
pt.advanceTo(4)
|
||||
}
|
||||
}
|
||||
|
||||
func (pt *ProgressTracker) advanceTo(idx int) {
|
||||
for i := range pt.milestones {
|
||||
if i < idx {
|
||||
pt.milestones[i].Status = "done"
|
||||
} else if i == idx {
|
||||
pt.milestones[i].Status = "active"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(s string, substrs ...string) bool {
|
||||
for _, sub := range substrs {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
55
internal/provider/provider.go
Normal file
55
internal/provider/provider.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPending Status = "pending"
|
||||
StatusRunning Status = "running"
|
||||
StatusCompleted Status = "completed"
|
||||
StatusFailed Status = "failed"
|
||||
StatusStopped Status = "stopped"
|
||||
)
|
||||
|
||||
type SessionConfig struct {
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
AppName string `json:"app_name"`
|
||||
RepoClone string `json:"repo_clone_url"`
|
||||
Branch string `json:"branch"`
|
||||
Prompt string `json:"prompt"`
|
||||
Provider json.RawMessage `json:"provider_config,omitempty"`
|
||||
}
|
||||
|
||||
type SessionHandle struct {
|
||||
SessionID uuid.UUID
|
||||
PodName string
|
||||
}
|
||||
|
||||
type Capability struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type Info struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
Capabilities []Capability `json:"capabilities"`
|
||||
ConfigSchema json.RawMessage `json:"config_schema,omitempty"`
|
||||
}
|
||||
|
||||
type AgentProvider interface {
|
||||
Info() Info
|
||||
CreateSession(ctx context.Context, cfg SessionConfig) (*SessionHandle, error)
|
||||
StopSession(ctx context.Context, handle *SessionHandle) error
|
||||
SendMessage(ctx context.Context, handle *SessionHandle, msg string) error
|
||||
StreamOutput(ctx context.Context, handle *SessionHandle) (io.ReadCloser, error)
|
||||
GetStatus(ctx context.Context, handle *SessionHandle) (Status, error)
|
||||
}
|
||||
31
internal/provider/registry.go
Normal file
31
internal/provider/registry.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package provider
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Registry struct {
|
||||
providers map[string]AgentProvider
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{providers: make(map[string]AgentProvider)}
|
||||
}
|
||||
|
||||
func (r *Registry) Register(p AgentProvider) {
|
||||
r.providers[p.Info().Name] = p
|
||||
}
|
||||
|
||||
func (r *Registry) Get(name string) (AgentProvider, error) {
|
||||
p, ok := r.providers[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *Registry) List() []Info {
|
||||
infos := make([]Info, 0, len(r.providers))
|
||||
for _, p := range r.providers {
|
||||
infos = append(infos, p.Info())
|
||||
}
|
||||
return infos
|
||||
}
|
||||
29
internal/store/migrations/001_init.sql
Normal file
29
internal/store/migrations/001_init.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS apps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
repo_owner TEXT NOT NULL DEFAULT '',
|
||||
repo_name TEXT NOT NULL DEFAULT '',
|
||||
preview_url TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL DEFAULT 'claude-code',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
prompt TEXT NOT NULL DEFAULT '',
|
||||
branch TEXT NOT NULL DEFAULT '',
|
||||
pod_name TEXT NOT NULL DEFAULT '',
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
cost_json JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_app_id ON sessions(app_id);
|
||||
CREATE INDEX idx_sessions_status ON sessions(status);
|
||||
CREATE INDEX idx_apps_status ON apps(status);
|
||||
202
internal/store/postgres.go
Normal file
202
internal/store/postgres.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(ctx context.Context, dsn string) (*Store, error) {
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to postgres: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
return nil, fmt.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
return &Store{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Migrate(ctx context.Context) error {
|
||||
sql, err := migrationsFS.ReadFile("migrations/001_init.sql")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration: %w", err)
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, string(sql))
|
||||
if err != nil {
|
||||
return fmt.Errorf("run migration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() {
|
||||
s.pool.Close()
|
||||
}
|
||||
|
||||
// App
|
||||
|
||||
type App struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
RepoOwner string `json:"repo_owner,omitempty"`
|
||||
RepoName string `json:"repo_name,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *Store) CreateApp(ctx context.Context, name, description string) (*App, error) {
|
||||
app := &App{}
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO apps (name, description) VALUES ($1, $2)
|
||||
RETURNING id, name, description, status, repo_owner, repo_name, preview_url, created_at, updated_at`,
|
||||
name, description,
|
||||
).Scan(&app.ID, &app.Name, &app.Description, &app.Status, &app.RepoOwner, &app.RepoName, &app.PreviewURL, &app.CreatedAt, &app.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create app: %w", err)
|
||||
}
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetApp(ctx context.Context, id uuid.UUID) (*App, error) {
|
||||
app := &App{}
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, name, description, status, repo_owner, repo_name, preview_url, created_at, updated_at
|
||||
FROM apps WHERE id = $1`, id,
|
||||
).Scan(&app.ID, &app.Name, &app.Description, &app.Status, &app.RepoOwner, &app.RepoName, &app.PreviewURL, &app.CreatedAt, &app.UpdatedAt)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("get app: %w", err)
|
||||
}
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListApps(ctx context.Context) ([]App, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, name, description, status, repo_owner, repo_name, preview_url, created_at, updated_at
|
||||
FROM apps ORDER BY updated_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list apps: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var apps []App
|
||||
for rows.Next() {
|
||||
var a App
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Description, &a.Status, &a.RepoOwner, &a.RepoName, &a.PreviewURL, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan app: %w", err)
|
||||
}
|
||||
apps = append(apps, a)
|
||||
}
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAppStatus(ctx context.Context, id uuid.UUID, status string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE apps SET status = $1, updated_at = now() WHERE id = $2`, status, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAppRepo(ctx context.Context, id uuid.UUID, owner, name string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE apps SET repo_owner = $1, repo_name = $2, updated_at = now() WHERE id = $3`, owner, name, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteApp(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM apps WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Session
|
||||
|
||||
type Session struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
AppID uuid.UUID `json:"app_id"`
|
||||
Provider string `json:"provider"`
|
||||
Status string `json:"status"`
|
||||
Prompt string `json:"prompt"`
|
||||
Branch string `json:"branch"`
|
||||
PodName string `json:"pod_name,omitempty"`
|
||||
Config []byte `json:"config,omitempty"`
|
||||
CostJSON []byte `json:"cost,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, appID uuid.UUID, provider, prompt, branch string, config []byte) (*Session, error) {
|
||||
sess := &Session{}
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO sessions (app_id, provider, prompt, branch, config)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, app_id, provider, status, prompt, branch, pod_name, config, cost_json, created_at, updated_at`,
|
||||
appID, provider, prompt, branch, config,
|
||||
).Scan(&sess.ID, &sess.AppID, &sess.Provider, &sess.Status, &sess.Prompt, &sess.Branch, &sess.PodName, &sess.Config, &sess.CostJSON, &sess.CreatedAt, &sess.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetSession(ctx context.Context, id uuid.UUID) (*Session, error) {
|
||||
sess := &Session{}
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, app_id, provider, status, prompt, branch, pod_name, config, cost_json, created_at, updated_at
|
||||
FROM sessions WHERE id = $1`, id,
|
||||
).Scan(&sess.ID, &sess.AppID, &sess.Provider, &sess.Status, &sess.Prompt, &sess.Branch, &sess.PodName, &sess.Config, &sess.CostJSON, &sess.CreatedAt, &sess.UpdatedAt)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("get session: %w", err)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListSessionsByApp(ctx context.Context, appID uuid.UUID) ([]Session, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, app_id, provider, status, prompt, branch, pod_name, config, cost_json, created_at, updated_at
|
||||
FROM sessions WHERE app_id = $1 ORDER BY created_at DESC`, appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sessions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []Session
|
||||
for rows.Next() {
|
||||
var sess Session
|
||||
if err := rows.Scan(&sess.ID, &sess.AppID, &sess.Provider, &sess.Status, &sess.Prompt, &sess.Branch, &sess.PodName, &sess.Config, &sess.CostJSON, &sess.CreatedAt, &sess.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan session: %w", err)
|
||||
}
|
||||
sessions = append(sessions, sess)
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSessionStatus(ctx context.Context, id uuid.UUID, status string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE sessions SET status = $1, updated_at = now() WHERE id = $2`, status, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSessionPod(ctx context.Context, id uuid.UUID, podName string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE sessions SET pod_name = $1, updated_at = now() WHERE id = $2`, podName, id)
|
||||
return err
|
||||
}
|
||||
24
ui/dist/index.html
vendored
Normal file
24
ui/dist/index.html
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>asp.now</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #0a0a0a; --surface: #141414; --surface-2: #1e1e1e;
|
||||
--border: #2a2a2a; --text: #e5e5e5; --text-dim: #888;
|
||||
--accent: #6366f1; --accent-hover: #818cf8;
|
||||
--green: #22c55e; --yellow: #eab308; --red: #ef4444;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { color: var(--accent-hover); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
17
ui/embed.go
Normal file
17
ui/embed.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed dist/*
|
||||
var distFS embed.FS
|
||||
|
||||
func StaticFS() fs.FS {
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return sub
|
||||
}
|
||||
16
ui/package.json
Normal file
16
ui/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "agent-mgr-ui",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "esbuild src/index.tsx --bundle --outdir=dist --servedir=dist --loader:.tsx=tsx --loader:.ts=ts --jsx=automatic --jsx-import-source=preact --define:process.env.NODE_ENV=\\\"development\\\"",
|
||||
"build": "esbuild src/index.tsx --bundle --outdir=dist --minify --loader:.tsx=tsx --loader:.ts=ts --jsx=automatic --jsx-import-source=preact --define:process.env.NODE_ENV=\\\"production\\\""
|
||||
},
|
||||
"dependencies": {
|
||||
"preact": "^10.25.0",
|
||||
"preact-router": "^4.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.24.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
97
ui/src/api.ts
Normal file
97
ui/src/api.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
const BASE = '/api/v1';
|
||||
|
||||
async function request<T>(path: string, opts?: RequestInit): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...opts,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`${res.status}: ${body}`);
|
||||
}
|
||||
if (res.status === 204) return {} as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface App {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
repo_owner: string;
|
||||
repo_name: string;
|
||||
preview_url: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
app_id: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
prompt: string;
|
||||
branch: string;
|
||||
pod_name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Provider {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
capabilities: { name: string; description: string }[];
|
||||
}
|
||||
|
||||
export const api = {
|
||||
createApp(name: string, description: string) {
|
||||
return request<{ app: App; session: Session }>('/apps', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description }),
|
||||
});
|
||||
},
|
||||
|
||||
listApps() {
|
||||
return request<{ apps: App[] }>('/apps');
|
||||
},
|
||||
|
||||
getApp(id: string) {
|
||||
return request<{ app: App; sessions: Session[] }>(`/apps/${id}`);
|
||||
},
|
||||
|
||||
deleteApp(id: string) {
|
||||
return request<{}>(`/apps/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
createSession(appId: string, prompt: string) {
|
||||
return request<{ session: Session }>(`/apps/${appId}/sessions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt }),
|
||||
});
|
||||
},
|
||||
|
||||
getSession(id: string) {
|
||||
return request<{ session: Session }>(`/sessions/${id}`);
|
||||
},
|
||||
|
||||
stopSession(id: string) {
|
||||
return request<{}>(`/sessions/${id}/stop`, { method: 'POST' });
|
||||
},
|
||||
|
||||
sendMessage(id: string, message: string) {
|
||||
return request<{}>(`/sessions/${id}/message`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
},
|
||||
|
||||
listProviders() {
|
||||
return request<{ providers: Provider[] }>('/providers');
|
||||
},
|
||||
|
||||
sessionWsUrl(id: string): string {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${proto}//${location.host}${BASE}/sessions/${id}/ws`;
|
||||
},
|
||||
};
|
||||
77
ui/src/components/app-card.tsx
Normal file
77
ui/src/components/app-card.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { App } from '../api';
|
||||
import { route } from 'preact-router';
|
||||
|
||||
interface Props {
|
||||
app: App;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: 'var(--text-dim)',
|
||||
building: 'var(--yellow)',
|
||||
live: 'var(--green)',
|
||||
stopped: 'var(--text-dim)',
|
||||
failed: 'var(--red)',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: 'Draft',
|
||||
building: 'Building...',
|
||||
live: 'Live',
|
||||
stopped: 'Stopped',
|
||||
failed: 'Failed',
|
||||
};
|
||||
|
||||
export function AppCard({ app }: Props) {
|
||||
const color = statusColors[app.status] || 'var(--text-dim)';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => route(app.status === 'building' ? `/apps/${app.id}/live` : `/apps/${app.id}`)}
|
||||
style={styles.card}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.borderColor = 'var(--accent)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.borderColor = 'var(--border)')}
|
||||
>
|
||||
<div style={styles.icon}>
|
||||
{app.name.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div style={styles.name}>{app.name}</div>
|
||||
<div style={{ ...styles.status, color }}>
|
||||
{app.status === 'building' || app.status === 'live' ? '\u25CF ' : '\u25CB '}
|
||||
{statusLabels[app.status] || app.status}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
card: {
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '8px',
|
||||
},
|
||||
icon: {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--surface-2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '14px',
|
||||
fontWeight: '700',
|
||||
color: 'var(--accent)',
|
||||
},
|
||||
name: {
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
},
|
||||
status: {
|
||||
fontSize: '13px',
|
||||
},
|
||||
};
|
||||
121
ui/src/components/chat.tsx
Normal file
121
ui/src/components/chat.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
|
||||
interface Message {
|
||||
from: 'agent' | 'user';
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
messages: Message[];
|
||||
onSend: (msg: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Chat({ messages, onSend, disabled }: Props) {
|
||||
const [input, setInput] = useState('');
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages.length]);
|
||||
|
||||
const handleSend = (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (input.trim() && !disabled) {
|
||||
onSend(input.trim());
|
||||
setInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.messages}>
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} style={{
|
||||
...styles.message,
|
||||
...(m.from === 'user' ? styles.userMessage : styles.agentMessage),
|
||||
}}>
|
||||
{m.text}
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
<form onSubmit={handleSend} style={styles.inputRow}>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onInput={(e) => setInput((e.target as HTMLInputElement).value)}
|
||||
placeholder={disabled ? 'Session ended' : 'Send a follow-up...'}
|
||||
disabled={disabled}
|
||||
style={styles.input}
|
||||
/>
|
||||
<button type="submit" disabled={!input.trim() || disabled} style={{
|
||||
...styles.sendButton,
|
||||
opacity: (!input.trim() || disabled) ? 0.5 : 1,
|
||||
}}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
background: 'var(--surface)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
messages: {
|
||||
flex: 1,
|
||||
padding: '16px',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto' as const,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '8px',
|
||||
},
|
||||
message: {
|
||||
padding: '10px 14px',
|
||||
borderRadius: '10px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
maxWidth: '85%',
|
||||
},
|
||||
agentMessage: {
|
||||
background: 'var(--surface-2)',
|
||||
alignSelf: 'flex-start',
|
||||
color: 'var(--text)',
|
||||
},
|
||||
userMessage: {
|
||||
background: 'var(--accent)',
|
||||
alignSelf: 'flex-end',
|
||||
color: 'white',
|
||||
},
|
||||
inputRow: {
|
||||
display: 'flex',
|
||||
borderTop: '1px solid var(--border)',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--text)',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
fontFamily: 'inherit',
|
||||
},
|
||||
sendButton: {
|
||||
padding: '12px 20px',
|
||||
border: 'none',
|
||||
background: 'var(--accent)',
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
};
|
||||
44
ui/src/components/nav.tsx
Normal file
44
ui/src/components/nav.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
interface Props {
|
||||
title?: string;
|
||||
back?: string;
|
||||
}
|
||||
|
||||
export function Nav({ title, back }: Props) {
|
||||
return (
|
||||
<nav style={styles.nav}>
|
||||
<div style={styles.inner}>
|
||||
{back ? (
|
||||
<a href={back} style={styles.backLink}>{'\u2190'} {title || 'Back'}</a>
|
||||
) : (
|
||||
<a href="/" style={styles.logo}>asp.now</a>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
nav: {
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
},
|
||||
inner: {
|
||||
maxWidth: '960px',
|
||||
margin: '0 auto',
|
||||
padding: '14px 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
logo: {
|
||||
fontSize: '18px',
|
||||
fontWeight: '700',
|
||||
color: 'var(--text)',
|
||||
textDecoration: 'none',
|
||||
},
|
||||
backLink: {
|
||||
fontSize: '15px',
|
||||
color: 'var(--text)',
|
||||
textDecoration: 'none',
|
||||
},
|
||||
};
|
||||
55
ui/src/components/progress.tsx
Normal file
55
ui/src/components/progress.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
interface Milestone {
|
||||
label: string;
|
||||
status: 'done' | 'active' | 'pending';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
milestones: Milestone[];
|
||||
}
|
||||
|
||||
export function Progress({ milestones }: Props) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
{milestones.map((m) => (
|
||||
<div key={m.label} style={styles.item}>
|
||||
<span style={{ ...styles.icon, color: iconColor(m.status) }}>
|
||||
{m.status === 'done' ? '\u2705' : m.status === 'active' ? '\u25D0' : '\u25CB'}
|
||||
</span>
|
||||
<span style={{
|
||||
color: m.status === 'pending' ? 'var(--text-dim)' : 'var(--text)',
|
||||
fontWeight: m.status === 'active' ? '600' : '400',
|
||||
}}>
|
||||
{m.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function iconColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'done': return 'var(--green)';
|
||||
case 'active': return 'var(--accent)';
|
||||
default: return 'var(--text-dim)';
|
||||
}
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '12px',
|
||||
},
|
||||
item: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
fontSize: '15px',
|
||||
},
|
||||
icon: {
|
||||
fontSize: '16px',
|
||||
width: '20px',
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
};
|
||||
77
ui/src/components/prompt-input.tsx
Normal file
77
ui/src/components/prompt-input.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
|
||||
interface Props {
|
||||
onSubmit: (text: string) => void;
|
||||
placeholder?: string;
|
||||
buttonText?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function PromptInput({ onSubmit, placeholder, buttonText, loading }: Props) {
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (text.trim() && !loading) {
|
||||
onSubmit(text.trim());
|
||||
setText('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={styles.form}>
|
||||
<textarea
|
||||
value={text}
|
||||
onInput={(e) => setText((e.target as HTMLTextAreaElement).value)}
|
||||
placeholder={placeholder || 'Describe your app idea...'}
|
||||
style={styles.textarea}
|
||||
rows={3}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="submit" disabled={!text.trim() || loading} style={{
|
||||
...styles.button,
|
||||
opacity: (!text.trim() || loading) ? 0.5 : 1,
|
||||
}}>
|
||||
{loading ? 'Starting...' : (buttonText || 'Build it')}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
form: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '12px',
|
||||
width: '100%',
|
||||
maxWidth: '640px',
|
||||
},
|
||||
textarea: {
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text)',
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.5',
|
||||
resize: 'vertical' as const,
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
},
|
||||
button: {
|
||||
alignSelf: 'flex-end',
|
||||
padding: '10px 24px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: 'var(--accent)',
|
||||
color: 'white',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
};
|
||||
21
ui/src/index.tsx
Normal file
21
ui/src/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { render } from 'preact';
|
||||
import Router from 'preact-router';
|
||||
import { Home } from './pages/home';
|
||||
import { AppDetail } from './pages/app-detail';
|
||||
import { Building } from './pages/building';
|
||||
import { Apps } from './pages/apps';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh' }}>
|
||||
<Router>
|
||||
<Home path="/" />
|
||||
<Apps path="/apps" />
|
||||
<AppDetail path="/apps/:id" />
|
||||
<Building path="/apps/:id/live" />
|
||||
</Router>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(<App />, document.getElementById('app')!);
|
||||
264
ui/src/pages/app-detail.tsx
Normal file
264
ui/src/pages/app-detail.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { route } from 'preact-router';
|
||||
import { api, App, Session } from '../api';
|
||||
import { PromptInput } from '../components/prompt-input';
|
||||
import { Nav } from '../components/nav';
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: 'var(--text-dim)',
|
||||
building: 'var(--yellow)',
|
||||
live: 'var(--green)',
|
||||
stopped: 'var(--text-dim)',
|
||||
failed: 'var(--red)',
|
||||
};
|
||||
|
||||
export function AppDetail({ id }: Props) {
|
||||
const [app, setApp] = useState<App | null>(null);
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [improving, setImproving] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
api.getApp(id).then((r) => {
|
||||
setApp(r.app);
|
||||
setSessions(r.sessions);
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const handleImprove = async (prompt: string) => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.createSession(id, prompt);
|
||||
route(`/apps/${id}/live`);
|
||||
} catch (e) {
|
||||
console.error('create session failed', e);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<div>
|
||||
<Nav />
|
||||
<main style={styles.main}>
|
||||
<div style={styles.loading}>Loading...</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusColor = statusColors[app.status] || 'var(--text-dim)';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Nav title={app.name} back="/apps" />
|
||||
<main style={styles.main}>
|
||||
<div style={styles.header}>
|
||||
<div>
|
||||
<h1 style={styles.heading}>{app.name}</h1>
|
||||
<p style={styles.description}>{app.description}</p>
|
||||
</div>
|
||||
<span style={{ ...styles.status, color: statusColor }}>
|
||||
{'\u25CF'} {app.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={styles.actions}>
|
||||
{app.preview_url && (
|
||||
<a href={app.preview_url} target="_blank" rel="noopener" style={styles.primaryButton}>
|
||||
Open App
|
||||
</a>
|
||||
)}
|
||||
<button onClick={() => setImproving(!improving)} style={styles.secondaryButton}>
|
||||
{improving ? 'Cancel' : 'Improve this app'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{improving && (
|
||||
<div style={styles.improveSection}>
|
||||
<PromptInput
|
||||
onSubmit={handleImprove}
|
||||
placeholder="What would you like to change or add?"
|
||||
buttonText="Start"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={styles.historySection}>
|
||||
<h2 style={styles.sectionTitle}>History</h2>
|
||||
{sessions.length === 0 ? (
|
||||
<div style={styles.empty}>No sessions yet.</div>
|
||||
) : (
|
||||
<div style={styles.timeline}>
|
||||
{sessions.map((sess, i) => (
|
||||
<div key={sess.id} style={styles.timelineItem}>
|
||||
<div style={styles.timelineDot} />
|
||||
<div style={styles.timelineContent}>
|
||||
<div style={styles.timelineHeader}>
|
||||
<span style={styles.version}>v{sessions.length - i}</span>
|
||||
<span style={styles.timeAgo}>{timeAgo(sess.created_at)}</span>
|
||||
</div>
|
||||
<div style={styles.timelinePrompt}>
|
||||
{i === sessions.length - 1 ? 'Created app' : `"${sess.prompt}"`}
|
||||
</div>
|
||||
<div style={{
|
||||
...styles.sessionStatus,
|
||||
color: sess.status === 'completed' ? 'var(--green)' :
|
||||
sess.status === 'running' ? 'var(--yellow)' :
|
||||
sess.status === 'failed' ? 'var(--red)' : 'var(--text-dim)',
|
||||
}}>
|
||||
{sess.status}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
main: {
|
||||
maxWidth: '720px',
|
||||
margin: '0 auto',
|
||||
padding: '32px 24px',
|
||||
},
|
||||
loading: {
|
||||
textAlign: 'center' as const,
|
||||
padding: '60px 0',
|
||||
color: 'var(--text-dim)',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '24px',
|
||||
},
|
||||
heading: {
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
marginBottom: '8px',
|
||||
},
|
||||
description: {
|
||||
color: 'var(--text-dim)',
|
||||
fontSize: '15px',
|
||||
},
|
||||
status: {
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
marginBottom: '32px',
|
||||
},
|
||||
primaryButton: {
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--accent)',
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
textDecoration: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
secondaryButton: {
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'transparent',
|
||||
color: 'var(--text)',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
improveSection: {
|
||||
marginBottom: '32px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
historySection: {
|
||||
borderTop: '1px solid var(--border)',
|
||||
paddingTop: '24px',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
empty: {
|
||||
color: 'var(--text-dim)',
|
||||
fontSize: '14px',
|
||||
},
|
||||
timeline: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '0',
|
||||
},
|
||||
timelineItem: {
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
paddingBottom: '20px',
|
||||
paddingLeft: '8px',
|
||||
borderLeft: '2px solid var(--border)',
|
||||
position: 'relative' as const,
|
||||
},
|
||||
timelineDot: {
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--accent)',
|
||||
position: 'absolute' as const,
|
||||
left: '-6px',
|
||||
top: '4px',
|
||||
},
|
||||
timelineContent: {
|
||||
paddingLeft: '8px',
|
||||
},
|
||||
timelineHeader: {
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
alignItems: 'center',
|
||||
marginBottom: '4px',
|
||||
},
|
||||
version: {
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text)',
|
||||
},
|
||||
timeAgo: {
|
||||
fontSize: '13px',
|
||||
color: 'var(--text-dim)',
|
||||
},
|
||||
timelinePrompt: {
|
||||
fontSize: '14px',
|
||||
color: 'var(--text)',
|
||||
marginBottom: '2px',
|
||||
},
|
||||
sessionStatus: {
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
},
|
||||
};
|
||||
73
ui/src/pages/apps.tsx
Normal file
73
ui/src/pages/apps.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { api, App } from '../api';
|
||||
import { AppCard } from '../components/app-card';
|
||||
import { Nav } from '../components/nav';
|
||||
|
||||
export function Apps() {
|
||||
const [apps, setApps] = useState<App[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.listApps().then((r) => setApps(r.apps));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Nav />
|
||||
<main style={styles.main}>
|
||||
<div style={styles.header}>
|
||||
<h1 style={styles.heading}>Your Apps</h1>
|
||||
<a href="/" style={styles.newButton}>+ New App</a>
|
||||
</div>
|
||||
{apps.length === 0 ? (
|
||||
<div style={styles.empty}>
|
||||
No apps yet. <a href="/">Create your first one.</a>
|
||||
</div>
|
||||
) : (
|
||||
<div style={styles.grid}>
|
||||
{apps.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
main: {
|
||||
maxWidth: '960px',
|
||||
margin: '0 auto',
|
||||
padding: '32px 24px',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
},
|
||||
heading: {
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
},
|
||||
newButton: {
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--accent)',
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
textDecoration: 'none',
|
||||
},
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: '12px',
|
||||
},
|
||||
empty: {
|
||||
textAlign: 'center' as const,
|
||||
padding: '60px 0',
|
||||
color: 'var(--text-dim)',
|
||||
fontSize: '15px',
|
||||
},
|
||||
};
|
||||
191
ui/src/pages/building.tsx
Normal file
191
ui/src/pages/building.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import { route } from 'preact-router';
|
||||
import { api, App, Session } from '../api';
|
||||
import { Progress } from '../components/progress';
|
||||
import { Chat } from '../components/chat';
|
||||
import { Nav } from '../components/nav';
|
||||
|
||||
interface Milestone {
|
||||
label: string;
|
||||
status: 'done' | 'active' | 'pending';
|
||||
}
|
||||
|
||||
interface Message {
|
||||
from: 'agent' | 'user';
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const defaultMilestones: Milestone[] = [
|
||||
{ label: 'Setting up project', status: 'active' },
|
||||
{ label: 'Creating data models', status: 'pending' },
|
||||
{ label: 'Building UI components', status: 'pending' },
|
||||
{ label: 'Adding styling and polish', status: 'pending' },
|
||||
{ label: 'Final checks', status: 'pending' },
|
||||
];
|
||||
|
||||
export function Building({ id }: Props) {
|
||||
const [app, setApp] = useState<App | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [milestones, setMilestones] = useState<Milestone[]>(defaultMilestones);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
api.getApp(id).then((r) => {
|
||||
setApp(r.app);
|
||||
const running = r.sessions.find((s) => s.status === 'running' || s.status === 'pending');
|
||||
if (running) {
|
||||
setSession(running);
|
||||
connectWs(running.id);
|
||||
} else if (r.sessions.length > 0) {
|
||||
setSession(r.sessions[0]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => wsRef.current?.close();
|
||||
}, [id]);
|
||||
|
||||
const connectWs = (sessionId: string) => {
|
||||
const ws = new WebSocket(api.sessionWsUrl(sessionId));
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const line = e.data as string;
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.type === 'assistant' && data.message?.content) {
|
||||
for (const block of data.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
setMessages((prev) => [...prev, { from: 'agent', text: block.text }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON line — might be a status update
|
||||
if (line.trim()) {
|
||||
updateMilestones(line);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (id) {
|
||||
api.getApp(id).then((r) => {
|
||||
setApp(r.app);
|
||||
if (r.sessions.length > 0) setSession(r.sessions[0]);
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const updateMilestones = (line: string) => {
|
||||
const lower = line.toLowerCase();
|
||||
setMilestones((prev) => {
|
||||
const next = [...prev];
|
||||
let target = 0;
|
||||
if (lower.includes('model') || lower.includes('schema') || lower.includes('database')) target = 1;
|
||||
if (lower.includes('component') || lower.includes('page') || lower.includes('route')) target = 2;
|
||||
if (lower.includes('css') || lower.includes('style') || lower.includes('tailwind')) target = 3;
|
||||
if (lower.includes('test') || lower.includes('done') || lower.includes('complete')) target = 4;
|
||||
|
||||
for (let i = 0; i < next.length; i++) {
|
||||
if (i < target) next[i] = { ...next[i], status: 'done' };
|
||||
else if (i === target) next[i] = { ...next[i], status: 'active' };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSend = async (msg: string) => {
|
||||
if (!session) return;
|
||||
setMessages((prev) => [...prev, { from: 'user', text: msg }]);
|
||||
try {
|
||||
await api.sendMessage(session.id, msg);
|
||||
} catch (e) {
|
||||
console.error('send message failed', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!session) return;
|
||||
await api.stopSession(session.id);
|
||||
route(`/apps/${id}`);
|
||||
};
|
||||
|
||||
const done = session?.status === 'completed' || session?.status === 'failed' || session?.status === 'stopped';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Nav title={app?.name || 'Building...'} back={`/apps/${id}`} />
|
||||
<main style={styles.main}>
|
||||
<div style={styles.header}>
|
||||
<h1 style={styles.heading}>
|
||||
{done ? (session?.status === 'completed' ? 'Build complete!' : 'Build ended') : 'Building your app...'}
|
||||
</h1>
|
||||
{!done && (
|
||||
<button onClick={handleStop} style={styles.stopButton}>Stop</button>
|
||||
)}
|
||||
{done && (
|
||||
<a href={`/apps/${id}`} style={styles.viewButton}>View App</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={styles.content}>
|
||||
<Progress milestones={milestones} />
|
||||
<div style={styles.chatSection}>
|
||||
<Chat messages={messages} onSend={handleSend} disabled={done} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
main: {
|
||||
maxWidth: '720px',
|
||||
margin: '0 auto',
|
||||
padding: '32px 24px',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '32px',
|
||||
},
|
||||
heading: {
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
},
|
||||
stopButton: {
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--red)',
|
||||
background: 'transparent',
|
||||
color: 'var(--red)',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
viewButton: {
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--accent)',
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
textDecoration: 'none',
|
||||
},
|
||||
content: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '32px',
|
||||
},
|
||||
chatSection: {
|
||||
marginTop: '8px',
|
||||
},
|
||||
};
|
||||
109
ui/src/pages/home.tsx
Normal file
109
ui/src/pages/home.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { route } from 'preact-router';
|
||||
import { api, App } from '../api';
|
||||
import { PromptInput } from '../components/prompt-input';
|
||||
import { AppCard } from '../components/app-card';
|
||||
import { Nav } from '../components/nav';
|
||||
|
||||
export function Home() {
|
||||
const [apps, setApps] = useState<App[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.listApps().then((r) => setApps(r.apps));
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (description: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await api.createApp('', description);
|
||||
route(`/apps/${result.app.id}/live`);
|
||||
} catch (e) {
|
||||
console.error('create app failed', e);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Nav />
|
||||
<main style={styles.main}>
|
||||
<section style={styles.hero}>
|
||||
<h1 style={styles.heading}>What do you want to build?</h1>
|
||||
<PromptInput
|
||||
onSubmit={handleCreate}
|
||||
placeholder="A todo app with categories, due dates, and a clean minimal design..."
|
||||
buttonText="Build it"
|
||||
loading={loading}
|
||||
/>
|
||||
<div style={styles.examples}>
|
||||
Try: "A recipe manager" · "A habit tracker" · "A team standup board"
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{apps.length > 0 && (
|
||||
<section style={styles.appsSection}>
|
||||
<div style={styles.sectionHeader}>
|
||||
<h2 style={styles.sectionTitle}>Your Apps</h2>
|
||||
<a href="/apps" style={styles.viewAll}>View all</a>
|
||||
</div>
|
||||
<div style={styles.grid}>
|
||||
{apps.slice(0, 6).map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
main: {
|
||||
maxWidth: '960px',
|
||||
margin: '0 auto',
|
||||
padding: '0 24px',
|
||||
},
|
||||
hero: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
alignItems: 'center',
|
||||
textAlign: 'center' as const,
|
||||
padding: '80px 0 60px',
|
||||
gap: '20px',
|
||||
},
|
||||
heading: {
|
||||
fontSize: '36px',
|
||||
fontWeight: '700',
|
||||
letterSpacing: '-0.02em',
|
||||
},
|
||||
examples: {
|
||||
fontSize: '14px',
|
||||
color: 'var(--text-dim)',
|
||||
},
|
||||
appsSection: {
|
||||
paddingBottom: '60px',
|
||||
},
|
||||
sectionHeader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
paddingTop: '24px',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
},
|
||||
viewAll: {
|
||||
fontSize: '14px',
|
||||
color: 'var(--accent)',
|
||||
},
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: '12px',
|
||||
},
|
||||
};
|
||||
14
ui/tsconfig.json
Normal file
14
ui/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user