commit 89f2114b06d49bac98ce20bfb515e617fceecab6 Author: Evan Reichard Date: Wed Dec 31 15:33:16 2025 -0500 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18faab5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +data +.opencode diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c02fee1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# Aethera Monorepo + +This repository is a **monorepo** with two main packages that can be built and run independently. + +## Directory Structure + +``` +├── frontend/ # Client‑side TypeScript application +├── backend/ # Server‑side Go micro‑service +├── .envrc, flake.nix, README.md +└── … +``` + +## Package Details + +See package‑specific instructions: + +- **frontend/** - `@frontend/AGENTS.md` +- **backend/** - `@backend/AGENTS.md` diff --git a/README.md b/README.md new file mode 100644 index 0000000..5da12dd --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# Aethera + +A sophisticated web dashboard for AI-powered conversations and image generation with chat interface, multiple conversations, and local storage capabilities. + +## Features + +- **Chat Interface**: Engage with AI models through a clean, responsive chat interface +- **Multiple Conversations**: Switch between different conversation threads +- **Image Generation**: Create and manage AI-generated images with customizable prompts +- **Theme Support**: Toggle between light and dark modes +- **Local Storage**: All conversations and images are stored locally on your system +- **Markdown Rendering**: View beautifully formatted responses with syntax highlighting + +## Quick Start + +### Prerequisites + +- Go 1.25.5 or later +- Bun package manager +- An OpenAI-compatible API endpoint (OpenAI, local LLM, etc.) + +### Installation + +1. Clone the repository: + +```bash +git clone +cd aethera +``` + +2. Build the backend: + +```bash +cd backend +go build -o ./dist/aethera ./cmd +``` + +3. Build the frontend: + +```bash +cd ../frontend +bun run build +``` + +### Running the Application + +Start the server from the backend directory: + +```bash +./dist/aethera +``` + +By default, the application runs at `http://localhost:8080` + +Open your browser and navigate to the URL to begin using Aethera. + +## Configuration Options + +You can customize the server behavior with these command-line flags: + +- `--data-dir`: Directory for storing generated images (default: `data`) +- `--listen`: Address to listen on (default: `localhost`) +- `--port`: Port to listen on (default: `8080`) + +Example: + +```bash +./dist/aethera --port 3000 --listen 0.0.0.0 +``` + +## Getting Started + +1. **Configure Your API**: Navigate to the Settings page and enter your OpenAI-compatible API endpoint URL +2. **Start Chatting**: Use the Chat interface to begin conversations with your AI model +3. **Generate Images**: Visit the Images page to create images using text prompts +4. **Manage Your Content**: View and delete images, organize conversations + +## Supported AI Services + +Aethera works with any OpenAI-compatible API, including: + +- OpenAI +- Local LLMs (Ollama, LocalAI, etc.) +- Other compatible AI services + +Configure your preferred service in the Settings page. + +## Troubleshooting + +### API Connection Issues + +If you see authentication errors, verify your API endpoint URL is correct and accessible. + +### Port Already in Use + +Change the port using the `--port` flag if port 8080 is unavailable. + +## License + +See LICENSE file for details. diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..1521c8b --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +dist diff --git a/backend/AGENTS.md b/backend/AGENTS.md new file mode 100644 index 0000000..993752b --- /dev/null +++ b/backend/AGENTS.md @@ -0,0 +1,53 @@ +# Backend Agent Instructions + +## Stack + +- **Go 1.25.5** +- **cobra** (CLI framework) +- **logrus** (structured logging) +- **openai-go/v3** (OpenAI API client) +- **golangci-lint** (linting) + +## Commands + +```bash +go build -o ./dist/aethera ./cmd +golangci-lint run +go test ./... +``` + +## Non-Negotiables + +- ❌ No unhandled errors - always check `err` +- ❌ No ignored linter warnings +- ❌ No sensitive data in logs +- ❌ No hardcoded paths - use `path.Join` +- ❌ No unsafe file access - use `filepath.Base` +- ❌ Don't skip tests or linting + +## Code Style + +- tab indentation, PascalCase exports, camelCase internal +- Error wrapping with context: `fmt.Errorf("...: %w", err)` +- Custom error types for domain errors (e.g., `ChatNotFoundError`) +- Struct tags for JSON with `omitempty` +- Log with context: `log.WithField("key", val)` +- Clean up resources with `defer` + +## Key Patterns + +- **Interfaces**: `Store` interface for swappable backends +- **DI**: Dependencies through constructors (`New*` functions) +- **HTTP**: Handlers receive `store.Store`, validate inputs, return proper status codes +- **Streaming**: Use `FlushWriter` for SSE/text streams +- **Storage**: JSON file-based (`FileStore` implementation) + +## What Goes Where + +- CLI entry: `cmd/` (main.go, config.go) +- HTTP handlers: `internal/api/` +- OpenAI client: `internal/client/` +- Server setup: `internal/server/` +- Storage interface & impl: `internal/store/` +- Storage utilities: `internal/storage/` +- Utilities: `pkg/` (ptr, slices) diff --git a/backend/cmd/config.go b/backend/cmd/config.go new file mode 100644 index 0000000..a7961d6 --- /dev/null +++ b/backend/cmd/config.go @@ -0,0 +1,24 @@ +package main + +import ( + "fmt" + "os" + "path" +) + +type cliParams struct { + ListenAddr string + ListenPort int + DataDir string + SettingsFile string +} + +func (p *cliParams) Validate() error { + // Ensure Generated Directories + imgDir := path.Join(p.DataDir, "generated/images") + if err := os.MkdirAll(imgDir, 0755); err != nil { + return fmt.Errorf("failed to create images directory: %w", err) + } + + return nil +} diff --git a/backend/cmd/main.go b/backend/cmd/main.go new file mode 100644 index 0000000..329ecc7 --- /dev/null +++ b/backend/cmd/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "os" + "path" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "reichard.io/aethera/internal/server" + "reichard.io/aethera/internal/store" +) + +var ( + params = cliParams{ + ListenAddr: "localhost", + ListenPort: 8080, + DataDir: "./data", + } + rootCmd = &cobra.Command{Use: "aethera"} +) + +func init() { + rootCmd.PersistentFlags().StringVar(¶ms.DataDir, "data-dir", "data", "Directory to store generated images") + rootCmd.PersistentFlags().StringVar(¶ms.ListenAddr, "listen", "localhost", "Address to listen on") + rootCmd.PersistentFlags().IntVar(¶ms.ListenPort, "port", 8080, "Port to listen on") +} + +func main() { + // Validate Params + if err := params.Validate(); err != nil { + logrus.Fatalf("failed to validate parameters: %v", err) + } + + // Initialize Store + fileStore, err := store.NewFileStore(path.Join(params.DataDir, "settings.json")) + if err != nil { + logrus.Fatalf("failed to create store: %v", err) + } + + // Start Server + rootCmd.Run = func(cmd *cobra.Command, args []string) { + server.StartServer(fileStore, params.DataDir, params.ListenAddr, params.ListenPort) + } + + if err := rootCmd.Execute(); err != nil { + logrus.Fatal(err) + os.Exit(1) + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..84fb79c --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,21 @@ +module reichard.io/aethera + +go 1.25.5 + +require ( + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/openai/openai-go/v3 v3.15.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + golang.org/x/sys v0.29.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..2379ed9 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,42 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/openai/openai-go/v3 v3.15.0 h1:hk99rM7YPz+M99/5B/zOQcVwFRLLMdprVGx1vaZ8XMo= +github.com/openai/openai-go/v3 v3.15.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +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/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/api/convert.go b/backend/internal/api/convert.go new file mode 100644 index 0000000..ae49b0c --- /dev/null +++ b/backend/internal/api/convert.go @@ -0,0 +1,28 @@ +package api + +import ( + "reichard.io/aethera/internal/store" + "reichard.io/aethera/pkg/slices" +) + +func toChat(c *store.Chat) *Chat { + chat := &Chat{ + ID: c.ID, + CreatedAt: c.CreatedAt, + Title: c.Title, + MessageCount: len(c.Messages), + Messages: c.Messages, + } + + if firstMessage, found := slices.First(c.Messages); found { + chat.InitialMessage = firstMessage.Content + } + + return chat +} + +func toChatNoMessages(c *store.Chat) *Chat { + chat := toChat(c) + chat.Messages = []*store.Message{} + return chat +} diff --git a/backend/internal/api/flush_writer.go b/backend/internal/api/flush_writer.go new file mode 100644 index 0000000..999101c --- /dev/null +++ b/backend/internal/api/flush_writer.go @@ -0,0 +1,27 @@ +package api + +import ( + "net/http" +) + +type flushWriter struct { + w http.ResponseWriter + f http.Flusher +} + +func (fw *flushWriter) Write(p []byte) (n int, err error) { + // Write Data + n, err = fw.w.Write(p) + if err == nil && fw.f != nil { + fw.f.Flush() + } + return +} + +func newFlushWriter(w http.ResponseWriter) *flushWriter { + flusher, _ := w.(http.Flusher) + return &flushWriter{ + w: w, + f: flusher, + } +} diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go new file mode 100644 index 0000000..821704b --- /dev/null +++ b/backend/internal/api/handlers.go @@ -0,0 +1,549 @@ +package api + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/google/uuid" + "github.com/openai/openai-go/v3" + "github.com/sirupsen/logrus" + "reichard.io/aethera/internal/client" + "reichard.io/aethera/internal/store" + "reichard.io/aethera/pkg/slices" +) + +type API struct { + logger *logrus.Entry + store store.Store + client *client.Client + dataDir string +} + +func New(s store.Store, dataDir string, logger *logrus.Logger) *API { + return &API{ + store: s, + dataDir: dataDir, + logger: logger.WithField("service", "api"), + } +} + +func (a *API) GetSettings(w http.ResponseWriter, r *http.Request) { + log := a.logger.WithField("handler", "GetSettingsHandler") + + settings, err := a.store.GetSettings() + if err != nil { + log.WithError(err).Error("failed to retrieve settings") + http.Error(w, "Failed to retrieve application settings", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(settings); err != nil { + log.WithError(err).Error("failed to encode application settings response") + http.Error(w, "Failed to encode application settings response", http.StatusInternalServerError) + return + } +} + +func (a *API) PostSettings(w http.ResponseWriter, r *http.Request) { + log := a.logger.WithField("handler", "PostSettingsHandler") + + var newSettings store.Settings + if err := json.NewDecoder(r.Body).Decode(&newSettings); err != nil { + log.WithError(err).Error("invalid JSON in settings update request") + http.Error(w, "Invalid request body format for settings", http.StatusBadRequest) + return + } + + if apiEndpoint := newSettings.APIEndpoint; apiEndpoint != "" { + baseURL, err := url.Parse(apiEndpoint) + if err != nil { + errMsg := fmt.Sprintf("Invalid API Endpoint URL: %q", baseURL) + log.WithError(err).Error(errMsg) + http.Error(w, errMsg, http.StatusBadRequest) + return + } + + testClient := client.NewClient(baseURL) + if _, err := testClient.GetModels(r.Context()); err != nil { + log.WithError(err).Error("failed to access configured API endpoint") + http.Error(w, "API endpoint inaccessible", http.StatusBadRequest) + return + } + a.client = nil + } + + if err := a.store.SaveSettings(&newSettings); err != nil { + log.WithError(err).Error("failed to save settings") + http.Error(w, "Failed to save application settings", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (a *API) GetModels(w http.ResponseWriter, r *http.Request) { + log := a.logger.WithField("handler", "GetModelsHandler") + + client, err := a.getClient() + if err != nil { + log.WithError(err).Error("failed to initialize API client") + http.Error(w, "Failed to initialize API client", http.StatusInternalServerError) + return + } + + models, err := client.GetModels(r.Context()) + if err != nil { + log.WithError(err).Error("failed to retrieve available models") + http.Error(w, "Failed to retrieve available models from API", http.StatusInternalServerError) + return + } + + if err := json.NewEncoder(w).Encode(models); err != nil { + log.WithError(err).Error("failed to encode available models response") + http.Error(w, "Failed to encode available models response", http.StatusInternalServerError) + return + } +} + +func (a *API) GetImages(w http.ResponseWriter, r *http.Request) { + log := a.logger.WithField("handler", "GetImagesHandler") + + files, err := os.ReadDir(path.Join(a.dataDir, "generated/images")) + if err != nil { + log.WithError(err).Error("failed to read images directory") + http.Error(w, "Failed to read images directory", http.StatusInternalServerError) + return + } + + imageList := make([]ImageRecord, 0) + for _, file := range files { + if !file.IsDir() && strings.HasSuffix(strings.ToLower(file.Name()), ".png") { + info, err := file.Info() + if err != nil { + continue + } + imageList = append(imageList, ImageRecord{ + Name: file.Name(), + Path: "/generated/images/" + file.Name(), + Size: info.Size(), + Date: info.ModTime().Format(time.RFC3339), + }) + } + } + sort.Slice(imageList, func(i, j int) bool { + return imageList[i].Date > imageList[j].Date + }) + + if err := json.NewEncoder(w).Encode(imageList); err != nil { + log.WithError(err).Error("failed to encode image list metadata response") + http.Error(w, "Failed to encode image list metadata response", http.StatusInternalServerError) + return + } +} + +func (a *API) PostImage(w http.ResponseWriter, r *http.Request) { + log := a.logger.WithField("handler", "PostImageHandler") + + client, err := a.getClient() + if err != nil { + log.WithError(err).Error("failed to initialize API client") + http.Error(w, "Failed to initialize API client", http.StatusInternalServerError) + return + } + + var genReq GenerateImageRequest + if err := json.NewDecoder(r.Body).Decode(&genReq); err != nil { + log.WithError(err).Error("invalid JSON in image generation request") + http.Error(w, "Invalid request body format for image generation", http.StatusBadRequest) + return + } + if err := genReq.Validate(); err != nil { + log.WithError(err).Error("invalid request") + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Edit vs Generate Request + var images []openai.Image + var reqErr error + if genReq.isEdit() { + editParams, err := genReq.getEditParams() + if err != nil { + log.WithError(err).Error("invalid image edit parameters") + http.Error(w, "Invalid image edit parameters", http.StatusBadRequest) + return + + } + images, reqErr = client.EditImage(r.Context(), *editParams) + } else { + genParams, err := genReq.getGenerateParams() + if err != nil { + log.WithError(err).Error("invalid image generation parameters") + http.Error(w, "Invalid image generation parameters", http.StatusBadRequest) + return + + } + images, reqErr = client.GenerateImages(r.Context(), *genParams) + } + + // Check Error + if reqErr != nil { + log.WithError(reqErr).Error("failed to generate images") + http.Error(w, "Failed to generate images via API", http.StatusInternalServerError) + return + } + + // Normalize Responses + imageRecords := make([]ImageRecord, 0) + for i, img := range images { + if img.B64JSON == "" { + log.Warnf("empty image data at index %d, skipping", i) + continue + } + + // Decode Image + imgBytes, err := base64.StdEncoding.DecodeString(img.B64JSON) + if err != nil { + log.WithError(err).WithField("index", i).Error("failed to decode image") + continue + } + + // Save Image + filename := fmt.Sprintf("image_%d_%d.png", time.Now().Unix(), i) + filePath := path.Join(a.dataDir, "generated/images", filename) + if err := os.WriteFile(filePath, imgBytes, 0644); err != nil { + log.WithError(err).WithField("file", filePath).Error("failed to save generated image") + continue + } + + // Record Image + imageRecords = append(imageRecords, ImageRecord{ + Name: filename, + Path: fmt.Sprintf("/generated/images/%s", filename), + Date: time.Now().Format(time.RFC3339), + Size: int64(len(imgBytes)), + }) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(imageRecords); err != nil { + log.WithError(err).Error("failed to encode generated images response") + http.Error(w, "Failed to encode generated images response", http.StatusInternalServerError) + return + } +} + +func (a *API) DeleteImage(w http.ResponseWriter, r *http.Request) { + log := a.logger.WithField("handler", "DeleteImageHandler") + + filename := r.PathValue("filename") + if filename == "" { + log.Error("missing filename parameter") + http.Error(w, "Filename parameter is required", http.StatusBadRequest) + return + } + + // Delete Image + imgDir := path.Join(a.dataDir, "generated/images") + safePath := path.Join(imgDir, filepath.Base(filename)) + if err := os.Remove(safePath); err != nil { + log.WithError(err).WithField("file", safePath).Error("failed to delete image file") + http.Error(w, "Failed to delete image file", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (a *API) GetChats(w http.ResponseWriter, r *http.Request) { + log := a.logger.WithField("handler", "GetChatsHandler") + + chats, err := a.store.ListChats() + if err != nil { + log.WithError(err).Error("failed to list chats") + http.Error(w, "Failed to retrieve chats", http.StatusInternalServerError) + return + } + sort.Slice(chats, func(i, j int) bool { + iLast, iFound := slices.Last(chats[i].Messages) + if !iFound { + return false + } + + jLast, jFound := slices.Last(chats[j].Messages) + if !jFound { + return true + } + + return iLast.CreatedAt.After(jLast.CreatedAt) + }) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(ChatListResponse{Chats: slices.Map(chats, toChatNoMessages)}); err != nil { + log.WithError(err).Error("failed to encode chats list response") + http.Error(w, "Failed to encode chats list response", http.StatusInternalServerError) + return + } +} + +func (a *API) PostChat(w http.ResponseWriter, r *http.Request) { + log := a.logger.WithField("handler", "PostChatHandler") + + // Decode Request + var genReq GenerateTextRequest + if err := json.NewDecoder(r.Body).Decode(&genReq); err != nil { + log.WithError(err).Error("invalid JSON in text generation request") + http.Error(w, "Invalid request body format for new chat", http.StatusBadRequest) + return + } + if err := genReq.Validate(); err != nil { + log.WithError(err).Error("invalid request") + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Create Chat + var chat store.Chat + if err := a.store.SaveChat(&chat); err != nil { + log.WithError(err).Error("failed to create new chat") + http.Error(w, "Failed to create new chat", http.StatusInternalServerError) + return + } + + // Send Message + if err := a.sendMessage(r.Context(), w, chat.ID, genReq.Model, genReq.Prompt); err != nil { + log.WithError(err).WithField("chat_id", chat.ID).Error("failed to send message") + http.Error(w, "Failed to send message", http.StatusInternalServerError) + } +} + +func (a *API) DeleteChat(w http.ResponseWriter, r *http.Request) { + log := a.logger.WithField("handler", "DeleteChatHandler") + + chatIDStr := r.PathValue("chatId") + if chatIDStr == "" { + log.Error("missing chat ID parameter") + http.Error(w, "Chat ID is required", http.StatusBadRequest) + return + } + + chatID, err := uuid.Parse(chatIDStr) + if err != nil { + log.WithError(err).Error("invalid chat ID format") + http.Error(w, "Invalid chat ID format", http.StatusBadRequest) + return + } + + // Delete Chat + if err := a.store.DeleteChat(chatID); err != nil { + log.WithError(err).WithField("chat_id", chatID).Error("failed to delete chat") + if errors.Is(err, store.ErrChatNotFound) { + http.Error(w, "Chat not found", http.StatusNotFound) + } else { + http.Error(w, "Failed to delete chat", http.StatusInternalServerError) + } + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (a *API) GetChat(w http.ResponseWriter, r *http.Request) { + log := a.logger.WithField("handler", "GetChatHandler") + + chatID := r.PathValue("chatId") + if chatID == "" { + log.Error("missing chat ID parameter") + http.Error(w, "Chat ID is required", http.StatusBadRequest) + return + } + + parsedChatID, err := uuid.Parse(chatID) + if err != nil { + log.WithError(err).Error("invalid chat ID format") + http.Error(w, "Invalid chat ID format", http.StatusBadRequest) + return + } + + chat, err := a.store.GetChat(parsedChatID) + if err != nil { + log.WithError(err).WithField("chat_id", parsedChatID).Error("failed to get chat") + http.Error(w, "Failed to get chat", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(toChat(chat)); err != nil { + log.WithError(err).Error("failed to encode chat messages response") + http.Error(w, "Failed to encode chat messages response", http.StatusInternalServerError) + return + } +} + +func (a *API) PostChatMessage(w http.ResponseWriter, r *http.Request) { + log := a.logger.WithField("handler", "PostChatMessageHandler") + + rawChatID := r.PathValue("chatId") + if rawChatID == "" { + log.Error("missing chat ID parameter") + http.Error(w, "Chat ID is required", http.StatusBadRequest) + return + } + + chatID, err := uuid.Parse(rawChatID) + if err != nil { + log.WithError(err).Error("invalid chat ID format") + http.Error(w, "Invalid chat ID format", http.StatusBadRequest) + return + } + + var genReq GenerateTextRequest + if err := json.NewDecoder(r.Body).Decode(&genReq); err != nil { + log.WithError(err).Error("invalid JSON in text generation request") + http.Error(w, "Invalid request body format for text generation", http.StatusBadRequest) + return + } + if err := genReq.Validate(); err != nil { + log.WithError(err).Error("invalid request") + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := a.sendMessage(r.Context(), w, chatID, genReq.Model, genReq.Prompt); err != nil { + log.WithError(err).WithField("chat_id", chatID).Error("failed to send message") + http.Error(w, "Failed to send message", http.StatusInternalServerError) + } +} + +func (a *API) getClient() (*client.Client, error) { + if a.client != nil { + return a.client, nil + } + + // Get Settings & Validate Endpoint + settings, err := a.store.GetSettings() + if err != nil { + return nil, fmt.Errorf("failed to retrieve application settings: %w", err) + } else if settings.APIEndpoint == "" { + return nil, errors.New("no API endpoint configured in settings") + } + + baseURL, err := url.Parse(settings.APIEndpoint) + if err != nil { + return nil, fmt.Errorf("invalid API endpoint URL: %w", err) + } + + a.client = client.NewClient(baseURL) + return a.client, nil +} + +func (a *API) sendMessage(ctx context.Context, w http.ResponseWriter, chatID uuid.UUID, chatModel, userMessage string) error { + apiClient, err := a.getClient() + if err != nil { + return fmt.Errorf("failed to get client: %w", err) + } + + // Detach Request Context + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute*5) + defer cancel() + + // Create User Message + userMsg := &store.Message{ChatID: chatID, Role: "user", Content: userMessage} + if err := a.store.SaveChatMessage(userMsg); err != nil { + return fmt.Errorf("failed to add user message to chat: %w", err) + } + + // Add Assistant Response - TODO: Ensure InProgress Flag? + assistantMsg := &store.Message{ChatID: chatID, Role: "assistant"} + if err := a.store.SaveChatMessage(assistantMsg); err != nil { + return fmt.Errorf("failed to add assistant message to chat: %w", err) + } + + // Get Chat + chat, err := a.store.GetChat(chatID) + if err != nil { + return fmt.Errorf("failed to get chat: %w", err) + } + + // Set Headers + w.Header().Set("Content-Type", "application/x-ndjson") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Transfer-Encoding", "chunked") + + // Create Flush Writer + flushWriter := newFlushWriter(w) + + // Send Initial Chunk - User Message & Chat + if err := json.NewEncoder(flushWriter).Encode(&MessageChunk{ + Chat: toChatNoMessages(chat), + UserMessage: userMsg, + }); err != nil { + return fmt.Errorf("failed to send initial chunk: %w", err) + } + + // Send Message + if _, err := apiClient.SendMessage(ctx, chat.Messages, chatModel, func(m *client.MessageChunk) error { + var apiMsgChunk MessageChunk + + if m.Stats != nil { + assistantMsg.Stats = m.Stats + } + + if m.Message != nil { + assistantMsg.Content += *m.Message + apiMsgChunk.AssistantMessage = assistantMsg + } + + if m.Thinking != nil { + assistantMsg.Thinking += *m.Thinking + apiMsgChunk.AssistantMessage = assistantMsg + } + + // Send Progress Chunk + if err := json.NewEncoder(flushWriter).Encode(apiMsgChunk); err != nil { + return fmt.Errorf("failed to send progress chunk: %w", err) + } + + return nil + }); err != nil { + return fmt.Errorf("failed to generate text stream: %w", err) + } + + // Summarize & Update Chat Title + if chat.Title == "" { + chat.Title, err = apiClient.CreateTitle(ctx, chat.Messages[0].Content, chatModel) + if err != nil { + a.logger.WithError(err).WithField("chat_id", chat.ID).Error("failed to create chat title") + } else if err := a.store.SaveChat(chat); err != nil { + a.logger.WithError(err).WithField("chat_id", chat.ID).Error("failed to update chat") + } + } + + // Update Assistant Message + if err := a.store.SaveChatMessage(assistantMsg); err != nil { + return fmt.Errorf("failed to save assistant message to chat: %w", err) + } + + // Send Final Chunk + if err := json.NewEncoder(flushWriter).Encode(&MessageChunk{ + Chat: toChatNoMessages(chat), + AssistantMessage: assistantMsg, + }); err != nil { + return fmt.Errorf("failed to send final chunk: %w", err) + } + + return nil +} diff --git a/backend/internal/api/types.go b/backend/internal/api/types.go new file mode 100644 index 0000000..0011224 --- /dev/null +++ b/backend/internal/api/types.go @@ -0,0 +1,163 @@ +package api + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/google/uuid" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/packages/param" + "reichard.io/aethera/internal/store" +) + +type ChatListResponse struct { + Chats []*Chat `json:"chats"` +} + +type Chat struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + Title string `json:"title"` + InitialMessage string `json:"initial_message"` + MessageCount int `json:"message_count"` + Messages []*store.Message `json:"messages"` +} + +type MessageChunk struct { + Chat *Chat `json:"chat,omitempty"` + UserMessage *store.Message `json:"user_message,omitempty"` + AssistantMessage *store.Message `json:"assistant_message,omitempty"` +} + +type GenerateImageRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + N int64 `json:"n"` + Size string `json:"size"` + + Mask *string `json:"mask"` // Data URL (e.g. "data:image/png;base64,...") + Image *string `json:"image"` // Data URL (e.g. "data:image/png;base64,...") + + GenerateImageRequestExtraArgs +} + +func (r *GenerateImageRequest) Validate() error { + if r.Model == "" { + return errors.New("model is required") + } + if r.Prompt == "" { + return errors.New("prompt is required") + } + return nil +} + +type GenerateImageRequestExtraArgs struct { + Seed *int32 `json:"seed,omitempty"` +} + +type ImageRecord struct { + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + Date string `json:"date"` +} + +type GenerateTextRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` +} + +func (r *GenerateTextRequest) Validate() error { + if r.Model == "" { + return errors.New("model is required") + } + if r.Prompt == "" { + return errors.New("prompt is required") + } + return nil +} + +func (r *GenerateImageRequest) getPrompt() string { + prompt := r.Prompt + d, _ := json.Marshal(r.GenerateImageRequestExtraArgs) + if extraArgs := string(d); extraArgs != "" { + prompt += fmt.Sprintf(" %s", extraArgs) + } + return strings.TrimSpace(prompt) +} + +func (r *GenerateImageRequest) isEdit() bool { + return r.Image != nil +} + +func (r *GenerateImageRequest) getEditParams() (*openai.ImageEditParams, error) { + if !r.isEdit() { + return nil, errors.New("not an edit request") + } + strippedImage, err := stripDataURLPrefix(*r.Image) + if err != nil { + return nil, fmt.Errorf("failed to strip data url prefix for image: %w", err) + } + imageBytes, err := base64.StdEncoding.DecodeString(strippedImage) + if err != nil { + return nil, fmt.Errorf("failed to decode image: %w", err) + } + iFile := openai.File(bytes.NewReader(imageBytes), "main.png", "image/png") + + editReq := &openai.ImageEditParams{ + Model: r.Model, + Prompt: r.getPrompt(), + Size: openai.ImageEditParamsSize(r.Size), + N: param.NewOpt(r.N), + OutputFormat: openai.ImageEditParamsOutputFormatPNG, + Image: openai.ImageEditParamsImageUnion{OfFileArray: []io.Reader{iFile}}, + } + if r.Mask == nil { + return editReq, nil + } + + strippedMask, err := stripDataURLPrefix(*r.Mask) + if err != nil { + return nil, fmt.Errorf("failed to strip data url prefix for mask: %w", err) + } + maskBytes, err := base64.StdEncoding.DecodeString(strippedMask) + if err != nil { + return nil, fmt.Errorf("failed to decode image: %w", err) + } + + editReq.Mask = openai.File(bytes.NewReader(maskBytes), "mask.png", "image/png") + return editReq, nil +} + +func (r *GenerateImageRequest) getGenerateParams() (*openai.ImageGenerateParams, error) { + if r.isEdit() { + return nil, errors.New("not a generate request") + } + return &openai.ImageGenerateParams{ + Model: r.Model, + Prompt: r.getPrompt(), + Size: openai.ImageGenerateParamsSize(r.Size), + N: param.NewOpt(r.N), + OutputFormat: openai.ImageGenerateParamsOutputFormatPNG, + }, nil +} + +func stripDataURLPrefix(dataURL string) (string, error) { + if !strings.Contains(dataURL, ",") { + return dataURL, nil + } + parts := strings.SplitN(dataURL, ",", 2) + prefix := parts[0] + switch prefix { + case "data:image/png;base64", "data:image/jpeg;base64": + return parts[1], nil + default: + return "", fmt.Errorf("unsupported image type: %s", prefix) + } +} diff --git a/backend/internal/client/client.go b/backend/internal/client/client.go new file mode 100644 index 0000000..4719d18 --- /dev/null +++ b/backend/internal/client/client.go @@ -0,0 +1,290 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "time" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" + "github.com/openai/openai-go/v3/packages/respjson" + "github.com/openai/openai-go/v3/shared" + "reichard.io/aethera/internal/store" + "reichard.io/aethera/internal/types" + "reichard.io/aethera/pkg/ptr" + "reichard.io/aethera/pkg/slices" +) + +type StreamCallback func(*MessageChunk) error + +type Client struct { + oaiClient *openai.Client +} + +func (c *Client) GetModels(ctx context.Context) ([]Model, error) { + // Get Models + currPage, err := c.oaiClient.Models.List(ctx) + if err != nil { + return nil, err + } + allData := currPage.Data + + // Pagination + for { + currPage, err = currPage.GetNextPage() + if err != nil { + return nil, err + } else if currPage == nil { + break + } + allData = append(allData, currPage.Data...) + } + + // Convert + return slices.Map(allData, fromOpenAIModel), nil +} + +func (c *Client) GenerateImages(ctx context.Context, body openai.ImageGenerateParams) ([]openai.Image, error) { + // Generate Images + resp, err := c.oaiClient.Images.Generate(ctx, body) + if err != nil { + return nil, err + } + return resp.Data, nil +} + +func (c *Client) EditImage(ctx context.Context, body openai.ImageEditParams) ([]openai.Image, error) { + // Edit Image + resp, err := c.oaiClient.Images.Edit(ctx, body) + if err != nil { + return nil, err + } + return resp.Data, nil +} + +func (c *Client) SendMessage(ctx context.Context, chatMessages []*store.Message, model string, cb StreamCallback) (string, error) { + // Ensure Callback + if cb == nil { + cb = func(mc *MessageChunk) error { return nil } + } + + // Map Messages + messages := slices.Map(chatMessages, func(m *store.Message) openai.ChatCompletionMessageParamUnion { + if m.Role == "user" { + return openai.UserMessage(m.Content) + } + return openai.AssistantMessage(m.Content) + }) + + // Create Request + chatReq := openai.ChatCompletionNewParams{ + Model: model, + Messages: messages, + StreamOptions: openai.ChatCompletionStreamOptionsParam{ + IncludeUsage: openai.Bool(true), + }, + } + chatReq.SetExtraFields(map[string]any{ + "timings_per_token": true, // Llama.cpp + }) + + // Perform Request & Allocate Stats + msgStats := types.MessageStats{StartTime: time.Now()} + stream := c.oaiClient.Chat.Completions.NewStreaming(ctx, chatReq) + + // Iterate Stream + var respContent string + for stream.Next() { + // Check Context + if ctx.Err() != nil { + return respContent, ctx.Err() + } + + // Load Chunk + chunk := stream.Current() + msgChunk := &MessageChunk{Stats: &msgStats} + + // Populate Timings + sendUpdate := populateLlamaCPPTimings(&msgStats, chunk.JSON.ExtraFields) + sendUpdate = populateUsageTimings(&msgStats, chunk.Usage) || sendUpdate + + if len(chunk.Choices) > 0 { + delta := chunk.Choices[0].Delta + + // Check Thinking + if thinkingField, found := delta.JSON.ExtraFields["reasoning_content"]; found { + var thinkingContent string + if err := json.Unmarshal([]byte(thinkingField.Raw()), &thinkingContent); err != nil { + return respContent, fmt.Errorf("thinking unmarshal error: %w", err) + } else if thinkingContent != "" { + msgStats.RecordFirstToken() + sendUpdate = true + msgChunk.Thinking = ptr.Of(thinkingContent) + } + } + + // Check Content + if delta.Content != "" { + msgStats.RecordFirstToken() + sendUpdate = true + msgChunk.Message = ptr.Of(delta.Content) + respContent += delta.Content + } + } + + // Send Timings + if sendUpdate { + msgStats.CalculateDerived() + if err := cb(msgChunk); err != nil { + return respContent, fmt.Errorf("chunk callback error: %w", err) + } + } + } + + // Check Error + if err := stream.Err(); err != nil { + return respContent, fmt.Errorf("stream error: %w", err) + } + + // Send Final Chunk + msgStats.RecordLastToken() + msgStats.CalculateDerived() + if err := cb(&MessageChunk{Stats: &msgStats}); err != nil { + return respContent, fmt.Errorf("chunk callback error: %w", err) + } + + return respContent, nil +} + +func (c *Client) CreateTitle(ctx context.Context, userMessage, model string) (string, error) { + prompt := "You are an agent responsible for creating titles for chats based on the initial message. " + + "Your titles should be succinct and short. Respond with JUST the chat title. Initial Message: \n\n" + userMessage + + // Generate Text Stream + output, err := c.SendMessage(ctx, []*store.Message{{ + Role: "user", + Content: prompt, + }}, model, nil) + if err != nil { + return "", fmt.Errorf("failed to sent message: %w", err) + } + + return output, nil +} + +func (c *Client) StructuredOutput(ctx context.Context, target any, prompt, model string) error { + // Validate Target Pointer + v := reflect.ValueOf(target) + if v.Kind() != reflect.Pointer { + return fmt.Errorf("target must be a pointer, got %T", target) + } + if v.IsNil() { + return fmt.Errorf("target pointer is nil") + } + + // Validate Target Struct + elem := v.Elem() + if elem.Kind() != reflect.Struct { + return fmt.Errorf("target must be a pointer to struct, got pointer to %s", elem.Kind()) + } + + // Build Schema + schema, err := buildJSONSchema(elem.Type()) + if err != nil { + return fmt.Errorf("failed to build schema: %w", err) + } + + // Perform Request + resp, err := c.oaiClient.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{ + Model: model, + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage(prompt), + }, + ResponseFormat: openai.ChatCompletionNewParamsResponseFormatUnion{ + OfJSONSchema: schema, + }, + }) + if err != nil { + return fmt.Errorf("API call failed: %w", err) + } + + // Parse Response + content := resp.Choices[0].Message.Content + if err := json.Unmarshal([]byte(content), target); err != nil { + return fmt.Errorf("failed to unmarshal response: %w", err) + } + + return nil +} + +func buildJSONSchema(rType reflect.Type) (*shared.ResponseFormatJSONSchemaParam, error) { + schema, err := jsonschema.ForType(rType, nil) + if err != nil { + return nil, err + } + + return &shared.ResponseFormatJSONSchemaParam{ + JSONSchema: shared.ResponseFormatJSONSchemaJSONSchemaParam{ + Name: rType.Name(), + Schema: map[string]any{ + "type": schema.Type, + "properties": schema.Properties, + "required": schema.Required, + "additionalProperties": false, + }, + Strict: openai.Bool(true), + }, + }, nil +} + +func populateLlamaCPPTimings(msgStats *types.MessageStats, extraFields map[string]respjson.Field) bool { + rawTimings, found := extraFields["timings"] + if !found { + return false + } + + var llamaTimings llamaCPPTimings + if err := json.Unmarshal([]byte(rawTimings.Raw()), &llamaTimings); err != nil { + return false + } + + if llamaTimings.PromptN != 0 { + msgStats.PromptTokens = ptr.Of(int32(llamaTimings.PromptN)) + } + if llamaTimings.PredictedN != 0 { + msgStats.GeneratedTokens = ptr.Of(int32(llamaTimings.PredictedN)) + } + + msgStats.PromptPerSec = ptr.Of(float32(llamaTimings.PromptPerSecond)) + msgStats.GeneratedPerSec = ptr.Of(float32(llamaTimings.PredictedPerSecond)) + + return true +} + +func populateUsageTimings(msgStats *types.MessageStats, usage openai.CompletionUsage) (didChange bool) { + if usage.PromptTokens == 0 && usage.CompletionTokens == 0 { + return + } + + if msgStats.PromptTokens == nil { + didChange = true + msgStats.PromptTokens = ptr.Of(int32(usage.PromptTokens)) + } + + if msgStats.GeneratedTokens == nil { + didChange = true + reasoningTokens := usage.CompletionTokensDetails.ReasoningTokens + msgStats.GeneratedTokens = ptr.Of(int32(usage.CompletionTokens + reasoningTokens)) + } + + return didChange +} + +func NewClient(baseURL *url.URL) *Client { + oaiClient := openai.NewClient(option.WithBaseURL(baseURL.String())) + return &Client{oaiClient: &oaiClient} +} diff --git a/backend/internal/client/client_test.go b/backend/internal/client/client_test.go new file mode 100644 index 0000000..534fdf0 --- /dev/null +++ b/backend/internal/client/client_test.go @@ -0,0 +1,79 @@ +package client + +import ( + "bytes" + "context" + "net/url" + "testing" + "time" + + "reichard.io/aethera/internal/store" +) + +const model = "devstral-small-2-instruct" + +func TestSendMessage(t *testing.T) { + // Initialize Client + baseURL, err := url.Parse("https://llm-api.va.reichard.io/v1") + if err != nil { + t.Fatalf("Failed to parse base URL: %v", err) + } + client := NewClient(baseURL) + + // Create Context + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Generate Text Stream + var buf bytes.Buffer + _, err = client.SendMessage(ctx, []*store.Message{{ + Role: "user", + Content: "Hello, how are you?", + }}, model, func(mc *MessageChunk) error { + if mc.Message != nil { + _, err := buf.Write([]byte(*mc.Message)) + return err + } + return nil + }) + if err != nil { + t.Fatalf("Failed to generate text stream: %v", err) + } + + // Verify Results + output := buf.String() + if output == "" { + t.Error("No content was written to the buffer") + } else { + t.Logf("Successfully received %d bytes from the stream", len(output)) + t.Logf("Output: %s", output) + } +} + +func TestSummarizeChat(t *testing.T) { + // Initialize Client + baseURL, err := url.Parse("https://llm-api.va.reichard.io/v1") + if err != nil { + t.Fatalf("Failed to parse base URL: %v", err) + } + client := NewClient(baseURL) + + // Create Context + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Generate Text Stream + userMessage := "Write me a go program that reads in a zip file and prints the contents along with their sizes and mimetype." + output, err := client.CreateTitle(ctx, userMessage, model) + if err != nil { + t.Fatalf("Failed to generate text stream: %v", err) + } + + // Verify Results + if output == "" { + t.Error("No content was written to the buffer") + } else { + t.Logf("Successfully received %d bytes from the stream", len(output)) + t.Logf("Output: %s", output) + } +} diff --git a/backend/internal/client/convert.go b/backend/internal/client/convert.go new file mode 100644 index 0000000..9587047 --- /dev/null +++ b/backend/internal/client/convert.go @@ -0,0 +1,41 @@ +package client + +import ( + "encoding/json" + + "github.com/openai/openai-go/v3" +) + +func fromOpenAIModel(m openai.Model) Model { + newModel := Model{ + Model: m, + Name: m.ID, + } + + extraFields := make(map[string]any) + for k, v := range m.JSON.ExtraFields { + var val any + if err := json.Unmarshal([]byte(v.Raw()), &val); err != nil { + continue + } + extraFields[k] = val + } + + // Extract Name + if rawName, found := extraFields["name"]; found { + if name, ok := rawName.(string); ok { + newModel.Name = name + } + } + + // Extract Meta + if rawMeta, found := extraFields["meta"]; found { + if parsedMeta, ok := rawMeta.(map[string]any); ok { + if llamaMeta, ok := parsedMeta["llamaswap"].(map[string]any); ok { + newModel.Meta = llamaMeta + } + } + } + + return newModel +} diff --git a/backend/internal/client/types.go b/backend/internal/client/types.go new file mode 100644 index 0000000..29e9d38 --- /dev/null +++ b/backend/internal/client/types.go @@ -0,0 +1,31 @@ +package client + +import ( + "github.com/openai/openai-go/v3" + "reichard.io/aethera/internal/types" +) + +type Model struct { + openai.Model + + Name string `json:"name"` + Meta map[string]any `json:"meta,omitempty"` +} + +type MessageChunk struct { + Thinking *string `json:"thinking,omitempty"` + Message *string `json:"message,omitempty"` + Stats *types.MessageStats `json:"stats,omitempty"` +} + +type llamaCPPTimings struct { + CacheN int `json:"cache_n"` + PredictedMS float64 `json:"predicted_ms"` + PredictedN int `json:"predicted_n"` + PredictedPerSecond float64 `json:"predicted_per_second"` + PredictedPerTokenMS float64 `json:"predicted_per_token_ms"` + PromptMS float64 `json:"prompt_ms"` + PromptN int `json:"prompt_n"` + PromptPerSecond float64 `json:"prompt_per_second"` + PromptPerTokenMS float64 `json:"prompt_per_token_ms"` +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go new file mode 100644 index 0000000..3c948ca --- /dev/null +++ b/backend/internal/server/server.go @@ -0,0 +1,95 @@ +package server + +import ( + "fmt" + "net/http" + "path" + "time" + + "github.com/sirupsen/logrus" + "reichard.io/aethera/internal/api" + "reichard.io/aethera/internal/store" +) + +func StartServer(settingsStore store.Store, dataDir, listenAddress string, listenPort int) { + mux := http.NewServeMux() + + // Create API Instance - use settingsStore as the unified store for both settings and chat + logger := logrus.New() + api := api.New(settingsStore, dataDir, logger) + feFS := http.FileServer(http.Dir("../frontend/public/")) + mux.Handle("GET /", feFS) + + // Serve UI Pages + pagesFS := http.FileServer(http.Dir("../frontend/public/pages/")) + mux.Handle("GET /pages/", http.StripPrefix("/pages/", pagesFS)) + + // Serve Generated Data + genFS := http.FileServer(http.Dir(path.Join(dataDir, "generated"))) + mux.Handle("GET /generated/", http.StripPrefix("/generated/", genFS)) + + // Register API Routes + mux.HandleFunc("POST /api/images", api.PostImage) + mux.HandleFunc("GET /api/settings", api.GetSettings) + mux.HandleFunc("POST /api/settings", api.PostSettings) + mux.HandleFunc("GET /api/models", api.GetModels) + mux.HandleFunc("GET /api/images", api.GetImages) + mux.HandleFunc("DELETE /api/images/{filename}", api.DeleteImage) + + // Register Chat Management Routes + mux.HandleFunc("GET /api/chats", api.GetChats) + mux.HandleFunc("POST /api/chats", api.PostChat) + mux.HandleFunc("GET /api/chats/{chatId}", api.GetChat) + mux.HandleFunc("POST /api/chats/{chatId}", api.PostChatMessage) + mux.HandleFunc("DELETE /api/chats/{chatId}", api.DeleteChat) + + // Wrap Logging + wrappedMux := loggingMiddleware(mux) + + logrus.Infof("Starting server on %s:%d with data directory: %s", listenAddress, listenPort, dataDir) + logrus.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%d", listenAddress, listenPort), wrappedMux)) +} + +// loggingMiddleware wraps an http.Handler and logs requests +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := &responseWriterWrapper{ResponseWriter: w} + next.ServeHTTP(ww, r) + + logrus.WithFields(logrus.Fields{ + "datetime": start.UTC().Format(time.RFC3339), + "method": r.Method, + "path": r.URL.Path, + "remote": r.RemoteAddr, + "status": ww.getStatusCode(), + "latency": time.Since(start), + }).Infof("%s %s", r.Method, r.URL.Path) + }) +} + +// responseWriterWrapper wraps http.ResponseWriter to capture status code +type responseWriterWrapper struct { + http.ResponseWriter + statusCode int +} + +func (w *responseWriterWrapper) Flush() { + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +func (rw *responseWriterWrapper) getStatusCode() int { + if rw.statusCode == 0 { + return 200 + } + return rw.statusCode +} + +func (rw *responseWriterWrapper) WriteHeader(code int) { + if code > 0 { + rw.statusCode = code + } + rw.ResponseWriter.WriteHeader(code) +} diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go new file mode 100644 index 0000000..419c9b6 --- /dev/null +++ b/backend/internal/storage/storage.go @@ -0,0 +1,34 @@ +package storage + +import ( + "os" + "path" + "strings" + + "reichard.io/aethera/internal/api" +) + +func ListImages(dataDir string) ([]api.ImageRecord, error) { + files, err := os.ReadDir(path.Join(dataDir, "generated/images")) + if err != nil { + return nil, err + } + + var imageList []api.ImageRecord + for _, file := range files { + if !file.IsDir() && strings.HasSuffix(strings.ToLower(file.Name()), ".png") { + info, err := file.Info() + if err != nil { + continue + } + imageList = append(imageList, api.ImageRecord{ + Name: file.Name(), + Path: "/generated/images" + file.Name(), + Size: info.Size(), + Date: info.ModTime().Format("2006-01-02T15:04:05Z07:00"), + }) + } + } + + return imageList, nil +} diff --git a/backend/internal/store/errors.go b/backend/internal/store/errors.go new file mode 100644 index 0000000..cf328e3 --- /dev/null +++ b/backend/internal/store/errors.go @@ -0,0 +1,10 @@ +package store + +import ( + "errors" +) + +var ( + ErrChatNotFound = errors.New("chat not found") + ErrNilChatID = errors.New("chat id cannot be nil") +) diff --git a/backend/internal/store/interface.go b/backend/internal/store/interface.go new file mode 100644 index 0000000..5f11f62 --- /dev/null +++ b/backend/internal/store/interface.go @@ -0,0 +1,18 @@ +package store + +import ( + "github.com/google/uuid" +) + +type Store interface { + // Settings Methods + SaveSettings(*Settings) error + GetSettings() (*Settings, error) + + // Chat Methods + GetChat(chatID uuid.UUID) (*Chat, error) + DeleteChat(chatID uuid.UUID) error + ListChats() ([]*Chat, error) + SaveChat(*Chat) error + SaveChatMessage(*Message) error +} diff --git a/backend/internal/store/memory.go b/backend/internal/store/memory.go new file mode 100644 index 0000000..5e697d3 --- /dev/null +++ b/backend/internal/store/memory.go @@ -0,0 +1,126 @@ +package store + +import ( + "sync" + + "github.com/google/uuid" + "reichard.io/aethera/pkg/slices" +) + +var _ Store = (*InMemoryStore)(nil) + +// InMemoryStore implements Store interface using in-memory storage +type InMemoryStore struct { + mu sync.RWMutex + chats map[uuid.UUID]*Chat + settings *Settings +} + +// NewInMemoryStore creates a new InMemoryStore +func NewInMemoryStore() *InMemoryStore { + return &InMemoryStore{ + chats: make(map[uuid.UUID]*Chat), + } +} + +// SaveChat creates or updates a chat +func (s *InMemoryStore) SaveChat(c *Chat) error { + s.mu.Lock() + defer s.mu.Unlock() + + c.ensureDefaults() + s.chats[c.ID] = c + + return nil +} + +// GetChat retrieves a chat by ID +func (s *InMemoryStore) GetChat(chatID uuid.UUID) (*Chat, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + chat, exists := s.chats[chatID] + if !exists { + return nil, ErrChatNotFound + } + + // Return a copy to avoid concurrent modification + return chat, nil +} + +// DeleteChat removes a chat by ID +func (s *InMemoryStore) DeleteChat(chatID uuid.UUID) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.chats[chatID]; !exists { + return ErrChatNotFound + } + + delete(s.chats, chatID) + + return nil +} + +// ListChats returns all chat +func (s *InMemoryStore) ListChats() ([]*Chat, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + // Convert Map + var chats []*Chat + for _, chat := range s.chats { + chats = append(chats, chat) + } + + return chats, nil +} + +// SaveChatMessage creates or updates a chat message to a chat +func (s *InMemoryStore) SaveChatMessage(m *Message) error { + s.mu.Lock() + defer s.mu.Unlock() + + if m.ChatID == uuid.Nil { + return ErrNilChatID + } + m.ensureDefaults() + + // Get Chat + chat, exists := s.chats[m.ChatID] + if !exists { + return ErrChatNotFound + } + + // Find Existing + existingMsg, found := slices.FindFirst(chat.Messages, func(item *Message) bool { + return item.ID == m.ID + }) + + // Upsert + if found { + *existingMsg = *m + } else { + chat.Messages = append(chat.Messages, m) + } + + return nil +} + +// SaveSettings saves settings to in-memory storage +func (s *InMemoryStore) SaveSettings(settings *Settings) error { + s.mu.Lock() + defer s.mu.Unlock() + s.settings = settings + return nil +} + +// GetSettings retrieves settings from in-memory storage +func (s *InMemoryStore) GetSettings() (*Settings, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if s.settings == nil { + return &Settings{}, nil + } + return s.settings, nil +} diff --git a/backend/internal/store/storage.go b/backend/internal/store/storage.go new file mode 100644 index 0000000..bef2e5e --- /dev/null +++ b/backend/internal/store/storage.go @@ -0,0 +1,190 @@ +package store + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" + "reichard.io/aethera/pkg/slices" +) + +var _ Store = (*FileStore)(nil) + +// Settings represents the application settings +type Settings struct { + APIEndpoint string `json:"api_endpoint,omitempty"` + ImageEditSelector string `json:"image_edit_selector,omitempty"` + ImageGenerationSelector string `json:"image_generation_selector,omitempty"` + TextGenerationSelector string `json:"text_generation_selector,omitempty"` +} + +// FileStore implements the Store interface using a file-based storage +type FileStore struct { + filePath string + chatDir string +} + +// NewFileStore creates a new FileStore with the specified file path +func NewFileStore(filePath string) (*FileStore, error) { + // Derive Chat Directory + chatDir := filepath.Join(filepath.Dir(filePath), "chats") + + // Ensure Chat Directory Exists + if err := os.MkdirAll(chatDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create directory: %w", err) + } + + return &FileStore{ + filePath: filePath, + chatDir: chatDir, + }, nil +} + +// GetSettings reads and returns the settings from the file +func (fs *FileStore) GetSettings() (*Settings, error) { + data, err := os.ReadFile(fs.filePath) + if err != nil { + return &Settings{}, nil + } + + var settings Settings + err = json.Unmarshal(data, &settings) + if err != nil { + return nil, err + } + + return &settings, nil +} + +// SaveSettings saves the settings to the file +func (fs *FileStore) SaveSettings(settings *Settings) error { + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + + return os.WriteFile(fs.filePath, data, 0644) +} + +// GetChat retrieves a chat from disk +func (fs *FileStore) GetChat(chatID uuid.UUID) (*Chat, error) { + filePath := filepath.Join(fs.chatDir, chatID.String()+".json") + + data, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrChatNotFound + } + return nil, fmt.Errorf("failed to read chat: %w", err) + } + + var chat Chat + if err := json.Unmarshal(data, &chat); err != nil { + return nil, fmt.Errorf("failed to unmarshal chat: %w", err) + } + + return &chat, nil +} + +// SaveChat creates or updates a chat and persists it to disk +func (fs *FileStore) SaveChat(c *Chat) error { + c.ensureDefaults() + return fs.saveChatSession(c) +} + +// DeleteChat removes a chat from disk +func (fs *FileStore) DeleteChat(chatID uuid.UUID) error { + filePath := filepath.Join(fs.chatDir, chatID.String()+".json") + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return ErrChatNotFound + } + + if err := os.Remove(filePath); err != nil { + return fmt.Errorf("failed to delete chat: %w", err) + } + + return nil +} + +// ListChats returns all persisted chats +func (fs *FileStore) ListChats() ([]*Chat, error) { + // Read Files + entries, err := os.ReadDir(fs.chatDir) + if err != nil { + return nil, fmt.Errorf("failed to read chat directory: %w", err) + } + + var chats []*Chat + for _, entry := range entries { + // Ensure JSON + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + + // Extract Chat ID + rawChatID := strings.TrimSuffix(entry.Name(), ".json") + chatID, err := uuid.Parse(rawChatID) + if err != nil { + return nil, fmt.Errorf("%w: invalid chat id %s", err, rawChatID) + } + + // Read & Parse Chat + chat, err := fs.GetChat(chatID) + if err != nil { + return nil, fmt.Errorf("%w: failed to read chat id %s", err, rawChatID) + } + + chats = append(chats, chat) + } + + return chats, nil +} + +// SaveChatMessage creates or updates a chat message to a chat and persists it to disk +func (fs *FileStore) SaveChatMessage(m *Message) error { + if m.ChatID == uuid.Nil { + return ErrNilChatID + } + m.ensureDefaults() + + // Get Chat + chat, err := fs.GetChat(m.ChatID) + if err != nil { + return err + } + + // Find Existing + existingMsg, found := slices.FindFirst(chat.Messages, func(item *Message) bool { + return item.ID == m.ID + }) + + // Upsert + if found { + *existingMsg = *m + } else { + chat.Messages = append(chat.Messages, m) + } + + // Save + return fs.saveChatSession(chat) +} + +// saveChatSession is a helper method to save a chat to disk +func (fs *FileStore) saveChatSession(session *Chat) error { + filePath := filepath.Join(fs.chatDir, session.ID.String()+".json") + + data, err := json.MarshalIndent(session, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal chat: %w", err) + } + + if err := os.WriteFile(filePath, data, 0644); err != nil { + return fmt.Errorf("failed to write chat file: %w", err) + } + + return nil +} diff --git a/backend/internal/store/types.go b/backend/internal/store/types.go new file mode 100644 index 0000000..8df0128 --- /dev/null +++ b/backend/internal/store/types.go @@ -0,0 +1,39 @@ +package store + +import ( + "time" + + "github.com/google/uuid" + "reichard.io/aethera/internal/types" +) + +type baseModel struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` +} + +func (b *baseModel) ensureDefaults() { + if b.ID == uuid.Nil { + b.ID = uuid.New() + } + if b.CreatedAt.IsZero() { + b.CreatedAt = time.Now() + } +} + +type Chat struct { + baseModel + + Title string `json:"title"` + Messages []*Message `json:"messages"` +} + +type Message struct { + baseModel + + ChatID uuid.UUID `json:"chat_id"` + Role string `json:"role"` + Thinking string `json:"thinking"` + Content string `json:"content"` + Stats *types.MessageStats `json:"stats,omitempty"` +} diff --git a/backend/internal/types/message_stats.go b/backend/internal/types/message_stats.go new file mode 100644 index 0000000..8e645b3 --- /dev/null +++ b/backend/internal/types/message_stats.go @@ -0,0 +1,50 @@ +package types + +import ( + "time" + + "reichard.io/aethera/pkg/ptr" +) + +type MessageStats struct { + StartTime time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time,omitempty"` + + PromptTokens *int32 `json:"prompt_tokens"` + GeneratedTokens *int32 `json:"generated_tokens"` + + PromptPerSec *float32 `json:"prompt_per_second"` + GeneratedPerSec *float32 `json:"generated_per_second"` + + TimeToFirstToken *int32 `json:"time_to_first_token,omitempty"` + TimeToLastToken *int32 `json:"time_to_last_token,omitempty"` +} + +func (s *MessageStats) RecordFirstToken() { + if s.TimeToFirstToken == nil { + s.TimeToFirstToken = ptr.Of(int32(time.Since(s.StartTime).Milliseconds())) + } +} + +func (s *MessageStats) RecordLastToken() { + s.TimeToLastToken = ptr.Of(int32(time.Since(s.StartTime).Milliseconds())) +} + +func (s *MessageStats) CalculateDerived() { + // Populate PromptPerSec + if s.PromptPerSec == nil && s.TimeToFirstToken != nil && s.PromptTokens != nil { + ttft := *s.TimeToFirstToken + pt := *s.PromptTokens + if ttft > 0 && pt > 0 { + s.PromptPerSec = ptr.Of(float32(1000 * pt / ttft)) + } + } + + // Populate GeneratedPerSec + if s.GeneratedPerSec == nil && s.TimeToFirstToken != nil && s.TimeToLastToken != nil && s.GeneratedTokens != nil { + genTimeMS := *s.TimeToLastToken - *s.TimeToFirstToken + if genTimeMS > 0 && *s.GeneratedTokens > 0 { + s.GeneratedPerSec = ptr.Of(float32(1000 * float32(*s.GeneratedTokens) / float32(genTimeMS))) + } + } +} diff --git a/backend/pkg/ptr/ptr.go b/backend/pkg/ptr/ptr.go new file mode 100644 index 0000000..1b680f0 --- /dev/null +++ b/backend/pkg/ptr/ptr.go @@ -0,0 +1,13 @@ +package ptr + +func DerefOrZero[T any](ptrVal *T) T { + var zeroT T + if ptrVal == nil { + return zeroT + } + return *ptrVal +} + +func Of[T any](v T) *T { + return &v +} diff --git a/backend/pkg/slices/map.go b/backend/pkg/slices/map.go new file mode 100644 index 0000000..f2878d9 --- /dev/null +++ b/backend/pkg/slices/map.go @@ -0,0 +1,36 @@ +package slices + +// Map consumes []T and a function that returns D +func Map[T, D any](srcItems []T, mapFunc func(T) D) []D { + dstItems := make([]D, len(srcItems)) + for i, v := range srcItems { + dstItems[i] = mapFunc(v) + } + return dstItems +} + +// First returns the first of a slice +func First[T any](s []T) (item T, found bool) { + if len(s) > 0 { + return s[0], true + } + return item, false +} + +// Last returns the last of a slice +func Last[T any](s []T) (item T, found bool) { + if len(s) > 0 { + return s[len(s)-1], true + } + return item, false +} + +// FindFirst finds the first matching item in s given fn +func FindFirst[T any](s []T, fn func(T) bool) (item T, found bool) { + for _, v := range s { + if fn(v) { + return v, true + } + } + return item, false +} diff --git a/backend/pkg/values/values.go b/backend/pkg/values/values.go new file mode 100644 index 0000000..a33f009 --- /dev/null +++ b/backend/pkg/values/values.go @@ -0,0 +1,24 @@ +package values + +// FirstNonZero will return the first non zero value. If none exists, +// it returns the zero value. +func FirstNonZero[T comparable](vals ...T) T { + var zeroT T + for _, v := range vals { + if v != zeroT { + return v + } + } + return zeroT +} + +// CountNonZero returns the count of items that are non zero +func CountNonZero[T comparable](vals ...T) (count int) { + var zeroT T + for _, v := range vals { + if v != zeroT { + count++ + } + } + return count +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..fe22ed6 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1767047869, + "narHash": "sha256-tzYsEzXEVa7op1LTnrLSiPGrcCY6948iD0EcNLWcmzo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "89dbf01df72eb5ebe3b24a86334b12c27d68016a", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..10e22a3 --- /dev/null +++ b/flake.nix @@ -0,0 +1,37 @@ +{ + description = "ARM cross-compilation environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + }; + + outputs = + { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + + oc = pkgs.writeShellScriptBin "oc" '' + PRJ_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) + cd "$PRJ_ROOT" && OPENCODE_EXPERIMENTAL_LSP_TOOL=true opencode + ''; + in + { + devShells.${system}.default = pkgs.mkShell { + buildInputs = with pkgs; [ + # Backend + go + gopls + golangci-lint + + # Frontend + bun + watchman + tailwindcss_4 + + # Custom Commands + oc + ]; + }; + }; +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..bbfbdb4 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,2 @@ +node_modules +public/dist diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 0000000..d62f1b3 --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,45 @@ +# Frontend Agent Instructions + +## Stack + +- **Tailwind CSS 4** (no config file, just `style.css`) +- **Bun only** (no npm commands) +- **TypeScript strict mode** +- **Alpine.js** (bundled in `main.js`, not via CDN) + +## Commands + +```bash +bun run build +bun run lint +``` + +## Non-Negotiables + +- ❌ No `any` type - use `unknown` and narrow it +- ❌ No `as` type assertions +- ❌ No `@ts-ignore` or `@ts-expect-error` +- ❌ Fix all TypeScript and ESLint errors - don't ignore them +- ❌ No Alpine.js via CDN (it's bundled) + +## Code Style + +- 2 spaces, single quotes, semicolons required +- camelCase for variables/functions +- PascalCase for types/interfaces +- UPPER_SNAKE_CASE for constants +- Explicit error handling with try/catch +- User-friendly error messages in UI + +## Key Patterns + +- **DRY**: Extract repeated code into shared functions +- **API calls**: Centralize in `src/client.ts` +- **State**: Use Alpine.js reactivity + localStorage for persistence +- **Errors**: Show in UI, don't just console.log + +## What Goes Where + +- Code: `src/` +- Styles: Tailwind classes in HTML + `style.css` +- Build output: `public/dist/` (don't commit this) diff --git a/frontend/bun.lock b/frontend/bun.lock new file mode 100644 index 0000000..f928dee --- /dev/null +++ b/frontend/bun.lock @@ -0,0 +1,271 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "ui", + "dependencies": { + "alpinejs": "^3.15.3", + "highlight.js": "^11.11.1", + "marked": "^17.0.1", + "marked-highlight": "^2.2.3", + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.19", + "@types/alpinejs": "^3.13.11", + "@types/bun": "latest", + "@typescript-eslint/eslint-plugin": "^8.52.0", + "@typescript-eslint/parser": "^8.52.0", + "eslint": "^9.39.2", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="], + + "@eslint/js": ["@eslint/js@9.39.2", "", {}, "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], + + "@types/alpinejs": ["@types/alpinejs@3.13.11", "", {}, "sha512-3KhGkDixCPiLdL3Z/ok1GxHwLxEWqQOKJccgaQL01wc0EVM2tCTaqlC3NIedmxAXkVzt/V6VTM8qPgnOHKJ1MA=="], + + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.52.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/type-utils": "8.52.0", "@typescript-eslint/utils": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.52.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.52.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.52.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.52.0", "@typescript-eslint/types": "^8.52.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0" } }, "sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.52.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/utils": "8.52.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.52.0", "", {}, "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.52.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.52.0", "@typescript-eslint/tsconfig-utils": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.52.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ=="], + + "@vue/reactivity": ["@vue/reactivity@3.1.5", "", { "dependencies": { "@vue/shared": "3.1.5" } }, "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg=="], + + "@vue/shared": ["@vue/shared@3.1.5", "", {}, "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="], + + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "alpinejs": ["alpinejs@3.15.3", "", { "dependencies": { "@vue/reactivity": "~3.1.1" } }, "sha512-fSI6F5213FdpMC4IWaup92KhuH3jBX0VVqajRJ6cOTCy1cL6888KyXdGO+seAAkn+g6fnrxBqQEx6gRpQ5EZoQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + + "marked-highlight": ["marked-highlight@2.2.3", "", { "peerDependencies": { "marked": ">=4 <18" } }, "sha512-FCfZRxW/msZAiasCML4isYpxyQWKEEx44vOgdn5Kloae+Qc3q4XR7WjpKKf8oMLk7JP9ZCRd2vhtclJFdwxlWQ=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + } +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..3e53596 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import tseslint from "@typescript-eslint/eslint-plugin"; +import tsparser from "@typescript-eslint/parser"; + +export default [ + { + files: ["src/**/*.ts"], + languageOptions: { + parser: tsparser, + ecmaVersion: "latest", + sourceType: "module", + globals: { + console: "readonly", + document: "readonly", + window: "readonly", + navigator: "readonly", + fetch: "readonly", + }, + }, + plugins: { + "@typescript-eslint": tseslint, + }, + rules: { + ...tseslint.configs.recommended.rules, + indent: ["error", 2], + quotes: ["error", "single"], + semi: ["error", "always"], + }, + }, +]; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b6a8840 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "aethera", + "private": true, + "scripts": { + "dev": "bun build src/main.ts --outdir public/dist --target browser --watch & tailwindcss -i styles.css -o public/dist/styles.css --watch", + "build": "bun build src/main.ts --outdir public/dist --target browser && tailwindcss -i styles.css -o public/dist/styles.css --minify", + "lint": "eslint ./src/**" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.19", + "@types/alpinejs": "^3.13.11", + "@types/bun": "latest", + "@typescript-eslint/eslint-plugin": "^8.52.0", + "@typescript-eslint/parser": "^8.52.0", + "eslint": "^9.39.2" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "alpinejs": "^3.15.3", + "highlight.js": "^11.11.1", + "marked": "^17.0.1", + "marked-highlight": "^2.2.3" + } +} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..d836cde --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,115 @@ + + + + + + Aethera - AI Conversation & Image Generator + + + + + +
+
+ + + + + + +
+ + +
+ + diff --git a/frontend/public/pages/chats.html b/frontend/public/pages/chats.html new file mode 100644 index 0000000..a60c685 --- /dev/null +++ b/frontend/public/pages/chats.html @@ -0,0 +1,397 @@ +
+ +
+ +
+ + +
+
+
+ +
+ + + + + + + + + + + +
+ + +
+ + + +
+ + +
+

