From a7feaeb2cbfbbe2fdcd4e527fc4b5addb3b6c559 Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 6 Sep 2026 08:10:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20M11=20=E5=8D=95=E4=BA=8C?= =?UTF-8?q?=E8=BF=9B=E5=88=B6=E8=A3=85=E9=85=8D=EF=BC=88cmd/cyrene=20?= =?UTF-8?q?=E5=85=A8=E9=93=BE=E8=B7=AF=20wire=20+=20go:embed=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20+=20=E5=A4=96=E7=BD=AE=20dist=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=20+=20=E4=BC=98=E9=9B=85=E5=85=B3=E9=97=AD=20+=20SPA=20?= =?UTF-8?q?=E9=9D=99=E6=80=81=E6=9C=8D=E5=8A=A1=EF=BC=89=EF=BC=9B=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20build=20=E6=8B=B7=E5=88=B0=20internal/webui/dist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/cyrene/main.go | 115 +++++++ cmd/cyrene/wire.go | 510 ++++++++++++++++++++++++++++ frontend/web/copy-dist.mjs | 15 + frontend/web/package.json | 2 +- internal/services/gateway/server.go | 10 + internal/services/gateway/static.go | 47 +++ internal/webui/webui.go | 47 +++ 7 files changed, 745 insertions(+), 1 deletion(-) create mode 100644 cmd/cyrene/main.go create mode 100644 cmd/cyrene/wire.go create mode 100644 frontend/web/copy-dist.mjs create mode 100644 internal/services/gateway/static.go create mode 100644 internal/webui/webui.go diff --git a/cmd/cyrene/main.go b/cmd/cyrene/main.go new file mode 100644 index 0000000..568ee65 --- /dev/null +++ b/cmd/cyrene/main.go @@ -0,0 +1,115 @@ +// Command cyrene is the single Cyrene v2 binary. It assembles every subsystem +// (config, logging, database, bus, personas, model adapter, tool dispatcher, +// memory stack, agent loop, orchestration, execution, IoT, channel sessions and +// adapters) into one process and serves the gateway + embedded frontend on a +// single port (:8080), shutting down gracefully on a termination signal. +// +// Build with `go build -ldflags="-s -w" -trimpath -o cyrene ./cmd/cyrene/`. +// The frontend must be built first so the SPA can be embedded: +// `cd frontend/web && npm run build`. +package main + +import ( + "context" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "time" + + capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/platform" + "git.yeij.top/AskaEth/Cyrene_v2/internal/webui" +) + +// envPath is the .env file merged at boot (development only; production uses +// systemd/env_file). A missing .env is not an error. +const envPath = ".env" + +// shutdownTimeout bounds how long the graceful teardown may take. +const shutdownTimeout = 15 * time.Second + +// externalDistCandidates are the on-disk directories checked (in order) for a +// frontend override before falling back to the embedded dist. The first that +// exists wins. They are relative to the process working directory. +func externalDistCandidates() []string { + return []string{ + filepath.Join("frontend", "web", "dist"), + filepath.Join("frontend", "dist"), + } +} + +// resolveFrontendFS returns the filesystem serving the SPA: an external dist +// directory when present (deploy-time hot override), otherwise the embedded dist. +func resolveFrontendFS(log *slog.Logger) fs.FS { + for _, dir := range externalDistCandidates() { + if info, err := os.Stat(dir); err == nil && info.IsDir() { + log.Info("serving external frontend dist", "dir", dir) + return os.DirFS(dir) + } + } + log.Info("serving embedded frontend dist") + return webui.Assets() +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "cyrene:", err) + os.Exit(1) + } +} + +// run boots the application, starts every service and the gateway, then blocks +// until a termination signal arrives or the gateway fails. +func run() error { + cfg := config.MustLoad(envPath) + format := logger.FormatText + if cfg.Env == "production" { + format = logger.FormatJSON + } + log := logger.New(logger.Options{Level: cfg.LogLevel, Format: string(format)}) + log = logger.WithSource(log, "main") + + ctx, stop := platform.SignalContext() + defer stop() + + a, err := newApp(ctx, cfg, log) + if err != nil { + return err + } + + if err := a.automation.Start(ctx); err != nil { + return fmt.Errorf("start automations: %w", err) + } + a.backthink.Start(ctx) + a.log.Info("cyrene subsystems started", + "personas", a.personas.CountPersonas(), + "tools", len(a.caps.List(capreg.KindTool)), + "channels", len(a.caps.List(capreg.KindChannel))) + a.log.Info("cyrene ready", "data_dir", resolveDataDir(cfg)) + + errCh, err := a.gw.Start(ctx) + if err != nil { + return fmt.Errorf("start gateway: %w", err) + } + + select { + case <-ctx.Done(): + a.log.Info("cyrene signal received, shutting down") + case gwErr := <-errCh: + if gwErr != nil { + shutCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + a.close(shutCtx) + return fmt.Errorf("gateway failed: %w", gwErr) + } + } + + shutCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + a.close(shutCtx) + return nil +} diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go new file mode 100644 index 0000000..f9641ac --- /dev/null +++ b/cmd/cyrene/wire.go @@ -0,0 +1,510 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/db" + "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" + personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/platform" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/persist" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" + "git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations" + "git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink" + "git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway" + "git.yeij.top/AskaEth/Cyrene_v2/internal/user" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink" + iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/basic" +) + +// defaultOwnerPath and defaultPersonasDir point at the on-disk persona/owner +// definitions shipped with the repo; they are relative to the working directory. +const ( + defaultOwnerPath = "configs/users/owner.yaml" + defaultPersonasDir = "configs/personas" + defaultSQLitePath = "./data/cyrene.db" + fallbackInstruction = "You are Cyrene, a warm and attentive companion." + + // Bound/knob constants. Compile-time so no bare magic number appears in the + // runtime path (project rule §10). + defaultMaxRounds = 8 + defaultMaxParallel = 4 + defaultAgentID = "orchestrator" + defaultToolTimeout = 30 * time.Second +) + +// app holds every subsystem the single Cyrene binary assembles. It is built by +// newApp and torn down by close on shutdown. +type app struct { + cfg *config.Config + log *slog.Logger + db *sql.DB + bus bus.Bus + personas *personareg.Registry + caps *registry.Registry + dispatcher *tool.Dispatcher + memStore *memory.Store + retriever *memory.Retriever + loop *loop.Loop + scheduler *orchestration.Scheduler + tasks *task.Manager + iot *iot.Manager + sessions *session.SessionManager + automation *automations.Service + backthink *backthink.Backthink + vindex vector.Index + owner *user.OwnerProfile + gw *gateway.Server +} + +// newApp assembles the full Cyrene runtime from cfg and log. It opens the +// database, migrates the schema, wires the persona registry, model adapter, tool +// dispatcher, memory stack, agent loop, orchestration, execution, IoT, channel +// sessions/adapters and the gateway, and returns the ready app. +// +// Fatal wiring failures (database open/migrate, required auth, gateway) are +// returned as errors; optional subsystems degrade gracefully so the binary can +// boot even when a model key or external channel is not configured. +func newApp(ctx context.Context, cfg *config.Config, log *slog.Logger) (*app, error) { + dataDir := resolveDataDir(cfg) + databasePath := resolveSQLitePath(cfg, dataDir) + dbCfg := cfg.DB + dbCfg.SQLitePath = databasePath + + sqlDB, err := db.Open(ctx, databasePath, log) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + if err := db.MigrateUp(ctx, sqlDB, log); err != nil { + _ = sqlDB.Close() + return nil, fmt.Errorf("migrate database: %w", err) + } + + eventBus := bus.New() + a := &app{ + cfg: cfg, + log: log, + db: sqlDB, + bus: eventBus, + caps: registry.New(), + } + + // Release already-opened resources if any later wiring step fails, without + // the full-service shutdown log (a partial boot is not a clean shutdown). + completed := false + defer func() { + if completed { + return + } + if a.vindex != nil { + _ = a.vindex.Close() + } + if a.tasks != nil { + _ = a.tasks.Close() + } + if a.bus != nil { + _ = a.bus.Close() + } + if a.db != nil { + _ = a.db.Close() + } + }() + + if err := a.buildPersonas(); err != nil { + return a, err + } + if err := a.wireMemory(ctx); err != nil { + return a, err + } + if err := a.wireRuntime(); err != nil { + return a, err + } + if err := a.wireAuxiliary(); err != nil { + return a, err + } + if err := a.wireChannels(); err != nil { + return a, err + } + if err := a.wireFrontend(); err != nil { + return a, err + } + completed = true + return a, nil +} + +// buildPersonas loads the persona definitions and registers them, plus the +// default character and the owner profile used by channel identity resolution. +func (a *app) buildPersonas() error { + a.personas = personareg.New() + + dirPersonas, err := loader.LoadDir(defaultPersonasDir) + if err != nil { + a.log.Warn("persona dir load skipped", "error", err) + } else { + for _, p := range dirPersonas { + if rerr := a.personas.RegisterPersona(p); rerr != nil { + a.log.Warn("persona register skipped", "persona", p.ID, "error", rerr) + } + } + } + + if owner, oerr := user.LoadOwnerProfile(defaultOwnerPath); oerr == nil { + a.owner = owner + } else { + a.log.Warn("owner profile load skipped", "error", oerr) + } + + // Load the default persona explicitly if it was not under the personas dir. + defaultID := "cyrene" + if a.owner != nil && a.owner.DefaultPersona != "" { + defaultID = a.owner.DefaultPersona + } + if _, ok := a.personas.Persona(defaultID); !ok { + if p, lerr := loader.Load(filepath.Join(defaultPersonasDir, defaultID+".yaml")); lerr == nil { + _ = a.personas.RegisterPersona(p) + } else { + a.log.Warn("default persona load skipped", "persona", defaultID, "error", lerr) + } + } + if p, ok := a.personas.Persona(defaultID); ok { + char := &personareg.Character{ID: p.ID, Name: p.Name, Default: true, PersonaID: p.ID} + if cerr := a.personas.RegisterCharacter(char); cerr != nil { + a.log.Warn("default character register skipped", "error", cerr) + } + } + a.log.Info("personas ready", "count", a.personas.CountPersonas(), "default", defaultID) + return nil +} + +// wireMemory builds the long-term memory stack: store, embedding manager, vector +// index, project index and the retriever wired into the io knowledge tool. +func (a *app) wireMemory(ctx context.Context) error { + store, err := memory.NewStore(ctx, a.db, a.log) + if err != nil { + return fmt.Errorf("memory store: %w", err) + } + a.memStore = store + + embedManager := embedding.New(a.cfg.Embedding) + embedder := embedManager.Embedder() + + index, err := vector.New(a.dbCfgForEmbed(), embedder, a.log) + if err != nil { + a.log.Warn("vector index unavailable, falling back to in-memory semantic", "error", err) + } else { + a.vindex = index + } + + projStore, err := projectindex.New(ctx, a.db, a.log) + if err != nil { + a.log.Warn("project index unavailable", "error", err) + projStore = nil + } + + opts := []memory.RetrieverOption{memory.WithEmbedder(embedder)} + if a.vindex != nil { + opts = append(opts, memory.WithVectorIndex(a.vindex)) + } + if projStore != nil { + opts = append(opts, memory.WithProjectIndex(projStore)) + } + a.retriever = memory.NewRetriever(store, opts...) + return nil +} + +// wireRuntime builds the model adapter, tool dispatcher, guardrails, the agent +// and its loop, tracing, persistence and the orchestration scheduler. +func (a *app) wireRuntime() error { + adapter, aerr := model.NewOpenAIChatChain(a.cfg.LLM) + if aerr != nil { + a.log.Warn("llm adapter unavailable", "error", aerr) + adapter = nil + } + router := model.NewRouter(a.cfg.LLM.Model) + + dispatcher, derr := tool.New(a.caps, tool.WithDefaultTimeout(defaultToolTimeout)) + if derr != nil { + return fmt.Errorf("tool dispatcher: %w", derr) + } + a.dispatcher = dispatcher + + if err := basic.Register(a.caps); err != nil { + return fmt.Errorf("register basic tools: %w", err) + } + ioTools, ioErr := iotools.New(a.cfg, iotools.WithKnowledgeRetriever(a.retriever), iotools.WithLogger(a.log)) + if ioErr != nil { + return fmt.Errorf("build io tools: %w", ioErr) + } + for _, t := range ioTools { + if rerr := a.dispatcher.Register(t); rerr != nil { + a.log.Warn("io tool register skipped", "tool", t.Name(), "error", rerr) + } + } + + guards := guardrail.New() + instructions := a.defaultInstructions() + ag, aerr := agent.New(instructions, a.dispatcher, guards, + agent.WithModel(adapter, router), + agent.WithOptions(agent.Options{MaxRounds: defaultMaxRounds, Purpose: "chat"})) + if aerr != nil { + return fmt.Errorf("agent: %w", aerr) + } + + personaID := a.defaultPersonaID() + store := a.memStore + + tracer := trace.New(trace.NewJSONRecorder(os.Stderr)) + checkpoint, perr := persist.New(context.Background(), a.db, a.log) + if perr != nil { + return fmt.Errorf("checkpoint store: %w", perr) + } + + a.loop = loop.New(loop.Options{ + Agent: ag, + Tools: a.dispatcher, + Memory: store, + Retriever: a.retriever, + Extractor: memory.NewManualExtractor(personaID, "owner"), + Checkpoint: checkpoint, + Tracer: tracer, + Guards: guards, + PersonaID: personaID, + MaxRounds: defaultMaxRounds, + WorkingMemory: memory.NewWorkingMemory(0), + Log: logger.WithSource(a.log, "runtime/loop"), + }) + + a.scheduler = orchestration.New(orchestration.Options{ + MaxParallel: defaultMaxParallel, + Loop: a.loop, + Bus: a.bus, + Memory: store, + Tracer: tracer, + Approver: audit.NewApprover(a.log), + Logger: logger.WithSource(a.log, "orchestration"), + AgentID: defaultAgentID, + }) + return nil +} + +// wireAuxiliary builds the execution provider, task manager, IoT manager, +// channel session manager, automations and background thinking services. +func (a *app) wireAuxiliary() error { + provider, err := exec.New(a.cfg.ExecSandbox, a.log) + if err != nil { + return fmt.Errorf("exec provider: %w", err) + } + tmgr, err := task.New(a.cfg, provider, a.log, task.WithBus(a.bus)) + if err != nil { + return fmt.Errorf("task manager: %w", err) + } + a.tasks = tmgr + + iotMgr, err := iot.New(a.cfg.IoT, a.log) + if err != nil { + return fmt.Errorf("iot: %w", err) + } + a.iot = iotMgr + + sess, err := session.New(context.Background(), a.db, a.log) + if err != nil { + return fmt.Errorf("session manager: %w", err) + } + a.sessions = sess + + automation, err := automations.NewService(automations.Config{}, automations.Deps{ + Bus: a.bus, + Loop: a.loop, + Log: logger.WithSource(a.log, "services/automations"), + }) + if err != nil { + return fmt.Errorf("automations: %w", err) + } + a.automation = automation + + bt, err := backthink.New(backthink.ConfigFromBackground(a.cfg.Background), backthink.Deps{ + Bus: a.bus, + Thinker: backthink.NewLoopThinker(a.loop), + State: backthink.NewIoTStateProvider(iotMgr, a.log), + DB: a.db, + Log: logger.WithSource(a.log, "services/backthink"), + }) + if err != nil { + return fmt.Errorf("backthink: %w", err) + } + a.backthink = bt + return nil +} + +// wireChannels constructs and registers the four IM channel adapters so the +// admin API can enumerate them. They are registered but not connected: real +// platform credentials are supplied per-deployment, and connecting with empty +// config would dial external networks. +func (a *app) wireChannels() error { + channels := []struct { + name string + value any + policy registry.ToolPolicy + }{ + {"qq-obv11", qq_obv11.NewAdapter(qq_obv11.Config{Logger: a.log}), registry.ToolPolicy{}}, + {"weixin-ilink", mustWeixin(a.log), registry.ToolPolicy{}}, + {"telegram", telegram.New(telegram.Config{}, telegram.WithLogger(a.log)), registry.ToolPolicy{}}, + {"webhook", webhook.New(webhook.WithLogger(a.log)), registry.ToolPolicy{}}, + } + for _, ch := range channels { + capp := registry.Capability{ + Name: ch.name, + Kind: registry.KindChannel, + Description: ch.name, + Policy: ch.policy, + Value: ch.value, + } + if err := a.caps.Register(capp); err != nil { + a.log.Warn("channel register skipped", "channel", ch.name, "error", err) + } + } + a.log.Info("channel adapters registered", "count", len(channels)) + return nil +} + +// wireFrontend builds the gateway with the embedded/external SPA filesystem and +// the JWT/RBAC authenticator. +func (a *app) wireFrontend() error { + jwt := auth.New(a.cfg.Auth) + gw, err := gateway.New(a.cfg, gateway.Deps{ + Log: logger.WithSource(a.log, "gateway"), + Auth: jwt, + Bus: a.bus, + Registry: a.caps, + Loop: a.loop, + IoT: a.iot, + Frontend: resolveFrontendFS(a.log), + }) + if err != nil { + return fmt.Errorf("gateway: %w", err) + } + a.gw = gw + return nil +} + +// dbCfgForEmbed returns the DB config used by the vector index, which opens the +// same SQLite file as the primary pool. +func (a *app) dbCfgForEmbed() config.DBConfig { + cfg := a.cfg.DB + cfg.SQLitePath = resolveSQLitePath(a.cfg, resolveDataDir(a.cfg)) + return cfg +} + +// defaultPersonaID returns the id of the default persona, falling back to +// "cyrene". +func (a *app) defaultPersonaID() string { + if c, ok := a.personas.DefaultCharacter(); ok { + return c.PersonaID + } + return "cyrene" +} + +// defaultInstructions returns the default persona's system prompt, falling back +// to its role text or a minimal instruction when neither is set. +func (a *app) defaultInstructions() string { + if c, ok := a.personas.DefaultCharacter(); ok { + if p, ok := a.personas.Persona(c.PersonaID); ok { + if p.Identity.SystemPrompt != "" { + return p.Identity.SystemPrompt + } + if p.Identity.Role != "" { + return p.Identity.Role + } + } + } + return fallbackInstruction +} + +// close shuts down every subsystem in dependency order and releases resources. +func (a *app) close(ctx context.Context) { + if a.backthink != nil { + _ = a.backthink.Stop(ctx) + } + if a.automation != nil { + _ = a.automation.Stop(ctx) + } + if a.scheduler != nil { + _ = a.scheduler.CancelAll(ctx) + } + if a.gw != nil { + a.gw.Close() + } + if a.tasks != nil { + _ = a.tasks.Close() + } + if a.vindex != nil { + _ = a.vindex.Close() + } + if a.bus != nil { + _ = a.bus.Close() + } + if a.db != nil { + _ = a.db.Close() + } + a.log.Info("cyrene shutdown complete") +} + +// resolveDataDir returns the configured data directory, or the OS-appropriate +// default when DATA_DIR is not exported to the process environment. +func resolveDataDir(cfg *config.Config) string { + if _, ok := os.LookupEnv("DATA_DIR"); ok { + return cfg.DataDir + } + return platform.DefaultDataDir() +} + +// resolveSQLitePath returns the SQLite path to use, deriving it under the data +// directory when the configured default is left untouched. +func resolveSQLitePath(cfg *config.Config, dataDir string) string { + p := cfg.DB.SQLitePath + if strings.TrimSpace(p) == "" || p == defaultSQLitePath { + return filepath.Join(dataDir, "cyrene.db") + } + return p +} + +// mustWeixin builds the weixin-ilink adapter, panicking on an invalid config +// (which should never happen with defaults). +func mustWeixin(log *slog.Logger) *weixin_ilink.Adapter { + adapter, err := weixin_ilink.New(weixin_ilink.Config{}, nil, log) + if err != nil { + panic(fmt.Sprintf("weixin-ilink adapter: %v", err)) + } + return adapter +} diff --git a/frontend/web/copy-dist.mjs b/frontend/web/copy-dist.mjs new file mode 100644 index 0000000..033fe1e --- /dev/null +++ b/frontend/web/copy-dist.mjs @@ -0,0 +1,15 @@ +// Copies the freshly built SPA (frontend/web/dist) into internal/webui/dist so +// the Go package can //go:embed it into the single Cyrene binary. It runs as the +// last step of `npm run build`. The source dist is the canonical build output, +// so it also serves as the external deploy-override directory. +import { cp, rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const src = resolve(here, "dist"); +const dest = resolve(here, "..", "..", "internal", "webui", "dist"); + +await rm(dest, { recursive: true, force: true }); +await cp(src, dest, { recursive: true }); +console.log(`webui: copied ${src} -> ${dest}`); diff --git a/frontend/web/package.json b/frontend/web/package.json index b3d547a..115ed68 100644 --- a/frontend/web/package.json +++ b/frontend/web/package.json @@ -6,7 +6,7 @@ "description": "Cyrene v2 user frontend (M9 modal UI) — Miuix design language", "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build", + "build": "tsc --noEmit && vite build && node copy-dist.mjs", "preview": "vite preview", "typecheck": "tsc --noEmit" }, diff --git a/internal/services/gateway/server.go b/internal/services/gateway/server.go index ad119a1..7d86099 100644 --- a/internal/services/gateway/server.go +++ b/internal/services/gateway/server.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "io" + "io/fs" "log/slog" "net" "net/http" @@ -116,6 +117,10 @@ type Deps struct { Memories MemoryStore // IoT lists IoT devices. Optional. IoT IoTStore + // Frontend is the SPA filesystem served for all non-API, non-WS routes. + // When nil the gateway serves no static assets (API and WS only). The + // embedded dist (internal/webui) or an external dist directory is used. + Frontend fs.FS } // Server is the gateway. It owns a ServeMux aggregating REST + WS + admin and a @@ -130,6 +135,7 @@ type Server struct { personas PersonaStore memories MemoryStore iot IoTStore + frontend fs.FS log *slog.Logger hub *hub } @@ -154,6 +160,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) { personas: deps.Personas, memories: deps.Memories, iot: deps.IoT, + frontend: deps.Frontend, log: sourcedLog(deps.Log), } s.hub = newHub(s) @@ -191,6 +198,9 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/v1/admin/plugins/policy", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.adminSetPolicy))) var h http.Handler = mux + if s.frontend != nil { + h = s.spa(h) + } h = s.recover(h) h = s.cors(h) h = s.logging(h) diff --git a/internal/services/gateway/static.go b/internal/services/gateway/static.go new file mode 100644 index 0000000..224cba5 --- /dev/null +++ b/internal/services/gateway/static.go @@ -0,0 +1,47 @@ +package gateway + +import ( + "net/http" + "path" + "strings" +) + +// spa wraps the API mux with the SPA file server. Requests under /api/ and /ws/ +// are served by the API handler; every other path is served from the configured +// frontend filesystem with a fallback to index.html so client-side routes (e.g. +// /chat) resolve in a single-page app. +func (s *Server) spa(api http.Handler) http.Handler { + fileServer := s.spaFileServer() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isAPIRoute(r.URL.Path) { + api.ServeHTTP(w, r) + return + } + fileServer.ServeHTTP(w, r) + }) +} + +// spaFileServer serves static assets, falling back to index.html for paths that +// are not real files in the embedded/external dist. +func (s *Server) spaFileServer() http.Handler { + fsrv := http.FileServer(http.FS(s.frontend)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rel := strings.TrimPrefix(path.Clean(r.URL.Path), "/") + if rel != "" { + if f, err := s.frontend.Open(rel); err == nil { + _ = f.Close() + fsrv.ServeHTTP(w, r) + return + } + } + r2 := r.Clone(r.Context()) + r2.URL.Path = "/" + fsrv.ServeHTTP(w, r2) + }) +} + +// isAPIRoute reports whether path belongs to the REST/WS API surface the +// gateway owns, which the SPA handler must leave untouched. +func isAPIRoute(p string) bool { + return strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/ws/") +} diff --git a/internal/webui/webui.go b/internal/webui/webui.go new file mode 100644 index 0000000..b3de3ad --- /dev/null +++ b/internal/webui/webui.go @@ -0,0 +1,47 @@ +// Package webui embeds the built frontend SPA (frontend/web/dist) into the +// single Cyrene binary and exposes it as a filesystem. It is the default asset +// source for the gateway's static server; a deployer may still drop an external +// dist directory next to the process to override it (see internal/services/ +// gateway and cmd/cyrene). +// +// The embedded tree is rooted at the dist directory, so index.html and +// assets/* appear at the filesystem root. Build the frontend with +// `cd frontend/web && npm run build` (which also syncs dist into this package) +// before running `go build ./...`; the //go:embed directive fails to compile +// when the dist directory is absent. +package webui + +import ( + "embed" + "io/fs" +) + +// dist holds the built SPA. It is generated by the frontend build and must +// exist for the package to compile. +// +//go:embed all:dist +var dist embed.FS + +// Assets returns the embedded dist as an fs.FS rooted at the dist directory. +// The returned FS is read-only and safe for concurrent use. +func Assets() fs.FS { + sub, err := fs.Sub(dist, "dist") + if err != nil { + // The embed directive guarantees dist exists, so this cannot fail; a + // panic here is a programming error, not a runtime condition. + panic("webui: dist embed missing: " + err.Error()) + } + return sub +} + +// Open opens a file under the dist root by its slash-separated (or +// filesystem) name. It is a convenience over Assets().Open. +func Open(name string) (fs.File, error) { + return Assets().Open(name) +} + +// Exists reports whether a file or directory exists within the embedded dist. +func Exists(path string) bool { + _, err := fs.Stat(Assets(), path) + return err == nil +}