Initial commit: deardiary project setup

This commit is contained in:
lotherk
2026-03-26 19:57:20 +00:00
commit 3f9bc1f484
73 changed files with 8627 additions and 0 deletions

20
backend/.env.example Normal file
View File

@@ -0,0 +1,20 @@
# Database connection (SQLite, PostgreSQL, or MySQL)
DATABASE_URL="file:./data/totalrecall.db"
# Media storage directory
MEDIA_DIR="./data/media"
# JWT secret for authentication tokens (REQUIRED in production)
JWT_SECRET="change-this-to-a-random-string-in-production"
# Server port
PORT="3000"
# CORS origin (use specific domain in production)
CORS_ORIGIN="*"
# Example PostgreSQL connection:
# DATABASE_URL="postgresql://postgres:password@db:5432/totalrecall"
# Example MySQL connection:
# DATABASE_URL="mysql://root:password@localhost:3306/totalrecall"

39
backend/Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
# Build stage
FROM oven/bun:1.1-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN bun install
COPY prisma ./prisma
RUN bunx prisma generate
COPY src ./src
RUN bun build src/index.ts --outdir ./dist --target bun
# Production stage
FROM oven/bun:1.1-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 bun
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
RUN mkdir -p /data && chown -R bun:nodejs /data
USER bun
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["bun", "run", "src/index.ts"]

50
backend/bun.lock Normal file
View File

@@ -0,0 +1,50 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "totalrecall-backend",
"dependencies": {
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"hono": "^4.6.10",
"jose": "^5.9.6",
"nanoid": "^5.0.8",
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"prisma": "^5.22.0",
"typescript": "^5.6.3",
},
},
},
"packages": {
"@prisma/client": ["@prisma/client@5.22.0", "", { "peerDependencies": { "prisma": "*" }, "optionalPeers": ["prisma"] }, "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA=="],
"@prisma/debug": ["@prisma/debug@5.22.0", "", {}, "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ=="],
"@prisma/engines": ["@prisma/engines@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "@prisma/fetch-engine": "5.22.0", "@prisma/get-platform": "5.22.0" } }, "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA=="],
"@prisma/engines-version": ["@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "", {}, "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ=="],
"@prisma/fetch-engine": ["@prisma/fetch-engine@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "@prisma/get-platform": "5.22.0" } }, "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA=="],
"@prisma/get-platform": ["@prisma/get-platform@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0" } }, "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q=="],
"@types/bcryptjs": ["@types/bcryptjs@2.4.6", "", {}, "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ=="],
"bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
"nanoid": ["nanoid@5.1.7", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ=="],
"prisma": ["prisma@5.22.0", "", { "dependencies": { "@prisma/engines": "5.22.0" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "prisma": "build/index.js" } }, "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
}
}

26
backend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "totalrecall-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "bun --watch src/index.ts",
"build": "bun build src/index.ts --outdir ./dist --target bun",
"start": "bun run src/index.ts",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"hono": "^4.6.10",
"jose": "^5.9.6",
"nanoid": "^5.0.8"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"prisma": "^5.22.0",
"typescript": "^5.6.3"
}
}

View File