+
+
+
+
+ + + + + +
+
+

+ Conversations +

+
+ + +
+
+ + + +

No chats yet

+
+ +
+ +
+
+ + +
+ +
+
+
diff --git a/frontend/public/pages/images.html b/frontend/public/pages/images.html new file mode 100644 index 0000000..cd366c2 --- /dev/null +++ b/frontend/public/pages/images.html @@ -0,0 +1,244 @@ +
+
+
+ +
+ + +
+ + +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+
+ + +
+
+ +
+
+ +
+ Original image for editing + +
+ + +
+
+ + +
+ + Clear Mask + + + Cancel + +
+
+
+ + +
+
+ +
+

Generated Images

+ +
+

+
+ +
+ No Images Found +
+ +
+ +
+
+ + +
+ Full size preview +
+
diff --git a/frontend/public/pages/settings.html b/frontend/public/pages/settings.html new file mode 100644 index 0000000..8cc5e12 --- /dev/null +++ b/frontend/public/pages/settings.html @@ -0,0 +1,108 @@ +
+
+ +
+ +

URL of your API endpoint

+
+
+
+ Selectors +
+
+ + +

Image generation selector

+
+ +
+ + +

+ Image edit generation selector +

+
+ +
+ + +

Text generation selector

+
+
+
+ +
+

