From f6c4da1da35b7da19714793d9b8596e21c4d5d48 Mon Sep 17 00:00:00 2001 From: lotherk Date: Fri, 27 Mar 2026 13:51:15 +0000 Subject: [PATCH] v0.1.1: System default AI config, configurable URLs, single .env, website refactor --- .env.example | 44 +++++++++ AGENTS.md | 19 ++++ Dockerfile | 2 + Dockerfile.docs | 7 -- Dockerfile.website | 11 +++ backend/.env.example | 51 ---------- backend/prisma/schema.prisma | 21 ++-- backend/src/index.ts | 107 +++++++++++++-------- backend/src/lib/types.ts | 19 +++- backend/src/middleware/auth.ts | 28 +++++- backend/src/routes/auth.ts | 4 +- backend/src/routes/events.ts | 21 ++-- backend/src/services/ai/provider.ts | 3 + backend/src/services/ai/xai.ts | 92 ++++++++++++++++++ docker-compose.yml | 45 +++++---- docker-entrypoint.d/30-envsubst.sh | 10 ++ frontend/src/App.tsx | 2 +- frontend/src/components/QuickAddWidget.tsx | 21 ++++ frontend/src/lib/api.ts | 5 +- frontend/src/pages/Settings.tsx | 101 ++++++++++++++++--- frontend/vite.config.ts | 27 ++++-- nginx.conf | 2 +- www/index.html | 24 +++-- 23 files changed, 486 insertions(+), 180 deletions(-) create mode 100644 .env.example delete mode 100644 Dockerfile.docs create mode 100644 Dockerfile.website delete mode 100644 backend/.env.example create mode 100644 backend/src/services/ai/xai.ts create mode 100755 docker-entrypoint.d/30-envsubst.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dca8e87 --- /dev/null +++ b/.env.example @@ -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" diff --git a/AGENTS.md b/AGENTS.md index 4120eb7..62505ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Dockerfile b/Dockerfile index c3609a2..7d91d17 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Dockerfile.docs b/Dockerfile.docs deleted file mode 100644 index 07fd72f..0000000 --- a/Dockerfile.docs +++ /dev/null @@ -1,7 +0,0 @@ -FROM nginx:alpine - -COPY www/ /usr/share/nginx/html/ - -EXPOSE 80 - -CMD ["nginx", "-g", "daemon off;"] diff --git a/Dockerfile.website b/Dockerfile.website new file mode 100644 index 0000000..981271e --- /dev/null +++ b/Dockerfile.website @@ -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;"] diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index 3f31d7b..0000000 --- a/backend/.env.example +++ /dev/null @@ -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" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 30e3555..dbd14fa 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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) } diff --git a/backend/src/index.ts b/backend/src/index.ts index 95f111f..6129bd1 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 = {}; 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(() => { diff --git a/backend/src/lib/types.ts b/backend/src/lib/types.ts index 1fe9b30..ed8b614 100644 --- a/backend/src/lib/types.ts +++ b/backend/src/lib/types.ts @@ -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; }; } diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index e531737..ea97588 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -34,6 +34,32 @@ export async function authMiddleware(c: Context, next: Next) { export function authenticate() { return async (c: Context, 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(); }; } diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 2a00117..2017e62 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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 { diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index e147cc7..d912c93 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -1,12 +1,15 @@ import { Hono } from 'hono'; import { HonoEnv } from '../lib/types'; +import { authenticate } from '../middleware/auth'; export const eventsRoutes = new Hono(); -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 }, diff --git a/backend/src/services/ai/provider.ts b/backend/src/services/ai/provider.ts index b7e2402..e3a2874 100644 --- a/backend/src/services/ai/provider.ts +++ b/backend/src/services/ai/provider.ts @@ -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: diff --git a/backend/src/services/ai/xai.ts b/backend/src/services/ai/xai.ts new file mode 100644 index 0000000..f6f62ec --- /dev/null +++ b/backend/src/services/ai/xai.ts @@ -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; + private customParameters?: Record; + + 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 { + const messages: Array<{ role: string; content: string }> = []; + + if (systemPrompt) { + messages.push({ role: 'system', content: systemPrompt }); + } + + messages.push({ role: 'user', content: prompt }); + + const requestBody: Record = { + model: this.model, + messages, + temperature: 0.7, + max_tokens: 2000, + ...this.customParameters, + }; + + if (options?.jsonMode) { + requestBody.response_format = { type: 'json_object' }; + } + + const headers: Record = { + '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 { + try { + const headers: Record = { + 'Authorization': `Bearer ${this.apiKey}`, + ...this.customHeaders, + }; + const response = await fetch(`${this.baseUrl}/models`, { headers }); + return response.ok; + } catch { + return false; + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 6893aac..21dd03c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docker-entrypoint.d/30-envsubst.sh b/docker-entrypoint.d/30-envsubst.sh new file mode 100755 index 0000000..1b07d1d --- /dev/null +++ b/docker-entrypoint.d/30-envsubst.sh @@ -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;' diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b93981f..f4d877a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -81,7 +81,7 @@ function Footer({ appName = 'DearDiary' }: { appName?: string }) { const { resolvedTheme } = useTheme(); return (
-

{appName} v{packageJson.version} — Self-hosted AI-powered journaling · GitHub · deardiary.io · MIT License · © 2026 Konrad Lother

+

{appName} v{packageJson.version} — Self-hosted AI-powered journaling · Git Repository · MIT License · © 2026 Konrad Lother

); } diff --git a/frontend/src/components/QuickAddWidget.tsx b/frontend/src/components/QuickAddWidget.tsx index c1c461a..16ec7ca 100644 --- a/frontend/src/components/QuickAddWidget.tsx +++ b/frontend/src/components/QuickAddWidget.tsx @@ -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) { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index bc84df3..54c2723 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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; + parameters?: Record; }>; } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 2f316e3..e274ef7 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -7,6 +7,8 @@ interface ProviderSettings { apiKey?: string; model?: string; baseUrl?: string; + headers?: Record; + parameters?: Record; } interface FullSettings extends Settings { @@ -15,15 +17,21 @@ interface FullSettings extends Settings { const DEFAULT_MODELS: Record = { 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 = { 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() { > + + - {(provider === 'openai' || provider === 'anthropic' || provider === 'groq') && ( +
+ 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" + /> + +
+

+ {settings.useSystemDefault + ? 'Using the default AI configuration set by the administrator.' + : 'Override with your own AI settings.'} +

+ + {!settings.useSystemDefault && PROVIDERS_WITH_API_KEY.includes(provider) && (
)} - {(provider === 'ollama' || provider === 'lmstudio') && ( + {!settings.useSystemDefault && PROVIDERS_WITH_BASE_URL.includes(provider) && (
)} -
- - 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" - /> -
+ {!settings.useSystemDefault && ( +
+ + 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" + /> +
+ )} + + {!settings.useSystemDefault && ( +
+ + + Advanced Settings + +
+
+ +