Compare commits
21 Commits
fad8ed865a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 03527e7dc4 | |||
| bf3b308a05 | |||
| 0c002cf5ee | |||
| f5bc4e2ae4 | |||
| eddf5bf12d | |||
| 6307a64c9c | |||
| 8f732e6fc7 | |||
| 9b77a473b7 | |||
| f359471a27 | |||
| b5e60ff0e2 | |||
| 74b8d43032 | |||
| 54e24cb304 | |||
| 0dd9521419 | |||
| 2154a9f203 | |||
| 0007250e5e | |||
| e60b1ea8d5 | |||
| c51c0ab070 | |||
| 5d5f10b2d8 | |||
| 91d4202874 | |||
| ce496d1caf | |||
| eb66801f58 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
data
|
||||
.opencode
|
||||
.env
|
||||
|
||||
26
AGENTS.md
26
AGENTS.md
@@ -5,15 +5,31 @@ This repository is a **monorepo** with two main packages that can be built and r
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
├── frontend/ # Client‑side TypeScript application
|
||||
├── backend/ # Server‑side Go micro‑service
|
||||
├── .envrc, flake.nix, README.md
|
||||
├── frontend/ # TypeScript + Alpine.js + Tailwind CSS client
|
||||
├── backend/ # Go HTTP server with embedded frontend assets
|
||||
├── Makefile # Orchestrates frontend → backend build pipeline
|
||||
├── Dockerfile # Multi-stage Docker build
|
||||
├── .drone.yml # CI pipeline (tests + Docker publish)
|
||||
├── flake.nix # Nix dev shell (Go, Bun, LSPs, linters)
|
||||
└── …
|
||||
```
|
||||
|
||||
## Build Pipeline
|
||||
|
||||
The frontend builds to `frontend/public/dist/`, then `make frontend` copies the full `frontend/public/` tree into `backend/web/static/` where it gets embedded into the Go binary via `//go:embed`.
|
||||
|
||||
```bash
|
||||
make all # Build frontend + backend
|
||||
make frontend # Build frontend, copy to backend/web/static/
|
||||
make backend # Build Go binary (requires frontend assets)
|
||||
make dev # Run frontend watcher + backend hot reload via air
|
||||
make tests # Run Go tests
|
||||
make docker # Build Docker image
|
||||
```
|
||||
|
||||
## Package Details
|
||||
|
||||
See package‑specific instructions:
|
||||
|
||||
- **frontend/** - `@frontend/AGENTS.md`
|
||||
- **backend/** - `@backend/AGENTS.md`
|
||||
- **frontend/** — `@frontend/AGENTS.md`
|
||||
- **backend/** — `@backend/AGENTS.md`
|
||||
|
||||
@@ -27,4 +27,7 @@ EXPOSE 8080
|
||||
ENV AETHERA_LISTEN=0.0.0.0
|
||||
ENV AETHERA_PORT=8080
|
||||
ENV AETHERA_DATA_DIR=/app/data
|
||||
# LLM Configuration (required)
|
||||
# ENV AETHERA_LLM_ENDPOINT=https://api.example.com/v1
|
||||
# ENV AETHERA_LLM_KEY=your-api-key-here
|
||||
ENTRYPOINT ["./aethera"]
|
||||
|
||||
2
Makefile
2
Makefile
@@ -27,7 +27,7 @@ clean:
|
||||
dev:
|
||||
rm -rf frontend/public/dist
|
||||
cd frontend && bun run build
|
||||
cd backend && AETHERA_STATIC_DIR=../frontend/public go run ./cmd --listen 0.0.0.0 & \
|
||||
cd backend && AETHERA_STATIC_DIR=../frontend/public air & \
|
||||
backend_pid=$$!; \
|
||||
trap 'kill $$backend_pid' INT TERM EXIT; \
|
||||
cd frontend && bun run dev
|
||||
|
||||
118
README.md
118
README.md
@@ -1,100 +1,112 @@
|
||||
# Aethera
|
||||
|
||||
A sophisticated web dashboard for AI-powered conversations and image generation with chat interface, multiple conversations, and local storage capabilities.
|
||||
A web dashboard for AI-powered conversations and image generation, backed by any OpenAI-compatible API.
|
||||
|
||||
## 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
|
||||
- **Chat Interface** — streaming responses with Markdown rendering and syntax highlighting
|
||||
- **Thinking Support** — displays model reasoning/thinking content when available
|
||||
- **Multiple Conversations** — switch between threads with auto-generated titles
|
||||
- **Image Generation & Editing** — create and edit images with customizable prompts, masks, and seeds
|
||||
- **Token Statistics** — real-time prompt/generation throughput and timing metrics
|
||||
- **Theme Support** — light and dark mode toggle
|
||||
- **Structured Output** — JSON schema-based structured responses from models
|
||||
- **Embedded Frontend** — single binary deployment with assets compiled in
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.25.5 or later
|
||||
- Bun package manager
|
||||
- An OpenAI-compatible API endpoint (OpenAI, local LLM, etc.)
|
||||
- Go 1.25.5+
|
||||
- Bun
|
||||
- An OpenAI-compatible API endpoint
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
### Using Make
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd aethera
|
||||
make all # Build frontend + backend
|
||||
./backend/dist/aethera
|
||||
```
|
||||
|
||||
2. Build the backend:
|
||||
### Using Docker
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go build -o ./dist/aethera ./cmd
|
||||
make docker
|
||||
docker run -p 8080:8080 \
|
||||
-e AETHERA_LLM_ENDPOINT=https://api.example.com/v1 \
|
||||
-e AETHERA_LLM_KEY=your-key \
|
||||
-v aethera-data:/app/data aethera
|
||||
```
|
||||
|
||||
3. Build the frontend:
|
||||
### Manual Build
|
||||
|
||||
```bash
|
||||
cd ../frontend
|
||||
bun run build
|
||||
```
|
||||
# Frontend
|
||||
cd frontend && bun install && bun run build && cd ..
|
||||
|
||||
### Running the Application
|
||||
# Copy assets to backend
|
||||
mkdir -p backend/web/static
|
||||
cp -R frontend/public/. backend/web/static/
|
||||
|
||||
Start the server from the backend directory:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && go build -o ./dist/aethera ./cmd
|
||||
./dist/aethera
|
||||
```
|
||||
|
||||
By default, the application runs at `http://localhost:8080`
|
||||
Open `http://localhost:8080` in your browser.
|
||||
|
||||
Open your browser and navigate to the URL to begin using Aethera.
|
||||
## Configuration
|
||||
|
||||
## Configuration Options
|
||||
Configuration is available via CLI flags and environment variables (prefixed `AETHERA_`):
|
||||
|
||||
You can customize the server behavior with these command-line flags:
|
||||
|
||||
- `--data-dir`: Directory for storing generated images (default: `data`)
|
||||
- `--static-dir`: Directory to serve frontend files from instead of embedded assets (useful for development)
|
||||
- `--listen`: Address to listen on (default: `localhost`)
|
||||
- `--port`: Port to listen on (default: `8080`)
|
||||
| Flag | Env Var | Default | Description |
|
||||
|----------------|---------------------|-------------|--------------------------------------------|
|
||||
| `--llm-endpoint` | `AETHERA_LLM_ENDPOINT` | *(required)* | OpenAI-compatible API endpoint URL |
|
||||
| `--llm-key` | `AETHERA_LLM_KEY` | | API key for authentication |
|
||||
| `--data-dir` | `AETHERA_DATA_DIR` | `./data` | Directory for chats, settings, and images |
|
||||
| `--static-dir` | `AETHERA_STATIC_DIR`| *(embedded)*| Serve frontend from disk (for development) |
|
||||
| `--listen` | `AETHERA_LISTEN` | `localhost` | Listen address |
|
||||
| `--port` | `AETHERA_PORT` | `8080` | Listen port |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
./dist/aethera --port 3000 --listen 0.0.0.0
|
||||
AETHERA_LLM_ENDPOINT=https://api.example.com/v1 AETHERA_LLM_KEY=your-key ./backend/dist/aethera
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
A Nix flake is provided for the development environment:
|
||||
|
||||
```bash
|
||||
nix develop # or use direnv with .envrc
|
||||
```
|
||||
|
||||
This provides Go, Bun, `gopls`, `typescript-language-server`, `golangci-lint`, and `watchman`.
|
||||
|
||||
For hot-reload development:
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
This starts the Go backend (serving frontend from disk) and the frontend in watch mode concurrently.
|
||||
|
||||
## 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
|
||||
1. **Configure Your API** — set `AETHERA_LLM_ENDPOINT` and optionally `AETHERA_LLM_KEY` environment variables
|
||||
2. **Start the Server** — run the binary and navigate to `http://localhost:8080`
|
||||
3. **Configure Model Selectors** — navigate to Settings to configure model selectors for chat and image generation
|
||||
|
||||
## Supported AI Services
|
||||
|
||||
Aethera works with any OpenAI-compatible API, including:
|
||||
|
||||
- OpenAI
|
||||
- Local LLMs (Ollama, LocalAI, etc.)
|
||||
- Other compatible AI services
|
||||
- Local LLMs (Ollama, llama.cpp, LocalAI, etc.)
|
||||
- Any other compatible service
|
||||
|
||||
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.
|
||||
Llama.cpp-specific features like per-token timings are automatically detected.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
30
backend/.air.toml
Normal file
30
backend/.air.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/aethera ./cmd"
|
||||
bin = "./tmp/aethera"
|
||||
full_bin = "./tmp/aethera --listen 0.0.0.0"
|
||||
delay = 1000
|
||||
exclude_dir = ["data", "dist", "tmp", "web/static"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
include_dir = []
|
||||
include_ext = ["go", "mod", "sum"]
|
||||
kill_delay = "500ms"
|
||||
log = "build-errors.log"
|
||||
send_interrupt = true
|
||||
stop_on_error = true
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
@@ -2,69 +2,95 @@
|
||||
|
||||
## Stack
|
||||
|
||||
- **Go 1.25.5**
|
||||
- **Go 1.25.5** (`reichard.io/aethera`)
|
||||
- **air** (backend hot reload for `make dev`)
|
||||
- **cobra** (CLI framework)
|
||||
- **logrus** (structured logging)
|
||||
- **openai-go/v3** (OpenAI API client)
|
||||
- **jsonschema-go** (structured output schema generation)
|
||||
- **testify** (testing assertions)
|
||||
- **golangci-lint** (linting)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
go build -o ./dist/aethera ./cmd
|
||||
golangci-lint run
|
||||
go test ./...
|
||||
go build -o ./dist/aethera ./cmd # Build binary
|
||||
golangci-lint run # Lint
|
||||
go test ./... # Run all tests
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Frontend assets are embedded at compile time via `web/embed.go` (`//go:embed static/*`). The `make frontend` target copies built frontend output into `backend/web/static/` before the Go build.
|
||||
|
||||
### Directory Layout
|
||||
|
||||
```
|
||||
cmd/ # CLI entry (main.go, config.go)
|
||||
internal/
|
||||
api/ # HTTP handlers, request/response types, streaming generation
|
||||
client/ # OpenAI-compatible API client (chat, images, structured output)
|
||||
server/ # HTTP server setup, routing, logging middleware
|
||||
store/ # Store interface + FileStore (JSON files) & InMemoryStore
|
||||
storage/ # Standalone file utilities (e.g. ListImages)
|
||||
types/ # Shared domain types (MessageStats)
|
||||
pkg/
|
||||
ptr/ # Generic pointer helpers (Of, DerefOrZero)
|
||||
slices/ # Generic slice helpers (Map, First, Last, FindFirst)
|
||||
values/ # Generic value helpers (FirstNonZero, CountNonZero)
|
||||
web/ # Embedded static assets (embed.go)
|
||||
```
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **Store interface** (`internal/store/interface.go`): abstraction over settings + chat persistence. Two implementations:
|
||||
- `FileStore` — JSON files on disk (settings.json + `chats/*.json`). Used in production.
|
||||
- `InMemoryStore` — map-based, thread-safe with `sync.RWMutex`. Used in tests.
|
||||
- **DI via constructors**: `api.New(store, dataDir, logger)`, `client.NewClient(baseURL)`, `store.NewFileStore(path)`
|
||||
- **Streaming**: `generationManager` coordinates concurrent chat completions. One active generation per chat. Subscribers receive `MessageChunk` updates via channels over NDJSON (`application/x-ndjson`).
|
||||
- **Client lazy init**: `api.getClient()` creates the OpenAI client on first use from stored settings, invalidated on settings change.
|
||||
- **Env vars**: all prefixed `AETHERA_` (e.g. `AETHERA_PORT`, `AETHERA_DATA_DIR`). CLI flags override env.
|
||||
|
||||
### API Routes
|
||||
|
||||
| Method | Path | Handler |
|
||||
|----------|-------------------------------|--------------------|
|
||||
| GET | `/api/settings` | GetSettings |
|
||||
| POST | `/api/settings` | PostSettings |
|
||||
| GET | `/api/models` | GetModels |
|
||||
| GET | `/api/images` | GetImages |
|
||||
| POST | `/api/images` | PostImage |
|
||||
| DELETE | `/api/images/{filename}` | DeleteImage |
|
||||
| GET | `/api/chats` | GetChats |
|
||||
| POST | `/api/chats` | PostChat |
|
||||
| GET | `/api/chats/{chatId}` | GetChat |
|
||||
| POST | `/api/chats/{chatId}` | PostChatMessage |
|
||||
| DELETE | `/api/chats/{chatId}` | DeleteChat |
|
||||
| GET | `/api/chats/{chatId}/stream` | GetChatStream |
|
||||
| POST | `/api/chats/{chatId}/stop` | StopChatGeneration |
|
||||
|
||||
## Testing
|
||||
|
||||
All Go code is tested using the [testify](https://github.com/stretchr/testify) framework with comprehensive test coverage:
|
||||
|
||||
- **Unit Tests**: Each function and method is thoroughly tested including edge cases
|
||||
- **Integration Tests**: Store implementations are tested end-to-end
|
||||
- **Error Handling**: All error conditions are explicitly tested
|
||||
- **Concurrency**: Thread-safety of concurrent operations is verified
|
||||
- **File Operations**: File-based storage tests use temporary directories
|
||||
|
||||
Tests follow these conventions:
|
||||
- Use `require.NoError(t, err)` for fatal errors that break test flow
|
||||
- Use `assert.NoError(t, err)` for non-fatal assertions
|
||||
- Test both success and error paths for all methods
|
||||
- Use table-driven tests where appropriate
|
||||
- All tests are run with `go test ./...` or `go test -v ./...`
|
||||
- Use `testify` (`require` for fatal, `assert` for non-fatal)
|
||||
- Test both success and error paths
|
||||
- Table-driven tests where appropriate
|
||||
- File-based storage tests use temp directories
|
||||
- Concurrency safety is verified
|
||||
|
||||
## Non-Negotiables
|
||||
|
||||
- ❌ No unhandled errors - always check `err`
|
||||
- ❌ 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`
|
||||
- ❌ 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
|
||||
- Tab indentation, PascalCase exports, camelCase internal
|
||||
- Error wrapping with context: `fmt.Errorf("...: %w", err)`
|
||||
- Custom error types for domain errors (e.g., `ChatNotFoundError`)
|
||||
- Sentinel errors for domain cases (`ErrChatNotFound`, `ErrNilChatID`)
|
||||
- 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) with in-memory alternative (`InMemoryStore`)
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -15,6 +15,8 @@ type cliParams struct {
|
||||
DataDir string
|
||||
StaticDir string
|
||||
SettingsFile string
|
||||
LLMEndpoint string
|
||||
LLMKey string
|
||||
}
|
||||
|
||||
// getEnvOrDefault returns the value of an environment variable or a default value
|
||||
@@ -36,6 +38,11 @@ func getEnvIntOrDefault(key string, defaultValue int) int {
|
||||
}
|
||||
|
||||
func (p *cliParams) Validate() error {
|
||||
// Require LLM Configuration
|
||||
if p.LLMEndpoint == "" {
|
||||
return fmt.Errorf("LLM endpoint is required (set AETHERA_LLM_ENDPOINT)")
|
||||
}
|
||||
|
||||
// Ensure Generated Directories
|
||||
imgDir := path.Join(p.DataDir, "generated/images")
|
||||
if err := os.MkdirAll(imgDir, 0755); err != nil {
|
||||
|
||||
@@ -17,6 +17,8 @@ var (
|
||||
ListenPort: getEnvIntOrDefault("PORT", 8080),
|
||||
DataDir: getEnvOrDefault("DATA_DIR", "./data"),
|
||||
StaticDir: getEnvOrDefault("STATIC_DIR", ""),
|
||||
LLMEndpoint: getEnvOrDefault("LLM_ENDPOINT", ""),
|
||||
LLMKey: getEnvOrDefault("LLM_KEY", ""),
|
||||
}
|
||||
rootCmd = &cobra.Command{Use: "aethera"}
|
||||
)
|
||||
@@ -26,6 +28,8 @@ func init() {
|
||||
rootCmd.PersistentFlags().StringVar(¶ms.StaticDir, "static-dir", params.StaticDir, "Directory to serve static frontend files from instead of embedded assets (env: AETHERA_STATIC_DIR)")
|
||||
rootCmd.PersistentFlags().StringVar(¶ms.ListenAddr, "listen", params.ListenAddr, "Address to listen on (env: AETHERA_LISTEN)")
|
||||
rootCmd.PersistentFlags().IntVar(¶ms.ListenPort, "port", params.ListenPort, "Port to listen on (env: AETHERA_PORT)")
|
||||
rootCmd.PersistentFlags().StringVar(¶ms.LLMEndpoint, "llm-endpoint", params.LLMEndpoint, "LLM API endpoint URL (env: AETHERA_LLM_ENDPOINT)")
|
||||
rootCmd.PersistentFlags().StringVar(¶ms.LLMKey, "llm-key", params.LLMKey, "LLM API key (env: AETHERA_LLM_KEY)")
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -42,7 +46,7 @@ func main() {
|
||||
|
||||
// Start Server
|
||||
rootCmd.Run = func(cmd *cobra.Command, args []string) {
|
||||
server.StartServer(fileStore, params.DataDir, params.StaticDir, params.ListenAddr, params.ListenPort)
|
||||
server.StartServer(fileStore, params.DataDir, params.StaticDir, params.ListenAddr, params.ListenPort, params.LLMEndpoint, params.LLMKey)
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
|
||||
216
backend/internal/api/generation.go
Normal file
216
backend/internal/api/generation.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"reichard.io/aethera/internal/store"
|
||||
"reichard.io/aethera/internal/types"
|
||||
)
|
||||
|
||||
var errGenerationActive = errors.New("generation already active")
|
||||
|
||||
type generationManager struct {
|
||||
mu sync.RWMutex
|
||||
generations map[uuid.UUID]*generation
|
||||
}
|
||||
|
||||
type generation struct {
|
||||
mu sync.RWMutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
subscribers map[chan *MessageChunk]struct{}
|
||||
done chan struct{}
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newGenerationManager() *generationManager {
|
||||
return &generationManager{generations: make(map[uuid.UUID]*generation)}
|
||||
}
|
||||
|
||||
func (m *generationManager) start(chatID uuid.UUID, prepare func(*generation) error, run func(*generation)) error {
|
||||
m.mu.Lock()
|
||||
if _, found := m.generations[chatID]; found {
|
||||
m.mu.Unlock()
|
||||
return errGenerationActive
|
||||
}
|
||||
|
||||
// Reserve Generation
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
gen := &generation{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
subscribers: make(map[chan *MessageChunk]struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
m.generations[chatID] = gen
|
||||
m.mu.Unlock()
|
||||
|
||||
// Prepare Generation - This runs while the generation is reserved so a
|
||||
// concurrent request cannot persist duplicate user/assistant messages.
|
||||
if err := prepare(gen); err != nil {
|
||||
gen.close()
|
||||
m.mu.Lock()
|
||||
delete(m.generations, chatID)
|
||||
m.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// Run Generation
|
||||
go func() {
|
||||
defer func() {
|
||||
gen.close()
|
||||
m.mu.Lock()
|
||||
delete(m.generations, chatID)
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
run(gen)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *generationManager) subscribe(chatID uuid.UUID) (<-chan *MessageChunk, func(), bool) {
|
||||
m.mu.RLock()
|
||||
gen, found := m.generations[chatID]
|
||||
m.mu.RUnlock()
|
||||
if !found {
|
||||
return nil, func() {}, false
|
||||
}
|
||||
|
||||
ch := gen.subscribe()
|
||||
return ch, func() { gen.unsubscribe(ch) }, true
|
||||
}
|
||||
|
||||
func (m *generationManager) stop(chatID uuid.UUID) bool {
|
||||
m.mu.RLock()
|
||||
gen, found := m.generations[chatID]
|
||||
m.mu.RUnlock()
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
|
||||
// Cancel Generation
|
||||
gen.cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *generation) subscribe() chan *MessageChunk {
|
||||
ch := make(chan *MessageChunk, 64)
|
||||
|
||||
// Add Subscriber
|
||||
g.mu.Lock()
|
||||
if g.closed {
|
||||
close(ch)
|
||||
} else {
|
||||
g.subscribers[ch] = struct{}{}
|
||||
}
|
||||
g.mu.Unlock()
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
func (g *generation) unsubscribe(ch chan *MessageChunk) {
|
||||
// Remove Subscriber
|
||||
g.mu.Lock()
|
||||
if _, found := g.subscribers[ch]; found {
|
||||
delete(g.subscribers, ch)
|
||||
close(ch)
|
||||
}
|
||||
g.mu.Unlock()
|
||||
}
|
||||
|
||||
func (g *generation) broadcast(chunk *MessageChunk) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
// Broadcast Chunk
|
||||
for subscriber := range g.subscribers {
|
||||
select {
|
||||
case subscriber <- cloneMessageChunk(chunk):
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *generation) close() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
// Close Subscribers
|
||||
if g.closed {
|
||||
return
|
||||
}
|
||||
g.closed = true
|
||||
g.cancel()
|
||||
close(g.done)
|
||||
for subscriber := range g.subscribers {
|
||||
close(subscriber)
|
||||
delete(g.subscribers, subscriber)
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMessageChunk(chunk *MessageChunk) *MessageChunk {
|
||||
if chunk == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clone Chunk
|
||||
cloned := &MessageChunk{
|
||||
Chat: chunk.Chat,
|
||||
UserMessage: cloneStoreMessage(chunk.UserMessage),
|
||||
AssistantMessage: cloneStoreMessage(chunk.AssistantMessage),
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneStoreMessage(msg *store.Message) *store.Message {
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clone Message
|
||||
cloned := *msg
|
||||
cloned.Images = slices.Clone(msg.Images)
|
||||
if msg.Stats != nil {
|
||||
stats := *msg.Stats
|
||||
cloned.Stats = &stats
|
||||
cloneMessageStatsPointers(msg.Stats, cloned.Stats)
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneMessageStatsPointers(src, dst *types.MessageStats) {
|
||||
// Clone Pointer Fields
|
||||
if src.EndTime != nil {
|
||||
v := *src.EndTime
|
||||
dst.EndTime = &v
|
||||
}
|
||||
if src.PromptTokens != nil {
|
||||
v := *src.PromptTokens
|
||||
dst.PromptTokens = &v
|
||||
}
|
||||
if src.GeneratedTokens != nil {
|
||||
v := *src.GeneratedTokens
|
||||
dst.GeneratedTokens = &v
|
||||
}
|
||||
if src.PromptPerSec != nil {
|
||||
v := *src.PromptPerSec
|
||||
dst.PromptPerSec = &v
|
||||
}
|
||||
if src.GeneratedPerSec != nil {
|
||||
v := *src.GeneratedPerSec
|
||||
dst.GeneratedPerSec = &v
|
||||
}
|
||||
if src.TimeToFirstToken != nil {
|
||||
v := *src.TimeToFirstToken
|
||||
dst.TimeToFirstToken = &v
|
||||
}
|
||||
if src.TimeToLastToken != nil {
|
||||
v := *src.TimeToLastToken
|
||||
dst.TimeToLastToken = &v
|
||||
}
|
||||
}
|
||||
@@ -24,20 +24,54 @@ import (
|
||||
)
|
||||
|
||||
type API struct {
|
||||
logger *logrus.Entry
|
||||
store store.Store
|
||||
client *client.Client
|
||||
dataDir string
|
||||
logger *logrus.Entry
|
||||
store store.Store
|
||||
client *client.Client
|
||||
dataDir string
|
||||
llmEndpoint string
|
||||
llmKey string
|
||||
generationManager *generationManager
|
||||
}
|
||||
|
||||
func New(s store.Store, dataDir string, logger *logrus.Logger) *API {
|
||||
func New(s store.Store, dataDir string, logger *logrus.Logger, llmEndpoint, llmKey string) *API {
|
||||
return &API{
|
||||
store: s,
|
||||
dataDir: dataDir,
|
||||
logger: logger.WithField("service", "api"),
|
||||
store: s,
|
||||
dataDir: dataDir,
|
||||
logger: logger.WithField("service", "api"),
|
||||
llmEndpoint: llmEndpoint,
|
||||
llmKey: llmKey,
|
||||
generationManager: newGenerationManager(),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSettings(settings *store.Settings) {
|
||||
// Default Text Generation Timeout
|
||||
if settings.TextGenerationTimeoutMinutes == 0 {
|
||||
settings.TextGenerationTimeoutMinutes = 5
|
||||
}
|
||||
|
||||
// Validate Text Generation Timeout
|
||||
switch settings.TextGenerationTimeoutMinutes {
|
||||
case 1, 5, 10, 15, 30:
|
||||
return
|
||||
default:
|
||||
settings.TextGenerationTimeoutMinutes = 5
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) textGenerationTimeout() time.Duration {
|
||||
// Load Settings
|
||||
settings, err := a.store.GetSettings()
|
||||
if err != nil {
|
||||
a.logger.WithError(err).Error("failed to retrieve settings for text generation timeout")
|
||||
return 5 * time.Minute
|
||||
}
|
||||
|
||||
// Normalize Timeout
|
||||
normalizeSettings(settings)
|
||||
return time.Duration(settings.TextGenerationTimeoutMinutes) * time.Minute
|
||||
}
|
||||
|
||||
func (a *API) GetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
log := a.logger.WithField("handler", "GetSettingsHandler")
|
||||
|
||||
@@ -48,6 +82,9 @@ func (a *API) GetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize Settings
|
||||
normalizeSettings(settings)
|
||||
|
||||
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")
|
||||
@@ -66,23 +103,8 @@ func (a *API) PostSettings(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
// Normalize Settings
|
||||
normalizeSettings(&newSettings)
|
||||
|
||||
if err := a.store.SaveSettings(&newSettings); err != nil {
|
||||
log.WithError(err).Error("failed to save settings")
|
||||
@@ -322,13 +344,18 @@ func (a *API) PostChat(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Send Message
|
||||
responseStarted, err := a.sendMessage(r.Context(), w, chat.ID, genReq.Model, genReq.Prompt)
|
||||
// Start Message
|
||||
chunk, err := a.startMessageGeneration(chat.ID, genReq.Model, genReq.Prompt, genReq.Images, genReq.EnableThinking())
|
||||
if err != nil {
|
||||
log.WithError(err).WithField("chat_id", chat.ID).Error("failed to send message")
|
||||
if !responseStarted {
|
||||
http.Error(w, "Failed to send message", http.StatusInternalServerError)
|
||||
}
|
||||
log.WithError(err).WithField("chat_id", chat.ID).Error("failed to start message generation")
|
||||
http.Error(w, "Failed to start message generation", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(chunk); err != nil {
|
||||
log.WithError(err).Error("failed to encode message generation response")
|
||||
http.Error(w, "Failed to encode message generation response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +422,93 @@ func (a *API) GetChat(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) GetChatStream(w http.ResponseWriter, r *http.Request) {
|
||||
log := a.logger.WithField("handler", "GetChatStreamHandler")
|
||||
|
||||
// Parse Chat ID
|
||||
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
|
||||
}
|
||||
|
||||
// Subscribe Before Snapshot
|
||||
updates, unsubscribe, active := a.generationManager.subscribe(chatID)
|
||||
defer unsubscribe()
|
||||
|
||||
// Get Chat Snapshot
|
||||
chat, err := a.store.GetChat(chatID)
|
||||
if err != nil {
|
||||
log.WithError(err).WithField("chat_id", chatID).Error("failed to get chat")
|
||||
http.Error(w, "Failed to get chat", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set Headers
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
flushWriter := newFlushWriter(w)
|
||||
|
||||
// Send Snapshot
|
||||
if err := json.NewEncoder(flushWriter).Encode(&MessageChunk{Chat: toChat(chat)}); err != nil {
|
||||
log.WithError(err).WithField("chat_id", chatID).Warn("failed to send stream snapshot")
|
||||
return
|
||||
}
|
||||
if !active {
|
||||
return
|
||||
}
|
||||
|
||||
// Forward Updates
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case chunk, ok := <-updates:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(flushWriter).Encode(chunk); err != nil {
|
||||
log.WithError(err).WithField("chat_id", chatID).Warn("client stream disconnected")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) StopChatGeneration(w http.ResponseWriter, r *http.Request) {
|
||||
log := a.logger.WithField("handler", "StopChatGenerationHandler")
|
||||
|
||||
// Parse Chat ID
|
||||
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
|
||||
}
|
||||
|
||||
// Stop Generation
|
||||
if !a.generationManager.stop(chatID) {
|
||||
http.Error(w, "Chat generation is not active", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (a *API) PostChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
log := a.logger.WithField("handler", "PostChatMessageHandler")
|
||||
|
||||
@@ -424,13 +538,22 @@ func (a *API) PostChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Send Message
|
||||
responseStarted, err := a.sendMessage(r.Context(), w, chatID, genReq.Model, genReq.Prompt)
|
||||
// Start Message
|
||||
chunk, err := a.startMessageGeneration(chatID, genReq.Model, genReq.Prompt, genReq.Images, genReq.EnableThinking())
|
||||
if err != nil {
|
||||
log.WithError(err).WithField("chat_id", chatID).Error("failed to send message")
|
||||
if !responseStarted {
|
||||
http.Error(w, "Failed to send message", http.StatusInternalServerError)
|
||||
log.WithError(err).WithField("chat_id", chatID).Error("failed to start message generation")
|
||||
if errors.Is(err, errGenerationActive) {
|
||||
http.Error(w, "Chat generation already active", http.StatusConflict)
|
||||
} else {
|
||||
http.Error(w, "Failed to start message generation", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(chunk); err != nil {
|
||||
log.WithError(err).Error("failed to encode message generation response")
|
||||
http.Error(w, "Failed to encode message generation response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,138 +562,150 @@ func (a *API) getClient() (*client.Client, error) {
|
||||
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)
|
||||
// Parse LLM Endpoint from Config
|
||||
baseURL, err := url.Parse(a.llmEndpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid API endpoint URL: %w", err)
|
||||
}
|
||||
|
||||
a.client = client.NewClient(baseURL)
|
||||
a.client = client.NewClient(baseURL, a.llmKey)
|
||||
return a.client, nil
|
||||
}
|
||||
|
||||
func (a *API) sendMessage(ctx context.Context, w http.ResponseWriter, chatID uuid.UUID, chatModel, userMessage string) (bool, error) {
|
||||
func (a *API) startMessageGeneration(chatID uuid.UUID, chatModel, userMessage string, images []string, enableThinking bool) (*MessageChunk, error) {
|
||||
apiClient, err := a.getClient()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get client: %w", err)
|
||||
return nil, fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// Detach Request Context
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute*5)
|
||||
var chat *store.Chat
|
||||
var userMsg *store.Message
|
||||
var assistantMsg *store.Message
|
||||
var initialChunk *MessageChunk
|
||||
|
||||
// Start Generation - The manager reserves the chat before messages are
|
||||
// persisted, preventing concurrent completions from creating duplicate rows.
|
||||
if err := a.generationManager.start(chatID, func(_ *generation) error {
|
||||
// Create User Message
|
||||
userMsg = &store.Message{ChatID: chatID, Role: "user", Content: userMessage, Images: images}
|
||||
if err := a.store.SaveChatMessage(userMsg); err != nil {
|
||||
return fmt.Errorf("failed to add user message to chat: %w", err)
|
||||
}
|
||||
|
||||
// Get Chat History - Fetch before creating the in-progress assistant message so the
|
||||
// LLM request does not include an empty assistant response prefill.
|
||||
chat, err = a.store.GetChat(chatID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get chat: %w", err)
|
||||
}
|
||||
|
||||
// Add Assistant Response
|
||||
assistantMsg = &store.Message{ChatID: chatID, Role: "assistant", Status: store.MessageStatusStreaming}
|
||||
if err := a.store.SaveChatMessage(assistantMsg); err != nil {
|
||||
return fmt.Errorf("failed to add assistant message to chat: %w", err)
|
||||
}
|
||||
|
||||
// Create Initial Chunk
|
||||
initialChunk = &MessageChunk{
|
||||
Chat: toChatNoMessages(chat),
|
||||
UserMessage: userMsg,
|
||||
AssistantMessage: assistantMsg,
|
||||
}
|
||||
return nil
|
||||
}, func(gen *generation) {
|
||||
a.runMessageGeneration(apiClient, chat, assistantMsg, chatModel, enableThinking, gen)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return initialChunk, nil
|
||||
}
|
||||
|
||||
func (a *API) runMessageGeneration(apiClient *client.Client, chat *store.Chat, assistantMsg *store.Message, chatModel string, enableThinking bool, gen *generation) {
|
||||
// Create Generation Context
|
||||
ctx, cancel := context.WithTimeout(gen.ctx, a.textGenerationTimeout())
|
||||
defer cancel()
|
||||
|
||||
// Create User Message
|
||||
userMsg := &store.Message{ChatID: chatID, Role: "user", Content: userMessage}
|
||||
if err := a.store.SaveChatMessage(userMsg); err != nil {
|
||||
return false, fmt.Errorf("failed to add user message to chat: %w", err)
|
||||
// Generate Title First - Doing this before the main response avoids busting the KV
|
||||
// cache between the user prompt and the assistant reply, and keeps the stream from
|
||||
// staying open past the visible response completing.
|
||||
if chat.Title == "" && len(chat.Messages) > 0 {
|
||||
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 {
|
||||
chat.Title = title
|
||||
if err := a.store.SaveChat(chat); err != nil {
|
||||
a.logger.WithError(err).WithField("chat_id", chat.ID).Error("failed to update chat")
|
||||
} else {
|
||||
gen.broadcast(&MessageChunk{Chat: toChatNoMessages(chat)})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get Chat History - Fetch before creating the in-progress assistant message so the
|
||||
// LLM request does not include an empty assistant response prefill.
|
||||
chat, err := a.store.GetChat(chatID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get 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 false, fmt.Errorf("failed to add assistant message to 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 false, fmt.Errorf("failed to send initial chunk: %w", err)
|
||||
}
|
||||
responseStarted := true
|
||||
streamToClient := true
|
||||
|
||||
// Send Message
|
||||
if _, err := apiClient.SendMessage(ctx, chat.Messages, chatModel, func(m *client.MessageChunk) error {
|
||||
var apiMsgChunk MessageChunk
|
||||
if _, err := apiClient.SendMessage(ctx, chat.Messages, chatModel, enableThinking, func(m *client.MessageChunk) error {
|
||||
messageChanged := false
|
||||
|
||||
if m.Stats != nil {
|
||||
messageChanged = true
|
||||
assistantMsg.Stats = m.Stats
|
||||
}
|
||||
|
||||
if m.Message != nil {
|
||||
messageChanged = true
|
||||
assistantMsg.Content += *m.Message
|
||||
}
|
||||
|
||||
if m.Thinking != nil {
|
||||
messageChanged = true
|
||||
assistantMsg.Thinking += *m.Thinking
|
||||
}
|
||||
|
||||
// Save Assistant Progress - Persist each streamed update so partial content
|
||||
// survives client disconnects or upstream stream failures.
|
||||
// Save And Broadcast Progress
|
||||
if messageChanged {
|
||||
if err := a.store.SaveChatMessage(assistantMsg); err != nil {
|
||||
return fmt.Errorf("failed to save assistant progress: %w", err)
|
||||
}
|
||||
apiMsgChunk.AssistantMessage = assistantMsg
|
||||
}
|
||||
|
||||
// Send Progress Chunk - If the browser disconnects, keep the detached
|
||||
// generation running and continue saving streamed content to the store.
|
||||
if streamToClient {
|
||||
if err := json.NewEncoder(flushWriter).Encode(apiMsgChunk); err != nil {
|
||||
streamToClient = false
|
||||
a.logger.WithError(err).WithField("chat_id", chat.ID).Warn("client stream disconnected")
|
||||
}
|
||||
gen.broadcast(&MessageChunk{AssistantMessage: assistantMsg})
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return responseStarted, 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")
|
||||
// Handle Stopped Generation
|
||||
if errors.Is(gen.ctx.Err(), context.Canceled) {
|
||||
assistantMsg.Status = store.MessageStatusStopped
|
||||
if saveErr := a.store.SaveChatMessage(assistantMsg); saveErr != nil {
|
||||
a.logger.WithError(saveErr).WithField("chat_id", chat.ID).Error("failed to save stopped assistant message")
|
||||
}
|
||||
gen.broadcast(&MessageChunk{AssistantMessage: assistantMsg})
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Error Generation
|
||||
assistantMsg.Status = store.MessageStatusError
|
||||
if saveErr := a.store.SaveChatMessage(assistantMsg); saveErr != nil {
|
||||
a.logger.WithError(saveErr).WithField("chat_id", chat.ID).Error("failed to save errored assistant message")
|
||||
}
|
||||
gen.broadcast(&MessageChunk{AssistantMessage: assistantMsg})
|
||||
a.logger.WithError(err).WithField("chat_id", chat.ID).Error("failed to generate text stream")
|
||||
return
|
||||
}
|
||||
|
||||
// Update Assistant Message
|
||||
// Handle Stopped Generation
|
||||
if errors.Is(gen.ctx.Err(), context.Canceled) {
|
||||
assistantMsg.Status = store.MessageStatusStopped
|
||||
if err := a.store.SaveChatMessage(assistantMsg); err != nil {
|
||||
a.logger.WithError(err).WithField("chat_id", chat.ID).Error("failed to save stopped assistant message")
|
||||
return
|
||||
}
|
||||
gen.broadcast(&MessageChunk{AssistantMessage: assistantMsg})
|
||||
return
|
||||
}
|
||||
|
||||
// Complete Assistant Message
|
||||
assistantMsg.Status = store.MessageStatusComplete
|
||||
if err := a.store.SaveChatMessage(assistantMsg); err != nil {
|
||||
return responseStarted, fmt.Errorf("failed to save assistant message to chat: %w", err)
|
||||
a.logger.WithError(err).WithField("chat_id", chat.ID).Error("failed to save assistant message")
|
||||
return
|
||||
}
|
||||
|
||||
// Send Final Chunk
|
||||
if streamToClient {
|
||||
if err := json.NewEncoder(flushWriter).Encode(&MessageChunk{
|
||||
Chat: toChatNoMessages(chat),
|
||||
AssistantMessage: assistantMsg,
|
||||
}); err != nil {
|
||||
return responseStarted, fmt.Errorf("failed to send final chunk: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return responseStarted, nil
|
||||
gen.broadcast(&MessageChunk{Chat: toChatNoMessages(chat), AssistantMessage: assistantMsg})
|
||||
}
|
||||
|
||||
@@ -69,16 +69,25 @@ type ImageRecord struct {
|
||||
}
|
||||
|
||||
type GenerateTextRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
Thinking *bool `json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
func (r *GenerateTextRequest) EnableThinking() bool {
|
||||
if r.Thinking == nil {
|
||||
return true
|
||||
}
|
||||
return *r.Thinking
|
||||
}
|
||||
|
||||
func (r *GenerateTextRequest) Validate() error {
|
||||
if r.Model == "" {
|
||||
return errors.New("model is required")
|
||||
}
|
||||
if r.Prompt == "" {
|
||||
return errors.New("prompt is required")
|
||||
if r.Prompt == "" && len(r.Images) == 0 {
|
||||
return errors.New("prompt or images are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func (c *Client) EditImage(ctx context.Context, body openai.ImageEditParams) ([]
|
||||
return resp.Data, nil
|
||||
}
|
||||
|
||||
func (c *Client) SendMessage(ctx context.Context, chatMessages []*store.Message, model string, cb StreamCallback) (string, error) {
|
||||
func (c *Client) SendMessage(ctx context.Context, chatMessages []*store.Message, model string, enableThinking bool, cb StreamCallback) (string, error) {
|
||||
// Ensure Callback
|
||||
if cb == nil {
|
||||
cb = func(mc *MessageChunk) error { return nil }
|
||||
@@ -75,7 +75,7 @@ func (c *Client) SendMessage(ctx context.Context, chatMessages []*store.Message,
|
||||
// Map Messages
|
||||
messages := slices.Map(chatMessages, func(m *store.Message) openai.ChatCompletionMessageParamUnion {
|
||||
if m.Role == "user" {
|
||||
return openai.UserMessage(m.Content)
|
||||
return buildUserMessage(m)
|
||||
}
|
||||
return openai.AssistantMessage(m.Content)
|
||||
})
|
||||
@@ -89,7 +89,8 @@ func (c *Client) SendMessage(ctx context.Context, chatMessages []*store.Message,
|
||||
},
|
||||
}
|
||||
chatReq.SetExtraFields(map[string]any{
|
||||
"timings_per_token": true, // Llama.cpp
|
||||
"timings_per_token": true, // Llama.cpp
|
||||
"chat_template_kwargs": map[string]any{"enable_thinking": enableThinking},
|
||||
})
|
||||
|
||||
// Perform Request & Allocate Stats
|
||||
@@ -115,15 +116,19 @@ func (c *Client) SendMessage(ctx context.Context, chatMessages []*store.Message,
|
||||
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 Thinking - Support both "reasoning_content" (DeepSeek)
|
||||
// and "reasoning" (vLLM) field names.
|
||||
for _, thinkingKey := range []string{"reasoning_content", "reasoning"} {
|
||||
if thinkingField, found := delta.JSON.ExtraFields[thinkingKey]; 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)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +173,7 @@ func (c *Client) CreateTitle(ctx context.Context, userMessage, model string) (st
|
||||
output, err := c.SendMessage(ctx, []*store.Message{{
|
||||
Role: "user",
|
||||
Content: prompt,
|
||||
}}, model, nil)
|
||||
}}, model, false, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sent message: %w", err)
|
||||
}
|
||||
@@ -284,7 +289,38 @@ func populateUsageTimings(msgStats *types.MessageStats, usage openai.CompletionU
|
||||
return didChange
|
||||
}
|
||||
|
||||
func NewClient(baseURL *url.URL) *Client {
|
||||
oaiClient := openai.NewClient(option.WithBaseURL(baseURL.String()))
|
||||
func NewClient(baseURL *url.URL, apiKey string) *Client {
|
||||
opts := []option.RequestOption{option.WithBaseURL(baseURL.String())}
|
||||
if apiKey != "" {
|
||||
opts = append(opts, option.WithAPIKey(apiKey))
|
||||
}
|
||||
oaiClient := openai.NewClient(opts...)
|
||||
return &Client{oaiClient: &oaiClient}
|
||||
}
|
||||
|
||||
func buildUserMessage(m *store.Message) openai.ChatCompletionMessageParamUnion {
|
||||
// Simple Text Message
|
||||
if len(m.Images) == 0 {
|
||||
return openai.UserMessage(m.Content)
|
||||
}
|
||||
|
||||
// Build Multimodal Content Parts
|
||||
parts := make([]openai.ChatCompletionContentPartUnionParam, 0, len(m.Images)+1)
|
||||
|
||||
// Add Image Parts
|
||||
for _, imgURL := range m.Images {
|
||||
parts = append(parts, openai.ImageContentPart(
|
||||
openai.ChatCompletionContentPartImageImageURLParam{
|
||||
URL: imgURL,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
// Add Text Part
|
||||
if m.Content != "" {
|
||||
parts = append(parts, openai.TextContentPart(m.Content))
|
||||
}
|
||||
|
||||
// Build User Message with Content Parts
|
||||
return openai.UserMessage(parts)
|
||||
}
|
||||
|
||||
@@ -3,35 +3,42 @@ package client
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reichard.io/aethera/internal/store"
|
||||
)
|
||||
|
||||
const model = "devstral-small-2-instruct"
|
||||
const model = "qwen3.5-9b-thinking"
|
||||
|
||||
func TestSendMessage(t *testing.T) {
|
||||
t.Skip("skipping integration test against llm-api endpoint")
|
||||
// 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)
|
||||
client := NewClient(baseURL, os.Getenv("AETHERA_LLM_KEY"))
|
||||
|
||||
// Create Context
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Generate Text Stream
|
||||
var buf bytes.Buffer
|
||||
var contentBuf, thinkingBuf bytes.Buffer
|
||||
_, err = client.SendMessage(ctx, []*store.Message{{
|
||||
Role: "user",
|
||||
Content: "Hello, how are you?",
|
||||
}}, model, func(mc *MessageChunk) error {
|
||||
Content: "What is 2+2? Think step by step.",
|
||||
}}, model, true, func(mc *MessageChunk) error {
|
||||
if mc.Thinking != nil {
|
||||
_, err := thinkingBuf.Write([]byte(*mc.Thinking))
|
||||
return err
|
||||
}
|
||||
if mc.Message != nil {
|
||||
_, err := buf.Write([]byte(*mc.Message))
|
||||
_, err := contentBuf.Write([]byte(*mc.Message))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -40,31 +47,38 @@ func TestSendMessage(t *testing.T) {
|
||||
t.Fatalf("Failed to generate text stream: %v", err)
|
||||
}
|
||||
|
||||
// Verify Results
|
||||
output := buf.String()
|
||||
// Verify Thinking
|
||||
thinking := thinkingBuf.String()
|
||||
if thinking == "" {
|
||||
t.Error("No thinking content was received")
|
||||
} else {
|
||||
t.Logf("Thinking (%d bytes): %s", len(thinking), thinking)
|
||||
}
|
||||
|
||||
// Verify Content
|
||||
output := contentBuf.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)
|
||||
t.Logf("Content (%d bytes): %s", len(output), output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeChat(t *testing.T) {
|
||||
t.Skip("skipping integration test against llm-api endpoint")
|
||||
// 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)
|
||||
client := NewClient(baseURL, os.Getenv("AETHERA_LLM_KEY"))
|
||||
|
||||
// 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)
|
||||
output, err := client.CreateTitle(ctx, "Hi!", model)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate text stream: %v", err)
|
||||
}
|
||||
@@ -77,3 +91,48 @@ func TestSummarizeChat(t *testing.T) {
|
||||
t.Logf("Output: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessageWithImage(t *testing.T) {
|
||||
t.Skip("skipping integration test against llm-api endpoint")
|
||||
// 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, os.Getenv("AETHERA_LLM_KEY"))
|
||||
|
||||
// Create Context
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Load Test Image and Convert to Base64 Data URL
|
||||
imgData, err := os.ReadFile("./testdata/test_image.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read test image: %v", err)
|
||||
}
|
||||
dataURL := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(imgData)
|
||||
|
||||
// Generate Text Stream
|
||||
var outputBuf bytes.Buffer
|
||||
_, err = client.SendMessage(ctx, []*store.Message{{
|
||||
Role: "user",
|
||||
Content: "Describe this image in detail.",
|
||||
Images: []string{dataURL},
|
||||
}}, model, true, func(mc *MessageChunk) error {
|
||||
if mc.Message != nil {
|
||||
outputBuf.WriteString(*mc.Message)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate text stream: %v", err)
|
||||
}
|
||||
|
||||
// Verify Response
|
||||
output := outputBuf.String()
|
||||
if output == "" {
|
||||
t.Error("No content was written to the buffer")
|
||||
} else {
|
||||
t.Logf("Model response (%d chars): %s", len(output), output)
|
||||
}
|
||||
}
|
||||
|
||||
BIN
backend/internal/client/testdata/test_image.jpg
vendored
Normal file
BIN
backend/internal/client/testdata/test_image.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -13,12 +13,12 @@ import (
|
||||
"reichard.io/aethera/web"
|
||||
)
|
||||
|
||||
func StartServer(settingsStore store.Store, dataDir, staticDir, listenAddress string, listenPort int) {
|
||||
func StartServer(settingsStore store.Store, dataDir, staticDir, listenAddress string, listenPort int, llmEndpoint, llmKey string) {
|
||||
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)
|
||||
api := api.New(settingsStore, dataDir, logger, llmEndpoint, llmKey)
|
||||
|
||||
// Serve Static Assets
|
||||
if staticDir != "" {
|
||||
@@ -48,6 +48,8 @@ func StartServer(settingsStore store.Store, dataDir, staticDir, listenAddress st
|
||||
mux.HandleFunc("GET /api/chats", api.GetChats)
|
||||
mux.HandleFunc("POST /api/chats", api.PostChat)
|
||||
mux.HandleFunc("GET /api/chats/{chatId}", api.GetChat)
|
||||
mux.HandleFunc("GET /api/chats/{chatId}/stream", api.GetChatStream)
|
||||
mux.HandleFunc("POST /api/chats/{chatId}/stop", api.StopChatGeneration)
|
||||
mux.HandleFunc("POST /api/chats/{chatId}", api.PostChatMessage)
|
||||
mux.HandleFunc("DELETE /api/chats/{chatId}", api.DeleteChat)
|
||||
|
||||
|
||||
@@ -141,10 +141,10 @@ func TestInMemoryStore_SaveSettings(t *testing.T) {
|
||||
store := NewInMemoryStore()
|
||||
|
||||
settings := &Settings{
|
||||
APIEndpoint: "http://example.com",
|
||||
ImageEditSelector: ".image-edit",
|
||||
ImageGenerationSelector: ".image-gen",
|
||||
TextGenerationSelector: ".text-gen",
|
||||
ImageEditSelector: ".image-edit",
|
||||
ImageGenerationSelector: ".image-gen",
|
||||
TextGenerationSelector: ".text-gen",
|
||||
TextGenerationTimeoutMinutes: 10,
|
||||
}
|
||||
|
||||
err := store.SaveSettings(settings)
|
||||
@@ -161,10 +161,10 @@ func TestInMemoryStore_GetSettings(t *testing.T) {
|
||||
|
||||
// Set some settings
|
||||
settings = &Settings{
|
||||
APIEndpoint: "http://example.com",
|
||||
ImageEditSelector: ".image-edit",
|
||||
ImageGenerationSelector: ".image-gen",
|
||||
TextGenerationSelector: ".text-gen",
|
||||
ImageEditSelector: ".image-edit",
|
||||
ImageGenerationSelector: ".image-gen",
|
||||
TextGenerationSelector: ".text-gen",
|
||||
TextGenerationTimeoutMinutes: 10,
|
||||
}
|
||||
err = store.SaveSettings(settings)
|
||||
require.NoError(t, err)
|
||||
@@ -172,5 +172,6 @@ func TestInMemoryStore_GetSettings(t *testing.T) {
|
||||
// Get the settings
|
||||
settings, err = store.GetSettings()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "http://example.com", settings.APIEndpoint)
|
||||
assert.Equal(t, ".image-edit", settings.ImageEditSelector)
|
||||
assert.Equal(t, 10, settings.TextGenerationTimeoutMinutes)
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@ 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"`
|
||||
ImageEditSelector string `json:"image_edit_selector,omitempty"`
|
||||
ImageGenerationSelector string `json:"image_generation_selector,omitempty"`
|
||||
TextGenerationSelector string `json:"text_generation_selector,omitempty"`
|
||||
TextGenerationTimeoutMinutes int `json:"text_generation_timeout_minutes,omitempty"`
|
||||
}
|
||||
|
||||
// FileStore implements the Store interface using a file-based storage
|
||||
|
||||
@@ -209,10 +209,10 @@ func TestFileStore_SaveSettings(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
settings := &Settings{
|
||||
APIEndpoint: "http://example.com",
|
||||
ImageEditSelector: ".image-edit",
|
||||
ImageGenerationSelector: ".image-gen",
|
||||
TextGenerationSelector: ".text-gen",
|
||||
ImageEditSelector: ".image-edit",
|
||||
ImageGenerationSelector: ".image-gen",
|
||||
TextGenerationSelector: ".text-gen",
|
||||
TextGenerationTimeoutMinutes: 10,
|
||||
}
|
||||
|
||||
err = store.SaveSettings(settings)
|
||||
@@ -237,10 +237,10 @@ func TestFileStore_GetSettings(t *testing.T) {
|
||||
|
||||
// Set some settings
|
||||
settings = &Settings{
|
||||
APIEndpoint: "http://example.com",
|
||||
ImageEditSelector: ".image-edit",
|
||||
ImageGenerationSelector: ".image-gen",
|
||||
TextGenerationSelector: ".text-gen",
|
||||
ImageEditSelector: ".image-edit",
|
||||
ImageGenerationSelector: ".image-gen",
|
||||
TextGenerationSelector: ".text-gen",
|
||||
TextGenerationTimeoutMinutes: 10,
|
||||
}
|
||||
err = store.SaveSettings(settings)
|
||||
require.NoError(t, err)
|
||||
@@ -248,5 +248,6 @@ func TestFileStore_GetSettings(t *testing.T) {
|
||||
// Get the settings
|
||||
settings, err = store.GetSettings()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "http://example.com", settings.APIEndpoint)
|
||||
assert.Equal(t, ".image-edit", settings.ImageEditSelector)
|
||||
assert.Equal(t, 10, settings.TextGenerationTimeoutMinutes)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,15 @@ type Chat struct {
|
||||
Messages []*Message `json:"messages"`
|
||||
}
|
||||
|
||||
type MessageStatus string
|
||||
|
||||
const (
|
||||
MessageStatusStreaming MessageStatus = "streaming"
|
||||
MessageStatusComplete MessageStatus = "complete"
|
||||
MessageStatusStopped MessageStatus = "stopped"
|
||||
MessageStatusError MessageStatus = "error"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
baseModel
|
||||
|
||||
@@ -35,5 +44,7 @@ type Message struct {
|
||||
Role string `json:"role"`
|
||||
Thinking string `json:"thinking"`
|
||||
Content string `json:"content"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
Status MessageStatus `json:"status,omitempty"`
|
||||
Stats *types.MessageStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
# Backend
|
||||
air
|
||||
go
|
||||
gopls
|
||||
golangci-lint
|
||||
@@ -38,6 +39,8 @@
|
||||
|
||||
shellHook = ''
|
||||
export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH
|
||||
|
||||
. .env
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,25 +2,61 @@
|
||||
|
||||
## 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)
|
||||
- **Bun** (bundler + package manager — no npm)
|
||||
- **TypeScript** (strict mode, ES2020 target)
|
||||
- **Alpine.js** (reactivity, bundled via `main.ts` — not CDN)
|
||||
- **Tailwind CSS 4** (no config file, just `styles.css` with `@import`)
|
||||
- **marked + marked-highlight + highlight.js** (Markdown rendering)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
bun run lint
|
||||
bun run build # Production build → public/dist/
|
||||
bun run dev # Watch mode (JS + CSS)
|
||||
bun run lint # ESLint
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Layout
|
||||
|
||||
```
|
||||
src/
|
||||
main.ts # Entry point — imports components, starts Alpine
|
||||
client.ts # All API calls (fetch-based, centralized)
|
||||
types/index.ts # Shared TypeScript interfaces
|
||||
utils.ts # Shared utility functions
|
||||
theme.ts # Theme toggle logic
|
||||
components/
|
||||
chatManager.ts # Chat UI — message list, streaming, Markdown
|
||||
imageManager.ts # Image generation/gallery UI
|
||||
settingsManager.ts # Settings form UI
|
||||
themeManager.ts # Theme Alpine component
|
||||
navigationManager.ts # Page navigation Alpine component
|
||||
public/
|
||||
index.html # SPA shell
|
||||
pages/
|
||||
chats.html # Chat page template
|
||||
images.html # Images page template
|
||||
settings.html # Settings page template
|
||||
styles.css # Tailwind entry point
|
||||
```
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **Alpine.js components**: each `*Manager.ts` registers an `Alpine.data(...)` component. State lives in Alpine's reactive scope + localStorage for persistence.
|
||||
- **API client**: all backend calls go through `src/client.ts`. Streaming uses NDJSON via `ReadableStream` reader.
|
||||
- **Build pipeline**: Bun bundles `src/main.ts` → `public/dist/main.js`; Tailwind CLI compiles `styles.css` → `public/dist/styles.css`. Output is copied to `backend/web/static/` by the root `Makefile`.
|
||||
- **No SPA router**: navigation is handled by Alpine component visibility toggling.
|
||||
|
||||
## Non-Negotiables
|
||||
|
||||
- ❌ No `any` type - use `unknown` and narrow it
|
||||
- ❌ No `any` type — use `unknown` and narrow
|
||||
- ❌ No `as` type assertions
|
||||
- ❌ No `@ts-ignore` or `@ts-expect-error`
|
||||
- ❌ Fix all TypeScript and ESLint errors - don't ignore them
|
||||
- ❌ Fix all TypeScript and ESLint errors — don't ignore them
|
||||
- ❌ No Alpine.js via CDN (it's bundled)
|
||||
- ❌ Don't commit `public/dist/`
|
||||
|
||||
## Code Style
|
||||
|
||||
@@ -29,17 +65,4 @@ bun run lint
|
||||
- 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)
|
||||
- User-friendly error messages in UI (not just console.log)
|
||||
|
||||
@@ -4,21 +4,56 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
|
||||
content="width=device-width, initial-scale=0.9, maximum-scale=0.9, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#f8f7ff" />
|
||||
<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"
|
||||
<!-- Nav - Fixed and fully transparent so page content always sits behind
|
||||
it (including under the iOS dynamic island, even at scroll=0). The
|
||||
pill inside provides its own background. Pages clear the nav with
|
||||
padding-top: var(--nav-h). -->
|
||||
<header
|
||||
class="fixed top-0 left-0 right-0 z-50 flex justify-between px-4 md:px-6 pb-3 pointer-events-none"
|
||||
style="padding-top: max(1rem, env(safe-area-inset-top));"
|
||||
>
|
||||
<div class="size-9"></div>
|
||||
<div class="size-9 flex items-center justify-start pointer-events-none">
|
||||
<button
|
||||
x-show="$store.navigation.activeTab === 'chats' && !$store.chatSidebar.mobileOpen"
|
||||
@click="$store.chatSidebar.toggleMobile()"
|
||||
:aria-expanded="$store.chatSidebar.mobileOpen ? 'true' : 'false'"
|
||||
aria-label="Toggle conversation list"
|
||||
class="md:hidden p-2 rounded-md text-primary-700 hover:bg-primary-300 transition-colors cursor-pointer pointer-events-auto"
|
||||
>
|
||||
<svg
|
||||
x-show="!$store.chatSidebar.mobileOpen"
|
||||
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 5l7 7-7 7" />
|
||||
</svg>
|
||||
<svg
|
||||
x-show="$store.chatSidebar.mobileOpen"
|
||||
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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Main Nav -->
|
||||
<nav class="inline-flex bg-primary-100 rounded-full shadow-sm">
|
||||
<nav class="inline-flex bg-primary-100 rounded-full pointer-events-auto">
|
||||
<a
|
||||
href="#/chats"
|
||||
:class="[
|
||||
@@ -58,7 +93,7 @@
|
||||
<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"
|
||||
class="p-2 cursor-pointer rounded-md text-primary-700 hover:bg-primary-300 transition-colors pointer-events-auto"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<svg
|
||||
@@ -107,9 +142,9 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main id="page-content" class="h-dvh"></main>
|
||||
<!-- Main Content Area - No fixed height; the document scrolls. -->
|
||||
<main id="page-content"></main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,397 +1,501 @@
|
||||
<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"
|
||||
:class="$store.chatSidebar.collapsed ? 'md:grid-cols-[3.5rem_minmax(0,1fr)]' : 'md:grid-cols-[18rem_minmax(0,1fr)]'"
|
||||
class="md:grid transition-[grid-template-columns] duration-300 ease-out"
|
||||
>
|
||||
<template x-for="message in currentChatMessages" :key="message.content">
|
||||
<div
|
||||
:class="['mb-4', message.role === 'user' ? 'text-right' : 'text-left']"
|
||||
>
|
||||
<!-- Desktop Sidebar - Flat rail flush with the page. Extends the full
|
||||
viewport height; the transparent nav floats above its top region.
|
||||
Internal padding-top clears the nav. -->
|
||||
<aside
|
||||
class="hidden md:flex md:flex-col md:sticky top-0 border-r border-primary-200/60"
|
||||
style="height: 100dvh;"
|
||||
>
|
||||
<!-- Expanded Rail -->
|
||||
<template x-if="!$store.chatSidebar.collapsed">
|
||||
<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'
|
||||
]"
|
||||
class="flex flex-col h-full"
|
||||
style="padding-top: max(1rem, env(safe-area-inset-top));"
|
||||
>
|
||||
<!-- 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="px-2 pb-3 flex items-center gap-1">
|
||||
<button
|
||||
@click="$store.chatSidebar.toggleCollapsed()"
|
||||
class="size-9 flex items-center justify-center rounded-md text-primary-500 hover:bg-primary-200 hover:text-primary-700 transition-colors cursor-pointer flex-shrink-0"
|
||||
title="Collapse sidebar"
|
||||
aria-label="Collapse sidebar"
|
||||
>
|
||||
<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 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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="selectChat(null)"
|
||||
class="size-9 flex items-center justify-center rounded-md text-primary-700 hover:bg-primary-200 transition-colors cursor-pointer"
|
||||
title="New conversation"
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-2 pb-4">
|
||||
<div
|
||||
x-show="chats.length === 0"
|
||||
class="h-full flex flex-col justify-center text-center py-8 text-primary-500"
|
||||
>
|
||||
<p class="text-sm">No chats yet</p>
|
||||
</div>
|
||||
|
||||
<template x-for="group in chatGroups" :key="group.label">
|
||||
<div class="mt-2 first:mt-0">
|
||||
<div
|
||||
class="px-3 pt-3 pb-1 text-[10px] uppercase tracking-wider text-primary-500/70 font-medium"
|
||||
x-text="group.label"
|
||||
></div>
|
||||
<div class="space-y-0.5">
|
||||
<template x-for="chat in group.chats" :key="chat.id">
|
||||
<div
|
||||
@click="selectChat(chat.id)"
|
||||
:class="selectedChatID === chat.id ? 'bg-primary-200' : 'hover:bg-primary-200/60'"
|
||||
class="group relative px-3 py-2 rounded-lg cursor-pointer transition-colors"
|
||||
:title="chat.title || chat.initial_message"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div
|
||||
:class="selectedChatID === chat.id ? 'font-semibold' : 'font-medium'"
|
||||
class="text-sm text-primary-900 truncate min-w-0 flex-1"
|
||||
x-text="chat.title || chat.initial_message || 'New conversation'"
|
||||
></div>
|
||||
<button
|
||||
@click.stop="deleteChat($event, chat.id)"
|
||||
class="opacity-0 group-hover:opacity-100 focus:opacity-100 shrink-0 p-1 -mr-1 text-primary-400 hover:text-tertiary-600 hover:bg-tertiary-100 rounded transition-opacity cursor-pointer"
|
||||
title="Delete chat"
|
||||
aria-label="Delete chat"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" 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
|
||||
x-show="chat.title && chat.initial_message"
|
||||
class="text-xs text-primary-500/80 truncate mt-0.5"
|
||||
x-text="chat.initial_message"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 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"
|
||||
<!-- Collapsed Icon Rail -->
|
||||
<template x-if="$store.chatSidebar.collapsed">
|
||||
<div
|
||||
class="flex flex-col items-center px-2 pb-3 gap-1"
|
||||
style="padding-top: max(1rem, env(safe-area-inset-top));"
|
||||
>
|
||||
<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"
|
||||
<button
|
||||
@click="$store.chatSidebar.toggleCollapsed()"
|
||||
class="size-9 flex items-center justify-center rounded-md text-primary-500 hover:bg-primary-200 hover:text-primary-700 transition-colors cursor-pointer"
|
||||
title="Expand sidebar"
|
||||
aria-label="Expand sidebar"
|
||||
>
|
||||
<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>
|
||||
<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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="selectChat(null)"
|
||||
class="size-9 flex items-center justify-center rounded-md text-primary-700 hover:bg-primary-200 transition-colors cursor-pointer"
|
||||
title="New conversation"
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<!-- Main Chat Column - min-h ensures the sticky input pins to the viewport bottom
|
||||
even when there are few messages (the column always fills the visible area).
|
||||
The reading column (max-w-3xl) is centered within whatever space the grid gives. -->
|
||||
<div
|
||||
class="flex flex-col min-w-0 px-4 md:px-6"
|
||||
style="min-height: 100dvh;"
|
||||
>
|
||||
<!-- Messages (oldest first, newest last) -->
|
||||
<div
|
||||
class="flex-1 flex flex-col gap-1 pb-4 w-full"
|
||||
style="padding-top: var(--nav-h);"
|
||||
>
|
||||
<template x-for="message in currentChatMessages" :key="message.id">
|
||||
<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%] border shadow-sm',
|
||||
message.role === 'user'
|
||||
? 'bg-primary-100/75 border-primary-300/30 text-primary-900 rounded-br-none'
|
||||
: 'bg-primary-200/65 border-primary-300/40 text-primary-900 rounded-bl-none'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
x-show="message.role === 'user' && message.images && message.images.length > 0"
|
||||
class="flex gap-1 mb-2 flex-wrap"
|
||||
>
|
||||
<template x-for="(img, imgIdx) in message.images" :key="imgIdx">
|
||||
<img :src="img" class="max-w-full h-auto rounded-lg max-h-48" />
|
||||
</template>
|
||||
</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
|
||||
x-show="message.thinking"
|
||||
x-data="{ expanded: false }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="expanded = !expanded"
|
||||
@keydown.enter.prevent="expanded = !expanded"
|
||||
@keydown.space.prevent="expanded = !expanded"
|
||||
class="mb-3 rounded-md border-l-2 border-secondary-500/60 bg-secondary-100/35 px-3 py-2 ring-1 ring-secondary-400/20 cursor-pointer"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-xs font-medium text-secondary-700 transition-colors">
|
||||
<span x-text="expanded ? '▾' : '▸'"></span>
|
||||
<span>Reasoning</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>
|
||||
x-show="expanded"
|
||||
class="prose mt-2 max-w-none text-xs text-secondary-700 opacity-90 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
|
||||
x-show="message.role === 'assistant' && message.status === 'streaming' && !message.thinking && !message.content"
|
||||
class="flex items-center gap-2 py-1"
|
||||
>
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-secondary-500 border-t-transparent"></div>
|
||||
<span class="text-xs text-secondary-600">Thinking...</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="message.content || message.status !== 'streaming'"
|
||||
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>
|
||||
|
||||
<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
|
||||
x-show="message.role === 'assistant' && ['stopped', 'error', 'failed'].includes(message.status)"
|
||||
:class="message.status === 'stopped' ? 'bg-red-500/15 text-red-700 ring-red-500/20' : 'bg-red-600 text-white ring-red-700/20'"
|
||||
class="px-2 py-0.5 rounded-full text-[10px] font-medium ring-1"
|
||||
x-text="message.status === 'stopped' ? 'Stopped' : 'Error'"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="message.role === 'assistant' && message.stats"
|
||||
class="flex items-center gap-1 py-2 flex-wrap justify-start text-primary-600"
|
||||
>
|
||||
<div
|
||||
x-show="message.stats?.prompt_tokens || message.stats?.generated_tokens"
|
||||
class="group relative px-2 py-0.5 text-[10px] bg-primary-200/40 rounded-full cursor-help"
|
||||
>
|
||||
<span x-text="`${(message.stats?.prompt_tokens || 0) + (message.stats?.generated_tokens || 0)} tokens`"></span>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<span
|
||||
x-show="message.stats?.prompt_per_second"
|
||||
class="px-2 py-0.5 text-[10px] bg-primary-200/40 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-200/40 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-200/40 rounded-full"
|
||||
x-text="`${(message.stats?.time_to_first_token / 1000).toFixed(2)}s TTFT`"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Left Nav Footer -->
|
||||
<!-- Sticky Input Bar -->
|
||||
<div
|
||||
class="sticky bottom-0 pt-2 bg-primary-50/95"
|
||||
style="padding-bottom: max(0.5rem, env(safe-area-inset-bottom));"
|
||||
>
|
||||
<form
|
||||
@submit.prevent="sendMessage"
|
||||
class="flex flex-col gap-2 p-3 bg-primary-100/35 rounded-2xl border border-primary-300/40 shadow-sm"
|
||||
>
|
||||
<input
|
||||
x-ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/webp"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="
|
||||
Array.from($event.target.files).forEach(file => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => { selectedImages.push(e.target.result); };
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
$event.target.value = '';
|
||||
"
|
||||
/>
|
||||
|
||||
<div x-show="selectedImages.length > 0" class="flex gap-2 flex-wrap">
|
||||
<template x-for="(img, idx) in selectedImages" :key="idx">
|
||||
<div class="relative">
|
||||
<img :src="img" class="w-20 h-20 object-cover rounded-lg" />
|
||||
<button
|
||||
type="button"
|
||||
@click="selectedImages.splice(idx, 1)"
|
||||
class="absolute -top-1 -right-1 w-5 h-5 bg-tertiary-700 text-white rounded-full flex items-center justify-center text-xs hover:bg-tertiary-900"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
x-model="inputMessage"
|
||||
placeholder="Type your message..."
|
||||
rows="2"
|
||||
class="scrollbar-hide w-full px-2 py-1 bg-transparent border-0 focus:outline-none focus:ring-0 text-primary-900 text-sm 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>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative min-w-0 flex-shrink inline-flex items-center pl-7 pr-6 py-1 bg-primary-100/60 hover:bg-primary-200/70 border border-primary-300/50 rounded-full transition-colors">
|
||||
<span
|
||||
class="text-primary-900 text-xs font-medium truncate pointer-events-none"
|
||||
x-text="(models.find(m => m.id === selectedModel)?.name) || selectedModel || 'Select Model'"
|
||||
></span>
|
||||
<select
|
||||
x-model="selectedModel"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer focus:outline-none"
|
||||
aria-label="Select model"
|
||||
>
|
||||
<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>
|
||||
<svg class="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary-500 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>
|
||||
<svg class="absolute right-1.5 top-1/2 -translate-y-1/2 h-3 w-3 text-primary-500 pointer-events-none" 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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="thinkingEnabled = !thinkingEnabled"
|
||||
:class="thinkingEnabled
|
||||
? 'bg-secondary-600 text-white border-secondary-600 hover:bg-secondary-700'
|
||||
: 'bg-transparent text-secondary-500 border-secondary-300 hover:bg-secondary-200/70'"
|
||||
class="relative h-7 w-7 rounded-full border transition-colors flex items-center justify-center flex-shrink-0 cursor-pointer"
|
||||
:title="thinkingEnabled ? 'Thinking on' : 'Thinking off'"
|
||||
:aria-pressed="thinkingEnabled ? 'true' : 'false'"
|
||||
aria-label="Toggle thinking"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5a3 3 0 0 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 0 0 12 21z" />
|
||||
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 0 1 12 21" />
|
||||
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4" />
|
||||
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375" />
|
||||
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" />
|
||||
<path d="M3.477 10.896a4 4 0 0 1 .585-.396" />
|
||||
<path d="M19.938 10.5a4 4 0 0 1 .585.396" />
|
||||
<path d="M6 18a4 4 0 0 1-1.967-.516" />
|
||||
<path d="M19.967 17.484A4 4 0 0 1 18 18" />
|
||||
</svg>
|
||||
<svg
|
||||
x-show="!thinkingEnabled"
|
||||
class="absolute inset-0 h-full w-full text-primary-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="5" y1="19" x2="19" y2="5" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.fileInput.click()"
|
||||
class="h-7 w-7 bg-primary-100/70 text-primary-700 border border-primary-300/50 rounded-full transition-colors flex items-center justify-center flex-shrink-0 hover:bg-primary-200/70"
|
||||
title="Attach Image"
|
||||
aria-label="Attach Image"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
x-show="loading"
|
||||
type="button"
|
||||
@click="stopResponse()"
|
||||
:disabled="!activeStreamChatID"
|
||||
:class="!activeStreamChatID ? 'opacity-50 cursor-not-allowed' : 'hover:bg-tertiary-700'"
|
||||
class="h-7 w-7 bg-tertiary-600 text-white rounded-full transition-colors flex items-center justify-center flex-shrink-0"
|
||||
title="Stop response"
|
||||
aria-label="Stop response"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 6h12v12H6z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="(!inputMessage.trim() && selectedImages.length === 0) || loading"
|
||||
:class=" ((!inputMessage.trim() && selectedImages.length === 0) || loading) ? 'opacity-50 cursor-not-allowed' : 'hover:bg-primary-600'"
|
||||
class="h-7 w-7 bg-gradient-to-r from-primary-600 to-primary-500 text-white rounded-full transition-all flex items-center justify-center flex-shrink-0"
|
||||
title="Send"
|
||||
aria-label="Send"
|
||||
>
|
||||
<template x-if="loading">
|
||||
<div class="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
|
||||
</template>
|
||||
<template x-if="!loading">
|
||||
<svg class="h-3.5 w-3.5" 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="error"
|
||||
class="bg-tertiary-50 border border-tertiary-200 rounded-lg px-4 py-2"
|
||||
>
|
||||
<p class="text-sm text-tertiary-700" x-text="error"></p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Sidebar Drawer (controlled from the top nav hamburger) -->
|
||||
<div
|
||||
x-show="$store.chatSidebar.mobileOpen"
|
||||
x-cloak
|
||||
x-transition.opacity
|
||||
@click="$store.chatSidebar.mobileOpen = false"
|
||||
class="md:hidden fixed inset-0 z-60 bg-black/40"
|
||||
></div>
|
||||
<div
|
||||
x-show="$store.chatSidebar.mobileOpen"
|
||||
x-cloak
|
||||
x-transition:enter="transform transition-transform duration-300 ease-out"
|
||||
x-transition:enter-start="-translate-x-full"
|
||||
x-transition:enter-end="translate-x-0"
|
||||
x-transition:leave="transform transition-transform duration-300 ease-in"
|
||||
x-transition:leave-start="translate-x-0"
|
||||
x-transition:leave-end="-translate-x-full"
|
||||
class="md:hidden fixed top-0 left-0 bottom-0 z-60 w-[calc(100vw-3.5rem)] max-w-none bg-primary-50 flex flex-col"
|
||||
>
|
||||
<div
|
||||
x-show="$store.navigation.activeTab === 'chats'"
|
||||
class="p-4 border-t border-primary-200 shrink-0"
|
||||
class="px-3 pb-3 flex items-center gap-2 border-b border-primary-200/60"
|
||||
style="padding-top: max(1rem, env(safe-area-inset-top));"
|
||||
>
|
||||
<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"
|
||||
@click="$store.chatSidebar.mobileOpen = false"
|
||||
class="size-9 flex items-center justify-center rounded-md text-primary-500 hover:bg-primary-200 hover:text-primary-700 transition-colors cursor-pointer flex-shrink-0"
|
||||
title="Collapse sidebar"
|
||||
aria-label="Collapse sidebar"
|
||||
>
|
||||
<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 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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
New Conversation
|
||||
</button>
|
||||
<button
|
||||
@click="selectChat(null); $store.chatSidebar.mobileOpen = false"
|
||||
class="size-9 flex items-center justify-center rounded-md text-primary-700 hover:bg-primary-200 transition-colors cursor-pointer"
|
||||
title="New conversation"
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-2 pb-4">
|
||||
<div
|
||||
x-show="chats.length === 0"
|
||||
class="h-full flex flex-col justify-center text-center py-8 text-primary-500"
|
||||
>
|
||||
<p class="text-sm">No chats yet</p>
|
||||
</div>
|
||||
|
||||
<template x-for="group in chatGroups" :key="group.label">
|
||||
<div class="mt-2 first:mt-0">
|
||||
<div
|
||||
class="px-3 pt-3 pb-1 text-[10px] uppercase tracking-wider text-primary-500/70 font-medium"
|
||||
x-text="group.label"
|
||||
></div>
|
||||
<div class="space-y-0.5">
|
||||
<template x-for="chat in group.chats" :key="chat.id">
|
||||
<div
|
||||
@click="selectChat(chat.id); $store.chatSidebar.mobileOpen = false"
|
||||
:class="selectedChatID === chat.id ? 'bg-primary-200' : 'hover:bg-primary-200/60'"
|
||||
class="group relative px-3 py-2 rounded-lg cursor-pointer transition-colors"
|
||||
:title="chat.title || chat.initial_message"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div
|
||||
:class="selectedChatID === chat.id ? 'font-semibold' : 'font-medium'"
|
||||
class="text-sm text-primary-900 truncate min-w-0 flex-1"
|
||||
x-text="chat.title || chat.initial_message || 'New conversation'"
|
||||
></div>
|
||||
<button
|
||||
@click.stop="deleteChat($event, chat.id)"
|
||||
class="shrink-0 p-1 -mr-1 text-primary-400 hover:text-tertiary-600 hover:bg-tertiary-100 rounded transition-colors cursor-pointer"
|
||||
title="Delete chat"
|
||||
aria-label="Delete chat"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" 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
|
||||
x-show="chat.title && chat.initial_message"
|
||||
class="text-xs text-primary-500/80 truncate mt-0.5"
|
||||
x-text="chat.initial_message"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<div
|
||||
class="flex flex-col gap-4 pt-16 mx-auto px-4 md:px-6 max-w-6xl"
|
||||
class="flex flex-col gap-4 pb-4 mx-auto px-4 md:px-6 max-w-6xl"
|
||||
style="padding-top: var(--nav-h);"
|
||||
x-data="imageGenerator()"
|
||||
>
|
||||
<div>
|
||||
@@ -145,13 +146,13 @@
|
||||
</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"
|
||||
class="mt-2 px-3 py-1 cursor-pointer bg-tertiary-100 text-tertiary-700 rounded hover:bg-tertiary-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"
|
||||
class="mt-2 px-3 py-1 cursor-pointer bg-tertiary-100 text-tertiary-700 rounded hover:bg-tertiary-600 hover:text-white text-center transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</span>
|
||||
@@ -163,7 +164,7 @@
|
||||
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"
|
||||
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-secondary-600 hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary-500 disabled:opacity-50 gap-2 transition-colors"
|
||||
>
|
||||
<span
|
||||
x-text="loading ? '' : editMode ? 'Edit Image' : 'Generate Image'"
|
||||
@@ -199,7 +200,7 @@
|
||||
>
|
||||
<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"
|
||||
class="flex flex-col gap-2 break-inside-avoid border border-secondary-200 rounded-lg p-2 mb-2 h-full bg-secondary-50/60 hover:border-secondary-300 transition-colors shadow"
|
||||
>
|
||||
<button
|
||||
@click="deleteImage(image.name)"
|
||||
@@ -214,7 +215,7 @@
|
||||
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"
|
||||
class="text-xs text-secondary-700 bg-secondary-100 px-2 py-1 rounded flex justify-center"
|
||||
x-text="image.date"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,9 @@
|
||||
<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"
|
||||
class="p-0.5 w-full flex flex-col gap-4 pb-4 mx-auto px-4 md:px-6 max-w-6xl"
|
||||
style="padding-top: var(--nav-h);"
|
||||
>
|
||||
<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
|
||||
@@ -82,6 +64,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<span class="text-sm font-medium font-semibold text-primary-700"
|
||||
>Generation</span
|
||||
>
|
||||
<div class="flex flex-col md:flex-row pl-1 gap-4 justify-between">
|
||||
<div class="w-full md:w-1/3">
|
||||
<label
|
||||
for="textGenerationTimeout"
|
||||
class="text-sm font-medium text-primary-700"
|
||||
>Chat Timeout</label
|
||||
>
|
||||
<select
|
||||
id="textGenerationTimeout"
|
||||
name="textGenerationTimeout"
|
||||
x-model.number="settings.text_generation_timeout_minutes"
|
||||
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"
|
||||
>
|
||||
<template x-for="minutes in timeoutOptions" x-bind:key="minutes">
|
||||
<option x-bind:value="minutes" x-text="`${minutes}m`"></option>
|
||||
</template>
|
||||
</select>
|
||||
<p class="mt-2 text-xs text-primary-500">
|
||||
Maximum time a chat response can stream before timing out
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="error"
|
||||
class="bg-tertiary-50 border border-tertiary-200 rounded-md p-4"
|
||||
|
||||
@@ -90,16 +90,51 @@ export async function sendMessage(
|
||||
requestData: GenerateTextRequest,
|
||||
onChunk: (chunk: MessageChunk) => void,
|
||||
) {
|
||||
const initialChunk = await startMessage(chatId, requestData);
|
||||
onChunk(initialChunk);
|
||||
|
||||
if (!initialChunk.chat) return;
|
||||
return streamChatUpdates(initialChunk.chat.id, onChunk);
|
||||
}
|
||||
|
||||
export async function startMessage(
|
||||
chatId: string,
|
||||
requestData: GenerateTextRequest,
|
||||
): Promise<MessageChunk> {
|
||||
const url = chatId ? `/api/chats/${chatId}` : '/api/chats';
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(readError(data) || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function streamChatUpdates(
|
||||
chatId: string,
|
||||
onChunk: (chunk: MessageChunk) => void,
|
||||
) {
|
||||
const response = await fetch(`/api/chats/${chatId}/stream?_t=${Date.now()}`);
|
||||
return streamMessage(response, onChunk);
|
||||
}
|
||||
|
||||
export async function stopChatGeneration(chatId: string): Promise<void> {
|
||||
const response = await fetch(`/api/chats/${chatId}/stop`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(readError(errorData) || `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChatMessages(chatId: string): Promise<Chat> {
|
||||
const response = await fetch(`/api/chats/${chatId}`);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
@@ -133,6 +168,15 @@ export async function deleteChat(chatId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function readError(data: unknown): string {
|
||||
if (typeof data !== 'object' || data === null || !('error' in data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const error = data.error;
|
||||
return typeof error === 'string' ? error : '';
|
||||
}
|
||||
|
||||
async function streamMessage(
|
||||
response: Response,
|
||||
onChunk: (chunk: MessageChunk) => void,
|
||||
|
||||
@@ -6,15 +6,19 @@ import {
|
||||
getSettings,
|
||||
getModels,
|
||||
sendMessage,
|
||||
streamChatUpdates,
|
||||
stopChatGeneration,
|
||||
getChatMessages,
|
||||
listChats,
|
||||
deleteChat,
|
||||
} from '../client';
|
||||
import { Chat, Message, MessageChunk, Model, Settings } from '../types';
|
||||
import { applyFilter } from '../utils';
|
||||
import { createAutoScroll, AutoScroll } from '../utils/autoScroll';
|
||||
|
||||
const CHAT_ROUTE = '#/chats';
|
||||
const MODEL_KEY = 'aethera-chat-model';
|
||||
const THINKING_KEY = 'aethera-chat-thinking';
|
||||
const IN_PROGRESS_UUID = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
// Markdown Renderer
|
||||
@@ -35,14 +39,20 @@ Alpine.data('chatManager', () => ({
|
||||
_models: [] as Model[],
|
||||
|
||||
selectedModel: '',
|
||||
thinkingEnabled: localStorage.getItem(THINKING_KEY) !== 'false',
|
||||
inputMessage: '',
|
||||
selectedImages: [] as string[],
|
||||
error: '',
|
||||
|
||||
selectedChatID: null as string | null,
|
||||
chatListOpen: false,
|
||||
loading: false,
|
||||
activeStreamChatID: null as string | null,
|
||||
|
||||
_autoScroll: null as AutoScroll | null,
|
||||
|
||||
async init() {
|
||||
this._autoScroll = createAutoScroll();
|
||||
|
||||
// Acquire Data
|
||||
this._models = await getModels();
|
||||
this.settings = await getSettings();
|
||||
@@ -52,6 +62,7 @@ Alpine.data('chatManager', () => ({
|
||||
// Route Chat
|
||||
const chatID = window.location.hash.split('/')[2];
|
||||
if (chatID) await this.selectChat(chatID);
|
||||
this._autoScroll.scrollToBottom();
|
||||
},
|
||||
|
||||
async loadChats() {
|
||||
@@ -76,7 +87,6 @@ Alpine.data('chatManager', () => ({
|
||||
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);
|
||||
@@ -84,17 +94,32 @@ Alpine.data('chatManager', () => ({
|
||||
}
|
||||
},
|
||||
|
||||
async stopResponse() {
|
||||
if (!this.activeStreamChatID) return;
|
||||
|
||||
// Stop Active Generation
|
||||
try {
|
||||
await stopChatGeneration(this.activeStreamChatID);
|
||||
} catch (err) {
|
||||
console.error('Error stopping response:', err);
|
||||
this.error = parseError(err);
|
||||
}
|
||||
},
|
||||
|
||||
async sendMessage() {
|
||||
const message = this.inputMessage.trim();
|
||||
if (!message || this.loading) return;
|
||||
if ((!message && this.selectedImages.length === 0) || this.loading) return;
|
||||
|
||||
// Update State
|
||||
const images = [...this.selectedImages];
|
||||
this.inputMessage = '';
|
||||
this.selectedImages = [];
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
// Save Model
|
||||
if (this.selectedModel) localStorage.setItem(MODEL_KEY, this.selectedModel);
|
||||
localStorage.setItem(THINKING_KEY, String(this.thinkingEnabled));
|
||||
|
||||
// New Chat
|
||||
if (!this.selectedChatID) {
|
||||
@@ -109,66 +134,29 @@ Alpine.data('chatManager', () => ({
|
||||
this.selectedChatID = IN_PROGRESS_UUID;
|
||||
}
|
||||
|
||||
// New User Message
|
||||
let userMessage: Message = {
|
||||
// Add Optimistic User Message
|
||||
const currentChat: Chat = this.chats.find(
|
||||
(c) => c.id === this.selectedChatID,
|
||||
)!;
|
||||
currentChat.messages.push({
|
||||
id: IN_PROGRESS_UUID,
|
||||
chat_id: this.selectedChatID,
|
||||
role: 'user',
|
||||
thinking: '',
|
||||
content: message,
|
||||
images: images,
|
||||
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;
|
||||
this._autoScroll?.scrollToBottom('smooth');
|
||||
|
||||
try {
|
||||
await sendMessage(
|
||||
this.selectedChatID === IN_PROGRESS_UUID ? '' : this.selectedChatID,
|
||||
{ model: this.selectedModel, prompt: message },
|
||||
{ model: this.selectedModel, prompt: message, images, thinking: this.thinkingEnabled },
|
||||
(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];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chunk.chat) this.activeStreamChatID = chunk.chat.id;
|
||||
this.applyMessageChunk(chunk);
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -176,9 +164,61 @@ Alpine.data('chatManager', () => ({
|
||||
this.error = parseError(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.activeStreamChatID = null;
|
||||
}
|
||||
},
|
||||
|
||||
applyMessageChunk(chunk: MessageChunk) {
|
||||
// Handle Chat
|
||||
if (chunk.chat) {
|
||||
let chat = this.chats.find((c) => c.id === chunk.chat!.id);
|
||||
if (!chat) chat = this.chats.find((c) => c.id === IN_PROGRESS_UUID);
|
||||
if (!chat) {
|
||||
chat = { ...chunk.chat, messages: chunk.chat.messages || [] };
|
||||
this.chats.unshift(chat);
|
||||
} else {
|
||||
// Preserve Messages - Object.assign would overwrite the existing
|
||||
// messages array before we can check whether the chunk has any.
|
||||
const existingMessages = chat.messages;
|
||||
Object.assign(chat, chunk.chat);
|
||||
chat.messages = chunk.chat.messages?.length
|
||||
? chunk.chat.messages
|
||||
: existingMessages;
|
||||
}
|
||||
this.selectedChatID = chunk.chat.id;
|
||||
this.updateHash(chunk.chat.id);
|
||||
}
|
||||
|
||||
const chatID = chunk.chat?.id || this.selectedChatID;
|
||||
const currentChat = this.chats.find((c) => c.id === chatID);
|
||||
if (!currentChat) return;
|
||||
|
||||
// Handle Messages
|
||||
if (chunk.user_message) this.upsertMessage(currentChat, chunk.user_message);
|
||||
if (chunk.assistant_message)
|
||||
this.upsertMessage(currentChat, chunk.assistant_message);
|
||||
|
||||
this._autoScroll?.maybeScrollToBottom();
|
||||
},
|
||||
|
||||
upsertMessage(chat: Chat, message: Message) {
|
||||
// Upsert Message
|
||||
const existingIndex = chat.messages.findIndex(
|
||||
(m) =>
|
||||
m.id === message.id ||
|
||||
(m.id === IN_PROGRESS_UUID && m.role === message.role),
|
||||
);
|
||||
if (existingIndex === -1) {
|
||||
chat.messages.push(message);
|
||||
} else {
|
||||
chat.messages[existingIndex] = {
|
||||
...chat.messages[existingIndex],
|
||||
...message,
|
||||
};
|
||||
}
|
||||
chat.messages = [...chat.messages];
|
||||
},
|
||||
|
||||
updateHash(chatID: string | null) {
|
||||
const newRoute = CHAT_ROUTE + (chatID ? '/' + chatID : '');
|
||||
window.history.pushState(null, '', newRoute);
|
||||
@@ -189,8 +229,10 @@ Alpine.data('chatManager', () => ({
|
||||
|
||||
// Load Messages
|
||||
this.selectedChatID = chatID;
|
||||
if (!this.selectedChatID) this.chatListOpen = false;
|
||||
else this.loadChatMessages();
|
||||
if (this.selectedChatID) {
|
||||
await this.loadChatMessages();
|
||||
this._autoScroll?.scrollToBottom();
|
||||
}
|
||||
},
|
||||
|
||||
async loadChatMessages() {
|
||||
@@ -202,13 +244,46 @@ Alpine.data('chatManager', () => ({
|
||||
(c) => c.id == this.selectedChatID,
|
||||
);
|
||||
|
||||
this.chats[chatIndex].messages = response.messages || [];
|
||||
if (chatIndex === -1) return;
|
||||
this.chats[chatIndex] = {
|
||||
...this.chats[chatIndex],
|
||||
...response,
|
||||
messages: response.messages || [],
|
||||
};
|
||||
await this.reconnectChatStream(response);
|
||||
} catch (err) {
|
||||
console.error('Error loading chat messages:', err);
|
||||
this.error = 'Failed to load messages';
|
||||
}
|
||||
},
|
||||
|
||||
async reconnectChatStream(chat: Chat) {
|
||||
const latestMessage = chat.messages[chat.messages.length - 1];
|
||||
if (
|
||||
!latestMessage ||
|
||||
latestMessage.role !== 'assistant' ||
|
||||
latestMessage.status !== 'streaming' ||
|
||||
this.activeStreamChatID === chat.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconnect Stream
|
||||
this.loading = true;
|
||||
this.activeStreamChatID = chat.id;
|
||||
try {
|
||||
await streamChatUpdates(chat.id, (chunk: MessageChunk) =>
|
||||
this.applyMessageChunk(chunk),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error reconnecting chat stream:', err);
|
||||
this.error = parseError(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.activeStreamChatID = null;
|
||||
}
|
||||
},
|
||||
|
||||
get models(): Model[] {
|
||||
if (!this.settings.text_generation_selector) return this._models;
|
||||
return applyFilter(this._models, this.settings.text_generation_selector);
|
||||
@@ -219,7 +294,11 @@ Alpine.data('chatManager', () => ({
|
||||
const currentChat =
|
||||
this.chats.find((c) => c.id === this.selectedChatID) ?? null;
|
||||
if (!currentChat) return [];
|
||||
return [...currentChat.messages].reverse();
|
||||
return currentChat.messages;
|
||||
},
|
||||
|
||||
get chatGroups(): { label: string; chats: Chat[] }[] {
|
||||
return groupChatsByDay(this.chats);
|
||||
},
|
||||
|
||||
renderMarkdown(content: string) {
|
||||
@@ -227,6 +306,47 @@ Alpine.data('chatManager', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function startOfDay(d: Date): number {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
}
|
||||
|
||||
function groupChatsByDay(
|
||||
chats: Chat[],
|
||||
): { label: string; chats: Chat[] }[] {
|
||||
const now = new Date();
|
||||
const today = startOfDay(now);
|
||||
const yesterday = today - 86_400_000;
|
||||
|
||||
const groups: { key: number; label: string; chats: Chat[] }[] = [];
|
||||
let current: { key: number; label: string; chats: Chat[] } | null = null;
|
||||
|
||||
for (const chat of chats) {
|
||||
const created = new Date(chat.created_at);
|
||||
const day = startOfDay(created);
|
||||
|
||||
if (!current || current.key !== day) {
|
||||
let label: string;
|
||||
if (day === today) label = 'Today';
|
||||
else if (day === yesterday) label = 'Yesterday';
|
||||
else {
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
};
|
||||
if (created.getFullYear() !== now.getFullYear())
|
||||
opts.year = 'numeric';
|
||||
label = created.toLocaleDateString(undefined, opts);
|
||||
}
|
||||
current = { key: day, label, chats: [] };
|
||||
groups.push(current);
|
||||
}
|
||||
|
||||
current.chats.push(chat);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function parseError(err: unknown): string {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
|
||||
24
frontend/src/components/chatSidebarStore.ts
Normal file
24
frontend/src/components/chatSidebarStore.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import Alpine from 'alpinejs';
|
||||
|
||||
const COLLAPSED_KEY = 'aethera-chat-sidebar-collapsed';
|
||||
|
||||
interface ChatSidebarStore {
|
||||
mobileOpen: boolean;
|
||||
collapsed: boolean;
|
||||
toggleMobile(): void;
|
||||
toggleCollapsed(): void;
|
||||
}
|
||||
|
||||
const store: ChatSidebarStore = {
|
||||
mobileOpen: false,
|
||||
collapsed: localStorage.getItem(COLLAPSED_KEY) === 'true',
|
||||
toggleMobile() {
|
||||
this.mobileOpen = !this.mobileOpen;
|
||||
},
|
||||
toggleCollapsed() {
|
||||
this.collapsed = !this.collapsed;
|
||||
localStorage.setItem(COLLAPSED_KEY, String(this.collapsed));
|
||||
},
|
||||
};
|
||||
|
||||
Alpine.store('chatSidebar', store);
|
||||
@@ -4,12 +4,14 @@ import { Settings } from '../types';
|
||||
|
||||
Alpine.data('settingsManager', () => ({
|
||||
settings: {} as Settings,
|
||||
timeoutOptions: [1, 5, 10, 15, 30],
|
||||
loading: false,
|
||||
saved: false,
|
||||
error: '',
|
||||
|
||||
async init() {
|
||||
this.settings = await getSettings();
|
||||
this.settings.text_generation_timeout_minutes ||= 5;
|
||||
},
|
||||
|
||||
async saveSettings() {
|
||||
|
||||
@@ -13,6 +13,7 @@ import './components/imageManager';
|
||||
import './components/settingsManager';
|
||||
import './components/themeManager';
|
||||
import './components/navigationManager';
|
||||
import './components/chatSidebarStore';
|
||||
|
||||
// Start Alpine
|
||||
window.Alpine = Alpine;
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface Chat {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export type MessageStatus = 'streaming' | 'complete' | 'stopped' | 'error' | 'failed';
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
chat_id: string;
|
||||
@@ -14,6 +16,8 @@ export interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
thinking: string;
|
||||
content: string;
|
||||
images?: string[];
|
||||
status?: MessageStatus;
|
||||
stats?: MessageStats;
|
||||
}
|
||||
|
||||
@@ -34,10 +38,10 @@ export interface Model {
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
api_endpoint?: string;
|
||||
image_edit_selector?: string;
|
||||
image_generation_selector?: string;
|
||||
text_generation_selector?: string;
|
||||
text_generation_timeout_minutes?: number;
|
||||
}
|
||||
|
||||
export interface ImageRecord {
|
||||
@@ -66,6 +70,8 @@ export interface GenerateImageRequest {
|
||||
export interface GenerateTextRequest {
|
||||
model: string;
|
||||
prompt: string;
|
||||
images?: string[];
|
||||
thinking?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatListResponse {
|
||||
|
||||
43
frontend/src/utils/autoScroll.ts
Normal file
43
frontend/src/utils/autoScroll.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Pin-to-bottom controller for the document scroll.
|
||||
//
|
||||
// Tracks whether the user is "near" the bottom of the page. While pinned, new
|
||||
// content (e.g. streaming tokens) scrolls the viewport to keep up. Once the
|
||||
// user scrolls up, pinning releases and updates stop forcing scroll until the
|
||||
// user returns to the bottom.
|
||||
|
||||
const PIN_THRESHOLD_PX = 80;
|
||||
|
||||
export interface AutoScroll {
|
||||
isPinned(): boolean;
|
||||
scrollToBottom(behavior?: ScrollBehavior): void;
|
||||
maybeScrollToBottom(): void;
|
||||
}
|
||||
|
||||
export function createAutoScroll(): AutoScroll {
|
||||
let pinned = true;
|
||||
|
||||
const scrollEl = () => document.scrollingElement || document.documentElement;
|
||||
const distanceFromBottom = () => {
|
||||
const el = scrollEl();
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
pinned = distanceFromBottom() < PIN_THRESHOLD_PX;
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
return {
|
||||
isPinned: () => pinned,
|
||||
scrollToBottom(behavior: ScrollBehavior = 'auto') {
|
||||
window.scrollTo({ top: scrollEl().scrollHeight, behavior });
|
||||
pinned = true;
|
||||
},
|
||||
maybeScrollToBottom() {
|
||||
if (!pinned) return;
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: scrollEl().scrollHeight });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,25 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
:root {
|
||||
/* Top nav clearance - pages use this to pad past the fixed nav. */
|
||||
--nav-h: calc(max(1rem, env(safe-area-inset-top)) + 3rem);
|
||||
background: var(--color-primary-50);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--color-primary-50);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -58,36 +77,36 @@
|
||||
|
||||
@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);
|
||||
/* Dark mode - background and surface steps are intentionally lifted for a softer dark theme. */
|
||||
--color-primary-50: oklch(25% 0.08 290);
|
||||
--color-primary-100: oklch(31% 0.1 290);
|
||||
--color-primary-200: oklch(36% 0.12 290);
|
||||
--color-primary-300: oklch(48% 0.15 290);
|
||||
--color-primary-400: oklch(52% 0.18 290);
|
||||
--color-primary-500: oklch(58% 0.2 290);
|
||||
--color-primary-600: oklch(62% 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-50: oklch(25% 0.05 180);
|
||||
--color-secondary-100: oklch(31% 0.07 180);
|
||||
--color-secondary-200: oklch(36% 0.09 180);
|
||||
--color-secondary-300: oklch(48% 0.11 180);
|
||||
--color-secondary-400: oklch(52% 0.13 180);
|
||||
--color-secondary-500: oklch(58% 0.15 180);
|
||||
--color-secondary-600: oklch(62% 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-50: oklch(25% 0.008 60);
|
||||
--color-tertiary-100: oklch(31% 0.01 60);
|
||||
--color-tertiary-200: oklch(36% 0.015 60);
|
||||
--color-tertiary-300: oklch(48% 0.02 60);
|
||||
--color-tertiary-400: oklch(52% 0.025 60);
|
||||
--color-tertiary-500: oklch(58% 0.03 60);
|
||||
--color-tertiary-600: oklch(62% 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);
|
||||
|
||||
Reference in New Issue
Block a user