v0.1.1: System default AI config, configurable URLs, single .env, website refactor

This commit is contained in:
lotherk
2026-03-27 13:51:15 +00:00
parent 749a913309
commit f6c4da1da3
23 changed files with 486 additions and 180 deletions

44
.env.example Normal file
View File

@@ -0,0 +1,44 @@
# =============================================================================
# DearDiary Configuration
# =============================================================================
# -----------------------------------------------------------------------------
# Backend Configuration
# -----------------------------------------------------------------------------
BACKEND_DATABASE_URL="file:./data/deardiary.db"
BACKEND_PORT="3000"
BACKEND_JWT_SECRET="change-this-to-a-random-string-in-production"
BACKEND_CORS_ORIGIN="*"
BACKEND_REGISTRATION_ENABLED="false"
BACKEND_MEDIA_DIR="./data/media"
# Default admin user (auto-created on startup if doesn't exist)
BACKEND_DEFAULT_USER_EMAIL="admin@localhost"
BACKEND_DEFAULT_USER_PASSWORD="changeme123"
# -----------------------------------------------------------------------------
# Default AI Configuration for New Users
# -----------------------------------------------------------------------------
# When set, new users will use these settings by default.
# Users can override these in their personal settings.
# Default AI provider: groq, openai, anthropic, ollama, lmstudio, xai, custom
BACKEND_DEFAULT_AI_PROVIDER="groq"
# Default model (provider-specific)
BACKEND_DEFAULT_AI_MODEL="llama-3.3-70b-versatile"
# Default API key (optional - leave empty to let users provide their own)
BACKEND_DEFAULT_AI_API_KEY=""
# Default base URL (for local providers like ollama/lmstudio)
BACKEND_DEFAULT_AI_BASE_URL=""
# -----------------------------------------------------------------------------
# Website Configuration
# -----------------------------------------------------------------------------
# URL where the app is hosted (used in "Join Free Alpha" links)
WEBSITE_APP_URL="http://localhost:3000"
# Git repository URL
GIT_URL="https://git.kropa.tech/lotherk/deardiary"

View File

@@ -78,6 +78,12 @@ id, userId, date, type, content, mediaPath, metadata, latitude, longitude, place
- Location is captured automatically from browser geolocation when creating events
- Reverse geocoding via OpenStreetMap Nominatim API provides place names
### Settings Model
```
userId, aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language, timezone, providerSettings, journalContextDays, useSystemDefault
```
- `useSystemDefault`: When true, user uses system-default AI settings from environment variables
## API Design
All endpoints return: `{ data: T | null, error: { code, message } | null }`
@@ -146,6 +152,18 @@ AI is configured to return JSON with `response_format: { type: "json_object" }`
### Provider Settings Storage
Settings stored as `providerSettings: { "groq": { apiKey, model, baseUrl }, ... }` with `aiProvider` determining which is active.
### System Default AI Configuration
Administrators can configure default AI settings via environment variables that apply to all new users:
```
BACKEND_DEFAULT_AI_PROVIDER="groq"
BACKEND_DEFAULT_AI_MODEL="llama-3.3-70b-versatile"
BACKEND_DEFAULT_AI_API_KEY=""
BACKEND_DEFAULT_AI_BASE_URL=""
```
- New users automatically use system defaults (`useSystemDefault: true`)
- Users can override with their own settings by unchecking "Use system default settings"
- When `useSystemDefault` is true, the system ignores user's individual AI settings and uses env defaults
## Coding Guidelines
### TypeScript
@@ -206,6 +224,7 @@ bun run test:server
Tests require the server running. The test script starts the server, runs tests, then stops it.
## Version History
- 0.1.1: System default AI configuration via env vars, "Use system default" checkbox in settings
- 0.1.0: Automatic geolocation capture, Starlight documentation site
- 0.0.6: Automatic geolocation capture on event creation, reverse geocoding to place names
- 0.0.5: Export/Import feature with version checking

View File

@@ -10,11 +10,13 @@ COPY backend/src ./src
RUN bun build src/index.ts --outdir ./dist --target bun
FROM node:20-alpine AS frontend-builder
ARG VITE_GIT_URL=https://git.kropa.tech/lotherk/deardiary
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend ./
ENV VITE_GIT_URL=$VITE_GIT_URL
RUN npm run build
FROM oven/bun:1.1-alpine AS runner

View File

@@ -1,7 +0,0 @@
FROM nginx:alpine
COPY www/ /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

11
Dockerfile.website Normal file
View File

