feat: v0.1.0 - geolocation capture, calendar, search, Starlight docs site
- Automatic browser geolocation capture on event creation - Reverse geocoding via Nominatim API for place names - Full-text search with SQLite FTS5 - Calendar view for browsing past entries - DateNavigator component for day navigation - SearchModal with Ctrl+K shortcut - QuickAddWidget with Ctrl+J shortcut - Starlight documentation site with GitHub Pages deployment - Multiple AI provider support (Groq, OpenAI, Anthropic, Ollama, LM Studio) - Multi-user registration support BREAKING: Events now include latitude/longitude/placeName fields
This commit is contained in:
@@ -1,27 +1,51 @@
|
||||
# Database connection (SQLite, PostgreSQL, or MySQL)
|
||||
# =============================================================================
|
||||
# DearDiary Configuration
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database (SQLite, PostgreSQL, or MySQL)
|
||||
# -----------------------------------------------------------------------------
|
||||
DATABASE_URL="file:./data/deardiary.db"
|
||||
|
||||
# Media storage directory
|
||||
MEDIA_DIR="./data/media"
|
||||
# Example PostgreSQL:
|
||||
# DATABASE_URL="postgresql://postgres:password@db:5432/deardiary"
|
||||
|
||||
# JWT secret for authentication tokens (REQUIRED in production)
|
||||
JWT_SECRET="change-this-to-a-random-string-in-production"
|
||||
# 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"
|
||||
|
||||
# CORS origin (use specific domain in production)
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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="*"
|
||||
|
||||
# Default user (auto-created on startup if doesn't exist)
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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"
|
||||
|
||||
# Default journal prompt (strict anti-hallucination)
|
||||
# JOURNAL_PROMPT="You are a factual diary summarizer. Your ONLY job is to summarize the entries provided to you - nothing more.\n\nCRITICAL RULES:\n1. ONLY use information explicitly stated in the entries below\n2. NEVER invent, assume, or hallucinate any detail not in the entries\n3. NEVER add activities, emotions, weather, or context not directly mentioned\n4. If something is unclear in the entries, simply state what IS clear\n5. Keep the summary grounded and factual - no embellishment\n6. Do not write in an overly creative or story-telling style\n7. Only reference what the user explicitly recorded"
|
||||
|
||||
# Example PostgreSQL connection:
|
||||
# DATABASE_URL="postgresql://postgres:password@db:5432/deardiary"
|
||||
|
||||
# Example MySQL connection:
|
||||
# DATABASE_URL="mysql://root:password@localhost:3306/deardiary"
|
||||
# -----------------------------------------------------------------------------
|
||||
# Storage
|
||||
# -----------------------------------------------------------------------------
|
||||
# Media storage directory
|
||||
MEDIA_DIR="./data/media"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "deardiary-backend",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/index.ts",
|
||||
|
||||
@@ -42,6 +42,9 @@ model Event {
|
||||
content String
|
||||
mediaPath String?
|
||||
metadata String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
placeName String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -55,6 +58,7 @@ model Journal {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
date String
|
||||
title String?
|
||||
content String
|
||||
eventCount Int
|
||||
generatedAt DateTime @default(now())
|
||||
@@ -78,6 +82,7 @@ model Task {
|
||||
request String?
|
||||
response String?
|
||||
error String?
|
||||
title String?
|
||||
createdAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
|
||||
@@ -94,7 +99,7 @@ model Settings {
|
||||
aiApiKey String?
|
||||
aiModel String @default("llama-3.3-70b-versatile")
|
||||
aiBaseUrl String?
|
||||
journalPrompt String @default("You are a factual diary summarizer. Your ONLY job is to summarize the entries provided to you - nothing more.\n\nCRITICAL RULES:\n1. ONLY use information explicitly stated in the entries below\n2. NEVER invent, assume, or hallucinate any detail not in the entries\n3. NEVER add activities, emotions, weather, or context not directly mentioned\n4. If something is unclear in the entries, simply state what IS clear\n5. Keep the summary grounded and factual - no embellishment\n6. Do not write in an overly creative or story-telling style\n7. Only reference what the user explicitly recorded\n\nStructure:\n- Start with what was recorded (meetings, tasks, activities)\n- Note any explicit feelings or observations mentioned\n- Keep it concise and factual\n- If there are gaps in the day, acknowledge only what was recorded")
|
||||
journalPrompt String?
|
||||
language String @default("en")
|
||||
timezone String @default("UTC")
|
||||
providerSettings String?
|
||||
|
||||
@@ -114,6 +114,17 @@ app.post('/api/v1/auth/login', async (c) => {
|
||||
return c.json({ data: { token, userId: user.id }, error: null });
|
||||
});
|
||||
|
||||
app.get('/api/v1/server-info', async (c) => {
|
||||
return c.json({
|
||||
data: {
|
||||
version: envVars.VERSION || '0.1.0',
|
||||
registrationEnabled: envVars.REGISTRATION_ENABLED !== 'false',
|
||||
appName: envVars.APP_NAME || 'DearDiary',
|
||||
},
|
||||
error: null
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/api-key', async (c) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
@@ -156,17 +167,22 @@ app.get('/api/v1/days', async (c) => {
|
||||
|
||||
const journals = await prisma.journal.findMany({
|
||||
where: { userId },
|
||||
select: { date: true, generatedAt: true },
|
||||
select: { date: true, title: true, generatedAt: true, content: true },
|
||||
});
|
||||
|
||||
const journalMap = new Map(journals.map(j => [j.date, j]));
|
||||
|
||||
const result = days.map(day => ({
|
||||
date: day.date,
|
||||
eventCount: day._count.id,
|
||||
hasJournal: journalMap.has(day.date),
|
||||
journalGeneratedAt: journalMap.get(day.date)?.generatedAt,
|
||||
}));
|
||||
const result = days.map(day => {
|
||||
const journal = journalMap.get(day.date);
|
||||
return {
|
||||
date: day.date,
|
||||
eventCount: day._count.id,
|
||||
hasJournal: !!journal,
|
||||
journalTitle: journal?.title,
|
||||
journalGeneratedAt: journal?.generatedAt,
|
||||
journalExcerpt: journal?.content?.substring(0, 250),
|
||||
};
|
||||
});
|
||||
|
||||
return c.json({ data: result, error: null });
|
||||
});
|
||||
@@ -203,80 +219,78 @@ app.delete('/api/v1/days/:date', async (c) => {
|
||||
return c.json({ data: { deleted: true }, error: null });
|
||||
});
|
||||
|
||||
// Delete journal only (keeps events)
|
||||
app.delete('/api/v1/journal/:date', async (c) => {
|
||||
app.get('/api/v1/search', 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: 'Journal not found' } }, 404);
|
||||
const query = c.req.query('q') || '';
|
||||
if (query.length < 2) {
|
||||
return c.json({ data: { journals: [], events: [] }, error: null });
|
||||
}
|
||||
|
||||
await prisma.journal.delete({ where: { id: journal.id } });
|
||||
return c.json({ data: { deleted: true }, error: null });
|
||||
});
|
||||
|
||||
// Events routes
|
||||
app.post('/api/v1/events', 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);
|
||||
try {
|
||||
const safeQuery = query.replace(/['"]/g, '');
|
||||
const searchTerm = safeQuery.split(/\s+/).filter(t => t.length > 0).map(t => `${t}*`).join(' OR ') || `${safeQuery}*`;
|
||||
|
||||
let journalResults: Array<{ userId: string; date: string; title: string; content: string }> = [];
|
||||
let eventResults: Array<{ userId: string; date: string; type: string; content: string }> = [];
|
||||
|
||||
try {
|
||||
journalResults = await prisma.$queryRaw<Array<{ userId: string; date: string; title: string; content: string }>>`
|
||||
SELECT userId, date, title, content
|
||||
FROM journal_fts
|
||||
WHERE userId = ${userId} AND journal_fts MATCH ${searchTerm}
|
||||
ORDER BY rank
|
||||
LIMIT 20
|
||||
`;
|
||||
} catch (e) {
|
||||
console.error('Journal FTS search error:', e);
|
||||
const journals = await prisma.journal.findMany({
|
||||
where: { userId, OR: [
|
||||
{ title: { contains: safeQuery } },
|
||||
{ content: { contains: safeQuery } }
|
||||
]},
|
||||
take: 20,
|
||||
});
|
||||
journalResults = journals.map(j => ({ userId: j.userId, date: j.date, title: j.title || '', content: j.content }));
|
||||
}
|
||||
|
||||
try {
|
||||
eventResults = await prisma.$queryRaw<Array<{ userId: string; date: string; type: string; content: string }>>`
|
||||
SELECT userId, date, type, content
|
||||
FROM event_fts
|
||||
WHERE userId = ${userId} AND event_fts MATCH ${searchTerm}
|
||||
ORDER BY rank
|
||||
LIMIT 20
|
||||
`;
|
||||
} catch (e) {
|
||||
console.error('Event FTS search error:', e);
|
||||
const events = await prisma.event.findMany({
|
||||
where: { userId, content: { contains: safeQuery } },
|
||||
take: 20,
|
||||
});
|
||||
eventResults = events.map(e => ({ userId: e.userId, date: e.date, type: e.type, content: e.content }));
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
journals: journalResults.map(j => ({
|
||||
date: j.date,
|
||||
title: j.title,
|
||||
excerpt: j.content.substring(0, 200),
|
||||
})),
|
||||
events: eventResults.map(e => ({
|
||||
date: e.date,
|
||||
type: e.type,
|
||||
content: e.content,
|
||||
})),
|
||||
},
|
||||
error: null
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Search error:', err);
|
||||
return c.json({ data: { journals: [], events: [] }, error: null });
|
||||
}
|
||||
|
||||
const validTypes = ['event'];
|
||||
if (!validTypes.includes(type)) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: `type must be one of: ${validTypes.join(', ')}` } }, 400);
|
||||
}
|
||||
|
||||
const event = await prisma.event.create({
|
||||
data: { userId, date, type, content, metadata: metadata ? JSON.stringify(metadata) : null },
|
||||
});
|
||||
|
||||
return c.json({ data: event, error: null }, 201);
|
||||
});
|
||||
|
||||
app.get('/api/v1/events/:id', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
const { id } = c.req.param();
|
||||
const event = await prisma.event.findFirst({ where: { id, userId } });
|
||||
|
||||
if (!event) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event not found' } }, 404);
|
||||
return c.json({ data: event, error: null });
|
||||
});
|
||||
|
||||
app.put('/api/v1/events/:id', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
const { id } = c.req.param();
|
||||
const { content, metadata } = await c.req.json();
|
||||
|
||||
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 edit event: journal already generated. Delete the journal first.' } }, 400);
|
||||
}
|
||||
|
||||
const event = await prisma.event.update({
|
||||
where: { id },
|
||||
data: {
|
||||
content: content ?? existing.content,
|
||||
metadata: metadata !== undefined ? JSON.stringify(metadata) : existing.metadata,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({ data: event, error: null });
|
||||
});
|
||||
|
||||
app.delete('/api/v1/events/:id', async (c) => {
|
||||
@@ -302,6 +316,8 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
const { date } = c.req.param();
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const additionalInstructions = body.instructions || '';
|
||||
|
||||
const [events, settings] = await Promise.all([
|
||||
prisma.event.findMany({ where: { userId, date }, orderBy: { createdAt: 'asc' } }),
|
||||
@@ -324,10 +340,14 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
|
||||
// Build events text
|
||||
const eventsText = events.map(event => {
|
||||
let text = `[EVENT] ${event.createdAt.toISOString()}\n${event.content}`;
|
||||
if (event.placeName) {
|
||||
text += `\nLocation: ${event.placeName}`;
|
||||
} else if (event.latitude && event.longitude) {
|
||||
text += `\nLocation: ${event.latitude}, ${event.longitude}`;
|
||||
}
|
||||
if (event.metadata) {
|
||||
try {
|
||||
const meta = JSON.parse(event.metadata);
|
||||
if (meta.location) text += `\nLocation: ${meta.location.lat}, ${meta.location.lng}`;
|
||||
if (meta.duration) text += `\nDuration: ${meta.duration}s`;
|
||||
} catch {}
|
||||
}
|
||||
@@ -356,22 +376,57 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
|
||||
|
||||
if (previousJournals.length > 0) {
|
||||
previousJournalsText = `PREVIOUS DIARIES:\n${previousJournals.map(j =>
|
||||
`[${j.date}]\n${j.content}`
|
||||
`[${j.date}]\n${j.title ? `Title: ${j.title}\n` : ''}${j.content}`
|
||||
).join('\n\n')}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Build prompts: 1. system prompt, 2. previous journals, 3. today's events
|
||||
const systemPrompt = settings?.journalPrompt || 'You are a thoughtful journal writer.';
|
||||
const userPrompt = `${previousJournalsText}EVENTS FROM TODAY (${date}):\n${eventsText}\n\nWrite a thoughtful, reflective journal entry based on the events above.`;
|
||||
const jsonInstruction = `IMPORTANT: Return ONLY valid JSON in this exact format, nothing else:
|
||||
{"title": "A short, descriptive title for this day (max 50 characters)", "content": "Your diary entry text here..."}
|
||||
|
||||
Do not include any text before or after the JSON. Do not use markdown code blocks.`;
|
||||
|
||||
// Build system prompt from user's settings + JSON instruction
|
||||
const defaultPrompt = `You are a factual diary summarizer. Your ONLY job is to summarize the entries provided to you - nothing more.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. ONLY use information explicitly stated in the entries below
|
||||
2. NEVER invent, assume, or hallucinate any detail not in the entries
|
||||
3. NEVER add activities, emotions, weather, or context not directly mentioned
|
||||
4. If something is unclear in the entries, simply state what IS clear
|
||||
5. Keep the summary grounded and factual - no embellishment
|
||||
6. Do not write in an overly creative or story-telling style
|
||||
7. Only reference what the user explicitly recorded
|
||||
8. NEVER write any preamble, meta-commentary, or statements about how you are writing
|
||||
9. NEVER include any closing remarks, sign-offs, or follow-up offers`;
|
||||
|
||||
const userInstructions = settings?.journalPrompt;
|
||||
const systemPromptWithJson = userInstructions
|
||||
? `${defaultPrompt}\n\n${jsonInstruction}\n\nCUSTOM USER INSTRUCTIONS:\n${userInstructions}`
|
||||
: `${defaultPrompt}\n\n${jsonInstruction}`;
|
||||
|
||||
let userPrompt = `${previousJournalsText}EVENTS FROM TODAY (${date}):\n${eventsText}`;
|
||||
|
||||
if (additionalInstructions) {
|
||||
userPrompt = `${userPrompt}\n\nADDITIONAL USER INSTRUCTIONS:\n${additionalInstructions}`;
|
||||
}
|
||||
|
||||
console.log(`[Journal Generate] Date: ${date}, Context days: ${contextDays}, Events: ${events.length}`);
|
||||
|
||||
// Create placeholder journal and task
|
||||
const placeholderJournal = await prisma.journal.create({
|
||||
data: { userId, date, content: 'Generating...', eventCount: events.length },
|
||||
// Check if journal already exists for this date
|
||||
const existingJournal = await prisma.journal.findUnique({
|
||||
where: { userId_date: { userId, date } },
|
||||
});
|
||||
|
||||
// Use upsert to handle both create and regenerate cases
|
||||
const placeholderJournal = await prisma.journal.upsert({
|
||||
where: { userId_date: { userId, date } },
|
||||
create: { userId, date, content: 'Generating...', eventCount: events.length },
|
||||
update: { content: 'Generating...', eventCount: events.length, generatedAt: new Date() },
|
||||
});
|
||||
|
||||
const model = providerConfig.model || settings?.aiModel;
|
||||
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
userId,
|
||||
@@ -379,23 +434,16 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
|
||||
type: 'journal_generate',
|
||||
status: 'pending',
|
||||
provider,
|
||||
model: settings?.aiModel,
|
||||
model: model,
|
||||
prompt: `${systemPrompt}\n\n---\n\n${userPrompt}`,
|
||||
request: '',
|
||||
response: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Update journal with taskId
|
||||
await prisma.journal.update({
|
||||
where: { id: placeholderJournal.id },
|
||||
data: { id: placeholderJournal.id },
|
||||
});
|
||||
|
||||
try {
|
||||
console.log(`[Journal Generate] Using provider: ${provider}`);
|
||||
|
||||
const model = providerConfig.model || settings?.aiModel;
|
||||
console.log(`[Journal Generate] Using model: ${model}`);
|
||||
const baseUrl = providerConfig.baseUrl || settings?.aiBaseUrl;
|
||||
|
||||
const aiProvider = createAIProvider({
|
||||
@@ -407,13 +455,13 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
|
||||
|
||||
console.log(`[Journal Generate] AI Provider created: ${aiProvider.provider}`);
|
||||
|
||||
const result = await aiProvider.generate(userPrompt, systemPrompt);
|
||||
const result = await aiProvider.generate(userPrompt, systemPromptWithJson, { jsonMode: true });
|
||||
|
||||
if (!result.content) {
|
||||
throw new Error('No content generated from AI');
|
||||
}
|
||||
|
||||
console.log(`[Journal Generate] Success! Content length: ${result.content.length}`);
|
||||
console.log(`[Journal Generate] Success! Content length: ${result.content.length}, Title: ${result.title || 'N/A'}`);
|
||||
|
||||
// Update task with success - store full request and response JSON
|
||||
await prisma.task.update({
|
||||
@@ -422,14 +470,19 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
|
||||
status: 'completed',
|
||||
request: JSON.stringify(result.request, null, 2),
|
||||
response: JSON.stringify(result.response, null, 2),
|
||||
title: result.title,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Update journal with content
|
||||
// Update journal with content and title
|
||||
const journal = await prisma.journal.update({
|
||||
where: { id: placeholderJournal.id },
|
||||
data: { content: result.content, generatedAt: new Date() },
|
||||
data: {
|
||||
content: result.content,
|
||||
title: result.title,
|
||||
generatedAt: new Date()
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({ data: { journal, task }, error: null });
|
||||
@@ -448,8 +501,16 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Delete placeholder journal
|
||||
await prisma.journal.delete({ where: { id: placeholderJournal.id } });
|
||||
// Only delete journal if it was newly created (not an existing one)
|
||||
if (!existingJournal) {
|
||||
await prisma.journal.delete({ where: { id: placeholderJournal.id } });
|
||||
} else {
|
||||
// Restore the existing content
|
||||
await prisma.journal.update({
|
||||
where: { id: placeholderJournal.id },
|
||||
data: { content: existingJournal.content },
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ data: null, error: { code: 'AI_ERROR', message: `Failed to generate journal: ${errorMessage}` } }, 500);
|
||||
}
|
||||
@@ -469,9 +530,10 @@ app.get('/api/v1/journal/:date/tasks', async (c) => {
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { journalId: journal.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { journal: { select: { title: true } } },
|
||||
});
|
||||
|
||||
return c.json({ data: tasks, error: null });
|
||||
return c.json({ data: tasks.map(t => ({ ...t, journalTitle: t.journal?.title || null })), error: null });
|
||||
});
|
||||
|
||||
app.get('/api/v1/tasks/:id', async (c) => {
|
||||
@@ -502,6 +564,30 @@ app.get('/api/v1/journal/:date', async (c) => {
|
||||
return c.json({ data: journal, error: null });
|
||||
});
|
||||
|
||||
app.get('/api/v1/journals', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
const page = parseInt(c.req.query('page') || '1');
|
||||
const limit = parseInt(c.req.query('limit') || '10');
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [journals, total] = await Promise.all([
|
||||
prisma.journal.findMany({
|
||||
where: { userId },
|
||||
orderBy: { date: 'desc' },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.journal.count({ where: { userId } }),
|
||||
]);
|
||||
|
||||
return c.json({
|
||||
data: { journals, total, page, limit, totalPages: Math.ceil(total / limit) },
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
// Settings routes
|
||||
app.get('/api/v1/settings', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
@@ -512,6 +598,11 @@ app.get('/api/v1/settings', async (c) => {
|
||||
settings = await prisma.settings.create({ data: { userId } });
|
||||
}
|
||||
|
||||
const oldDefaultLength = 400;
|
||||
if (settings.journalPrompt && settings.journalPrompt.length > oldDefaultLength) {
|
||||
settings.journalPrompt = null;
|
||||
}
|
||||
|
||||
return c.json({ data: settings, error: null });
|
||||
});
|
||||
|
||||
@@ -527,7 +618,7 @@ app.put('/api/v1/settings', async (c) => {
|
||||
if (aiApiKey !== undefined) data.aiApiKey = aiApiKey;
|
||||
if (aiModel !== undefined) data.aiModel = aiModel;
|
||||
if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl;
|
||||
if (journalPrompt !== undefined) data.journalPrompt = journalPrompt;
|
||||
if (journalPrompt !== undefined) data.journalPrompt = journalPrompt === '' ? null : journalPrompt;
|
||||
if (language !== undefined) data.language = language;
|
||||
if (providerSettings !== undefined) data.providerSettings = JSON.stringify(providerSettings);
|
||||
if (journalContextDays !== undefined) data.journalContextDays = journalContextDays;
|
||||
@@ -589,12 +680,489 @@ app.post('/api/v1/ai/test', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
const DEARDIARY_VERSION = '0.1.0';
|
||||
const MIN_IMPORT_VERSION = '0.0.3';
|
||||
|
||||
interface ExportData {
|
||||
version: string;
|
||||
exportedAt: string;
|
||||
settings: {
|
||||
aiProvider: string;
|
||||
aiApiKey?: string;
|
||||
aiModel?: string;
|
||||
aiBaseUrl?: string;
|
||||
journalPrompt?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
providerSettings?: string;
|
||||
journalContextDays?: number;
|
||||
};
|
||||
events: Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
type: string;
|
||||
content: string;
|
||||
mediaPath?: string;
|
||||
metadata?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
placeName?: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
journals: Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
title?: string;
|
||||
content: string;
|
||||
eventCount: number;
|
||||
generatedAt: string;
|
||||
}>;
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
journalId: string;
|
||||
date: string;
|
||||
type: string;
|
||||
status: string;
|
||||
provider: string;
|
||||
model?: string;
|
||||
prompt?: string;
|
||||
request?: string;
|
||||
response?: string;
|
||||
error?: string;
|
||||
title?: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
app.get('/api/v1/export', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
const [settings, events, journals, tasks] = await Promise.all([
|
||||
prisma.settings.findUnique({ where: { userId } }),
|
||||
prisma.event.findMany({ where: { userId }, orderBy: { createdAt: 'asc' } }),
|
||||
prisma.journal.findMany({ where: { userId }, orderBy: { date: 'asc' } }),
|
||||
prisma.task.findMany({ where: { userId }, orderBy: { createdAt: 'asc' } }),
|
||||
]);
|
||||
|
||||
const journalMap = new Map(journals.map(j => [j.id, j]));
|
||||
|
||||
const exportData: ExportData = {
|
||||
version: DEARDIARY_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
settings: {
|
||||
aiProvider: settings?.aiProvider || 'groq',
|
||||
aiApiKey: settings?.aiApiKey,
|
||||
aiModel: settings?.aiModel,
|
||||
aiBaseUrl: settings?.aiBaseUrl,
|
||||
journalPrompt: settings?.journalPrompt,
|
||||
language: settings?.language,
|
||||
timezone: settings?.timezone,
|
||||
providerSettings: settings?.providerSettings,
|
||||
journalContextDays: settings?.journalContextDays,
|
||||
},
|
||||
events: events.map(e => ({
|
||||
id: e.id,
|
||||
date: e.date,
|
||||
type: e.type,
|
||||
content: e.content,
|
||||
mediaPath: e.mediaPath || undefined,
|
||||
metadata: e.metadata || undefined,
|
||||
latitude: e.latitude ?? undefined,
|
||||
longitude: e.longitude ?? undefined,
|
||||
placeName: e.placeName ?? undefined,
|
||||
createdAt: e.createdAt.toISOString(),
|
||||
})),
|
||||
journals: journals.map(j => ({
|
||||
id: j.id,
|
||||
date: j.date,
|
||||
title: j.title || undefined,
|
||||
content: j.content,
|
||||
eventCount: j.eventCount,
|
||||
generatedAt: j.generatedAt.toISOString(),
|
||||
})),
|
||||
tasks: tasks.map(t => {
|
||||
const journal = journalMap.get(t.journalId);
|
||||
return {
|
||||
id: t.id,
|
||||
journalId: t.journalId,
|
||||
date: journal?.date || '',
|
||||
type: t.type,
|
||||
status: t.status,
|
||||
provider: t.provider,
|
||||
model: t.model || undefined,
|
||||
prompt: t.prompt || undefined,
|
||||
request: t.request || undefined,
|
||||
response: t.response || undefined,
|
||||
error: t.error || undefined,
|
||||
title: t.title || undefined,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
completedAt: t.completedAt?.toISOString() || undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return c.json({ data: exportData, error: null });
|
||||
});
|
||||
|
||||
app.post('/api/v1/import', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
let importData: ExportData;
|
||||
try {
|
||||
importData = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ data: null, error: { code: 'INVALID_JSON', message: 'Invalid JSON body' } }, 400);
|
||||
}
|
||||
|
||||
if (!importData.version || !importData.events || !importData.journals) {
|
||||
return c.json({ data: null, error: { code: 'INVALID_FORMAT', message: 'Invalid export format: missing required fields' } }, 400);
|
||||
}
|
||||
|
||||
const versionParts = importData.version.split('.');
|
||||
const minParts = MIN_IMPORT_VERSION.split('.');
|
||||
const versionNum = parseInt(versionParts[0]) * 10000 + parseInt(versionParts[1]) * 100 + parseInt(versionParts[2] || '0');
|
||||
const minNum = parseInt(minParts[0]) * 10000 + parseInt(minParts[1]) * 100 + parseInt(minParts[2]);
|
||||
|
||||
const compatible = versionNum >= minNum;
|
||||
|
||||
if (!compatible) {
|
||||
return c.json({
|
||||
data: {
|
||||
compatible: false,
|
||||
importVersion: importData.version,
|
||||
currentVersion: DEARDIARY_VERSION,
|
||||
warning: `Import version ${importData.version} is older than minimum supported version ${MIN_IMPORT_VERSION}. Import may fail or lose data.`,
|
||||
},
|
||||
error: { code: 'VERSION_INCOMPATIBLE', message: `Import version ${importData.version} is not compatible with current version ${DEARDIARY_VERSION}` }
|
||||
}, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
let importedEvents = 0;
|
||||
let importedJournals = 0;
|
||||
let importedTasks = 0;
|
||||
let skippedEvents = 0;
|
||||
let skippedJournals = 0;
|
||||
|
||||
const idMapping = {
|
||||
events: new Map<string, string>(),
|
||||
journals: new Map<string, string>(),
|
||||
};
|
||||
|
||||
for (const event of importData.events) {
|
||||
const existing = await prisma.event.findFirst({
|
||||
where: { userId, date: event.date, content: event.content, createdAt: new Date(event.createdAt) }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
skippedEvents++;
|
||||
idMapping.events.set(event.id, existing.id);
|
||||
} else {
|
||||
const created = await prisma.event.create({
|
||||
data: {
|
||||
userId,
|
||||
date: event.date,
|
||||
type: event.type,
|
||||
content: event.content,
|
||||
mediaPath: event.mediaPath,
|
||||
metadata: event.metadata,
|
||||
latitude: event.latitude,
|
||||
longitude: event.longitude,
|
||||
placeName: event.placeName,
|
||||
createdAt: new Date(event.createdAt),
|
||||
}
|
||||
});
|
||||
idMapping.events.set(event.id, created.id);
|
||||
importedEvents++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const journal of importData.journals) {
|
||||
const existing = await prisma.journal.findFirst({
|
||||
where: { userId, date: journal.date }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
skippedJournals++;
|
||||
} else {
|
||||
await prisma.journal.create({
|
||||
data: {
|
||||
userId,
|
||||
date: journal.date,
|
||||
title: journal.title,
|
||||
content: journal.content,
|
||||
eventCount: journal.eventCount,
|
||||
generatedAt: new Date(journal.generatedAt),
|
||||
}
|
||||
});
|
||||
importedJournals++;
|
||||
}
|
||||
}
|
||||
|
||||
const journalDateMap = new Map<string, string>();
|
||||
const journals = await prisma.journal.findMany({ where: { userId }, select: { id: true, date: true } });
|
||||
for (const j of journals) {
|
||||
journalDateMap.set(j.date, j.id);
|
||||
}
|
||||
|
||||
for (const task of importData.tasks) {
|
||||
const newJournalId = journalDateMap.get(task.date);
|
||||
if (!newJournalId) continue;
|
||||
|
||||
const existing = await prisma.task.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
journalId: newJournalId,
|
||||
provider: task.provider,
|
||||
createdAt: new Date(task.createdAt),
|
||||
}
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await prisma.task.create({
|
||||
data: {
|
||||
userId,
|
||||
journalId: newJournalId,
|
||||
type: task.type,
|
||||
status: task.status,
|
||||
provider: task.provider,
|
||||
model: task.model,
|
||||
prompt: task.prompt,
|
||||
request: task.request,
|
||||
response: task.response,
|
||||
error: task.error,
|
||||
title: task.title,
|
||||
createdAt: new Date(task.createdAt),
|
||||
completedAt: task.completedAt ? new Date(task.completedAt) : null,
|
||||
}
|
||||
});
|
||||
importedTasks++;
|
||||
}
|
||||
}
|
||||
|
||||
if (importData.settings) {
|
||||
await prisma.settings.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
aiProvider: importData.settings.aiProvider || 'groq',
|
||||
aiApiKey: importData.settings.aiApiKey,
|
||||
aiModel: importData.settings.aiModel,
|
||||
aiBaseUrl: importData.settings.aiBaseUrl,
|
||||
journalPrompt: importData.settings.journalPrompt,
|
||||
language: importData.settings.language,
|
||||
timezone: importData.settings.timezone,
|
||||
providerSettings: importData.settings.providerSettings,
|
||||
journalContextDays: importData.settings.journalContextDays,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
compatible: true,
|
||||
importedEvents,
|
||||
importedJournals,
|
||||
importedTasks,
|
||||
skippedEvents,
|
||||
skippedJournals,
|
||||
totalEvents: importData.events.length,
|
||||
totalJournals: importData.journals.length,
|
||||
totalTasks: importData.tasks.length,
|
||||
},
|
||||
error: null
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Import failed';
|
||||
return c.json({ data: null, error: { code: 'IMPORT_FAILED', message } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/v1/account', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
try {
|
||||
await prisma.user.delete({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
api.clearApiKey();
|
||||
|
||||
return c.json({ data: { deleted: true }, error: null });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Delete failed';
|
||||
return c.json({ data: null, error: { code: 'DELETE_FAILED', message } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/v1/account/reset', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
try {
|
||||
await prisma.event.deleteMany({ where: { userId } });
|
||||
await prisma.task.deleteMany({ where: { userId } });
|
||||
await prisma.journal.deleteMany({ where: { userId } });
|
||||
await prisma.settings.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
aiProvider: 'groq',
|
||||
aiApiKey: null,
|
||||
aiModel: 'llama-3.3-70b-versatile',
|
||||
aiBaseUrl: null,
|
||||
journalPrompt: null,
|
||||
language: 'en',
|
||||
timezone: 'UTC',
|
||||
providerSettings: null,
|
||||
journalContextDays: 10,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({ data: { reset: true }, error: null });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Reset failed';
|
||||
return c.json({ data: null, error: { code: 'RESET_FAILED', message } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/v1/account/password', 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 { currentPassword, newPassword } = body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'currentPassword and newPassword are required' } }, 400);
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'New password must be at least 6 characters' } }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'User not found' } }, 404);
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!valid) {
|
||||
return c.json({ data: null, error: { code: 'INVALID_PASSWORD', message: 'Current password is incorrect' } }, 400);
|
||||
}
|
||||
|
||||
const newHash = await bcrypt.hash(newPassword, 12);
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash: newHash },
|
||||
});
|
||||
|
||||
return c.json({ data: { changed: true }, error: null });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Password change failed';
|
||||
return c.json({ data: null, error: { code: 'CHANGE_FAILED', message } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/register', async (c) => {
|
||||
const registrationEnabled = envVars.REGISTRATION_ENABLED !== 'false';
|
||||
if (!registrationEnabled) {
|
||||
return c.json({ data: null, error: { code: 'REGISTRATION_DISABLED', message: 'Registration is currently disabled' } }, 403);
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const { email, password } = body;
|
||||
|
||||
if (!email || !password) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'email and password are required' } }, 400);
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Password must be at least 6 characters' } }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
return c.json({ data: null, error: { code: 'EMAIL_EXISTS', message: 'Email already registered' } }, 400);
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
settings: { create: {} },
|
||||
},
|
||||
});
|
||||
|
||||
const apiKey = randomBytes(32).toString('hex');
|
||||
const keyHash = createHash('sha256').update(apiKey).digest('hex');
|
||||
await prisma.apiKey.create({
|
||||
data: { userId: user.id, keyHash, name: 'Default' },
|
||||
});
|
||||
|
||||
return c.json({ data: { apiKey, userId: user.id }, error: null }, 201);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Registration failed';
|
||||
return c.json({ data: null, error: { code: 'REGISTRATION_FAILED', message } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
async function setupFTS() {
|
||||
try {
|
||||
await prisma.$executeRaw`CREATE VIRTUAL TABLE IF NOT EXISTS journal_fts USING fts5(
|
||||
userId UNINDEXED,
|
||||
date UNINDEXED,
|
||||
title,
|
||||
content,
|
||||
tokenize='porter unicode61'
|
||||
)`;
|
||||
|
||||
await prisma.$executeRaw`CREATE VIRTUAL TABLE IF NOT EXISTS event_fts USING fts5(
|
||||
userId UNINDEXED,
|
||||
date UNINDEXED,
|
||||
type UNINDEXED,
|
||||
content,
|
||||
tokenize='porter unicode61'
|
||||
)`;
|
||||
|
||||
console.log('FTS tables ready');
|
||||
} catch (err) {
|
||||
console.error('FTS setup error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildFTS() {
|
||||
try {
|
||||
const journals = await prisma.journal.findMany();
|
||||
await prisma.$executeRaw`DELETE FROM journal_fts`;
|
||||
for (const j of journals) {
|
||||
await prisma.$executeRaw`INSERT INTO journal_fts(userId, date, title, content) VALUES (${j.userId}, ${j.date}, ${j.title || ''}, ${j.content})`;
|
||||
}
|
||||
|
||||
const events = await prisma.event.findMany();
|
||||
await prisma.$executeRaw`DELETE FROM event_fts`;
|
||||
for (const e of events) {
|
||||
await prisma.$executeRaw`INSERT INTO event_fts(userId, date, type, content) VALUES (${e.userId}, ${e.date}, ${e.type}, ${e.content})`;
|
||||
}
|
||||
|
||||
console.log('FTS indexes rebuilt');
|
||||
} catch (err) {
|
||||
console.error('FTS rebuild error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDefaultUser() {
|
||||
const defaultEmail = envVars.DEFAULT_USER_EMAIL;
|
||||
const defaultPassword = envVars.DEFAULT_USER_PASSWORD;
|
||||
@@ -647,7 +1215,11 @@ async function createDefaultUser() {
|
||||
const port = parseInt(envVars.PORT || '3000', 10);
|
||||
console.log(`Starting DearDiary API on port ${port}`);
|
||||
|
||||
createDefaultUser().then(() => {
|
||||
setupFTS().then(() => {
|
||||
return rebuildFTS();
|
||||
}).then(() => {
|
||||
return createDefaultUser();
|
||||
}).then(() => {
|
||||
console.log('Server ready');
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ eventsRoutes.post('/', async (c) => {
|
||||
const mediaDir = c.env.MEDIA_DIR || './data/media';
|
||||
|
||||
const body = await c.req.json();
|
||||
const { date, type, content, metadata } = body;
|
||||
const { date, type, content, metadata, latitude, longitude, placeName } = body;
|
||||
|
||||
if (!date || !type || !content) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'date, type, and content are required' } }, 400);
|
||||
@@ -27,9 +27,18 @@ eventsRoutes.post('/', async (c) => {
|
||||
type,
|
||||
content,
|
||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||
latitude: latitude ?? null,
|
||||
longitude: longitude ?? null,
|
||||
placeName: placeName ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await prisma.$executeRaw`INSERT INTO event_fts(userId, date, type, content) VALUES (${userId}, ${date}, ${type}, ${content})`;
|
||||
} catch (e) {
|
||||
console.error('FTS index error:', e);
|
||||
}
|
||||
|
||||
return c.json({ data: event, error: null }, 201);
|
||||
});
|
||||
|
||||
@@ -78,6 +87,13 @@ eventsRoutes.put('/:id', async (c) => {
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await prisma.$executeRaw`DELETE FROM event_fts WHERE rowid = (SELECT rowid FROM event_fts WHERE userId = ${userId} AND date = ${existing.date} AND content = ${existing.content} LIMIT 1)`;
|
||||
await prisma.$executeRaw`INSERT INTO event_fts(userId, date, type, content) VALUES (${userId}, ${event.date}, ${event.type}, ${event.content})`;
|
||||
} catch (e) {
|
||||
console.error('FTS index error:', e);
|
||||
}
|
||||
|
||||
return c.json({ data: event, error: null });
|
||||
});
|
||||
|
||||
@@ -129,15 +145,16 @@ eventsRoutes.post('/:id/photo', async (c) => {
|
||||
const fileName = `${id}.${ext}`;
|
||||
const userMediaDir = `${mediaDir}/${userId}/${event.date}`;
|
||||
const filePath = `${userMediaDir}/${fileName}`;
|
||||
const mediaUrl = `/media/${userId}/${event.date}/${fileName}`;
|
||||
|
||||
await Bun.write(filePath, file);
|
||||
|
||||
await prisma.event.update({
|
||||
where: { id },
|
||||
data: { mediaPath: filePath },
|
||||
data: { mediaPath: mediaUrl },
|
||||
});
|
||||
|
||||
return c.json({ data: { mediaPath: filePath }, error: null }, 201);
|
||||
return c.json({ data: { mediaPath: mediaUrl }, error: null }, 201);
|
||||
});
|
||||
|
||||
eventsRoutes.post('/:id/voice', async (c) => {
|
||||
@@ -164,13 +181,14 @@ eventsRoutes.post('/:id/voice', async (c) => {
|
||||
const fileName = `${id}.webm`;
|
||||
const userMediaDir = `${mediaDir}/${userId}/${event.date}`;
|
||||
const filePath = `${userMediaDir}/${fileName}`;
|
||||
const mediaUrl = `/media/${userId}/${event.date}/${fileName}`;
|
||||
|
||||
await Bun.write(filePath, file);
|
||||
|
||||
await prisma.event.update({
|
||||
where: { id },
|
||||
data: { mediaPath: filePath },
|
||||
data: { mediaPath: mediaUrl },
|
||||
});
|
||||
|
||||
return c.json({ data: { mediaPath: filePath }, error: null }, 201);
|
||||
return c.json({ data: { mediaPath: mediaUrl }, error: null }, 201);
|
||||
});
|
||||
|
||||
@@ -12,8 +12,8 @@ export class AnthropicProvider implements AIProvider {
|
||||
this.baseUrl = config.baseUrl || 'https://api.anthropic.com/v1';
|
||||
}
|
||||
|
||||
async generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult> {
|
||||
const requestBody = {
|
||||
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: this.model,
|
||||
max_tokens: 2000,
|
||||
system: systemPrompt,
|
||||
@@ -22,6 +22,10 @@ export class AnthropicProvider implements AIProvider {
|
||||
],
|
||||
};
|
||||
|
||||
if (options?.jsonMode) {
|
||||
requestBody.output = { format: { type: 'json_object' } };
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -39,10 +43,22 @@ export class AnthropicProvider implements AIProvider {
|
||||
throw new Error(`Anthropic API error: ${response.status} ${JSON.stringify(responseData)}`);
|
||||
}
|
||||
|
||||
const content = responseData.content?.[0]?.text || '';
|
||||
let content = responseData.content?.[0]?.text || '';
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export class GroqProvider implements AIProvider {
|
||||
this.baseUrl = config.baseUrl || 'https://api.groq.com/openai/v1';
|
||||
}
|
||||
|
||||
async generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult> {
|
||||
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
|
||||
if (systemPrompt) {
|
||||
@@ -21,13 +21,17 @@ export class GroqProvider implements AIProvider {
|
||||
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
const requestBody = {
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: this.model,
|
||||
messages,
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
};
|
||||
|
||||
if (options?.jsonMode) {
|
||||
requestBody.response_format = { type: 'json_object' };
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -44,10 +48,22 @@ export class GroqProvider implements AIProvider {
|
||||
}
|
||||
|
||||
const responseData = JSON.parse(responseText);
|
||||
const content = responseData.choices?.[0]?.message?.content || '';
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ export class LMStudioProvider implements AIProvider {
|
||||
this.model = config.model || 'local-model';
|
||||
}
|
||||
|
||||
async generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult> {
|
||||
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
|
||||
if (systemPrompt) {
|
||||
@@ -40,10 +40,22 @@ export class LMStudioProvider implements AIProvider {
|
||||
throw new Error(`LM Studio API error: ${response.status} ${JSON.stringify(responseData)}`);
|
||||
}
|
||||
|
||||
const content = responseData.choices?.[0]?.message?.content || '';
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ export class OllamaProvider implements AIProvider {
|
||||
this.model = config.model || 'llama3.2';
|
||||
}
|
||||
|
||||
async generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult> {
|
||||
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
|
||||
const requestBody = {
|
||||
model: this.model,
|
||||
stream: false,
|
||||
@@ -34,10 +34,22 @@ export class OllamaProvider implements AIProvider {
|
||||
throw new Error(`Ollama API error: ${response.status} ${JSON.stringify(responseData)}`);
|
||||
}
|
||||
|
||||
const content = responseData.message?.content || '';
|
||||
let content = responseData.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,
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export class OpenAIProvider implements AIProvider {
|
||||
this.baseUrl = config.baseUrl || 'https://api.openai.com/v1';
|
||||
}
|
||||
|
||||
async generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult> {
|
||||
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
|
||||
if (systemPrompt) {
|
||||
@@ -21,13 +21,17 @@ export class OpenAIProvider implements AIProvider {
|
||||
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
const requestBody = {
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: this.model,
|
||||
messages,
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
};
|
||||
|
||||
if (options?.jsonMode) {
|
||||
requestBody.response_format = { type: 'json_object' };
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -43,10 +47,22 @@ export class OpenAIProvider implements AIProvider {
|
||||
throw new Error(`OpenAI API error: ${response.status} ${JSON.stringify(responseData)}`);
|
||||
}
|
||||
|
||||
const content = responseData.choices?.[0]?.message?.content || '';
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
export interface AIProviderResult {
|
||||
content: string;
|
||||
title?: string;
|
||||
request: Record<string, unknown>;
|
||||
response: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AIProvider {
|
||||
provider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq';
|
||||
generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult>;
|
||||
generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult>;
|
||||
validate?(): Promise<boolean>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user