initial commit

This commit is contained in:
2025-12-31 15:33:16 -05:00
commit 4641e7d0ef
51 changed files with 4779 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
data
.opencode

19
AGENTS.md Normal file
View File

@@ -0,0 +1,19 @@
# SDDash Monorepo
This repository is a **monorepo** with two main packages that can be built and run independently.
## Directory Structure
```
├── frontend/ # Clientside TypeScript application
├── backend/ # Serverside Go microservice
├── .envrc, flake.nix, README.md
└── …
```
## Package Details
See packagespecific instructions:
- **frontend/** - `@frontend/AGENTS.md`
- **backend/** - `@backend/AGENTS.md`

100
README.md Normal file
View File

@@ -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 <repository-url>
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.

1
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist

53
backend/AGENTS.md Normal file
View File

@@ -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)

24
backend/cmd/config.go Normal file
View File

@@ -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
}

50
backend/cmd/main.go Normal file
View File

@@ -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(&params.DataDir, "data-dir", "data", "Directory to store generated images")
rootCmd.PersistentFlags().StringVar(&params.ListenAddr, "listen", "localhost", "Address to listen on")
rootCmd.PersistentFlags().IntVar(&params.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)
}
}

21
backend/go.mod Normal file
View File

@@ -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
)

42
backend/go.sum Normal file
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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(" <sd_cpp_extra_args>%s</sd_cpp_extra_args>", 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)
}
}

View File

@@ -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}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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"`
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -0,0 +1,10 @@
package store
import (
"errors"
)
var (
ErrChatNotFound = errors.New("chat not found")
ErrNilChatID = errors.New("chat id cannot be nil")
)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"`
}

View File

@@ -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)))
}
}
}

13
backend/pkg/ptr/ptr.go Normal file
View File

@@ -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
}

36
backend/pkg/slices/map.go Normal file
View File

@@ -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
}

View File

@@ -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
}

27
flake.lock generated Normal file
View File

@@ -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
}

37
flake.nix Normal file
View File

@@ -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
];
};
};
}

2
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
public/dist

45
frontend/AGENTS.md Normal file
View File

@@ -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)

271
frontend/bun.lock Normal file
View File

@@ -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=="],
}
}

29
frontend/eslint.config.js Normal file
View File

@@ -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"],
},
},
];

26
frontend/package.json Normal file
View File

@@ -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"
}
}

115
frontend/public/index.html Normal file
View File

@@ -0,0 +1,115 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
/>
<title>Aethera - AI Conversation & Image Generator</title>
<script type="module" src="./dist/main.js"></script>
<link rel="stylesheet" href="./dist/styles.css" />
</head>
<body class="bg-primary-50" x-data x-init="$store.navigation.init()">
<!-- Nav -->
<div
class="isolate fixed z-50 w-full flex justify-between mt-4 px-4 md:px-6"
>
<div class="size-9"></div>
<!-- Main Nav -->
<nav class="inline-flex bg-primary-100 rounded-full shadow-sm">
<a
href="#/chats"
:class="[
'px-5 py-2 rounded-full text-sm font-medium transition-colors',
$store.navigation.activeTab === 'chats'
? 'bg-primary-600 text-white'
: 'text-primary-700 hover:bg-primary-200'
]"
>
Chats
</a>
<a
href="#/images"
:class="[
'px-5 py-2 rounded-full text-sm font-medium transition-colors',
$store.navigation.activeTab === 'images'
? 'bg-primary-600 text-white'
: 'text-primary-700 hover:bg-primary-200'
]"
>
Images
</a>
<a
href="#/settings"
:class="[
'px-5 py-2 rounded-full text-sm font-medium transition-colors',
$store.navigation.activeTab === 'settings'
? 'bg-primary-600 text-white'
: 'text-primary-700 hover:bg-primary-200'
]"
>
Settings
</a>
</nav>
<!-- Theme Toggle -->
<button
@click="$store.theme.cycleTheme()"
x-init="$store.theme.init()"
class="p-2 cursor-pointer rounded-md text-primary-700 hover:bg-primary-300 transition-colors"
aria-label="Toggle theme"
>
<svg
x-show="$store.theme.getThemeIcon() === 'sun'"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<svg
x-show="$store.theme.getThemeIcon() === 'moon'"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
<svg
x-show="$store.theme.getThemeIcon() === 'system'"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</button>
</div>
<!-- Main Content Area -->
<main id="page-content" class="h-dvh"></main>
</body>
</html>

View File

@@ -0,0 +1,397 @@
<div x-data="chatManager()">
<!-- Chat Content -->
<div
class="h-dvh pt-16 flex flex-col-reverse pb-36 overflow-scroll mx-auto px-4 md:px-6 max-w-6xl"
>
<template x-for="message in currentChatMessages" :key="message.content">
<div
:class="['mb-4', message.role === 'user' ? 'text-right' : 'text-left']"
>
<div
:class="['inline-block px-4 py-3 text-left rounded-lg max-w-[95%] md:max-w-[85%]',
message.role === 'user'
? 'bg-primary-100 text-primary-900 rounded-br-none'
: 'bg-primary-200 text-primary-900 rounded-bl-none'
]"
>
<!-- Thinking Section -->
<div
x-show="message.thinking"
x-data="{ expanded: false }"
@click="expanded = !expanded"
>
<div
class="cursor-pointer rounded-lg overflow-hidden bg-primary-100 hover:bg-primary-50"
>
<div
class="flex justify-center w-full px-3 py-2 text-xs text-primary-700 flex items-center gap-2 transition-colors"
>
<span x-text="expanded ? '▼' : '◀'"></span>
<span class="font-medium">Reasoning</span>
<span x-text="expanded ? '▼' : '▶'"></span>
</div>
<div
x-show="expanded"
class="prose p-4 max-w-none text-sm prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-pre:p-0"
x-html="renderMarkdown(message.thinking)"
></div>
</div>
</div>
<hr x-show="message.thinking" class="my-2 border-primary-400/50" />
<!-- Main Content -->
<div
class="prose max-w-none text-sm prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-pre:p-0"
x-html="renderMarkdown(message.content)"
></div>
<!-- Timestamp -->
<div class="flex items-center justify-between gap-2 mt-2">
<div
class="text-[10px] opacity-60"
x-text="new Date(message.created_at).toLocaleTimeString()"
></div>
</div>
</div>
<!-- Stats Badges (Assistant) -->
<div
x-show="message.role === 'assistant' && message.stats"
class="flex items-center gap-1 py-2 flex-wrap justify-start text-primary-700"
>
<!-- Cumulative Tokens with Hover Breakdown -->
<div
x-show="message.stats?.prompt_tokens || message.stats?.generated_tokens"
class="group relative px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full cursor-help"
>
<span
x-text="`${(message.stats?.prompt_tokens || 0) + (message.stats?.generated_tokens || 0)} tokens`"
></span>
<!-- Tokens -->
<div
class="invisible group-hover:visible absolute bottom-full left-1/2 -translate-x-1/2 m-2 px-2 py-1 bg-gray-900 text-white text-[10px] rounded whitespace-nowrap pointer-events-none grid grid-cols-[auto_1fr] gap-x-2"
>
<div
x-show="message.stats?.prompt_tokens"
x-text="message.stats?.prompt_tokens"
></div>
<div x-show="message.stats?.prompt_tokens">prompt tokens</div>
<div
x-show="message.stats?.generated_tokens"
x-text="message.stats?.generated_tokens"
></div>
<div x-show="message.stats?.generated_tokens">
generated tokens
</div>
</div>
</div>
<span
x-show="message.stats?.prompt_per_second"
class="px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full"
x-text="`${message.stats?.prompt_per_second.toFixed(1)} ppt/s`"
></span>
<span
x-show="message.stats?.generated_per_second"
class="px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full"
x-text="`${message.stats?.generated_per_second.toFixed(1)} tgt/s`"
></span>
<span
x-show="message.stats?.time_to_first_token"
class="px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full"
x-text="`${(message.stats?.time_to_first_token / 1000).toFixed(2)}s TTFT`"
></span>
</div>
</div>
</template>
</div>
<!-- Floating Input and Model Selection -->
<div class="fixed bottom-4 w-full flex justify-center px-4 md:px-6">
<div class="w-full sm:w-[calc(100%-2rem)] max-w-3xl z-10">
<div
class="flex flex-col gap-3 p-3 bg-primary-50/95 backdrop-blur-sm rounded-2xl shadow-2xl border border-primary-200"
>
<!-- Model Select -->
<div class="relative">
<select
x-model="selectedModel"
class="w-full appearance-none px-9 py-3 bg-gradient-to-r from-primary-50 to-primary-300 border border-primary-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-400 text-primary-900 text-sm font-medium cursor-pointer transition-shadow hover:shadow-md"
>
<option value="">Select Model</option>
<template x-for="model in models" :key="model.id">
<option
:value="model.id"
x-text="model.name || model.id"
></option>
</template>
</select>
<!-- Computer Icon -->
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-primary-400 pointer-events-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<!-- Chevron Icon -->
<svg
class="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-primary-400 pointer-events-none transition-colors hover:text-primary-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
<!-- Message Form -->
<form @submit.prevent="sendMessage" class="flex gap-2 items-end">
<textarea
x-model="inputMessage"
placeholder="Type your message..."
rows="1"
class="scrollbar-hide flex-1 p-3 bg-primary-50 border border-primary-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-400 text-primary-900 text-sm transition-shadow hover:bg-primary-100 resize-none overflow-y-auto max-h-60"
@keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); sendMessage(); }"
@input="$el.style.height = 'auto'; $el.style.height = $el.scrollHeight + 'px'"
></textarea>
<button
type="submit"
:disabled="!inputMessage.trim() || loading"
:class="(!inputMessage.trim() || loading) ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-md hover:scale-105'"
class="self-stretch w-[44px] bg-gradient-to-r from-primary-600 to-primary-500 text-white rounded-xl transition-all flex items-center justify-center flex-shrink-0"
>
<template x-if="loading">
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
></div>
</template>
<template x-if="!loading">
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
</template>
</button>
</form>
<!-- Error Message -->
<div
x-show="error"
class="bg-tertiary-50 border border-tertiary-200 px-4 py-2"
>
<p class="text-sm text-tertiary-700" x-text="error"></p>
</div>
</div>
</div>
</div>
<!-- Floating Conversation List Toggle -->
<button
@click="chatListOpen = !chatListOpen"
:aria-expanded="chatListOpen ? 'true' : 'false'"
aria-label="Toggle left navigation"
class="isolate cursor-pointer fixed z-50 flex justify-between top-4 left-4 md:left-6 p-2 rounded-md text-primary-700 hover:bg-primary-300 transition-colors"
>
<svg
x-show="!chatListOpen"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
<svg
x-show="chatListOpen"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<!-- Floating Conversation List -->
<div
x-show="chatListOpen"
x-transition:enter="transform transition-all duration-300 ease-out"
x-transition:enter-start="-translate-x-full opacity-0"
x-transition:enter-end="translate-x-0 opacity-100"
x-transition:leave="transform transition-all duration-300 ease-in"
x-transition:leave-start="translate-x-0 opacity-100"
x-transition:leave-end="-translate-x-full opacity-0"
class="fixed top-16 left-0 right-0 mx-auto md:left-6 md:right-auto md:mx-0 bottom-4 w-86 bg-primary-100 rounded-xl shadow-lg z-20 overflow-hidden flex flex-col"
>
<div class="px-4 py-3 border-b border-primary-200 flex justify-center">
<h4 class="font-semibold text-primary-900">
<span>Conversations</span>
</h4>
</div>
<!-- Conversation List-->
<div id="left-nav-desktop" class="flex-1 overflow-y-auto p-4">
<div
x-show="chats.length === 0"
class="h-full flex flex-col justify-center text-center py-8 text-primary-600"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 mx-auto mb-2 text-primary-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<p class="text-sm">No chats yet</p>
</div>
<div class="space-y-2">
<template x-for="chat in chats" :key="chat.id">
<div
@click="selectChat(chat.id); chatListOpen = false;"
:class="[
'p-3 rounded-lg cursor-pointer transition-all border-l-3',
selectedChatID === chat.id
? 'bg-primary-200 border-l-primary-600'
: 'hover:bg-primary-200 border-l-transparent'
]"
:title="chat.title"
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="mt-0.5 shrink-0">
<svg
xmlns="http://www.w3.org/2000/svg"
:class="[
'h-4 w-4',
selectedChatID === chat.id ? 'text-primary-600' : 'text-primary-400'
]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-sm text-primary-900 truncate">
<span x-text="chat.title || 'New Conversation'"></span>
</div>
<div
class="flex items-center gap-2 mt-1.5 text-xs text-primary-600"
>
<span
x-show="chat.message_count > 0"
class="shrink-0 bg-primary-300 text-primary-700 px-1.5 py-0.5 rounded text-[10px] font-medium"
x-text="chat.message_count"
></span>
<span class="truncate" x-text="chat.initial_message"></span>
</div>
</div>
<button
@click.stop="deleteChat($event, chat.id)"
class="cursor-pointer shrink-0 p-1 text-primary-400 hover:text-tertiary-600 hover:bg-tertiary-100 rounded transition-colors"
title="Delete Chat"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
</template>
</div>
</div>
<!-- Left Nav Footer -->
<div
x-show="$store.navigation.activeTab === 'chats'"
class="p-4 border-t border-primary-200 shrink-0"
>
<button
@click="selectChat(null)"
class="w-full px-4 py-2.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium flex cursor-pointer items-center justify-center gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
New Conversation
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,244 @@
<div
class="flex flex-col gap-4 pt-16 mx-auto px-4 md:px-6 max-w-6xl"
x-data="imageGenerator()"
>
<div>
<form @submit.prevent="generateImage" class="flex flex-col gap-4 w-full">
<!-- Prompt -->
<div class="flex-1">
<label for="prompt" class="text-sm font-medium text-primary-700"
>Prompt</label
>
<textarea
id="prompt"
name="prompt"
class="mt-1 p-2 w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm min-h-[100px] overflow-y-auto text-primary-900 resize-none"
required
x-model="prompt"
placeholder="Enter your image generation prompt here..."
></textarea>
</div>
<!-- Parameters -->
<div class="flex flex-col gap-3">
<div>
<label
for="nav-model"
class="block text-sm font-medium text-primary-700"
>Model</label
>
<select
id="nav-model"
name="model"
x-model="selectedModel"
class="mt-1 p-2 block w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm text-primary-900"
required
>
<option value="">Select Model</option>
<template x-for="model in models" :key="model.id">
<option :value="model.id" x-text="model.name"></option>
</template>
</select>
</div>
<div>
<label for="size" class="text-sm font-medium text-primary-700"
>Size</label
>
<input
type="text"
id="size"
name="size"
x-model="size"
class="mt-1 p-2 block w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm text-primary-900"
/>
</div>
<div class="flex gap-2">
<div class="flex-1">
<label
for="nav-n"
class="block text-sm font-medium text-primary-700"
>Count</label
>
<input
type="number"
id="nav-n"
name="n"
min="1"
max="10"
x-model="n"
class="mt-1 p-2 block w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm text-primary-900"
value="1"
/>
</div>
<div class="flex-1">
<label
for="nav-seed"
class="block text-sm font-medium text-primary-700"
>Seed</label
>
<input
type="number"
id="nav-seed"
name="seed"
x-model="seed"
class="mt-1 p-2 block w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm text-primary-900"
value="-1"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<label for="image-upload" class="text-sm font-medium text-primary-700"
>Upload Image to Edit</label
>
<input
type="file"
id="image-upload"
accept="image/*"
@change="startEdit"
class="mt-1 p-2 block w-full rounded-md border-primary-400 shadow text-primary-900"
/>
</div>
</div>
<!-- Edit Panel -->
<div
id="edit-panel"
x-show="editMode"
class="mt-2 bg-primary-50 p-4 rounded shadow"
>
<div
:class="['flex gap-4', isLandscape ? 'flex-col' : 'flex-col lg:flex-row']"
>
<!-- Image Preview -->
<div class="flex justify-center relative">
<img
id="editing-image"
:src="editingImage?.url"
alt="Original image for editing"
class="max-h-[75vh] rounded-lg shadow-md"
/>
<canvas
id="mask"
class="absolute top-0 left-0 w-full h-full rounded-lg cursor-crosshair"
></canvas>
</div>
<!-- Mask Options -->
<div class="flex-1 flex flex-col gap-2 mt-auto justify-end">
<div class="mt-2">
<label
for="lineWidthSlider"
class="block text-sm font-medium text-gray-700"
>
Line Width: <span x-text="lineWidth"></span>
</label>
<input
type="range"
id="lineWidthSlider"
x-model="lineWidth"
min="1"
max="100"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
</div>
<span
@click="clearMask"
class="mt-2 px-3 py-1 cursor-pointer bg-primary-200 text-primary-700 rounded hover:bg-primary-600 hover:text-white text-center transition-colors"
>
Clear Mask
</span>
<span
@click="cancelEdit"
class="mt-2 px-3 py-1 cursor-pointer bg-primary-200 text-primary-700 rounded hover:bg-primary-600 hover:text-white text-center transition-colors"
>
Cancel
</span>
</div>
</div>
</div>
<button
type="submit"
x-bind:disabled="loading || !selectedModel"
:class="loading || !selectedModel ? 'cursor-not-allowed' : 'cursor-pointer'"
class="inline-flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-primary-50 bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 gap-2 transition-colors"
>
<span
x-text="loading ? '' : editMode ? 'Edit Image' : 'Generate Image'"
></span>
<div
x-show="loading"
class="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"
></div>
</button>
</form>
</div>
<div>
<h3 class="text-lg font-medium text-primary-900 mb-2">Generated Images</h3>
<div
x-show="error"
class="bg-tertiary-50 border border-tertiary-200 rounded-md p-4 mb-4"
>
<p class="text-tertiary-700" x-text="error"></p>
</div>
<div
x-show="!generatedImages.length"
class="text-center py-8 text-primary-500"
>
No Images Found
</div>
<div
x-show="generatedImages.length"
class="columns-2 md:columns-3 lg:columns-4 gap-2"
>
<template x-for="(image, index) in generatedImages" :key="index">
<div
class="flex flex-col gap-2 break-inside-avoid border border-primary-200 rounded-lg p-2 mb-2 h-full bg-primary-100 hover:border-primary-300 transition-colors shadow"
>
<button
@click="deleteImage(image.name)"
class="text-white hover:text-white text-sm justify-center cursor-pointer p-1 rounded bg-red-600 hover:bg-red-700 flex items-center h-full transition-colors"
>
Delete
</button>
<img
:src="image.path"
:alt="image.prompt"
@click="openLightbox(image.path)"
class="rounded-lg shadow-sm max-w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
/>
<span
class="text-xs text-primary-500 bg-primary-200 px-2 py-1 rounded flex justify-center"
x-text="image.date"
></span>
</div>
</template>
</div>
</div>
<!-- Lightbox -->
<div
x-show="lightbox.open"
x-cloak
@click="closeLightbox"
@keydown.escape.window="closeLightbox"
@keydown.arrow-left.window="prevImage"
@keydown.arrow-right.window="nextImage"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm"
>
<img
:src="lightbox.imageSrc"
@click.stop
class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
alt="Full size preview"
/>
</div>
</div>

View File

@@ -0,0 +1,108 @@
<form
x-data="settingsManager()"
@submit.prevent="saveSettings"
class="p-0.5 w-full flex flex-col gap-4 pt-16 mx-auto px-4 md:px-6 max-w-6xl"
>
<div>
<label
for="apiEndpoint"
class="block text-sm font-semibold text-primary-700"
>API Endpoint URL</label
>
<div class="ml-1">
<input
type="url"
id="apiEndpoint"
name="apiEndpoint"
x-model="settings.api_endpoint"
class="mt-1 p-1 block w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm text-primary-900"
placeholder="https://api.example.com/v1"
required
/>
<p class="mt-2 text-xs text-primary-500">URL of your API endpoint</p>
</div>
</div>
<div>
<span class="text-sm font-medium font-semibold text-primary-700"
>Selectors</span
>
<div class="flex flex-col md:flex-row pl-1 gap-4 justify-between">
<div class="w-full">
<label
for="generateModelSelector"
class="text-sm font-medium text-primary-700"
>Image</label
>
<input
type="text"
id="generateModelSelector"
name="generateModelSelector"
x-model="settings.image_generation_selector"
class="mt-1 p-1 block w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm text-primary-900"
placeholder=".meta.type: image-generate"
/>
<p class="mt-2 text-xs text-primary-500">Image generation selector</p>
</div>
<div class="w-full">
<label
for="editModelSelector"
class="text-sm font-medium text-primary-700"
>Image Edit</label
>
<input
type="text"
id="editModelSelector"
name="editModelSelector"
x-model="settings.image_edit_selector"
class="mt-1 p-1 block w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm text-primary-900"
placeholder=".meta.type: image-edit"
/>
<p class="mt-2 text-xs text-primary-500">
Image edit generation selector
</p>
</div>
<div class="w-full">
<label
for="textModelSelector"
class="text-sm font-medium text-primary-700"
>Chat</label
>
<input
type="text"
id="textGenerationSelector"
name="textGenerationSelector"
x-model="settings.text_generation_selector"
class="mt-1 p-1 block w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm text-primary-900"
placeholder=".meta.type: text-generate"
/>
<p class="mt-2 text-xs text-primary-500">Text generation selector</p>
</div>
</div>
</div>
<div
x-show="error"
class="bg-tertiary-50 border border-tertiary-200 rounded-md p-4"
>
<p class="text-tertiary-700" x-text="error"></p>
</div>
<div
x-show="saved"
class="bg-secondary-50 border border-secondary-200 rounded-md p-4"
>
<p class="text-secondary-700">Settings saved successfully!</p>
</div>
<div class="flex justify-end">
<button
type="submit"
x-bind:disabled="loading"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-primary-50 bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 cursor-pointer transition-colors"
>
Save Settings
</button>
</div>
</form>

180
frontend/src/client.ts Normal file
View File

@@ -0,0 +1,180 @@
import {
Settings,
ImageRecord,
GenerateImageRequest,
Model,
Chat,
GenerateTextRequest,
ChatListResponse,
MessageChunk,
} from './types/index';
export async function saveSettings(settings: Settings): Promise<Settings> {
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<Settings> {
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<ImageRecord[]> {
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<Model[]> {
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<ImageRecord[]> {
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<void> {
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<Chat> {
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<ChatListResponse> {
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<void> {
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}`);
}
}
}
}

View File

@@ -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';
}

View File

@@ -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<string> =>
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<string, unknown>,
_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();
}
}
},
};
});

View File

@@ -0,0 +1,48 @@
import Alpine from 'alpinejs';
declare global {
interface Window {
Alpine: typeof Alpine;
}
}
interface NavigationStore {
activeTab: string;
init(): void;
loadPage(): Promise<void>;
}
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 = `
<div class="bg-tertiary-50 border border-tertiary-200 rounded-lg p-4">
<p class="text-tertiary-700">Failed to load page. Please try again.</p>
</div>
`;
}
},
};
Alpine.store('navigation', navigationStore);

View File

@@ -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;
}
},
}));

View File

@@ -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);

19
frontend/src/main.ts Normal file
View File

@@ -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();

83
frontend/src/theme.ts Normal file
View File

@@ -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];
}

View File

@@ -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<string, unknown>;
}
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[];
}

37
frontend/src/utils.ts Normal file
View File

@@ -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 = <T>(
obj: T,
path: string,
value: string,
): boolean => {
const fieldValue = path
.split('.')
.reduce<unknown>(
(o, key) => (o as Record<string, unknown>)?.[key],
obj as Record<string, unknown>,
);
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;
};

153
frontend/styles.css Normal file
View File

@@ -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");
}

13
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"lib": ["ES2020", "DOM"],
"strict": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"noImplicitAny": true,
"moduleResolution": "bundler"
}
}