Compare commits
2 Commits
fcfa43cca3
...
fad8ed865a
| Author | SHA1 | Date | |
|---|---|---|---|
| fad8ed865a | |||
| 4c1523d81b |
@@ -14,7 +14,7 @@ COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
COPY backend/ ./
|
||||
COPY --from=frontend-builder /app/frontend/public/ ./web/static/
|
||||
RUN go build -ldflags="-w -s" -o aethera ./cmd
|
||||
RUN find ./web/static -type d -empty -delete && go build -ldflags="-w -s" -o aethera ./cmd
|
||||
|
||||
# Stage 3: Minimal Runtime
|
||||
FROM alpine:3.21
|
||||
|
||||
22
Makefile
22
Makefile
@@ -1,14 +1,22 @@
|
||||
.PHONY: all frontend backend clean dev docker docker-run tests
|
||||
.PHONY: all frontend backend clean dev docker docker-run tests check-static
|
||||
|
||||
all: frontend backend
|
||||
|
||||
frontend:
|
||||
rm -rf frontend/public/dist
|
||||
cd frontend && bun run build
|
||||
rm -rf backend/web/static
|
||||
mkdir -p backend/web/static
|
||||
cp frontend/public/index.html backend/web/static/ 2>/dev/null || true
|
||||
cp -r frontend/public/pages backend/web/static/ 2>/dev/null || true
|
||||
cp -R frontend/public/. backend/web/static/
|
||||
find backend/web/static -type d -empty -delete
|
||||
touch backend/web/static/.gitkeep
|
||||
|
||||
backend:
|
||||
check-static:
|
||||
@test -f backend/web/static/index.html || (echo "missing backend/web/static/index.html; run 'make frontend' first" && exit 1)
|
||||
@test -f backend/web/static/dist/main.js || (echo "missing backend/web/static/dist/main.js; run 'make frontend' first" && exit 1)
|
||||
@test -f backend/web/static/dist/styles.css || (echo "missing backend/web/static/dist/styles.css; run 'make frontend' first" && exit 1)
|
||||
|
||||
backend: check-static
|
||||
cd backend && go build -o ./dist/aethera ./cmd
|
||||
|
||||
clean:
|
||||
@@ -17,7 +25,11 @@ clean:
|
||||
rm -rf backend/web/static
|
||||
|
||||
dev:
|
||||
cd backend && go run ./cmd --listen 0.0.0.0 &
|
||||
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 & \
|
||||
backend_pid=$$!; \
|
||||
trap 'kill $$backend_pid' INT TERM EXIT; \
|
||||
cd frontend && bun run dev
|
||||
|
||||
docker:
|
||||
|
||||
@@ -59,6 +59,7 @@ Open your browser and navigate to the URL to begin using 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`)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ type cliParams struct {
|
||||
ListenAddr string
|
||||
ListenPort int
|
||||
DataDir string
|
||||
StaticDir string
|
||||
SettingsFile string
|
||||
}
|
||||
|
||||
@@ -41,5 +42,16 @@ func (p *cliParams) Validate() error {
|
||||
return fmt.Errorf("failed to create images directory: %w", err)
|
||||
}
|
||||
|
||||
// Validate Static Directory
|
||||
if p.StaticDir != "" {
|
||||
info, err := os.Stat(p.StaticDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to access static directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("static directory is not a directory: %s", p.StaticDir)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,12 +16,14 @@ var (
|
||||
ListenAddr: getEnvOrDefault("LISTEN", "localhost"),
|
||||
ListenPort: getEnvIntOrDefault("PORT", 8080),
|
||||
DataDir: getEnvOrDefault("DATA_DIR", "./data"),
|
||||
StaticDir: getEnvOrDefault("STATIC_DIR", ""),
|
||||
}
|
||||
rootCmd = &cobra.Command{Use: "aethera"}
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVar(¶ms.DataDir, "data-dir", params.DataDir, "Directory to store generated images (env: AETHERA_DATA_DIR)")
|
||||
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)")
|
||||
}
|
||||
@@ -40,7 +42,7 @@ func main() {
|
||||
|
||||
// Start Server
|
||||
rootCmd.Run = func(cmd *cobra.Command, args []string) {
|
||||
server.StartServer(fileStore, params.DataDir, params.ListenAddr, params.ListenPort)
|
||||
server.StartServer(fileStore, params.DataDir, params.StaticDir, params.ListenAddr, params.ListenPort)
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
|
||||
@@ -323,11 +323,14 @@ func (a *API) PostChat(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Send Message
|
||||
if err := a.sendMessage(r.Context(), w, chat.ID, genReq.Model, genReq.Prompt); err != nil {
|
||||
responseStarted, err := a.sendMessage(r.Context(), w, chat.ID, genReq.Model, genReq.Prompt)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) DeleteChat(w http.ResponseWriter, r *http.Request) {
|
||||
log := a.logger.WithField("handler", "DeleteChatHandler")
|
||||
@@ -421,11 +424,15 @@ func (a *API) PostChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.sendMessage(r.Context(), w, chatID, genReq.Model, genReq.Prompt); err != nil {
|
||||
// Send Message
|
||||
responseStarted, err := a.sendMessage(r.Context(), w, chatID, genReq.Model, genReq.Prompt)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) getClient() (*client.Client, error) {
|
||||
if a.client != nil {
|
||||
@@ -449,10 +456,10 @@ func (a *API) getClient() (*client.Client, error) {
|
||||
return a.client, nil
|
||||
}
|
||||
|
||||
func (a *API) sendMessage(ctx context.Context, w http.ResponseWriter, chatID uuid.UUID, chatModel, userMessage string) error {
|
||||
func (a *API) sendMessage(ctx context.Context, w http.ResponseWriter, chatID uuid.UUID, chatModel, userMessage string) (bool, error) {
|
||||
apiClient, err := a.getClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get client: %w", err)
|
||||
return false, fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// Detach Request Context
|
||||
@@ -462,19 +469,20 @@ func (a *API) sendMessage(ctx context.Context, w http.ResponseWriter, chatID uui
|
||||
// Create User Message
|
||||
userMsg := &store.Message{ChatID: chatID, Role: "user", Content: userMessage}
|
||||
if err := a.store.SaveChatMessage(userMsg); err != nil {
|
||||
return fmt.Errorf("failed to add user message to chat: %w", err)
|
||||
return false, 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 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 fmt.Errorf("failed to add assistant message to chat: %w", err)
|
||||
}
|
||||
|
||||
// Get Chat
|
||||
chat, err := a.store.GetChat(chatID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get chat: %w", err)
|
||||
return false, fmt.Errorf("failed to add assistant message to chat: %w", err)
|
||||
}
|
||||
|
||||
// Set Headers
|
||||
@@ -491,35 +499,52 @@ func (a *API) sendMessage(ctx context.Context, w http.ResponseWriter, chatID uui
|
||||
Chat: toChatNoMessages(chat),
|
||||
UserMessage: userMsg,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to send initial chunk: %w", err)
|
||||
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
|
||||
messageChanged := false
|
||||
|
||||
if m.Stats != nil {
|
||||
messageChanged = true
|
||||
assistantMsg.Stats = m.Stats
|
||||
}
|
||||
|
||||
if m.Message != nil {
|
||||
messageChanged = true
|
||||
assistantMsg.Content += *m.Message
|
||||
apiMsgChunk.AssistantMessage = assistantMsg
|
||||
}
|
||||
|
||||
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.
|
||||
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
|
||||
// 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 {
|
||||
return fmt.Errorf("failed to send progress chunk: %w", err)
|
||||
streamToClient = false
|
||||
a.logger.WithError(err).WithField("chat_id", chat.ID).Warn("client stream disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to generate text stream: %w", err)
|
||||
return responseStarted, fmt.Errorf("failed to generate text stream: %w", err)
|
||||
}
|
||||
|
||||
// Summarize & Update Chat Title
|
||||
@@ -534,16 +559,18 @@ func (a *API) sendMessage(ctx context.Context, w http.ResponseWriter, chatID uui
|
||||
|
||||
// Update Assistant Message
|
||||
if err := a.store.SaveChatMessage(assistantMsg); err != nil {
|
||||
return fmt.Errorf("failed to save assistant message to chat: %w", err)
|
||||
return responseStarted, fmt.Errorf("failed to save assistant message to chat: %w", err)
|
||||
}
|
||||
|
||||
// Send Final Chunk
|
||||
if streamToClient {
|
||||
if err := json.NewEncoder(flushWriter).Encode(&MessageChunk{
|
||||
Chat: toChatNoMessages(chat),
|
||||
AssistantMessage: assistantMsg,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to send final chunk: %w", err)
|
||||
return responseStarted, fmt.Errorf("failed to send final chunk: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return responseStarted, nil
|
||||
}
|
||||
|
||||
@@ -13,19 +13,24 @@ import (
|
||||
"reichard.io/aethera/web"
|
||||
)
|
||||
|
||||
func StartServer(settingsStore store.Store, dataDir, listenAddress string, listenPort int) {
|
||||
func StartServer(settingsStore store.Store, dataDir, staticDir, listenAddress string, listenPort int) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Create API Instance - use settingsStore as the unified store for both settings and chat
|
||||
logger := logrus.New()
|
||||
api := api.New(settingsStore, dataDir, logger)
|
||||
|
||||
// Serve embedded static assets
|
||||
// Serve Static Assets
|
||||
if staticDir != "" {
|
||||
logrus.Infof("Serving static assets from directory: %s", staticDir)
|
||||
mux.Handle("GET /", http.FileServer(http.Dir(staticDir)))
|
||||
} else {
|
||||
staticFS, err := fs.Sub(web.Assets, "static")
|
||||
if err != nil {
|
||||
logrus.Fatal("Failed to create static filesystem: ", err)
|
||||
}
|
||||
mux.Handle("GET /", http.FileServer(http.FS(staticFS)))
|
||||
}
|
||||
|
||||
// Serve Generated Data
|
||||
genFS := http.FileServer(http.Dir(path.Join(dataDir, "generated")))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "aethera",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "mkdir -p public/dist && bun build src/main.ts --outdir public/dist --target browser --watch & bunx tailwindcss -i styles.css -o public/dist/styles.css --watch",
|
||||
"build": "bun build src/main.ts --outdir public/dist --target browser && bunx tailwindcss -i styles.css -o public/dist/styles.css --minify",
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getModels,
|
||||
getSettings,
|
||||
} from '../client';
|
||||
import { ImageRecord } from '../types';
|
||||
import { GenerateImageRequest, ImageRecord } from '../types';
|
||||
import { applyFilter } from '../utils';
|
||||
|
||||
// Constants
|
||||
@@ -26,6 +26,12 @@ interface StoredSettings {
|
||||
}
|
||||
|
||||
// Utilities
|
||||
const errorMessage = (err: unknown): string => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return 'An unexpected error occurred';
|
||||
};
|
||||
|
||||
const fileToDataURL = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -252,8 +258,8 @@ Alpine.data('imageGenerator', () => {
|
||||
});
|
||||
},
|
||||
|
||||
async buildRequestData() {
|
||||
const requestData: any = {
|
||||
async buildRequestData(): Promise<GenerateImageRequest> {
|
||||
const requestData: GenerateImageRequest = {
|
||||
prompt: this.prompt,
|
||||
n: parseInt(this.n.toString()),
|
||||
seed: parseInt(this.seed.toString()),
|
||||
@@ -281,8 +287,8 @@ Alpine.data('imageGenerator', () => {
|
||||
const requestData = await this.buildRequestData();
|
||||
const data = await generateImage(requestData);
|
||||
this.generatedImages.unshift(...data);
|
||||
} catch (err: any) {
|
||||
this.error = err;
|
||||
} catch (err) {
|
||||
this.error = errorMessage(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -294,8 +300,8 @@ Alpine.data('imageGenerator', () => {
|
||||
this.generatedImages = this.generatedImages.filter(
|
||||
(img: ImageRecord) => img.name !== filename,
|
||||
);
|
||||
} catch (err: any) {
|
||||
this.error = err;
|
||||
} catch (err) {
|
||||
this.error = errorMessage(err);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user