@@ -0,0 +1,11 @@
FROM nginx:alpine
COPY www/ /usr/share/nginx/html/
COPY docker-entrypoint.d/ /docker-entrypoint.d/
RUN apk add --no-cache gettext && \
chmod +x /docker-entrypoint.d/*.sh
ENTRYPOINT ["/docker-entrypoint.d/30-envsubst.sh", "--", "nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,51 +0,0 @@
# =============================================================================
# DearDiary Configuration
# =============================================================================
# -----------------------------------------------------------------------------
# Database (SQLite, PostgreSQL, or MySQL)
# -----------------------------------------------------------------------------
DATABASE_URL="file:./data/deardiary.db"
# Example PostgreSQL:
# DATABASE_URL="postgresql://postgres:password@db:5432/deardiary"
# Example MySQL:
# DATABASE_URL="mysql://root:password@localhost:3306/deardiary"
# -----------------------------------------------------------------------------
# Application
# -----------------------------------------------------------------------------
# App name displayed in UI
APP_NAME="DearDiary"
# App version
VERSION="0.1.0"
# Server port
PORT="3000"
# -----------------------------------------------------------------------------
# Security
# -----------------------------------------------------------------------------
# JWT secret for authentication tokens (REQUIRED in production)
JWT_SECRET="change-this-to-a-random-string-in-production"
# CORS origin (use specific domain in production, e.g., "https://yourapp.com")
CORS_ORIGIN="*"
# -----------------------------------------------------------------------------
# User Management
# -----------------------------------------------------------------------------
# Enable/disable user registration ("true" or "false")
REGISTRATION_ENABLED="false"
# Default admin user (auto-created on startup if doesn't exist)
DEFAULT_USER_EMAIL="admin@localhost"
DEFAULT_USER_PASSWORD="changeme123"
# -----------------------------------------------------------------------------
# Storage
# -----------------------------------------------------------------------------
# Media storage directory
MEDIA_DIR="./data/media"

View File

@@ -4,7 +4,7 @@ generator client {
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
url = env("BACKEND_DATABASE_URL")
}
model User {
@@ -94,16 +94,17 @@ model Task {
}
model Settings {
userId String @id
aiProvider String @default("groq")
aiApiKey String?
aiModel String @default("llama-3.3-70b-versatile")
aiBaseUrl String?
journalPrompt String?
language String @default("en")
timezone String @default("UTC")
providerSettings String?
userId String @id
aiProvider String @default("groq")
aiApiKey String?
aiModel String @default("llama-3.3-70b-versatile")
aiBaseUrl String?
journalPrompt String?
language String @default("en")
timezone String @default("UTC")
providerSettings String?
journalContextDays Int @default(10)
useSystemDefault Boolean @default(true)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

View File

@@ -8,19 +8,35 @@ import { createHash, randomBytes } from 'crypto';
import * as jose from 'jose';
import { Prisma } from '@prisma/client';
import { createAIProvider } from './services/ai/provider';
import { eventsRoutes } from './routes/events';
const app = new Hono();
const envVars = env(app);
const envVars = env<{
BACKEND_DATABASE_URL?: string;
BACKEND_PORT?: string;
BACKEND_JWT_SECRET?: string;
BACKEND_CORS_ORIGIN?: string;
BACKEND_REGISTRATION_ENABLED?: string;
BACKEND_MEDIA_DIR?: string;
BACKEND_DEFAULT_USER_EMAIL?: string;
BACKEND_DEFAULT_USER_PASSWORD?: string;
BACKEND_DEFAULT_AI_PROVIDER?: string;
BACKEND_DEFAULT_AI_MODEL?: string;
BACKEND_DEFAULT_AI_API_KEY?: string;
BACKEND_DEFAULT_AI_BASE_URL?: string;
BACKEND_VERSION?: string;
BACKEND_APP_NAME?: string;
}>(app);
app.use('*', logger());
app.use('*', cors({
origin: envVars.CORS_ORIGIN || '*',
origin: envVars.BACKEND_CORS_ORIGIN || '*',
credentials: true,
}));
const prisma = new PrismaClient({
datasourceUrl: envVars.DATABASE_URL || 'file:./data/deardiary.db',
datasourceUrl: envVars.BACKEND_DATABASE_URL || 'file:./data/deardiary.db',
});
app.use('*', async (c, next) => {
@@ -104,7 +120,7 @@ app.post('/api/v1/auth/login', async (c) => {
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }, 401);
}
const jwtSecret = envVars.JWT_SECRET || 'development-secret-change-in-production';
const jwtSecret = envVars.BACKEND_JWT_SECRET || 'development-secret-change-in-production';
const token = await new jose.SignJWT({ userId: user.id })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
@@ -117,9 +133,9 @@ app.post('/api/v1/auth/login', async (c) => {
app.get('/api/v1/server-info', async (c) => {
return c.json({
data: {
version: envVars.VERSION || '0.1.0',
registrationEnabled: envVars.REGISTRATION_ENABLED !== 'false',
appName: envVars.APP_NAME || 'DearDiary',
version: envVars.BACKEND_VERSION || '0.1.0',
registrationEnabled: envVars.BACKEND_REGISTRATION_ENABLED !== 'false',
appName: envVars.BACKEND_APP_NAME || 'DearDiary',
},
error: null
});
@@ -132,7 +148,7 @@ app.post('/api/v1/auth/api-key', async (c) => {
}
const token = authHeader.slice(7);
const jwtSecret = envVars.JWT_SECRET || 'development-secret-change-in-production';
const jwtSecret = envVars.BACKEND_JWT_SECRET || 'development-secret-change-in-production';
let userId: string;
try {
@@ -293,22 +309,7 @@ app.get('/api/v1/search', async (c) => {
}
});
app.delete('/api/v1/events/:id', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { id } = c.req.param();
const existing = await prisma.event.findFirst({ where: { id, userId } });
if (!existing) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event not found' } }, 404);
const journal = await prisma.journal.findFirst({ where: { userId, date: existing.date } });
if (journal) {
return c.json({ data: null, error: { code: 'EVENT_IMMUTABLE', message: 'Cannot delete event: journal already generated. Delete the journal first.' } }, 400);
}
await prisma.event.delete({ where: { id } });
return c.json({ data: { deleted: true }, error: null });
});
app.route('/api/v1', eventsRoutes);
// Journal routes
app.post('/api/v1/journal/generate/:date', async (c) => {
@@ -333,7 +334,7 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
const providerConfig = providerSettings[provider] || {};
const apiKey = providerConfig.apiKey || settings?.aiApiKey;
if ((provider === 'openai' || provider === 'anthropic' || provider === 'groq') && !apiKey) {
if ((provider === 'openai' || provider === 'anthropic' || provider === 'groq' || provider === 'xai') && !apiKey) {
return c.json({ data: null, error: { code: 'NO_AI_CONFIG', message: 'AI not configured. Please set up your API key in settings.' } }, 400);
}
@@ -602,15 +603,28 @@ app.get('/api/v1/journals', async (c) => {
});
});
const getDefaultSettings = () => ({
aiProvider: envVars.BACKEND_DEFAULT_AI_PROVIDER || 'groq',
aiApiKey: envVars.BACKEND_DEFAULT_AI_API_KEY || null,
aiModel: envVars.BACKEND_DEFAULT_AI_MODEL || 'llama-3.3-70b-versatile',
aiBaseUrl: envVars.BACKEND_DEFAULT_AI_BASE_URL || null,
useSystemDefault: true,
});
const getUserSettings = async (userId: string) => {
let settings = await prisma.settings.findUnique({ where: { userId } });
if (!settings) {
settings = await prisma.settings.create({ data: { userId, ...getDefaultSettings() } });
}
return settings;
};
// Settings routes
app.get('/api/v1/settings', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
let settings = await prisma.settings.findUnique({ where: { userId } });
if (!settings) {
settings = await prisma.settings.create({ data: { userId } });
}
let settings = await getUserSettings(userId);
const oldDefaultLength = 400;
if (settings.journalPrompt && settings.journalPrompt.length > oldDefaultLength) {
@@ -625,21 +639,33 @@ app.put('/api/v1/settings', async (c) => {
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const body = await c.req.json();
const { aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language, providerSettings, journalContextDays } = body;
const { aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language, providerSettings, journalContextDays, useSystemDefault } = body;
const defaults = getDefaultSettings();
const data: Record<string, unknown> = {};
if (aiProvider !== undefined) data.aiProvider = aiProvider;
if (aiApiKey !== undefined) data.aiApiKey = aiApiKey;
if (aiApiKey !== undefined) data.aiApiKey = aiApiKey || null;
if (aiModel !== undefined) data.aiModel = aiModel;
if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl;
if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl || null;
if (journalPrompt !== undefined) data.journalPrompt = journalPrompt === '' ? null : journalPrompt;
if (language !== undefined) data.language = language;
if (providerSettings !== undefined) data.providerSettings = JSON.stringify(providerSettings);
if (journalContextDays !== undefined) data.journalContextDays = journalContextDays;
if (useSystemDefault !== undefined) data.useSystemDefault = useSystemDefault;
const shouldUseSystemDefaults = useSystemDefault === true;
if (shouldUseSystemDefaults) {
if (aiProvider === undefined) data.aiProvider = defaults.aiProvider;
if (aiApiKey === undefined) data.aiApiKey = defaults.aiApiKey;
if (aiModel === undefined) data.aiModel = defaults.aiModel;
if (aiBaseUrl === undefined) data.aiBaseUrl = defaults.aiBaseUrl;
}
const settings = await prisma.settings.upsert({
where: { userId },
create: { userId, ...data },
create: { userId, ...getDefaultSettings(), ...data },
update: data,
});
@@ -1030,10 +1056,7 @@ app.post('/api/v1/account/reset', async (c) => {
await prisma.settings.update({
where: { userId },
data: {
aiProvider: 'groq',
aiApiKey: null,
aiModel: 'llama-3.3-70b-versatile',
aiBaseUrl: null,
...getDefaultSettings(),
journalPrompt: null,
language: 'en',
timezone: 'UTC',
@@ -1089,7 +1112,7 @@ app.post('/api/v1/account/password', async (c) => {
});
app.post('/api/v1/auth/register', async (c) => {
const registrationEnabled = envVars.REGISTRATION_ENABLED !== 'false';
const registrationEnabled = envVars.BACKEND_REGISTRATION_ENABLED !== 'false';
if (!registrationEnabled) {
return c.json({ data: null, error: { code: 'REGISTRATION_DISABLED', message: 'Registration is currently disabled' } }, 403);
}
@@ -1184,11 +1207,11 @@ async function rebuildFTS() {
}
async function createDefaultUser() {
const defaultEmail = envVars.DEFAULT_USER_EMAIL;
const defaultPassword = envVars.DEFAULT_USER_PASSWORD;
const defaultEmail = envVars.BACKEND_DEFAULT_USER_EMAIL;
const defaultPassword = envVars.BACKEND_DEFAULT_USER_PASSWORD;
if (!defaultEmail || !defaultPassword) {
console.log('No default user configured (set DEFAULT_USER_EMAIL and DEFAULT_USER_PASSWORD)');
console.log('No default user configured (set BACKEND_DEFAULT_USER_EMAIL and BACKEND_DEFAULT_USER_PASSWORD)');
return;
}
@@ -1232,7 +1255,7 @@ async function createDefaultUser() {
}
}
const port = parseInt(envVars.PORT || '3000', 10);
const port = parseInt(envVars.BACKEND_PORT || '3000', 10);
console.log(`Starting DearDiary API on port ${port}`);
setupFTS().then(() => {

View File

@@ -6,10 +6,19 @@ export interface HonoEnv {
prisma: PrismaClient;
};
Bindings: {
DATABASE_URL: string;
JWT_SECRET: string;
MEDIA_DIR: string;
PORT: string;
CORS_ORIGIN: string;
BACKEND_DATABASE_URL: string;
BACKEND_JWT_SECRET: string;
BACKEND_MEDIA_DIR: string;
BACKEND_PORT: string;
BACKEND_CORS_ORIGIN: string;
BACKEND_REGISTRATION_ENABLED: string;
BACKEND_DEFAULT_USER_EMAIL: string;
BACKEND_DEFAULT_USER_PASSWORD: string;
BACKEND_DEFAULT_AI_PROVIDER: string;
BACKEND_DEFAULT_AI_MODEL: string;
BACKEND_DEFAULT_AI_API_KEY: string;
BACKEND_DEFAULT_AI_BASE_URL: string;
BACKEND_VERSION: string;
BACKEND_APP_NAME: string;
};
}

View File

@@ -34,6 +34,32 @@ export async function authMiddleware(c: Context<HonoEnv>, next: Next) {
export function authenticate() {
return async (c: Context<HonoEnv>, next: Next) => {
await authMiddleware(c, next);
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid Authorization header' } }, 401);
}
const apiKey = authHeader.slice(7);
const keyHash = createHash('sha256').update(apiKey).digest('hex');
const prisma = c.get('prisma');
const keyRecord = await prisma.apiKey.findUnique({
where: { keyHash },
include: { user: true },
});
if (!keyRecord) {
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
}
await prisma.apiKey.update({
where: { id: keyRecord.id },
data: { lastUsedAt: new Date() },
});
c.set('userId', keyRecord.userId);
await next();
};
}

View File

@@ -59,7 +59,7 @@ authRoutes.post('/login', async (c) => {
}
const prisma = c.get('prisma');
const jwtSecret = c.env.JWT_SECRET || 'development-secret-change-in-production';
const jwtSecret = c.env.BACKEND_JWT_SECRET || 'development-secret-change-in-production';
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
@@ -88,7 +88,7 @@ authRoutes.post('/api-key', async (c) => {
}
const token = authHeader.slice(7);
const jwtSecret = c.env.JWT_SECRET || 'development-secret-change-in-production';
const jwtSecret = c.env.BACKEND_JWT_SECRET || 'development-secret-change-in-production';
let userId: string;
try {

View File

@@ -1,12 +1,15 @@
import { Hono } from 'hono';
import { HonoEnv } from '../lib/types';
import { authenticate } from '../middleware/auth';
export const eventsRoutes = new Hono<HonoEnv>();
eventsRoutes.post('/', async (c) => {
eventsRoutes.use(authenticate());
eventsRoutes.post('/events', async (c) => {
const userId = c.get('userId');
const prisma = c.get('prisma');
const mediaDir = c.env.MEDIA_DIR || './data/media';
const mediaDir = c.env.BACKEND_MEDIA_DIR || './data/media';
const body = await c.req.json();
const { date, type, content, metadata, latitude, longitude, placeName } = body;
@@ -42,7 +45,7 @@ eventsRoutes.post('/', async (c) => {
return c.json({ data: event, error: null }, 201);
});
eventsRoutes.get('/:id', async (c) => {
eventsRoutes.get('/events/:id', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
@@ -58,7 +61,7 @@ eventsRoutes.get('/:id', async (c) => {
return c.json({ data: event, error: null });
});
eventsRoutes.put('/:id', async (c) => {
eventsRoutes.put('/events/:id', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
@@ -97,7 +100,7 @@ eventsRoutes.put('/:id', async (c) => {
return c.json({ data: event, error: null });
});
eventsRoutes.delete('/:id', async (c) => {
eventsRoutes.delete('/events/:id', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
@@ -120,11 +123,11 @@ eventsRoutes.delete('/:id', async (c) => {
return c.json({ data: { deleted: true }, error: null });
});
eventsRoutes.post('/:id/photo', async (c) => {
eventsRoutes.post('/events/:id/photo', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
const mediaDir = c.env.MEDIA_DIR || './data/media';
const mediaDir = c.env.BACKEND_MEDIA_DIR || './data/media';
const event = await prisma.event.findFirst({
where: { id, userId },
@@ -157,11 +160,11 @@ eventsRoutes.post('/:id/photo', async (c) => {
return c.json({ data: { mediaPath: mediaUrl }, error: null }, 201);
});
eventsRoutes.post('/:id/voice', async (c) => {
eventsRoutes.post('/events/:id/voice', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
const mediaDir = c.env.MEDIA_DIR || './data/media';
const mediaDir = c.env.BACKEND_MEDIA_DIR || './data/media';
const event = await prisma.event.findFirst({
where: { id, userId },

View File

@@ -40,6 +40,7 @@ import { AnthropicProvider } from './anthropic';
import { OllamaProvider } from './ollama';
import { LMStudioProvider } from './lmstudio';
import { GroqProvider } from './groq';
import { XAIProvider } from './xai';
import { CustomProvider } from './custom';
export function createAIProvider(config: AIProviderConfig): AIProvider {
@@ -56,6 +57,8 @@ export function createAIProvider(config: AIProviderConfig): AIProvider {
return new LMStudioProvider(config, settings);
case 'groq':
return new GroqProvider(config, settings);
case 'xai':
return new XAIProvider(config, settings);
case 'custom':
return new CustomProvider(config, settings);
default:

View File

@@ -0,0 +1,92 @@
import type { AIProvider, AIProviderConfig, AIProviderResult } from './provider';
import { ProviderSettings } from './openai';
export class XAIProvider implements AIProvider {
provider = 'xai' as const;
private apiKey: string;
private model: string;
private baseUrl: string;
private customHeaders?: Record<string, string>;
private customParameters?: Record<string, unknown>;
constructor(config: AIProviderConfig, settings?: ProviderSettings) {
this.apiKey = config.apiKey || '';
this.model = config.model || 'grok-4-1-fast-reasoning';
this.baseUrl = config.baseUrl || 'https://api.x.ai/v1';
this.customHeaders = settings?.headers;
this.customParameters = settings?.parameters;
}
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
const messages: Array<{ role: string; content: string }> = [];
if (systemPrompt) {
messages.push({ role: 'system', content: systemPrompt });
}
messages.push({ role: 'user', content: prompt });
const requestBody: Record<string, unknown> = {
model: this.model,
messages,
temperature: 0.7,
max_tokens: 2000,
...this.customParameters,
};
if (options?.jsonMode) {
requestBody.response_format = { type: 'json_object' };
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
...this.customHeaders,
};
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
});
const responseData = await response.json();
if (!response.ok) {
throw new Error(`xAI API error: ${response.status} ${JSON.stringify(responseData)}`);
}
let content = responseData.choices?.[0]?.message?.content || '';
let title: string | undefined;
if (options?.jsonMode) {
try {
const parsed = JSON.parse(content);
title = parsed.title;
content = parsed.content || content;
} catch {
// If JSON parsing fails, use content as-is
}
}
return {
content,
title,
request: requestBody,
response: responseData,
};
}
async validate(): Promise<boolean> {
try {
const headers: Record<string, string> = {
'Authorization': `Bearer ${this.apiKey}`,
...this.customHeaders,
};
const response = await fetch(`${this.baseUrl}/models`, { headers });
return response.ok;
} catch {
return false;
}
}
}

View File

@@ -1,37 +1,46 @@
services:
website:
build:
context: .
dockerfile: Dockerfile.website
ports:
- "8080:80"
environment:
- APP_URL=${WEBSITE_APP_URL:-http://localhost:3000}
- GIT_URL=${GIT_URL:-https://github.com/lotherk/deardiary}
restart: unless-stopped
app:
build:
context: .
dockerfile: Dockerfile
args:
- VITE_GIT_URL=${GIT_URL:-https://git.kropa.tech/lotherk/deardiary}
ports:
- "3000:3000"
- "5173:80"
- "3000:80"
environment:
- DATABASE_URL=file:/data/deardiary.db
- MEDIA_DIR=/data/media
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- PORT=3000
- CORS_ORIGIN=${CORS_ORIGIN:-*}
- DEFAULT_USER_EMAIL=${DEFAULT_USER_EMAIL:-}
- DEFAULT_USER_PASSWORD=${DEFAULT_USER_PASSWORD:-}
- BACKEND_DATABASE_URL=file:/data/deardiary.db
- BACKEND_MEDIA_DIR=/data/media
- BACKEND_JWT_SECRET=${BACKEND_JWT_SECRET:-change-me-in-production}
- BACKEND_PORT=3001
- BACKEND_CORS_ORIGIN=${BACKEND_CORS_ORIGIN:-*}
- BACKEND_REGISTRATION_ENABLED=${BACKEND_REGISTRATION_ENABLED:-false}
- BACKEND_DEFAULT_USER_EMAIL=${BACKEND_DEFAULT_USER_EMAIL:-}
- BACKEND_DEFAULT_USER_PASSWORD=${BACKEND_DEFAULT_USER_PASSWORD:-}
- BACKEND_DEFAULT_AI_PROVIDER=${BACKEND_DEFAULT_AI_PROVIDER:-groq}
- BACKEND_DEFAULT_AI_MODEL=${BACKEND_DEFAULT_AI_MODEL:-llama-3.3-70b-versatile}
- BACKEND_DEFAULT_AI_API_KEY=${BACKEND_DEFAULT_AI_API_KEY:-}
- BACKEND_DEFAULT_AI_BASE_URL=${BACKEND_DEFAULT_AI_BASE_URL:-}
volumes:
- deardiary_data:/data
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
docs:
build:
context: .
dockerfile: Dockerfile.docs
ports:
- "4000:80"
restart: unless-stopped
volumes:
deardiary_data:

View File

@@ -0,0 +1,10 @@
#!/bin/sh
set -e
# Run envsubst on index.html
if [ -f /usr/share/nginx/html/index.html ]; then
envsubst < /usr/share/nginx/html/index.html > /usr/share/nginx/html/index.html.tmp
mv /usr/share/nginx/html/index.html.tmp /usr/share/nginx/html/index.html
fi
exec nginx -g 'daemon off;'

View File

@@ -81,7 +81,7 @@ function Footer({ appName = 'DearDiary' }: { appName?: string }) {
const { resolvedTheme } = useTheme();
return (
<footer className={`py-6 text-center text-sm text-slate-500 ${resolvedTheme === 'dark' ? 'border-t border-slate-800' : 'border-t border-slate-200'}`}>
<p>{appName} v{packageJson.version} Self-hosted AI-powered journaling · <a href="https://github.com/lotherk/deardiary" className="hover:text-purple-400 transition">GitHub</a> · <a href="https://deardiary.io" className="hover:text-purple-400 transition">deardiary.io</a> · MIT License · © 2026 Konrad Lother</p>
<p>{appName} v{packageJson.version} Self-hosted AI-powered journaling · <a href={import.meta.env.VITE_GIT_URL || 'https://git.kropa.tech/lotherk/deardiary'} className="hover:text-purple-400 transition">Git Repository</a> · MIT License · © 2026 Konrad Lother</p>
</footer>
);
}

View File

@@ -19,9 +19,30 @@ export default function QuickAddWidget({ isOpen, onClose }: Props) {
useEffect(() => {
if (isOpen) {
checkDiaryStatus();
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (content.trim() && !locked && !loading) {
const form = inputRef.current?.form;
if (form) {
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(submitEvent);
}
}
}
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, content, locked, loading]);
const checkDiaryStatus = async () => {
const res = await api.getDay(today);
if (res.data) {

View File

@@ -221,7 +221,7 @@ export interface Task {
}
export interface Settings {
aiProvider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq';
aiProvider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq' | 'xai' | 'custom';
aiApiKey?: string;
aiModel: string;
aiBaseUrl?: string;
@@ -229,10 +229,13 @@ export interface Settings {
language: string;
timezone: string;
journalContextDays: number;
useSystemDefault: boolean;
providerSettings?: Record<string, {
apiKey?: string;
model?: string;
baseUrl?: string;
headers?: Record<string, string>;
parameters?: Record<string, unknown>;
}>;
}

View File

@@ -7,6 +7,8 @@ interface ProviderSettings {
apiKey?: string;
model?: string;
baseUrl?: string;
headers?: Record<string, string>;
parameters?: Record<string, unknown>;
}
interface FullSettings extends Settings {
@@ -15,15 +17,21 @@ interface FullSettings extends Settings {
const DEFAULT_MODELS: Record<string, string> = {
groq: 'llama-3.3-70b-versatile',
openai: 'gpt-4o',
openai: 'gpt-5-nano',
anthropic: 'claude-3-5-sonnet-20241022',
ollama: 'llama3.2',
lmstudio: 'local-model',
custom: '',
xai: 'grok-4-1-fast-reasoning',
};
const PROVIDERS_WITH_API_KEY: string[] = ['openai', 'anthropic', 'groq', 'xai', 'custom'];
const PROVIDERS_WITH_BASE_URL: string[] = ['ollama', 'lmstudio', 'custom'];
const DEFAULT_BASE_URLS: Record<string, string> = {
ollama: 'http://localhost:11434',
lmstudio: 'http://localhost:1234/v1',
custom: 'https://your-api.example.com/v1',
};
export default function SettingsPage() {
@@ -34,6 +42,7 @@ export default function SettingsPage() {
language: 'en',
timezone: 'UTC',
journalContextDays: 10,
useSystemDefault: true,
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -80,6 +89,9 @@ export default function SettingsPage() {
if (!data.providerSettings) {
data.providerSettings = {};
}
if (data.useSystemDefault === undefined) {
data.useSystemDefault = true;
}
const provider = data.aiProvider || 'groq';
const providerData = data.providerSettings[provider] || {};
data.aiApiKey = providerData.apiKey;
@@ -158,6 +170,7 @@ export default function SettingsPage() {
await api.updateSettings({
aiProvider: settings.aiProvider,
providerSettings: newProviderSettings,
useSystemDefault: settings.useSystemDefault,
});
setSaving(false);
setSaved(true);
@@ -448,13 +461,33 @@ export default function SettingsPage() {
>
<option value="groq">Groq (Free, Fast)</option>
<option value="openai">OpenAI (GPT-4)</option>
<option value="xai">xAI (Grok)</option>
<option value="anthropic">Anthropic (Claude)</option>
<option value="ollama">Ollama (Local)</option>
<option value="lmstudio">LM Studio (Local)</option>
<option value="custom">Custom (OpenAI-compatible)</option>
</select>
</div>
{(provider === 'openai' || provider === 'anthropic' || provider === 'groq') && (
<div className="flex items-center gap-3">
<input
type="checkbox"
id="useSystemDefault"
checked={settings.useSystemDefault}
onChange={(e) => setSettings({ ...settings, useSystemDefault: e.target.checked })}
className="w-4 h-4 rounded border-slate-600 bg-slate-800 text-purple-600 focus:ring-purple-500"
/>
<label htmlFor="useSystemDefault" className="text-sm text-slate-300">
Use system default settings
</label>
</div>
<p className="text-xs text-slate-500 -mt-2">
{settings.useSystemDefault
? 'Using the default AI configuration set by the administrator.'
: 'Override with your own AI settings.'}
</p>
{!settings.useSystemDefault && PROVIDERS_WITH_API_KEY.includes(provider) && (
<div>
<label className="block text-sm text-slate-400 mb-1">API Key</label>
<input
@@ -467,7 +500,7 @@ export default function SettingsPage() {
</div>
)}
{(provider === 'ollama' || provider === 'lmstudio') && (
{!settings.useSystemDefault && PROVIDERS_WITH_BASE_URL.includes(provider) && (
<div>
<label className="block text-sm text-slate-400 mb-1">Base URL</label>
<input
@@ -480,16 +513,58 @@ export default function SettingsPage() {
</div>
)}
<div>
<label className="block text-sm text-slate-400 mb-1">Model</label>
<input
type="text"
value={currentProviderSettings.model || ''}
onChange={(e) => updateProviderSettings({ model: e.target.value })}
placeholder={DEFAULT_MODELS[provider]}
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none"
/>
</div>
{!settings.useSystemDefault && (
<div>
<label className="block text-sm text-slate-400 mb-1">Model</label>
<input
type="text"
value={currentProviderSettings.model || ''}
onChange={(e) => updateProviderSettings({ model: e.target.value })}
placeholder={DEFAULT_MODELS[provider]}
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none"
/>
</div>
)}
{!settings.useSystemDefault && (
<details className="group">
<summary className="cursor-pointer text-sm text-slate-400 hover:text-slate-300 list-none flex items-center gap-2">
<span className="transform transition-transform group-open:rotate-90"></span>
Advanced Settings
</summary>
<div className="mt-4 space-y-4 pl-4 border-l-2 border-slate-700">
<div>
<label className="block text-sm text-slate-400 mb-1">Custom Headers (JSON)</label>
<textarea
value={JSON.stringify(currentProviderSettings.headers || {}, null, 2)}
onChange={(e) => {
try {
updateProviderSettings({ headers: JSON.parse(e.target.value || '{}') });
} catch {}
}}
rows={3}
placeholder={'{"Authorization": "Bearer ..."}'}
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none resize-none font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">Custom Parameters (JSON)</label>
<textarea
value={JSON.stringify(currentProviderSettings.parameters || {}, null, 2)}
onChange={(e) => {
try {
updateProviderSettings({ parameters: JSON.parse(e.target.value || '{}') });
} catch {}
}}
rows={4}
placeholder={'{"think": false}'}
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none resize-none font-mono text-sm"
/>
<p className="text-xs text-slate-500 mt-1">Examples: {"{ \"think\": false }"} for Ollama, {"{ \"thinking\": { \"type\": \"disabled\" } }"} for OpenAI</p>
</div>
</div>
</details>
)}
<div className="flex gap-3 items-center">
<button

View File

@@ -1,15 +1,22 @@
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:3000',
changeOrigin: true,
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:3000',
changeOrigin: true,
},
},
},
},
define: {
'import.meta.env.VITE_GIT_URL': JSON.stringify(env.VITE_GIT_URL || 'https://git.kropa.tech/lotherk/deardiary'),
},
};
});

View File

@@ -16,7 +16,7 @@ server {
}
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';

View File

@@ -18,8 +18,8 @@
<div class="nav-links">
<a href="#features">Features</a>
<a href="docs/">Docs</a>
<a href="https://github.com/lotherk/deardiary" target="_blank">GitHub</a>
<a href="#try" class="btn btn-primary">Get Started</a>
<a href="${GIT_URL}" target="_blank">Git Repository</a>
<a href="${APP_URL}" class="btn btn-primary">Join Free Alpha</a>
</div>
</div>
</nav>
@@ -29,11 +29,11 @@
<h1>Your AI-Powered<br><span class="gradient-text">Daily Journal</span></h1>
<p class="hero-subtitle">Capture events throughout the day and let AI transform them into beautiful, thoughtful diary pages. Self-hosted. Private. Yours.</p>
<div class="hero-actions">
<a href="#try" class="btn btn-primary btn-large">Start Free</a>
<a href="${APP_URL}" class="btn btn-primary btn-large">Join Free Alpha</a>
<a href="docs/" class="btn btn-secondary btn-large">Read Docs</a>
</div>
<div class="hero-meta">
<span>v0.1.0</span>
<span>v0.1.1</span>
<span></span>
<span>Docker</span>
<span></span>
@@ -113,11 +113,16 @@
<div class="container">
<div class="try-content">
<h2>Ready to start journaling?</h2>
<p>Self-host in minutes with Docker. Your data stays on your server.</p>
<p>Try our free alpha hosted for you, or self-host in minutes with Docker.</p>
<div class="try-actions">
<a href="${APP_URL}" class="btn btn-primary btn-large">Join Free Alpha</a>
<span class="try-or">or</span>
<a href="docs/getting-started/installation.html" class="btn btn-secondary btn-large">Self-Host</a>
</div>
<div class="try-code">
<code>git clone https://github.com/lotherk/deardiary.git && cd deardiary && docker compose up -d</code>
</div>
<p class="try-hint">Default login: admin@localhost / changeme123</p>
<p class="try-hint">Self-hosted default login: admin@localhost / changeme123</p>
</div>
</div>
</section>
@@ -130,6 +135,7 @@
<a href="#features">Features</a>
<a href="docs/">Documentation</a>
<a href="#how">How it Works</a>
<a href="${APP_URL}">Free Alpha</a>
</div>
<div class="footer-col">
<h4>Resources</h4>
@@ -139,9 +145,9 @@
</div>
<div class="footer-col">
<h4>Project</h4>
<a href="https://github.com/lotherk/deardiary" target="_blank">GitHub</a>
<a href="https://github.com/lotherk/deardiary/issues" target="_blank">Issues</a>
<a href="https://github.com/lotherk/deardiary/releases" target="_blank">Releases</a>
<a href="${GIT_URL}" target="_blank">Source Code</a>
<a href="${GIT_URL}/issues" target="_blank">Issues</a>
<a href="${GIT_URL}/releases" target="_blank">Releases</a>
</div>
</div>
<div class="footer-bottom">