v0.1.1: System default AI config, configurable URLs, single .env, website refactor
This commit is contained in:
44
.env.example
Normal file
44
.env.example
Normal 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"
|
||||||
19
AGENTS.md
19
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
|
- Location is captured automatically from browser geolocation when creating events
|
||||||
- Reverse geocoding via OpenStreetMap Nominatim API provides place names
|
- 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
|
## API Design
|
||||||
|
|
||||||
All endpoints return: `{ data: T | null, error: { code, message } | null }`
|
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
|
### Provider Settings Storage
|
||||||
Settings stored as `providerSettings: { "groq": { apiKey, model, baseUrl }, ... }` with `aiProvider` determining which is active.
|
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
|
## Coding Guidelines
|
||||||
|
|
||||||
### TypeScript
|
### 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.
|
Tests require the server running. The test script starts the server, runs tests, then stops it.
|
||||||
|
|
||||||
## Version History
|
## 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.1.0: Automatic geolocation capture, Starlight documentation site
|
||||||
- 0.0.6: Automatic geolocation capture on event creation, reverse geocoding to place names
|
- 0.0.6: Automatic geolocation capture on event creation, reverse geocoding to place names
|
||||||
- 0.0.5: Export/Import feature with version checking
|
- 0.0.5: Export/Import feature with version checking
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ COPY backend/src ./src
|
|||||||
RUN bun build src/index.ts --outdir ./dist --target bun
|
RUN bun build src/index.ts --outdir ./dist --target bun
|
||||||
|
|
||||||
FROM node:20-alpine AS frontend-builder
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
ARG VITE_GIT_URL=https://git.kropa.tech/lotherk/deardiary
|
||||||
|
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY frontend ./
|
COPY frontend ./
|
||||||
|
ENV VITE_GIT_URL=$VITE_GIT_URL
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM oven/bun:1.1-alpine AS runner
|
FROM oven/bun:1.1-alpine AS runner
|
||||||
|
|||||||
@@ -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
11
Dockerfile.website
Normal 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;"]
|
||||||
@@ -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"
|
|
||||||
@@ -4,7 +4,7 @@ generator client {
|
|||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "sqlite"
|
||||||
url = env("DATABASE_URL")
|
url = env("BACKEND_DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -94,16 +94,17 @@ model Task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Settings {
|
model Settings {
|
||||||
userId String @id
|
userId String @id
|
||||||
aiProvider String @default("groq")
|
aiProvider String @default("groq")
|
||||||
aiApiKey String?
|
aiApiKey String?
|
||||||
aiModel String @default("llama-3.3-70b-versatile")
|
aiModel String @default("llama-3.3-70b-versatile")
|
||||||
aiBaseUrl String?
|
aiBaseUrl String?
|
||||||
journalPrompt String?
|
journalPrompt String?
|
||||||
language String @default("en")
|
language String @default("en")
|
||||||
timezone String @default("UTC")
|
timezone String @default("UTC")
|
||||||
providerSettings String?
|
providerSettings String?
|
||||||
journalContextDays Int @default(10)
|
journalContextDays Int @default(10)
|
||||||
|
useSystemDefault Boolean @default(true)
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,19 +8,35 @@ import { createHash, randomBytes } from 'crypto';
|
|||||||
import * as jose from 'jose';
|
import * as jose from 'jose';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { createAIProvider } from './services/ai/provider';
|
import { createAIProvider } from './services/ai/provider';
|
||||||
|
import { eventsRoutes } from './routes/events';
|
||||||
|
|
||||||
const app = new Hono();
|
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('*', logger());
|
||||||
app.use('*', cors({
|
app.use('*', cors({
|
||||||
origin: envVars.CORS_ORIGIN || '*',
|
origin: envVars.BACKEND_CORS_ORIGIN || '*',
|
||||||
credentials: true,
|
credentials: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const prisma = new PrismaClient({
|
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) => {
|
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);
|
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 })
|
const token = await new jose.SignJWT({ userId: user.id })
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
@@ -117,9 +133,9 @@ app.post('/api/v1/auth/login', async (c) => {
|
|||||||
app.get('/api/v1/server-info', async (c) => {
|
app.get('/api/v1/server-info', async (c) => {
|
||||||
return c.json({
|
return c.json({
|
||||||
data: {
|
data: {
|
||||||
version: envVars.VERSION || '0.1.0',
|
version: envVars.BACKEND_VERSION || '0.1.0',
|
||||||
registrationEnabled: envVars.REGISTRATION_ENABLED !== 'false',
|
registrationEnabled: envVars.BACKEND_REGISTRATION_ENABLED !== 'false',
|
||||||
appName: envVars.APP_NAME || 'DearDiary',
|
appName: envVars.BACKEND_APP_NAME || 'DearDiary',
|
||||||
},
|
},
|
||||||
error: null
|
error: null
|
||||||
});
|
});
|
||||||
@@ -132,7 +148,7 @@ app.post('/api/v1/auth/api-key', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = authHeader.slice(7);
|
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;
|
let userId: string;
|
||||||
try {
|
try {
|
||||||
@@ -293,22 +309,7 @@ app.get('/api/v1/search', async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/v1/events/:id', async (c) => {
|
app.route('/api/v1', eventsRoutes);
|
||||||
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 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Journal routes
|
// Journal routes
|
||||||
app.post('/api/v1/journal/generate/:date', async (c) => {
|
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 providerConfig = providerSettings[provider] || {};
|
||||||
const apiKey = providerConfig.apiKey || settings?.aiApiKey;
|
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);
|
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
|
// Settings routes
|
||||||
app.get('/api/v1/settings', async (c) => {
|
app.get('/api/v1/settings', async (c) => {
|
||||||
const userId = await getUserId(c);
|
const userId = await getUserId(c);
|
||||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||||
|
|
||||||
let settings = await prisma.settings.findUnique({ where: { userId } });
|
let settings = await getUserSettings(userId);
|
||||||
if (!settings) {
|
|
||||||
settings = await prisma.settings.create({ data: { userId } });
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldDefaultLength = 400;
|
const oldDefaultLength = 400;
|
||||||
if (settings.journalPrompt && settings.journalPrompt.length > oldDefaultLength) {
|
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);
|
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||||
|
|
||||||
const body = await c.req.json();
|
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> = {};
|
const data: Record<string, unknown> = {};
|
||||||
if (aiProvider !== undefined) data.aiProvider = aiProvider;
|
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 (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 (journalPrompt !== undefined) data.journalPrompt = journalPrompt === '' ? null : journalPrompt;
|
||||||
if (language !== undefined) data.language = language;
|
if (language !== undefined) data.language = language;
|
||||||
if (providerSettings !== undefined) data.providerSettings = JSON.stringify(providerSettings);
|
if (providerSettings !== undefined) data.providerSettings = JSON.stringify(providerSettings);
|
||||||
if (journalContextDays !== undefined) data.journalContextDays = journalContextDays;
|
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({
|
const settings = await prisma.settings.upsert({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
create: { userId, ...data },
|
create: { userId, ...getDefaultSettings(), ...data },
|
||||||
update: data,
|
update: data,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1030,10 +1056,7 @@ app.post('/api/v1/account/reset', async (c) => {
|
|||||||
await prisma.settings.update({
|
await prisma.settings.update({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
data: {
|
data: {
|
||||||
aiProvider: 'groq',
|
...getDefaultSettings(),
|
||||||
aiApiKey: null,
|
|
||||||
aiModel: 'llama-3.3-70b-versatile',
|
|
||||||
aiBaseUrl: null,
|
|
||||||
journalPrompt: null,
|
journalPrompt: null,
|
||||||
language: 'en',
|
language: 'en',
|
||||||
timezone: 'UTC',
|
timezone: 'UTC',
|
||||||
@@ -1089,7 +1112,7 @@ app.post('/api/v1/account/password', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/v1/auth/register', 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) {
|
if (!registrationEnabled) {
|
||||||
return c.json({ data: null, error: { code: 'REGISTRATION_DISABLED', message: 'Registration is currently disabled' } }, 403);
|
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() {
|
async function createDefaultUser() {
|
||||||
const defaultEmail = envVars.DEFAULT_USER_EMAIL;
|
const defaultEmail = envVars.BACKEND_DEFAULT_USER_EMAIL;
|
||||||
const defaultPassword = envVars.DEFAULT_USER_PASSWORD;
|
const defaultPassword = envVars.BACKEND_DEFAULT_USER_PASSWORD;
|
||||||
|
|
||||||
if (!defaultEmail || !defaultPassword) {
|
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;
|
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}`);
|
console.log(`Starting DearDiary API on port ${port}`);
|
||||||
|
|
||||||
setupFTS().then(() => {
|
setupFTS().then(() => {
|
||||||
|
|||||||
@@ -6,10 +6,19 @@ export interface HonoEnv {
|
|||||||
prisma: PrismaClient;
|
prisma: PrismaClient;
|
||||||
};
|
};
|
||||||
Bindings: {
|
Bindings: {
|
||||||
DATABASE_URL: string;
|
BACKEND_DATABASE_URL: string;
|
||||||
JWT_SECRET: string;
|
BACKEND_JWT_SECRET: string;
|
||||||
MEDIA_DIR: string;
|
BACKEND_MEDIA_DIR: string;
|
||||||
PORT: string;
|
BACKEND_PORT: string;
|
||||||
CORS_ORIGIN: 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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,32 @@ export async function authMiddleware(c: Context<HonoEnv>, next: Next) {
|
|||||||
|
|
||||||
export function authenticate() {
|
export function authenticate() {
|
||||||
return async (c: Context<HonoEnv>, next: Next) => {
|
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();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ authRoutes.post('/login', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prisma = c.get('prisma');
|
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 } });
|
const user = await prisma.user.findUnique({ where: { email } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -88,7 +88,7 @@ authRoutes.post('/api-key', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = authHeader.slice(7);
|
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;
|
let userId: string;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { HonoEnv } from '../lib/types';
|
import { HonoEnv } from '../lib/types';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
|
||||||
export const eventsRoutes = new Hono<HonoEnv>();
|
export const eventsRoutes = new Hono<HonoEnv>();
|
||||||
|
|
||||||
eventsRoutes.post('/', async (c) => {
|
eventsRoutes.use(authenticate());
|
||||||
|
|
||||||
|
eventsRoutes.post('/events', async (c) => {
|
||||||
const userId = c.get('userId');
|
const userId = c.get('userId');
|
||||||
const prisma = c.get('prisma');
|
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 body = await c.req.json();
|
||||||
const { date, type, content, metadata, latitude, longitude, placeName } = body;
|
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);
|
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 userId = c.get('userId');
|
||||||
const { id } = c.req.param();
|
const { id } = c.req.param();
|
||||||
const prisma = c.get('prisma');
|
const prisma = c.get('prisma');
|
||||||
@@ -58,7 +61,7 @@ eventsRoutes.get('/:id', async (c) => {
|
|||||||
return c.json({ data: event, error: null });
|
return c.json({ data: event, error: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
eventsRoutes.put('/:id', async (c) => {
|
eventsRoutes.put('/events/:id', async (c) => {
|
||||||
const userId = c.get('userId');
|
const userId = c.get('userId');
|
||||||
const { id } = c.req.param();
|
const { id } = c.req.param();
|
||||||
const prisma = c.get('prisma');
|
const prisma = c.get('prisma');
|
||||||
@@ -97,7 +100,7 @@ eventsRoutes.put('/:id', async (c) => {
|
|||||||
return c.json({ data: event, error: null });
|
return c.json({ data: event, error: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
eventsRoutes.delete('/:id', async (c) => {
|
eventsRoutes.delete('/events/:id', async (c) => {
|
||||||
const userId = c.get('userId');
|
const userId = c.get('userId');
|
||||||
const { id } = c.req.param();
|
const { id } = c.req.param();
|
||||||
const prisma = c.get('prisma');
|
const prisma = c.get('prisma');
|
||||||
@@ -120,11 +123,11 @@ eventsRoutes.delete('/:id', async (c) => {
|
|||||||
return c.json({ data: { deleted: true }, error: null });
|
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 userId = c.get('userId');
|
||||||
const { id } = c.req.param();
|
const { id } = c.req.param();
|
||||||
const prisma = c.get('prisma');
|
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({
|
const event = await prisma.event.findFirst({
|
||||||
where: { id, userId },
|
where: { id, userId },
|
||||||
@@ -157,11 +160,11 @@ eventsRoutes.post('/:id/photo', async (c) => {
|
|||||||
return c.json({ data: { mediaPath: mediaUrl }, error: null }, 201);
|
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 userId = c.get('userId');
|
||||||
const { id } = c.req.param();
|
const { id } = c.req.param();
|
||||||
const prisma = c.get('prisma');
|
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({
|
const event = await prisma.event.findFirst({
|
||||||
where: { id, userId },
|
where: { id, userId },
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { AnthropicProvider } from './anthropic';
|
|||||||
import { OllamaProvider } from './ollama';
|
import { OllamaProvider } from './ollama';
|
||||||
import { LMStudioProvider } from './lmstudio';
|
import { LMStudioProvider } from './lmstudio';
|
||||||
import { GroqProvider } from './groq';
|
import { GroqProvider } from './groq';
|
||||||
|
import { XAIProvider } from './xai';
|
||||||
import { CustomProvider } from './custom';
|
import { CustomProvider } from './custom';
|
||||||
|
|
||||||
export function createAIProvider(config: AIProviderConfig): AIProvider {
|
export function createAIProvider(config: AIProviderConfig): AIProvider {
|
||||||
@@ -56,6 +57,8 @@ export function createAIProvider(config: AIProviderConfig): AIProvider {
|
|||||||
return new LMStudioProvider(config, settings);
|
return new LMStudioProvider(config, settings);
|
||||||
case 'groq':
|
case 'groq':
|
||||||
return new GroqProvider(config, settings);
|
return new GroqProvider(config, settings);
|
||||||
|
case 'xai':
|
||||||
|
return new XAIProvider(config, settings);
|
||||||
case 'custom':
|
case 'custom':
|
||||||
return new CustomProvider(config, settings);
|
return new CustomProvider(config, settings);
|
||||||
default:
|
default:
|
||||||
|
|||||||
92
backend/src/services/ai/xai.ts
Normal file
92
backend/src/services/ai/xai.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +1,46 @@
|
|||||||
services:
|
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:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
- VITE_GIT_URL=${GIT_URL:-https://git.kropa.tech/lotherk/deardiary}
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:80"
|
||||||
- "5173:80"
|
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=file:/data/deardiary.db
|
- BACKEND_DATABASE_URL=file:/data/deardiary.db
|
||||||
- MEDIA_DIR=/data/media
|
- BACKEND_MEDIA_DIR=/data/media
|
||||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
- BACKEND_JWT_SECRET=${BACKEND_JWT_SECRET:-change-me-in-production}
|
||||||
- PORT=3000
|
- BACKEND_PORT=3001
|
||||||
- CORS_ORIGIN=${CORS_ORIGIN:-*}
|
- BACKEND_CORS_ORIGIN=${BACKEND_CORS_ORIGIN:-*}
|
||||||
- DEFAULT_USER_EMAIL=${DEFAULT_USER_EMAIL:-}
|
- BACKEND_REGISTRATION_ENABLED=${BACKEND_REGISTRATION_ENABLED:-false}
|
||||||
- DEFAULT_USER_PASSWORD=${DEFAULT_USER_PASSWORD:-}
|
- 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:
|
volumes:
|
||||||
- deardiary_data:/data
|
- deardiary_data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
docs:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.docs
|
|
||||||
ports:
|
|
||||||
- "4000:80"
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
deardiary_data:
|
deardiary_data:
|
||||||
|
|||||||
10
docker-entrypoint.d/30-envsubst.sh
Executable file
10
docker-entrypoint.d/30-envsubst.sh
Executable 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;'
|
||||||
@@ -81,7 +81,7 @@ function Footer({ appName = 'DearDiary' }: { appName?: string }) {
|
|||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<footer className={`py-6 text-center text-sm text-slate-500 ${resolvedTheme === 'dark' ? 'border-t border-slate-800' : 'border-t border-slate-200'}`}>
|
<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>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,30 @@ export default function QuickAddWidget({ isOpen, onClose }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
checkDiaryStatus();
|
checkDiaryStatus();
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [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 checkDiaryStatus = async () => {
|
||||||
const res = await api.getDay(today);
|
const res = await api.getDay(today);
|
||||||
if (res.data) {
|
if (res.data) {
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export interface Task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
aiProvider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq';
|
aiProvider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq' | 'xai' | 'custom';
|
||||||
aiApiKey?: string;
|
aiApiKey?: string;
|
||||||
aiModel: string;
|
aiModel: string;
|
||||||
aiBaseUrl?: string;
|
aiBaseUrl?: string;
|
||||||
@@ -229,10 +229,13 @@ export interface Settings {
|
|||||||
language: string;
|
language: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
journalContextDays: number;
|
journalContextDays: number;
|
||||||
|
useSystemDefault: boolean;
|
||||||
providerSettings?: Record<string, {
|
providerSettings?: Record<string, {
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
parameters?: Record<string, unknown>;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ interface ProviderSettings {
|
|||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
parameters?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FullSettings extends Settings {
|
interface FullSettings extends Settings {
|
||||||
@@ -15,15 +17,21 @@ interface FullSettings extends Settings {
|
|||||||
|
|
||||||
const DEFAULT_MODELS: Record<string, string> = {
|
const DEFAULT_MODELS: Record<string, string> = {
|
||||||
groq: 'llama-3.3-70b-versatile',
|
groq: 'llama-3.3-70b-versatile',
|
||||||
openai: 'gpt-4o',
|
openai: 'gpt-5-nano',
|
||||||
anthropic: 'claude-3-5-sonnet-20241022',
|
anthropic: 'claude-3-5-sonnet-20241022',
|
||||||
ollama: 'llama3.2',
|
ollama: 'llama3.2',
|
||||||
lmstudio: 'local-model',
|
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> = {
|
const DEFAULT_BASE_URLS: Record<string, string> = {
|
||||||
ollama: 'http://localhost:11434',
|
ollama: 'http://localhost:11434',
|
||||||
lmstudio: 'http://localhost:1234/v1',
|
lmstudio: 'http://localhost:1234/v1',
|
||||||
|
custom: 'https://your-api.example.com/v1',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@@ -34,6 +42,7 @@ export default function SettingsPage() {
|
|||||||
language: 'en',
|
language: 'en',
|
||||||
timezone: 'UTC',
|
timezone: 'UTC',
|
||||||
journalContextDays: 10,
|
journalContextDays: 10,
|
||||||
|
useSystemDefault: true,
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -80,6 +89,9 @@ export default function SettingsPage() {
|
|||||||
if (!data.providerSettings) {
|
if (!data.providerSettings) {
|
||||||
data.providerSettings = {};
|
data.providerSettings = {};
|
||||||
}
|
}
|
||||||
|
if (data.useSystemDefault === undefined) {
|
||||||
|
data.useSystemDefault = true;
|
||||||
|
}
|
||||||
const provider = data.aiProvider || 'groq';
|
const provider = data.aiProvider || 'groq';
|
||||||
const providerData = data.providerSettings[provider] || {};
|
const providerData = data.providerSettings[provider] || {};
|
||||||
data.aiApiKey = providerData.apiKey;
|
data.aiApiKey = providerData.apiKey;
|
||||||
@@ -158,6 +170,7 @@ export default function SettingsPage() {
|
|||||||
await api.updateSettings({
|
await api.updateSettings({
|
||||||
aiProvider: settings.aiProvider,
|
aiProvider: settings.aiProvider,
|
||||||
providerSettings: newProviderSettings,
|
providerSettings: newProviderSettings,
|
||||||
|
useSystemDefault: settings.useSystemDefault,
|
||||||
});
|
});
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
@@ -448,13 +461,33 @@ export default function SettingsPage() {
|
|||||||
>
|
>
|
||||||
<option value="groq">Groq (Free, Fast)</option>
|
<option value="groq">Groq (Free, Fast)</option>
|
||||||
<option value="openai">OpenAI (GPT-4)</option>
|
<option value="openai">OpenAI (GPT-4)</option>
|
||||||
|
<option value="xai">xAI (Grok)</option>
|
||||||
<option value="anthropic">Anthropic (Claude)</option>
|
<option value="anthropic">Anthropic (Claude)</option>
|
||||||
<option value="ollama">Ollama (Local)</option>
|
<option value="ollama">Ollama (Local)</option>
|
||||||
<option value="lmstudio">LM Studio (Local)</option>
|
<option value="lmstudio">LM Studio (Local)</option>
|
||||||
|
<option value="custom">Custom (OpenAI-compatible)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm text-slate-400 mb-1">API Key</label>
|
<label className="block text-sm text-slate-400 mb-1">API Key</label>
|
||||||
<input
|
<input
|
||||||
@@ -467,7 +500,7 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(provider === 'ollama' || provider === 'lmstudio') && (
|
{!settings.useSystemDefault && PROVIDERS_WITH_BASE_URL.includes(provider) && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-slate-400 mb-1">Base URL</label>
|
<label className="block text-sm text-slate-400 mb-1">Base URL</label>
|
||||||
<input
|
<input
|
||||||
@@ -480,16 +513,58 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
{!settings.useSystemDefault && (
|
||||||
<label className="block text-sm text-slate-400 mb-1">Model</label>
|
<div>
|
||||||
<input
|
<label className="block text-sm text-slate-400 mb-1">Model</label>
|
||||||
type="text"
|
<input
|
||||||
value={currentProviderSettings.model || ''}
|
type="text"
|
||||||
onChange={(e) => updateProviderSettings({ model: e.target.value })}
|
value={currentProviderSettings.model || ''}
|
||||||
placeholder={DEFAULT_MODELS[provider]}
|
onChange={(e) => updateProviderSettings({ model: e.target.value })}
|
||||||
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none"
|
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>
|
/>
|
||||||
|
</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">
|
<div className="flex gap-3 items-center">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [react()],
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
server: {
|
|
||||||
port: 5173,
|
return {
|
||||||
proxy: {
|
plugins: [react()],
|
||||||
'/api': {
|
server: {
|
||||||
target: process.env.VITE_API_URL || 'http://localhost:3000',
|
port: 5173,
|
||||||
changeOrigin: true,
|
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'),
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://127.0.0.1:3000;
|
proxy_pass http://127.0.0.1:3001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection 'upgrade';
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="#features">Features</a>
|
<a href="#features">Features</a>
|
||||||
<a href="docs/">Docs</a>
|
<a href="docs/">Docs</a>
|
||||||
<a href="https://github.com/lotherk/deardiary" target="_blank">GitHub</a>
|
<a href="${GIT_URL}" target="_blank">Git Repository</a>
|
||||||
<a href="#try" class="btn btn-primary">Get Started</a>
|
<a href="${APP_URL}" class="btn btn-primary">Join Free Alpha</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -29,11 +29,11 @@
|
|||||||
<h1>Your AI-Powered<br><span class="gradient-text">Daily Journal</span></h1>
|
<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>
|
<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">
|
<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>
|
<a href="docs/" class="btn btn-secondary btn-large">Read Docs</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-meta">
|
<div class="hero-meta">
|
||||||
<span>v0.1.0</span>
|
<span>v0.1.1</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>Docker</span>
|
<span>Docker</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
@@ -113,11 +113,16 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="try-content">
|
<div class="try-content">
|
||||||
<h2>Ready to start journaling?</h2>
|
<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">
|
<div class="try-code">
|
||||||
<code>git clone https://github.com/lotherk/deardiary.git && cd deardiary && docker compose up -d</code>
|
<code>git clone https://github.com/lotherk/deardiary.git && cd deardiary && docker compose up -d</code>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -130,6 +135,7 @@
|
|||||||
<a href="#features">Features</a>
|
<a href="#features">Features</a>
|
||||||
<a href="docs/">Documentation</a>
|
<a href="docs/">Documentation</a>
|
||||||
<a href="#how">How it Works</a>
|
<a href="#how">How it Works</a>
|
||||||
|
<a href="${APP_URL}">Free Alpha</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-col">
|
<div class="footer-col">
|
||||||
<h4>Resources</h4>
|
<h4>Resources</h4>
|
||||||
@@ -139,9 +145,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="footer-col">
|
<div class="footer-col">
|
||||||
<h4>Project</h4>
|
<h4>Project</h4>
|
||||||
<a href="https://github.com/lotherk/deardiary" target="_blank">GitHub</a>
|
<a href="${GIT_URL}" target="_blank">Source Code</a>
|
||||||
<a href="https://github.com/lotherk/deardiary/issues" target="_blank">Issues</a>
|
<a href="${GIT_URL}/issues" target="_blank">Issues</a>
|
||||||
<a href="https://github.com/lotherk/deardiary/releases" target="_blank">Releases</a>
|
<a href="${GIT_URL}/releases" target="_blank">Releases</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-bottom">
|
<div class="footer-bottom">
|
||||||
|
|||||||
Reference in New Issue
Block a user