initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
data
|
||||||
|
.opencode
|
||||||
19
AGENTS.md
Normal file
19
AGENTS.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Aethera Monorepo
|
||||||
|
|
||||||
|
This repository is a **monorepo** with two main packages that can be built and run independently.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── frontend/ # Client‑side TypeScript application
|
||||||
|
├── backend/ # Server‑side Go micro‑service
|
||||||
|
├── .envrc, flake.nix, README.md
|
||||||
|
└── …
|
||||||
|
```
|
||||||
|
|
||||||
|
## Package Details
|
||||||
|
|
||||||
|
See package‑specific instructions:
|
||||||
|
|
||||||
|
- **frontend/** - `@frontend/AGENTS.md`
|
||||||
|
- **backend/** - `@backend/AGENTS.md`
|
||||||
100
README.md
Normal file
100
README.md
Normal 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
1
backend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
dist
|
||||||
53
backend/AGENTS.md
Normal file
53
backend/AGENTS.md
Normal 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
24
backend/cmd/config.go
Normal 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
50
backend/cmd/main.go
Normal 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(¶ms.DataDir, "data-dir", "data", "Directory to store generated images")
|
||||||
|
rootCmd.PersistentFlags().StringVar(¶ms.ListenAddr, "listen", "localhost", "Address to listen on")
|
||||||
|
rootCmd.PersistentFlags().IntVar(¶ms.ListenPort, "port", 8080, "Port to listen on")
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Validate Params
|
||||||
|
if err := params.Validate(); err != nil {
|
||||||
|
logrus.Fatalf("failed to validate parameters: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Store
|
||||||
|
fileStore, err := store.NewFileStore(path.Join(params.DataDir, "settings.json"))
|
||||||
|
if err != nil {
|
||||||
|
logrus.Fatalf("failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Server
|
||||||
|
rootCmd.Run = func(cmd *cobra.Command, args []string) {
|
||||||
|
server.StartServer(fileStore, params.DataDir, params.ListenAddr, params.ListenPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
logrus.Fatal(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
backend/go.mod
Normal file
21
backend/go.mod
Normal 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
42
backend/go.sum
Normal 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=
|
||||||
28
backend/internal/api/convert.go
Normal file
28
backend/internal/api/convert.go
Normal 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
|
||||||
|
}
|
||||||
27
backend/internal/api/flush_writer.go
Normal file
27
backend/internal/api/flush_writer.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
549
backend/internal/api/handlers.go
Normal file
549
backend/internal/api/handlers.go
Normal 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
|
||||||
|
}
|
||||||
163
backend/internal/api/types.go
Normal file
163
backend/internal/api/types.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
290
backend/internal/client/client.go
Normal file
290
backend/internal/client/client.go
Normal 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}
|
||||||
|
}
|
||||||
79
backend/internal/client/client_test.go
Normal file
79
backend/internal/client/client_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/internal/client/convert.go
Normal file
41
backend/internal/client/convert.go
Normal 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
|
||||||
|
}
|
||||||
31
backend/internal/client/types.go
Normal file
31
backend/internal/client/types.go
Normal 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"`
|
||||||
|
}
|
||||||
95
backend/internal/server/server.go
Normal file
95
backend/internal/server/server.go
Normal 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)
|
||||||
|
}
|
||||||
34
backend/internal/storage/storage.go
Normal file
34
backend/internal/storage/storage.go
Normal 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
|
||||||
|
}
|
||||||
10
backend/internal/store/errors.go
Normal file
10
backend/internal/store/errors.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrChatNotFound = errors.New("chat not found")
|
||||||
|
ErrNilChatID = errors.New("chat id cannot be nil")
|
||||||
|
)
|
||||||
18
backend/internal/store/interface.go
Normal file
18
backend/internal/store/interface.go
Normal 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
|
||||||
|
}
|
||||||
126
backend/internal/store/memory.go
Normal file
126
backend/internal/store/memory.go
Normal 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
|
||||||
|
}
|
||||||
190
backend/internal/store/storage.go
Normal file
190
backend/internal/store/storage.go
Normal 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
|
||||||
|
}
|
||||||
39
backend/internal/store/types.go
Normal file
39
backend/internal/store/types.go
Normal 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"`
|
||||||
|
}
|
||||||
50
backend/internal/types/message_stats.go
Normal file
50
backend/internal/types/message_stats.go
Normal 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
13
backend/pkg/ptr/ptr.go
Normal 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
36
backend/pkg/slices/map.go
Normal 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
|
||||||
|
}
|
||||||
24
backend/pkg/values/values.go
Normal file
24
backend/pkg/values/values.go
Normal 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
27
flake.lock
generated
Normal 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
37
flake.nix
Normal 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
2
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
public/dist
|
||||||
45
frontend/AGENTS.md
Normal file
45
frontend/AGENTS.md
Normal 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
271
frontend/bun.lock
Normal 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
29
frontend/eslint.config.js
Normal 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
26
frontend/package.json
Normal 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
115
frontend/public/index.html
Normal 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>
|
||||||
397
frontend/public/pages/chats.html
Normal file
397
frontend/public/pages/chats.html
Normal 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>
|
||||||
244
frontend/public/pages/images.html
Normal file
244
frontend/public/pages/images.html
Normal 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>
|
||||||
108
frontend/public/pages/settings.html
Normal file
108
frontend/public/pages/settings.html
Normal 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
180
frontend/src/client.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
243
frontend/src/components/chatManager.ts
Normal file
243
frontend/src/components/chatManager.ts
Normal 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';
|
||||||
|
}
|
||||||
396
frontend/src/components/imageManager.ts
Normal file
396
frontend/src/components/imageManager.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
48
frontend/src/components/navigationManager.ts
Normal file
48
frontend/src/components/navigationManager.ts
Normal 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);
|
||||||
29
frontend/src/components/settingsManager.ts
Normal file
29
frontend/src/components/settingsManager.ts
Normal 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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
49
frontend/src/components/themeManager.ts
Normal file
49
frontend/src/components/themeManager.ts
Normal 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
19
frontend/src/main.ts
Normal 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
83
frontend/src/theme.ts
Normal 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];
|
||||||
|
}
|
||||||
73
frontend/src/types/index.ts
Normal file
73
frontend/src/types/index.ts
Normal 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
37
frontend/src/utils.ts
Normal 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
153
frontend/styles.css
Normal 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
13
frontend/tsconfig.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user