@@ -0,0 +1,101 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
apiKeys ApiKey[]
entries Entry[]
journals Journal[]
tasks Task[]
settings Settings?
}
model ApiKey {
id String @id @default(uuid())
userId String
keyHash String @unique
name String
lastUsedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model Entry {
id String @id @default(uuid())
userId String
date String
type String
content String
mediaPath String?
metadata String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, date])
@@index([date])
}
model Journal {
id String @id @default(uuid())
userId String
date String
content String
entryCount Int
generatedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tasks Task[]
@@unique([userId, date])
@@index([userId])
}
model Task {
id String @id @default(uuid())
userId String
journalId String
type String @default("journal_generate")
status String @default("pending")
provider String
model String?
prompt String
request String?
response String?
error String?
createdAt DateTime @default(now())
completedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
journal Journal @relation(fields: [journalId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([journalId])
}
model Settings {
userId String @id
aiProvider String @default("openai")
aiApiKey String?
aiModel String @default("gpt-4")
aiBaseUrl String?
journalPrompt String @default("You are a thoughtful journal writer. Based on the entries provided, write a reflective journal entry for this day in a warm, personal tone.")
language String @default("en")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

588
backend/src/index.ts Normal file
View File

@@ -0,0 +1,588 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { env } from 'hono/adapter';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { createHash, randomBytes } from 'crypto';
import * as jose from 'jose';
import { Prisma } from '@prisma/client';
const app = new Hono();
const envVars = env(app);
app.use('*', logger());
app.use('*', cors({
origin: envVars.CORS_ORIGIN || '*',
credentials: true,
}));
const prisma = new PrismaClient({
datasourceUrl: envVars.DATABASE_URL || 'file:./data/totalrecall.db',
});
app.use('*', async (c, next) => {
c.set('prisma', prisma as any);
await next();
});
const getUserId = async (c: any): Promise<string | null> => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) return null;
const apiKey = authHeader.slice(7);
const keyHash = createHash('sha256').update(apiKey).digest('hex');
const keyRecord = await prisma.apiKey.findUnique({
where: { keyHash },
include: { user: true },
});
if (!keyRecord) return null;
await prisma.apiKey.update({
where: { id: keyRecord.id },
data: { lastUsedAt: new Date() },
});
return keyRecord.userId;
};
app.get('/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() }));
// Auth routes
app.post('/api/v1/auth/register', async (c) => {
const { email, password } = await c.req.json();
if (!email || !password) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Email and password are required' } }, 400);
}
if (password.length < 8) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Password must be at least 8 characters' } }, 400);
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Invalid email format' } }, 400);
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return c.json({ data: null, error: { code: 'CONFLICT', message: 'Email already registered' } }, 409);
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: {
email,
passwordHash,
settings: { create: {} },
},
select: { id: true, email: true, createdAt: true },
});
return c.json({ data: { user }, error: null }, 201);
});
app.post('/api/v1/auth/login', async (c) => {
const { email, password } = await c.req.json();
if (!email || !password) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Email and password are required' } }, 400);
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }, 401);
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }, 401);
}
const jwtSecret = envVars.JWT_SECRET || 'development-secret-change-in-production';
const token = await new jose.SignJWT({ userId: user.id })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(new TextEncoder().encode(jwtSecret));
return c.json({ data: { token, userId: user.id }, error: null });
});
app.post('/api/v1/auth/api-key', async (c) => {
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 token = authHeader.slice(7);
const jwtSecret = envVars.JWT_SECRET || 'development-secret-change-in-production';
let userId: string;
try {
const { payload } = await jose.jwtVerify(token, new TextEncoder().encode(jwtSecret));
userId = payload.userId as string;
} catch {
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid or expired token' } }, 401);
}
const { name } = (await c.req.json()) || {};
const apiKey = randomBytes(32).toString('hex');
const keyHash = createHash('sha256').update(apiKey).digest('hex');
const keyRecord = await prisma.apiKey.create({
data: { userId, keyHash, name: name || 'Default' },
});
return c.json({ data: { apiKey, id: keyRecord.id, name: keyRecord.name }, error: null }, 201);
});
// Days routes
app.get('/api/v1/days', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const days = await prisma.entry.groupBy({
by: ['date'],
where: { userId },
_count: { id: true },
orderBy: { date: 'desc' },
});
const journals = await prisma.journal.findMany({
where: { userId },
select: { date: true, generatedAt: true },
});
const journalMap = new Map(journals.map(j => [j.date, j]));
const result = days.map(day => ({
date: day.date,
entryCount: day._count.id,
hasJournal: journalMap.has(day.date),
journalGeneratedAt: journalMap.get(day.date)?.generatedAt,
}));
return c.json({ data: result, error: null });
});
app.get('/api/v1/days/:date', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { date } = c.req.param();
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(date)) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Invalid date format. Use YYYY-MM-DD' } }, 400);
}
const [entries, journal] = await Promise.all([
prisma.entry.findMany({ where: { userId, date }, orderBy: { createdAt: 'asc' } }),
prisma.journal.findFirst({ where: { userId, date } }),
]);
return c.json({ data: { date, entries, journal }, error: null });
});
app.delete('/api/v1/days/:date', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { date } = c.req.param();
await prisma.$transaction([
prisma.entry.deleteMany({ where: { userId, date } }),
prisma.journal.deleteMany({ where: { userId, date } }),
]);
return c.json({ data: { deleted: true }, error: null });
});
// Entries routes
app.post('/api/v1/entries', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { date, type, content, metadata } = await c.req.json();
if (!date || !type || !content) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'date, type, and content are required' } }, 400);
}
const validTypes = ['text', 'voice', 'photo', 'health', 'location'];
if (!validTypes.includes(type)) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: `type must be one of: ${validTypes.join(', ')}` } }, 400);
}
const entry = await prisma.entry.create({
data: { userId, date, type, content, metadata: metadata ? JSON.stringify(metadata) : null },
});
return c.json({ data: entry, error: null }, 201);
});
app.get('/api/v1/entries/: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 entry = await prisma.entry.findFirst({ where: { id, userId } });
if (!entry) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
return c.json({ data: entry, error: null });
});
app.put('/api/v1/entries/: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 { content, metadata } = await c.req.json();
const existing = await prisma.entry.findFirst({ where: { id, userId } });
if (!existing) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
const entry = await prisma.entry.update({
where: { id },
data: {
content: content ?? existing.content,
metadata: metadata !== undefined ? JSON.stringify(metadata) : existing.metadata,
},
});
return c.json({ data: entry, error: null });
});
app.delete('/api/v1/entries/: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.entry.findFirst({ where: { id, userId } });
if (!existing) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
await prisma.entry.delete({ where: { id } });
return c.json({ data: { deleted: true }, error: null });
});
// Journal routes
app.post('/api/v1/journal/generate/:date', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { date } = c.req.param();
const [entries, settings] = await Promise.all([
prisma.entry.findMany({ where: { userId, date }, orderBy: { createdAt: 'asc' } }),
prisma.settings.findUnique({ where: { userId } }),
]);
if (entries.length === 0) {
return c.json({ data: null, error: { code: 'NO_ENTRIES', message: 'No entries found for this date' } }, 400);
}
const provider = settings?.aiProvider || 'openai';
if ((provider === 'openai' || provider === 'anthropic') && !settings?.aiApiKey) {
return c.json({ data: null, error: { code: 'NO_AI_CONFIG', message: 'AI not configured. Please set up your API key in settings.' } }, 400);
}
const entriesText = entries.map(entry => {
let text = `[${entry.type.toUpperCase()}] ${entry.createdAt.toISOString()}\n${entry.content}`;
if (entry.metadata) {
try {
const meta = JSON.parse(entry.metadata);
if (meta.location) text += `\nLocation: ${meta.location.lat}, ${meta.location.lng}`;
if (meta.duration) text += `\nDuration: ${meta.duration}s`;
} catch {}
}
return text;
}).join('\n\n');
const systemPrompt = settings?.journalPrompt || 'You are a thoughtful journal writer.';
const userPrompt = `The following entries were captured throughout the day (${date}). Write a thoughtful, reflective journal entry.
ENTRIES:
${entriesText}
JOURNAL:`;
// Create placeholder journal and task
const placeholderJournal = await prisma.journal.create({
data: { userId, date, content: 'Generating...', entryCount: entries.length },
});
const task = await prisma.task.create({
data: {
userId,
journalId: placeholderJournal.id,
type: 'journal_generate',
status: 'pending',
provider,
model: settings?.aiModel,
prompt: `${systemPrompt}\n\n${userPrompt}`,
},
});
// Update journal with taskId
await prisma.journal.update({
where: { id: placeholderJournal.id },
data: { id: placeholderJournal.id },
});
let requestBody: any = null;
let responseBody: any = null;
let content = '';
try {
if (provider === 'openai') {
requestBody = {
model: settings?.aiModel || 'gpt-4',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
temperature: 0.7,
max_tokens: 2000,
};
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${settings?.aiApiKey}`,
},
body: JSON.stringify(requestBody),
});
responseBody = await response.json();
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status} ${JSON.stringify(responseBody)}`);
}
content = responseBody.choices?.[0]?.message?.content || '';
} else if (provider === 'anthropic') {
requestBody = {
model: settings?.aiModel || 'claude-3-sonnet-20240229',
max_tokens: 2000,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
};
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': settings?.aiApiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(requestBody),
});
responseBody = await response.json();
if (!response.ok) {
throw new Error(`Anthropic API error: ${response.status} ${JSON.stringify(responseBody)}`);
}
content = responseBody.content?.[0]?.text || '';
} else if (provider === 'ollama') {
const baseUrl = settings?.aiBaseUrl || 'http://localhost:11434';
requestBody = {
model: settings?.aiModel || 'llama3.2',
stream: false,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
};
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
responseBody = await response.json();
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${JSON.stringify(responseBody)}`);
}
content = responseBody.message?.content || '';
} else if (provider === 'lmstudio') {
const baseUrl = settings?.aiBaseUrl || 'http://localhost:1234/v1';
requestBody = {
model: settings?.aiModel || 'local-model',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
temperature: 0.7,
max_tokens: 2000,
};
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
responseBody = await response.json();
if (!response.ok) {
throw new Error(`LM Studio API error: ${response.status} ${JSON.stringify(responseBody)}`);
}
content = responseBody.choices?.[0]?.message?.content || '';
}
if (!content) {
throw new Error('No content generated from AI');
}
// Update task with success
await prisma.task.update({
where: { id: task.id },
data: {
status: 'completed',
request: JSON.stringify(requestBody, null, 2),
response: JSON.stringify(responseBody, null, 2),
completedAt: new Date(),
},
});
// Update journal with content
const journal = await prisma.journal.update({
where: { id: placeholderJournal.id },
data: { content, generatedAt: new Date() },
});
return c.json({ data: { journal, task }, error: null });
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
console.error('AI generation failed:', errorMessage);
// Update task with error
await prisma.task.update({
where: { id: task.id },
data: {
status: 'failed',
error: errorMessage,
completedAt: new Date(),
},
});
// Delete placeholder journal
await prisma.journal.delete({ where: { id: placeholderJournal.id } });
return c.json({ data: null, error: { code: 'AI_ERROR', message: `Failed to generate journal: ${errorMessage}` } }, 500);
}
});
app.get('/api/v1/journal/:date/tasks', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { date } = c.req.param();
const journal = await prisma.journal.findFirst({ where: { userId, date } });
if (!journal) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'No journal found for this date' } }, 404);
}
const tasks = await prisma.task.findMany({
where: { journalId: journal.id },
orderBy: { createdAt: 'desc' },
});
return c.json({ data: tasks, error: null });
});
app.get('/api/v1/tasks/: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 task = await prisma.task.findFirst({
where: { id, userId },
});
if (!task) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
}
return c.json({ data: task, error: null });
});
app.get('/api/v1/journal/:date', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { date } = c.req.param();
const journal = await prisma.journal.findFirst({ where: { userId, date } });
if (!journal) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'No journal found for this date' } }, 404);
return c.json({ data: journal, error: null });
});
// 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 } });
}
return c.json({ data: settings, error: null });
});
app.put('/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);
const body = await c.req.json();
const { aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language } = body;
const data: Record<string, unknown> = {};
if (aiProvider !== undefined) data.aiProvider = aiProvider;
if (aiApiKey !== undefined) data.aiApiKey = aiApiKey;
if (aiModel !== undefined) data.aiModel = aiModel || 'gpt-4';
if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl;
if (journalPrompt !== undefined) data.journalPrompt = journalPrompt;
if (language !== undefined) data.language = language;
const settings = await prisma.settings.upsert({
where: { userId },
create: { userId, ...data },
update: data,
});
return c.json({ data: settings, error: null });
});
app.notFound((c) => c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Not found' } }, 404));
app.onError((err, c) => {
console.error('Unhandled error:', err);
return c.json({ data: null, error: { code: 'INTERNAL_ERROR', message: 'Internal server error' } }, 500);
});
const port = parseInt(envVars.PORT || '3000', 10);
console.log(`Starting TotalRecall API on port ${port}`);
export default {
port,
fetch: app.fetch,
};

15
backend/src/lib/types.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client';
export interface HonoEnv {
Variables: {
userId: string;
prisma: PrismaClient;
};
Bindings: {
DATABASE_URL: string;
JWT_SECRET: string;
MEDIA_DIR: string;
PORT: string;
CORS_ORIGIN: string;
};
}

View File

@@ -0,0 +1,39 @@
import { Context, Next } from 'hono';
import { HonoEnv } from '../lib/types';
import { createHash } from 'crypto';
export async function authMiddleware(c: Context<HonoEnv>, next: 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();
}
export function authenticate() {
return async (c: Context<HonoEnv>, next: Next) => {
await authMiddleware(c, next);
};
}

115
backend/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,115 @@
import { Hono } from 'hono';
import { HonoEnv } from '../lib/types';
import bcrypt from 'bcryptjs';
import { createHash, randomBytes } from 'crypto';
import * as jose from 'jose';
function createAuthRoutes() {
const app = new Hono<HonoEnv>();
authRoutes.post('/register', async (c) => {
const { email, password } = await c.req.json();
if (!email || !password) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Email and password are required' } }, 400);
}
if (password.length < 8) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Password must be at least 8 characters' } }, 400);
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Invalid email format' } }, 400);
}
const prisma = c.get('prisma');
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return c.json({ data: null, error: { code: 'CONFLICT', message: 'Email already registered' } }, 409);
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: {
email,
passwordHash,
settings: {
create: {},
},
},
select: {
id: true,
email: true,
createdAt: true,
},
});
return c.json({ data: { user }, error: null }, 201);
});
authRoutes.post('/login', async (c) => {
const { email, password } = await c.req.json();
if (!email || !password) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Email and password are required' } }, 400);
}
const prisma = c.get('prisma');
const jwtSecret = c.env.JWT_SECRET || 'development-secret-change-in-production';
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }, 401);
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }, 401);
}
const token = await new jose.SignJWT({ userId: user.id })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(new TextEncoder().encode(jwtSecret));
return c.json({ data: { token, userId: user.id }, error: null });
});
authRoutes.post('/api-key', async (c) => {
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 token = authHeader.slice(7);
const jwtSecret = c.env.JWT_SECRET || 'development-secret-change-in-production';
let userId: string;
try {
const { payload } = await jose.jwtVerify(token, new TextEncoder().encode(jwtSecret));
userId = payload.userId as string;
} catch {
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid or expired token' } }, 401);
}
const { name } = await c.req.json();
const apiKey = randomBytes(32).toString('hex');
const keyHash = createHash('sha256').update(apiKey).digest('hex');
const prisma = c.get('prisma');
const keyRecord = await prisma.apiKey.create({
data: {
userId,
keyHash,
name: name || 'Default',
},
});
return c.json({ data: { apiKey, id: keyRecord.id, name: keyRecord.name }, error: null }, 201);
});

View File

@@ -0,0 +1,68 @@
import { Hono } from 'hono';
import { HonoEnv } from '../lib/types';
export const daysRoutes = new Hono<HonoEnv>();
daysRoutes.get('/', async (c) => {
const userId = c.get('userId');
const prisma = c.get('prisma');
const days = await prisma.entry.groupBy({
by: ['date'],
where: { userId },
_count: { id: true },
orderBy: { date: 'desc' },
});
const journals = await prisma.journal.findMany({
where: { userId },
select: { date: true, generatedAt: true, entryCount: true },
});
const journalMap = new Map(journals.map(j => [j.date, j]));
const result = days.map(day => ({
date: day.date,
entryCount: day._count.id,
hasJournal: journalMap.has(day.date),
journalGeneratedAt: journalMap.get(day.date)?.generatedAt,
}));
return c.json({ data: result, error: null });
});
daysRoutes.get('/:date', async (c) => {
const userId = c.get('userId');
const { date } = c.req.param();
const prisma = c.get('prisma');
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(date)) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Invalid date format. Use YYYY-MM-DD' } }, 400);
}
const [entries, journal] = await Promise.all([
prisma.entry.findMany({
where: { userId, date },
orderBy: { createdAt: 'asc' },
}),
prisma.journal.findUnique({
where: { userId_date: { userId, date } },
}),
]);
return c.json({ data: { date, entries, journal }, error: null });
});
daysRoutes.delete('/:date', async (c) => {
const userId = c.get('userId');
const { date } = c.req.param();
const prisma = c.get('prisma');
await prisma.$transaction([
prisma.entry.deleteMany({ where: { userId, date } }),
prisma.journal.deleteMany({ where: { userId, date } }),
]);
return c.json({ data: { deleted: true }, error: null });
});

View File

@@ -0,0 +1,166 @@
import { Hono } from 'hono';
import { HonoEnv } from '../lib/types';
export const entriesRoutes = new Hono<HonoEnv>();
entriesRoutes.post('/', async (c) => {
const userId = c.get('userId');
const prisma = c.get('prisma');
const mediaDir = c.env.MEDIA_DIR || './data/media';
const body = await c.req.json();
const { date, type, content, metadata } = body;
if (!date || !type || !content) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'date, type, and content are required' } }, 400);
}
const validTypes = ['text', 'voice', 'photo', 'health', 'location'];
if (!validTypes.includes(type)) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: `type must be one of: ${validTypes.join(', ')}` } }, 400);
}
const entry = await prisma.entry.create({
data: {
userId,
date,
type,
content,
metadata: metadata ? JSON.stringify(metadata) : null,
},
});
return c.json({ data: entry, error: null }, 201);
});
entriesRoutes.get('/:id', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
const entry = await prisma.entry.findFirst({
where: { id, userId },
});
if (!entry) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
}
return c.json({ data: entry, error: null });
});
entriesRoutes.put('/:id', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
const body = await c.req.json();
const { content, metadata } = body;
const existing = await prisma.entry.findFirst({
where: { id, userId },
});
if (!existing) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
}
const entry = await prisma.entry.update({
where: { id },
data: {
content: content ?? existing.content,
metadata: metadata !== undefined ? JSON.stringify(metadata) : existing.metadata,
},
});
return c.json({ data: entry, error: null });
});
entriesRoutes.delete('/:id', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
const existing = await prisma.entry.findFirst({
where: { id, userId },
});
if (!existing) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
}
await prisma.entry.delete({ where: { id } });
return c.json({ data: { deleted: true }, error: null });
});
entriesRoutes.post('/: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 entry = await prisma.entry.findFirst({
where: { id, userId },
});
if (!entry) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
}
const body = await c.req.parseBody();
const file = body.file;
if (!file || !(file instanceof File)) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'No file provided' } }, 400);
}
const ext = file.name.split('.').pop() || 'jpg';
const fileName = `${id}.${ext}`;
const userMediaDir = `${mediaDir}/${userId}/${entry.date}`;
const filePath = `${userMediaDir}/${fileName}`;
await Bun.write(filePath, file);
await prisma.entry.update({
where: { id },
data: { mediaPath: filePath },
});
return c.json({ data: { mediaPath: filePath }, error: null }, 201);
});
entriesRoutes.post('/: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 entry = await prisma.entry.findFirst({
where: { id, userId },
});
if (!entry) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
}
const body = await c.req.parseBody();
const file = body.file;
if (!file || !(file instanceof File)) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'No audio file provided' } }, 400);
}
const fileName = `${id}.webm`;
const userMediaDir = `${mediaDir}/${userId}/${entry.date}`;
const filePath = `${userMediaDir}/${fileName}`;
await Bun.write(filePath, file);
await prisma.entry.update({
where: { id },
data: { mediaPath: filePath },
});
return c.json({ data: { mediaPath: filePath }, error: null }, 201);
});

View File

@@ -0,0 +1,112 @@
import { Hono } from 'hono';
import { HonoEnv } from '../lib/types';
import { AIProvider, createAIProvider } from '../services/ai/provider';
export const journalRoutes = new Hono<HonoEnv>();
journalRoutes.post('/generate/:date', async (c) => {
const userId = c.get('userId');
const { date } = c.req.param();
const prisma = c.get('prisma');
const [entries, settings] = await Promise.all([
prisma.entry.findMany({
where: { userId, date },
orderBy: { createdAt: 'asc' },
}),
prisma.settings.findUnique({
where: { userId },
}),
]);
if (entries.length === 0) {
return c.json({ data: null, error: { code: 'NO_ENTRIES', message: 'No entries found for this date' } }, 400);
}
if (!settings?.aiApiKey) {
return c.json({ data: null, error: { code: 'NO_AI_CONFIG', message: 'AI not configured. Please set up your API key in settings.' } }, 400);
}
const provider = createAIProvider({
provider: settings.aiProvider as AIProvider['provider'],
apiKey: settings.aiApiKey,
model: settings.aiModel,
baseUrl: settings.aiBaseUrl,
});
const entriesText = entries.map(entry => {
let text = `[${entry.type.toUpperCase()}] ${entry.createdAt.toISOString()}\n${entry.content}`;
if (entry.metadata) {
try {
const meta = JSON.parse(entry.metadata);
if (meta.location) text += `\nLocation: ${meta.location.lat}, ${meta.location.lng}`;
if (meta.duration) text += `\nDuration: ${meta.duration}s`;
} catch {}
}
return text;
}).join('\n\n');
const prompt = `The following entries were captured throughout the day (${date}). Write a thoughtful, reflective journal entry that:
1. Summarizes the key moments and activities
2. Reflects on any patterns, feelings, or insights
3. Ends with a forward-looking thought
Use a warm, personal tone. The journal should flow naturally as prose.
ENTRIES:
${entriesText}
JOURNAL:`;
try {
const content = await provider.generate(prompt, settings.journalPrompt);
const journal = await prisma.journal.upsert({
where: { userId_date: { userId, date } },
create: {
userId,
date,
content,
entryCount: entries.length,
},
update: {
content,
entryCount: entries.length,
generatedAt: new Date(),
},
});
return c.json({ data: journal, error: null });
} catch (err) {
console.error('AI generation failed:', err);
return c.json({ data: null, error: { code: 'AI_ERROR', message: 'Failed to generate journal. Check your AI configuration.' } }, 500);
}
});
journalRoutes.get('/:date', async (c) => {
const userId = c.get('userId');
const { date } = c.req.param();
const prisma = c.get('prisma');
const journal = await prisma.journal.findUnique({
where: { userId_date: { userId, date } },
});
if (!journal) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'No journal found for this date' } }, 404);
}
return c.json({ data: journal, error: null });
});
journalRoutes.delete('/:date', async (c) => {
const userId = c.get('userId');
const { date } = c.req.param();
const prisma = c.get('prisma');
await prisma.journal.deleteMany({
where: { userId, date },
});
return c.json({ data: { deleted: true }, error: null });
});

View File

@@ -0,0 +1,69 @@
import { Hono } from 'hono';
import { HonoEnv } from '../lib/types';
export const settingsRoutes = new Hono<HonoEnv>();
settingsRoutes.get('/', async (c) => {
const userId = c.get('userId');
const prisma = c.get('prisma');
const settings = await prisma.settings.findUnique({
where: { userId },
});
if (!settings) {
const newSettings = await prisma.settings.create({
data: { userId },
});
return c.json({ data: newSettings, error: null });
}
return c.json({ data: settings, error: null });
});
settingsRoutes.put('/', async (c) => {
const userId = c.get('userId');
const prisma = c.get('prisma');
const body = await c.req.json();
const { aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language } = body;
const data: Record<string, unknown> = {};
if (aiProvider !== undefined) data.aiProvider = aiProvider;
if (aiApiKey !== undefined) data.aiApiKey = aiApiKey;
if (aiModel !== undefined) data.aiModel = aiModel || 'gpt-4';
if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl;
if (journalPrompt !== undefined) data.journalPrompt = journalPrompt;
if (language !== undefined) data.language = language;
const settings = await prisma.settings.upsert({
where: { userId },
create: { userId, ...data },
update: data,
});
return c.json({ data: settings, error: null });
});
settingsRoutes.post('/validate-key', async (c) => {
const body = await c.req.json();
const { provider, apiKey, baseUrl } = body;
if (!provider || !apiKey) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'provider and apiKey are required' } }, 400);
}
const { createAIProvider } = await import('../services/ai/provider');
try {
const aiProvider = createAIProvider({
provider,
apiKey,
baseUrl,
});
const valid = await aiProvider.validate?.();
return c.json({ data: { valid: true }, error: null });
} catch {
return c.json({ data: { valid: false }, error: null });
}
});

View File

@@ -0,0 +1,64 @@
import type { AIProvider, AIProviderConfig } from './provider';
export class AnthropicProvider implements AIProvider {
provider = 'anthropic' as const;
private apiKey: string;
private model: string;
private baseUrl: string;
constructor(config: AIProviderConfig) {
this.apiKey = config.apiKey;
this.model = config.model || 'claude-3-sonnet-20240229';
this.baseUrl = config.baseUrl || 'https://api.anthropic.com/v1';
}
async generate(prompt: string, systemPrompt?: string): Promise<string> {
const response = await fetch(`${this.baseUrl}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify({
model: this.model,
max_tokens: 2000,
system: systemPrompt,
messages: [
{ role: 'user', content: prompt }
],
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Anthropic API error: ${response.status} ${error}`);
}
const data = await response.json() as { content: Array<{ text: string }> };
return data.content[0]?.text || '';
}
async validate(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify({
model: this.model,
max_tokens: 1,
messages: [{ role: 'user', content: 'hi' }],
}),
});
return response.ok;
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,52 @@
import type { AIProvider, AIProviderConfig } from './provider';
export class LMStudioProvider implements AIProvider {
provider = 'lmstudio' as const;
private baseUrl: string;
private model: string;
constructor(config: AIProviderConfig) {
this.baseUrl = config.baseUrl || 'http://localhost:1234/v1';
this.model = config.model || 'local-model';
}
async generate(prompt: string, systemPrompt?: string): Promise<string> {
const messages: Array<{ role: string; content: string }> = [];
if (systemPrompt) {
messages.push({ role: 'system', content: systemPrompt });
}
messages.push({ role: 'user', content: prompt });
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.model,
messages,
temperature: 0.7,
max_tokens: 2000,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`LM Studio API error: ${response.status} ${error}`);
}
const data = await response.json() as { choices: Array<{ message: { content: string } }> };
return data.choices[0]?.message?.content || '';
}
async validate(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/models`);
return response.ok;
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,46 @@
import type { AIProvider, AIProviderConfig } from './provider';
export class OllamaProvider implements AIProvider {
provider = 'ollama' as const;
private baseUrl: string;
private model: string;
constructor(config: AIProviderConfig) {
this.baseUrl = config.baseUrl || 'http://localhost:11434';
this.model = config.model || 'llama3.2';
}
async generate(prompt: string, systemPrompt?: string): Promise<string> {
const response = await fetch(`${this.baseUrl}/api/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.model,
stream: false,
messages: [
...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
{ role: 'user', content: prompt },
],
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Ollama API error: ${response.status} ${error}`);
}
const data = await response.json() as { message: { content: string } };
return data.message?.content || '';
}
async validate(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/api/tags`);
return response.ok;
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,59 @@
import type { AIProvider, AIProviderConfig } from './provider';
export class OpenAIProvider implements AIProvider {
provider = 'openai' as const;
private apiKey: string;
private model: string;
private baseUrl: string;
constructor(config: AIProviderConfig) {
this.apiKey = config.apiKey;
this.model = config.model || 'gpt-4';
this.baseUrl = config.baseUrl || 'https://api.openai.com/v1';
}
async generate(prompt: string, systemPrompt?: string): Promise<string> {
const messages: Array<{ role: string; content: string }> = [];
if (systemPrompt) {
messages.push({ role: 'system', content: systemPrompt });
}
messages.push({ role: 'user', content: prompt });
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.model,
messages,
temperature: 0.7,
max_tokens: 2000,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`OpenAI API error: ${response.status} ${error}`);
}
const data = await response.json() as { choices: Array<{ message: { content: string } }> };
return data.choices[0]?.message?.content || '';
}
async validate(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/models`, {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
},
});
return response.ok;
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,32 @@
export interface AIProvider {
provider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio';
generate(prompt: string, systemPrompt?: string): Promise<string>;
validate?(): Promise<boolean>;
}
export interface AIProviderConfig {
provider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio';
apiKey: string;
model?: string;
baseUrl?: string;
}
import { OpenAIProvider } from './openai';
import { AnthropicProvider } from './anthropic';
import { OllamaProvider } from './ollama';
import { LMStudioProvider } from './lmstudio';
export function createAIProvider(config: AIProviderConfig): AIProvider {
switch (config.provider) {
case 'openai':
return new OpenAIProvider(config);
case 'anthropic':
return new AnthropicProvider(config);
case 'ollama':
return new OllamaProvider(config);
case 'lmstudio':
return new LMStudioProvider(config);
default:
throw new Error(`Unknown AI provider: ${config.provider}`);
}
}

21
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": ["@types/bun"]
},
"include": ["src/**/*", "prisma/**/*"],
"exclude": ["node_modules", "dist"]
}