+
+ +
+

Settings saved successfully!

+
+ +
+ +
+
diff --git a/frontend/src/client.ts b/frontend/src/client.ts new file mode 100644 index 0000000..b7c85b3 --- /dev/null +++ b/frontend/src/client.ts @@ -0,0 +1,180 @@ +import { + Settings, + ImageRecord, + GenerateImageRequest, + Model, + Chat, + GenerateTextRequest, + ChatListResponse, + MessageChunk, +} from './types/index'; + +export async function saveSettings(settings: Settings): Promise { + const response = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }); + const data = await response.json().catch(() => ({})); + + if (!response.ok || data.error) { + throw new Error(data.error || `HTTP ${response.status}`); + } + + return data; +} + +export async function getSettings(): Promise { + const response = await fetch('/api/settings'); + const data = await response.json().catch(() => ({})); + + if (!response.ok || data.error) { + throw new Error(data.error || `HTTP ${response.status}`); + } + + return data; +} + +export async function generateImage( + requestData: GenerateImageRequest, +): Promise { + const response = await fetch('/api/images', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestData), + }); + const data = await response.json().catch(() => ({})); + + if (!response.ok || data.error) { + throw new Error(data.error || `HTTP ${response.status}`); + } + + return data; +} + +export async function getModels(): Promise { + const response = await fetch('/api/models'); + const data = await response.json().catch(() => ({})); + + if (!response.ok || data.error) { + throw new Error(data.error || `HTTP ${response.status}`); + } + + return data; +} + +export async function getGeneratedImages(): Promise { + const response = await fetch('/api/images'); + const data = await response.json().catch(() => ({})); + + if (!response.ok || data.error) { + throw new Error(data.error || `HTTP ${response.status}`); + } + + return data; +} + +export async function deleteImage(filename: string): Promise { + const response = await fetch(`/api/images/${filename}`, { + method: 'DELETE', + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP ${response.status}`); + } +} + +export async function sendMessage( + chatId: string, + requestData: GenerateTextRequest, + onChunk: (chunk: MessageChunk) => void, +) { + const url = chatId ? `/api/chats/${chatId}` : '/api/chats'; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestData), + }); + + return streamMessage(response, onChunk); +} + +export async function getChatMessages(chatId: string): Promise { + const response = await fetch(`/api/chats/${chatId}`); + const data = await response.json().catch(() => ({})); + + if (!response.ok || data.error) { + throw new Error(data.error || `HTTP ${response.status}`); + } + + return data; +} + +export async function listChats(): Promise { + const response = await fetch('/api/chats'); + const data = await response.json().catch(() => ({})); + + if (!response.ok || data.error) { + throw new Error(data.error || `HTTP ${response.status}`); + } + + return data; +} + +export async function deleteChat(chatId: string): Promise { + const response = await fetch(`/api/chats/${chatId}`, { + method: 'DELETE', + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP ${response.status}`); + } +} + +async function streamMessage( + response: Response, + onChunk: (chunk: MessageChunk) => void, +) { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP ${response.status}`); + } + + if (!response.body) { + throw new Error(`HTTP ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + // Add Buffer + buffer += decoder.decode(value, { stream: true }); + + // Split + const lines = buffer.split('\n'); + + // Keep Incomplete Line + buffer = lines.pop() || ''; + + // Parse Complete Lines + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const msgChunk: MessageChunk = JSON.parse(trimmed); + onChunk(msgChunk); + } catch (e) { + console.error('Failed to parse:', trimmed); + throw new Error(`JSON Metadata Parsing ${e}`); + } + } + } +} diff --git a/frontend/src/components/chatManager.ts b/frontend/src/components/chatManager.ts new file mode 100644 index 0000000..5d4de02 --- /dev/null +++ b/frontend/src/components/chatManager.ts @@ -0,0 +1,243 @@ +import Alpine from 'alpinejs'; +import { Marked } from 'marked'; +import { markedHighlight } from 'marked-highlight'; +import hljs from 'highlight.js/lib/common'; +import { + getSettings, + getModels, + sendMessage, + getChatMessages, + listChats, + deleteChat, +} from '../client'; +import { Chat, Message, MessageChunk, Model, Settings } from '../types'; +import { applyFilter } from '../utils'; + +const CHAT_ROUTE = '#/chats'; +const MODEL_KEY = 'aethera-chat-model'; +const IN_PROGRESS_UUID = '00000000-0000-0000-0000-000000000000'; + +// Markdown Renderer +const marked = new Marked( + markedHighlight({ + emptyLangClass: 'hljs', + langPrefix: 'hljs language-', + highlight(code, lang) { + const language = hljs.getLanguage(lang) ? lang : 'plaintext'; + return hljs.highlight(code, { language }).value; + }, + }), +); + +Alpine.data('chatManager', () => ({ + chats: [] as Chat[], + settings: {} as Settings, + _models: [] as Model[], + + selectedModel: '', + inputMessage: '', + error: '', + + selectedChatID: null as string | null, + chatListOpen: false, + loading: false, + + async init() { + // Acquire Data + this._models = await getModels(); + this.settings = await getSettings(); + this.selectedModel = localStorage.getItem(MODEL_KEY) || ''; + await this.loadChats(); + + // Route Chat + const chatID = window.location.hash.split('/')[2]; + if (chatID) await this.selectChat(chatID); + }, + + async loadChats() { + try { + const response = await listChats(); + this.chats = response.chats || []; + } catch (err) { + console.error('Error loading conversations:', err); + } + }, + + async deleteChat(event: Event, chatId: string) { + event.stopPropagation(); + try { + await deleteChat(chatId); + + // Delete Chat + const chatIndex = this.chats.findIndex((c) => c.id == chatId); + this.chats.splice(chatIndex, 1); + + // Update Index + if (this.selectedChatID == chatId) { + const newIndex = Math.min(chatIndex, this.chats.length - 1); + this.selectChat(this.chats[newIndex]?.id); + if (!this.selectedChatID) this.chatListOpen = false; + } + } catch (err) { + console.error('Error deleting conversation:', err); + this.error = 'Failed to delete conversation'; + } + }, + + async sendMessage() { + const message = this.inputMessage.trim(); + if (!message || this.loading) return; + + // Update State + this.inputMessage = ''; + this.loading = true; + this.error = ''; + + // Save Model + if (this.selectedModel) localStorage.setItem(MODEL_KEY, this.selectedModel); + + // New Chat + if (!this.selectedChatID) { + this.chats.unshift({ + id: IN_PROGRESS_UUID, + created_at: new Date().toISOString(), + title: '', + initial_message: message, + message_count: 0, + messages: [], + }); + this.selectedChatID = IN_PROGRESS_UUID; + } + + // New User Message + let userMessage: Message = { + id: IN_PROGRESS_UUID, + chat_id: this.selectedChatID, + role: 'user', + thinking: '', + content: message, + created_at: new Date().toISOString(), + }; + + // Get Chat + let currentChat: Chat = this.chats.find( + (c) => c.id === this.selectedChatID, + )!; + + // Add User Message + currentChat.messages.push(userMessage); + currentChat.message_count += 1; + + // Assistant Message Placeholder + let assistantMessage: Message | undefined; + + try { + await sendMessage( + this.selectedChatID === IN_PROGRESS_UUID ? '' : this.selectedChatID, + { model: this.selectedModel, prompt: message }, + (chunk: MessageChunk) => { + // Handle Chat + if (chunk.chat) { + Object.assign(currentChat, { + ...chunk.chat, + messages: currentChat.messages, + }); + this.selectedChatID = chunk.chat.id; + this.updateHash(chunk.chat.id); + } + + // Handle User Message + if (chunk.user_message) { + Object.assign(userMessage, chunk.user_message); + } + + // Handle Assistant Message + if (chunk.assistant_message) { + if (!assistantMessage) { + assistantMessage = chunk.assistant_message; + currentChat.messages.push(assistantMessage); + } else { + const index = currentChat.messages.findIndex( + (m) => m.id === assistantMessage!.id, + ); + if (index !== -1) { + currentChat.messages[index] = { + ...assistantMessage, + ...chunk.assistant_message, + }; + currentChat.messages = [...currentChat.messages]; + } + } + } + }, + ); + } catch (err) { + console.error('Error sending message:', err); + this.error = parseError(err); + } finally { + this.loading = false; + } + }, + + updateHash(chatID: string | null) { + const newRoute = CHAT_ROUTE + (chatID ? '/' + chatID : ''); + window.history.pushState(null, '', newRoute); + }, + + async selectChat(chatID: string | null) { + this.updateHash(chatID); + + // Load Messages + this.selectedChatID = chatID; + if (!this.selectedChatID) this.chatListOpen = false; + else this.loadChatMessages(); + }, + + async loadChatMessages() { + if (!this.selectedChatID) return; + + try { + const response = await getChatMessages(this.selectedChatID); + const chatIndex = this.chats.findIndex( + (c) => c.id == this.selectedChatID, + ); + + this.chats[chatIndex].messages = response.messages || []; + } catch (err) { + console.error('Error loading chat messages:', err); + this.error = 'Failed to load messages'; + } + }, + + get models(): Model[] { + if (!this.settings.text_generation_selector) return this._models; + return applyFilter(this._models, this.settings.text_generation_selector); + }, + + get currentChatMessages(): Message[] { + if (!this.selectedChatID) return []; + const currentChat = + this.chats.find((c) => c.id === this.selectedChatID) ?? null; + if (!currentChat) return []; + return [...currentChat.messages].reverse(); + }, + + renderMarkdown(content: string) { + return marked.parse(content); + }, +})); + +function parseError(err: unknown): string { + const msg = err instanceof Error ? err.message : String(err); + + if (msg.includes('401')) + return 'Authentication failed. Please check your API settings.'; + if (msg.includes('404')) + return 'API endpoint not found. Please check your configuration.'; + if (msg.includes('500')) + return 'Server error. The text generation service is unavailable.'; + if (msg.includes('network') || msg.includes('failed to fetch')) + return 'Network error. Please check your internet connection.'; + + return msg || 'Failed to send message'; +} diff --git a/frontend/src/components/imageManager.ts b/frontend/src/components/imageManager.ts new file mode 100644 index 0000000..319d864 --- /dev/null +++ b/frontend/src/components/imageManager.ts @@ -0,0 +1,396 @@ +import Alpine from 'alpinejs'; +import { + deleteImage, + generateImage, + getGeneratedImages, + getModels, + getSettings, +} from '../client'; +import { ImageRecord } from '../types'; +import { applyFilter } from '../utils'; + +// Constants +const STORAGE_KEYS = { + MODEL: 'aethera-model', + N: 'aethera-n', + SEED: 'aethera-seed', + SIZE: 'aethera-size', +}; + +// Types +interface StoredSettings { + model: string | null; + n: string | null; + seed: string | null; + size: string | null; +} + +// Utilities +const fileToDataURL = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => resolve(e.target?.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + +// Storage Manager +const storageManager = { + load(): StoredSettings { + return { + model: localStorage.getItem(STORAGE_KEYS.MODEL), + n: localStorage.getItem(STORAGE_KEYS.N), + seed: localStorage.getItem(STORAGE_KEYS.SEED), + size: localStorage.getItem(STORAGE_KEYS.SIZE), + }; + }, + + save({ + model, + n, + seed, + size, + }: { + model: string; + n: number; + seed: number; + size: string; + }) { + localStorage.setItem(STORAGE_KEYS.MODEL, model); + localStorage.setItem(STORAGE_KEYS.N, n.toString()); + localStorage.setItem(STORAGE_KEYS.SEED, seed.toString()); + localStorage.setItem(STORAGE_KEYS.SIZE, size); + }, +}; + +// Canvas Manager +const canvasManager = (canvasId: string) => { + let canvas: HTMLCanvasElement | null = null; + let ctx: CanvasRenderingContext2D | null = null; + + const getCanvas = () => { + if (!canvas) + canvas = document.getElementById(canvasId) as HTMLCanvasElement; + return canvas; + }; + + const getContext = () => { + if (!ctx) ctx = getCanvas()?.getContext('2d'); + return ctx; + }; + + const clearContext = () => { + const context = getContext(); + if (!context) return; + context.fillStyle = 'rgba(0, 0, 0, 0)'; + context.fillRect(0, 0, context.canvas.width, context.canvas.height); + }; + + return { + getCanvas, + getContext, + + clear() { + const context = getContext(); + if (!context) return; + context.clearRect(0, 0, context.canvas.width, context.canvas.height); + }, + + reset() { + clearContext(); + }, + + toDataURL(format = 'image/png') { + return getCanvas()?.toDataURL(format); + }, + + async resizeToImage(imageUrl: string) { + const cnv = getCanvas(); + if (!cnv) return { width: 0, height: 0 }; + + const img = new Image(); + return new Promise<{ width: number; height: number }>((resolve) => { + img.onload = () => { + cnv.width = img.width; + cnv.height = img.height; + resolve({ width: img.width, height: img.height }); + }; + img.src = imageUrl; + }); + }, + + initDrawing(lineWidthGetter: () => number) { + const cnv = getCanvas(); + const context = getContext(); + if (!cnv || !context) return; + + let isDrawing = false; + clearContext(); + + const getCoords = (e: MouseEvent | TouchEvent) => { + const rect = cnv.getBoundingClientRect(); + const scaleX = cnv.width / rect.width; + const scaleY = cnv.height / rect.height; + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + + return { + x: (clientX - rect.left) * scaleX, + y: (clientY - rect.top) * scaleY, + scaleX, + scaleY, + }; + }; + + const startDrawing = (e: MouseEvent | TouchEvent) => { + e.preventDefault(); + isDrawing = true; + const { x, y } = getCoords(e); + context.beginPath(); + context.moveTo(x, y); + }; + + const draw = (e: MouseEvent | TouchEvent) => { + if (!isDrawing) return; + e.preventDefault(); + const { x, y, scaleX, scaleY } = getCoords(e); + + context.lineWidth = lineWidthGetter() * Math.max(scaleX, scaleY); + context.lineCap = 'round'; + context.strokeStyle = 'black'; + context.lineTo(x, y); + context.stroke(); + context.beginPath(); + context.moveTo(x, y); + }; + + const stopDrawing = () => { + isDrawing = false; + context.beginPath(); + }; + + cnv.addEventListener('mousedown', startDrawing as EventListener); + cnv.addEventListener('mousemove', draw as EventListener); + cnv.addEventListener('mouseup', stopDrawing); + cnv.addEventListener('mouseout', stopDrawing); + cnv.addEventListener('touchstart', startDrawing as EventListener); + cnv.addEventListener('touchmove', draw as EventListener); + cnv.addEventListener('touchend', stopDrawing); + cnv.addEventListener('touchcancel', stopDrawing); + }, + }; +}; + +// Main Component +Alpine.data('imageGenerator', () => { + const canvas = canvasManager('mask'); + + return { + // Generation State + prompt: '', + n: 1, + seed: -1, + selectedModel: '', + size: 'auto', + + // Editing State + editingImage: null as { url: string; name: string } | null, + editMode: false, + isLandscape: false, + lineWidth: 20, + + // Object State + generatedImages: [] as ImageRecord[], + _settings: {} as Record, + _models: [] as unknown[], + + // API State + loading: false, + error: '', + + // Lightbox State + lightbox: { + open: false, + imageSrc: '', + currentIndex: 0, + touchStartX: 0, + touchEndX: 0, + }, + + async init() { + [this._models, this._settings, this.generatedImages] = await Promise.all([ + getModels(), + getSettings(), + getGeneratedImages(), + ]); + + this.loadStoredSettings(); + canvas.initDrawing(() => this.lineWidth); + }, + + get models() { + return applyFilter( + this._models, + this._settings.image_generation_selector as string, + ); + }, + + loadStoredSettings() { + const saved = storageManager.load(); + if (saved.model) this.selectedModel = saved.model; + if (saved.n) this.n = parseInt(saved.n); + if (saved.seed) this.seed = parseInt(saved.seed); + if (saved.size) this.size = saved.size; + }, + + saveSettings() { + storageManager.save({ + model: this.selectedModel, + n: this.n, + seed: this.seed, + size: this.size, + }); + }, + + async buildRequestData() { + const requestData: any = { + prompt: this.prompt, + n: parseInt(this.n.toString()), + seed: parseInt(this.seed.toString()), + size: this.size || 'auto', + model: this.selectedModel, + }; + + if (this.editMode) { + const imageUploader = document.querySelector( + '#image-upload', + ) as HTMLInputElement; + requestData.mask = canvas.toDataURL(); + requestData.image = await fileToDataURL(imageUploader.files![0]); + } + + return requestData; + }, + + async generateImage() { + this.loading = true; + this.error = ''; + this.saveSettings(); + + try { + const requestData = await this.buildRequestData(); + const data = await generateImage(requestData); + this.generatedImages.unshift(...data); + } catch (err: any) { + this.error = err; + } finally { + this.loading = false; + } + }, + + async deleteImage(filename: string) { + try { + await deleteImage(filename); + this.generatedImages = this.generatedImages.filter( + (img: ImageRecord) => img.name !== filename, + ); + } catch (err: any) { + this.error = err; + } + }, + + async startEdit(e: Event) { + const target = e.target as HTMLInputElement; + const file = target.files?.[0]; + if (!file) return; + + if (!file.type.match('image/*')) { + this.error = 'Please select a valid image file'; + return; + } + + try { + const imageUrl = URL.createObjectURL(file); + this.editMode = true; + this.editingImage = { url: imageUrl, name: file.name }; + + canvas.reset(); + + document + .getElementById('edit-panel') + ?.scrollIntoView({ behavior: 'smooth' }); + + const dimensions = await canvas.resizeToImage(imageUrl); + this.isLandscape = dimensions.width > dimensions.height; + } catch (err) { + console.error('Error starting image edit:', err); + this.error = 'Failed to start editing uploaded image'; + } + }, + + cancelEdit() { + this.editMode = false; + this.editingImage = null; + const fileInput = document.getElementById( + 'image-upload', + ) as HTMLInputElement; + if (fileInput) fileInput.value = ''; + }, + + clearMask() { + canvas.clear(); + }, + + openLightbox(imageSrc: string) { + this.lightbox.currentIndex = this.generatedImages.findIndex( + (img: ImageRecord) => img.path === imageSrc, + ); + this.lightbox.imageSrc = imageSrc; + this.lightbox.open = true; + document.body.style.overflow = 'hidden'; + }, + + closeLightbox() { + this.lightbox.open = false; + document.body.style.overflow = ''; + }, + + nextImage() { + if (this.lightbox.currentIndex < this.generatedImages.length - 1) { + this.lightbox.currentIndex++; + this.lightbox.imageSrc = + this.generatedImages[this.lightbox.currentIndex].path; + } + }, + + prevImage() { + if (this.lightbox.currentIndex > 0) { + this.lightbox.currentIndex--; + this.lightbox.imageSrc = + this.generatedImages[this.lightbox.currentIndex].path; + } + }, + + handleTouchStart(e: TouchEvent) { + this.lightbox.touchStartX = e.changedTouches[0].screenX; + }, + + handleTouchEnd(e: TouchEvent) { + this.lightbox.touchEndX = e.changedTouches[0].screenX; + this.handleSwipe(); + }, + + handleSwipe() { + const swipeThreshold = 50; + const diff = this.lightbox.touchStartX - this.lightbox.touchEndX; + + if (Math.abs(diff) > swipeThreshold) { + if (diff > 0) { + this.nextImage(); + } else { + this.prevImage(); + } + } + }, + }; +}); diff --git a/frontend/src/components/navigationManager.ts b/frontend/src/components/navigationManager.ts new file mode 100644 index 0000000..0979fee --- /dev/null +++ b/frontend/src/components/navigationManager.ts @@ -0,0 +1,48 @@ +import Alpine from 'alpinejs'; + +declare global { + interface Window { + Alpine: typeof Alpine; + } +} + +interface NavigationStore { + activeTab: string; + + init(): void; + loadPage(): Promise; +} + +const navigationStore: NavigationStore = { + activeTab: '', + + async init() { + await this.loadPage(); + window.addEventListener('hashchange', () => this.loadPage()); + }, + + async loadPage() { + const tab = window.location.hash.split('/')[1] || 'chats'; + if (this.activeTab === tab) return; + this.activeTab = tab; + + const pageContent = document.getElementById('page-content'); + if (!pageContent) throw new Error('Failed to find #page-content'); + + try { + const response = await fetch(`/pages/${tab}.html`); + if (!response.ok) throw new Error('Failed to load page'); + + pageContent.innerHTML = await response.text(); + } catch (error) { + console.error('Page load error:', error); + pageContent.innerHTML = ` +
+

Failed to load page. Please try again.

+
+ `; + } + }, +}; + +Alpine.store('navigation', navigationStore); diff --git a/frontend/src/components/settingsManager.ts b/frontend/src/components/settingsManager.ts new file mode 100644 index 0000000..3a7d295 --- /dev/null +++ b/frontend/src/components/settingsManager.ts @@ -0,0 +1,29 @@ +import Alpine from 'alpinejs'; +import { getSettings, saveSettings } from '../client'; +import { Settings } from '../types'; + +Alpine.data('settingsManager', () => ({ + settings: {} as Settings, + loading: false, + saved: false, + error: '', + + async init() { + this.settings = await getSettings(); + }, + + async saveSettings() { + this.loading = true; + this.saved = false; + this.error = ''; + + try { + await saveSettings(this.settings); + this.saved = true; + } catch (err) { + this.error = String(err); + } finally { + this.loading = false; + } + }, +})); diff --git a/frontend/src/components/themeManager.ts b/frontend/src/components/themeManager.ts new file mode 100644 index 0000000..fa73234 --- /dev/null +++ b/frontend/src/components/themeManager.ts @@ -0,0 +1,49 @@ +import Alpine from 'alpinejs'; +import { + type ThemeMode, + loadTheme, + saveThemeMode, + applyTheme, + getNextThemeMode, +} from '../theme'; + +interface ThemeStore { + mode: ThemeMode; + + init(): void; + cycleTheme(): void; + getThemeIcon(): string; +} + +const themeStore: ThemeStore = { + mode: 'system', + + init() { + const { mode } = loadTheme(); + this.mode = mode; + applyTheme(mode); + + // System Theme Changes + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + mediaQuery.addEventListener('change', () => { + if (this.mode === 'system') { + applyTheme('system'); + } + }); + }, + + cycleTheme() { + const nextMode = getNextThemeMode(this.mode); + this.mode = nextMode; + saveThemeMode(nextMode); + applyTheme(nextMode); + }, + + getThemeIcon() { + if (this.mode === 'dark') return 'moon'; + if (this.mode === 'light') return 'sun'; + return 'system'; + }, +}; + +Alpine.store('theme', themeStore); diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..d02bade --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,19 @@ +import Alpine from 'alpinejs'; + +// Define Window +declare global { + interface Window { + Alpine: typeof Alpine; + } +} + +// Import Components +import './components/chatManager'; +import './components/imageManager'; +import './components/settingsManager'; +import './components/themeManager'; +import './components/navigationManager'; + +// Start Alpine +window.Alpine = Alpine; +Alpine.start(); diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts new file mode 100644 index 0000000..f1b1166 --- /dev/null +++ b/frontend/src/theme.ts @@ -0,0 +1,83 @@ +export const AETHERA_THEME_KEY = 'aethera-theme'; + +export type ThemeMode = 'light' | 'dark' | 'system'; + +export interface ThemeState { + mode: ThemeMode; +} + +export function getSystemTheme(): 'light' | 'dark' { + if (typeof window !== 'undefined') { + return window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + } + return 'light'; +} + +export function getEffectiveTheme(themeMode: ThemeMode): 'light' | 'dark' { + if (themeMode === 'system') { + return getSystemTheme(); + } + return themeMode; +} + +export function loadTheme(): ThemeState { + if (typeof localStorage === 'undefined') { + return { mode: 'system' }; + } + + const stored = localStorage.getItem(AETHERA_THEME_KEY); + if (stored === 'light' || stored === 'dark' || stored === 'system') { + return { mode: stored }; + } + + return { mode: 'system' }; +} + +export function saveThemeMode(mode: ThemeMode): void { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(AETHERA_THEME_KEY, mode); + } +} + +export function applyTheme(mode: ThemeMode): void { + const effectiveTheme = getEffectiveTheme(mode); + if (typeof document !== 'undefined') { + if (effectiveTheme === 'dark') { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + } + applySyntaxTheme(effectiveTheme); +} + +export function applySyntaxTheme(theme: 'light' | 'dark'): void { + if (typeof document === 'undefined') return; + + const linkId = 'hljs-theme'; + let link = document.getElementById(linkId) as HTMLLinkElement; + + const cssFile = + theme === 'dark' + ? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/stackoverflow-dark.css' + : 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.css'; + + if (!link) { + link = document.createElement('link'); + link.id = linkId; + link.rel = 'stylesheet'; + link.href = cssFile; + document.head.appendChild(link); + } else if (link.href !== cssFile) { + link.href = cssFile; + } +} + +export function getNextThemeMode(currentMode: ThemeMode): ThemeMode { + const cycle: ThemeMode[] = ['light', 'dark', 'system']; + const currentIndex = cycle.indexOf(currentMode); + const nextIndex = (currentIndex + 1) % cycle.length; + return cycle[nextIndex]; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..c4d1598 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,73 @@ +export interface Chat { + id: string; + created_at: string; + title: string; + initial_message: string; + message_count: number; + messages: Message[]; +} + +export interface Message { + id: string; + chat_id: string; + created_at: string; + role: 'user' | 'assistant'; + thinking: string; + content: string; + stats?: MessageStats; +} + +export interface MessageStats { + start_time: string; + end_time?: string; + prompt_tokens?: number; + generated_tokens?: number; + prompt_per_second?: number; + generated_per_second?: number; + time_to_first_token?: number; + time_to_last_token?: number; +} + +export interface Model { + name: string; + meta?: Record; +} + +export interface Settings { + api_endpoint?: string; + image_edit_selector?: string; + image_generation_selector?: string; + text_generation_selector?: string; +} + +export interface ImageRecord { + name: string; + path: string; + size: number; + date: string; +} + +export interface MessageChunk { + chat?: Chat; + user_message?: Message; + assistant_message?: Message; +} + +export interface GenerateImageRequest { + model: string; + prompt: string; + n: number; + size: string; + mask?: string; + image?: string; + seed?: number; +} + +export interface GenerateTextRequest { + model: string; + prompt: string; +} + +export interface ChatListResponse { + chats: Chat[]; +} diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts new file mode 100644 index 0000000..9f0af53 --- /dev/null +++ b/frontend/src/utils.ts @@ -0,0 +1,37 @@ +import { Model } from './types'; + +export const parseFilter = (filterStr: string) => { + const colonIndex = (filterStr || '').indexOf(':'); + if (colonIndex === -1) return null; + + const path = filterStr.slice(0, colonIndex).trim().replace(/^\./, ''); + const value = filterStr + .slice(colonIndex + 1) + .trim() + .replace(/^["']|["']$/g, ''); + + return { path, value }; +}; + +export const matchesFilter = ( + obj: T, + path: string, + value: string, +): boolean => { + const fieldValue = path + .split('.') + .reduce( + (o, key) => (o as Record)?.[key], + obj as Record, + ); + return Array.isArray(fieldValue) + ? fieldValue.includes(value) + : fieldValue === value; +}; + +export const applyFilter = (data: Model[], filterStr: string) => { + const filter = parseFilter(filterStr); + return filter + ? data.filter((item) => matchesFilter(item, filter.path, filter.value)) + : data; +}; diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..7e5ba8d --- /dev/null +++ b/frontend/styles.css @@ -0,0 +1,153 @@ +@import "tailwindcss"; +@plugin "@tailwindcss/typography"; + +[x-cloak] { + display: none !important; +} + +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} + +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + +.scrollbar-hide::-webkit-scrollbar-thumb { + display: none; +} + +@variant dark (&:where(.dark, .dark *)); + +@theme { + /* Light mode - light backgrounds, dark text */ + --color-primary-50: oklch(98% 0.02 290); + --color-primary-100: oklch(95% 0.04 290); + --color-primary-200: oklch(90% 0.08 290); + --color-primary-300: oklch(82% 0.14 290); + --color-primary-400: oklch(70% 0.18 290); + --color-primary-500: oklch(60% 0.2 290); + --color-primary-600: oklch(50% 0.18 290); + --color-primary-700: oklch(42% 0.15 290); + --color-primary-800: oklch(35% 0.12 290); + --color-primary-900: oklch(28% 0.1 290); + + --color-secondary-50: oklch(98% 0.02 180); + --color-secondary-100: oklch(94% 0.04 180); + --color-secondary-200: oklch(88% 0.08 180); + --color-secondary-300: oklch(80% 0.12 180); + --color-secondary-400: oklch(68% 0.14 180); + --color-secondary-500: oklch(58% 0.15 180); + --color-secondary-600: oklch(48% 0.13 180); + --color-secondary-700: oklch(40% 0.11 180); + --color-secondary-800: oklch(33% 0.09 180); + --color-secondary-900: oklch(27% 0.07 180); + + --color-tertiary-50: oklch(98% 0.005 60); + --color-tertiary-100: oklch(95% 0.01 60); + --color-tertiary-200: oklch(90% 0.015 60); + --color-tertiary-300: oklch(82% 0.02 60); + --color-tertiary-400: oklch(70% 0.025 60); + --color-tertiary-500: oklch(58% 0.03 60); + --color-tertiary-600: oklch(48% 0.025 60); + --color-tertiary-700: oklch(40% 0.02 60); + --color-tertiary-800: oklch(33% 0.015 60); + --color-tertiary-900: oklch(26% 0.01 60); +} + +@layer base { + .dark { + /* Dark mode - dark backgrounds (50-300), light text (700-900) */ + --color-primary-50: oklch(15% 0.08 290); + --color-primary-100: oklch(18% 0.1 290); + --color-primary-200: oklch(22% 0.12 290); + --color-primary-300: oklch(28% 0.15 290); + --color-primary-400: oklch(36% 0.18 290); + --color-primary-500: oklch(45% 0.2 290); + --color-primary-600: oklch(55% 0.18 290); + --color-primary-700: oklch(65% 0.15 290); + --color-primary-800: oklch(75% 0.12 290); + --color-primary-900: oklch(85% 0.08 290); + + --color-secondary-50: oklch(15% 0.05 180); + --color-secondary-100: oklch(18% 0.07 180); + --color-secondary-200: oklch(22% 0.09 180); + --color-secondary-300: oklch(28% 0.11 180); + --color-secondary-400: oklch(36% 0.13 180); + --color-secondary-500: oklch(45% 0.15 180); + --color-secondary-600: oklch(55% 0.14 180); + --color-secondary-700: oklch(65% 0.12 180); + --color-secondary-800: oklch(75% 0.09 180); + --color-secondary-900: oklch(85% 0.06 180); + + --color-tertiary-50: oklch(15% 0.008 60); + --color-tertiary-100: oklch(18% 0.01 60); + --color-tertiary-200: oklch(22% 0.015 60); + --color-tertiary-300: oklch(28% 0.02 60); + --color-tertiary-400: oklch(36% 0.025 60); + --color-tertiary-500: oklch(45% 0.03 60); + --color-tertiary-600: oklch(55% 0.025 60); + --color-tertiary-700: oklch(65% 0.02 60); + --color-tertiary-800: oklch(75% 0.015 60); + --color-tertiary-900: oklch(85% 0.01 60); + } +} + +.prose { + --tw-prose-body: theme("colors.primary.900"); + --tw-prose-headings: theme("colors.primary.900"); + --tw-prose-links: theme("colors.primary.600"); + --tw-prose-bold: theme("colors.primary.900"); + --tw-prose-counters: theme("colors.primary.700"); + --tw-prose-bullets: theme("colors.primary.700"); + --tw-prose-hr: theme("colors.primary.200"); + --tw-prose-quotes: theme("colors.primary.700"); + --tw-prose-quote-borders: theme("colors.primary.400"); + --tw-prose-captions: theme("colors.primary.700"); + --tw-prose-code: theme("colors.primary.900"); + --tw-prose-pre-code: theme("colors.primary.900"); + --tw-prose-pre-bg: theme("colors.primary.100"); + --tw-prose-th-borders: theme("colors.primary.300"); + --tw-prose-td-borders: theme("colors.primary.300"); +} + +.dark .prose { + --tw-prose-body: theme("colors.primary.100"); + --tw-prose-headings: theme("colors.primary.100"); + --tw-prose-links: theme("colors.primary.400"); + --tw-prose-bold: theme("colors.primary.100"); + --tw-prose-counters: theme("colors.primary.300"); + --tw-prose-bullets: theme("colors.primary.300"); + --tw-prose-hr: theme("colors.primary.700"); + --tw-prose-quotes: theme("colors.primary.300"); + --tw-prose-quote-borders: theme("colors.primary.500"); + --tw-prose-captions: theme("colors.primary.300"); + --tw-prose-code: theme("colors.primary.100"); + --tw-prose-pre-code: theme("colors.primary.100"); + --tw-prose-pre-bg: theme("colors.primary.800"); + --tw-prose-th-borders: theme("colors.primary.700"); + --tw-prose-td-borders: theme("colors.primary.700"); +} + +.prose pre { + background: theme("colors.primary.100"); + border: 1px solid theme("colors.primary.200"); +} + +.dark .prose pre { + background: theme("colors.primary.800"); + border: 1px solid theme("colors.primary.700"); +} + +.prose code:not(pre code) { + background: theme("colors.primary.300"); + color: theme("colors.primary.900"); + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; +} + +.dark .prose code:not(pre code) { + background: theme("colors.primary.700"); + color: theme("colors.primary.100"); +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..e1b5c5c --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "lib": ["ES2020", "DOM"], + "strict": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "noImplicitAny": true, + "moduleResolution": "bundler" + } +}