feat: v0.0.1 - Groq provider, timezone, journal context, test connection, task logging

Added:
- Groq AI provider (free, fast with llama-3.3-70b-versatile)
- Timezone setting (22 timezones)
- Journal context: include previous journals (3/7/14/30 days)
- Test connection button for AI providers
- Per-provider settings (API key, model, base URL remembered)
- Detailed task logging (full prompts and responses)
- Tasks page with expandable details
- Progress modal with steps and AI output details

Fixed:
- Groq API endpoint (https://api.groq.com/openai/v1/chat/completions)
- Ollama baseUrl leaking to other providers
- Database schema references
- Proper Prisma migrations (data-safe)

Changed:
- Default AI: OpenAI → Groq
- Project renamed: TotalRecall → DearDiary
- Strict anti-hallucination prompt
- Docker uses prisma migrate deploy (non-destructive)
This commit is contained in:
lotherk
2026-03-26 21:56:29 +00:00
parent 37871271cc
commit 5c217853de
27 changed files with 1026 additions and 260 deletions

View File

@@ -4,12 +4,33 @@ All notable changes to DearDiary will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [0.1.0] - 2026-03-26
### Added
- **Groq Provider**: Free, fast AI provider with default model `llama-3.3-70b-versatile`
- **Timezone setting**: Users can select their local timezone (22 timezones)
- **Journal context**: Option to include previous journals (3/7/14/30 days) for AI context
- **Generating modal**: Progress bar with steps and expandable "Details" section
- **Tasks page**: Route `/tasks/:date` for viewing generation tasks with full prompts/responses
- **Test connection**: Button to test AI provider connectivity
- **Per-provider settings**: Each AI provider remembers its own API key, model, and base URL
- **Detailed task logging**: Full prompts and responses stored for debugging
### Changed ### Changed
- **Strict anti-hallucination default prompt**: New users get a grounded, factual prompt that ONLY summarizes what's recorded - no invention, no embellishment - **Default AI**: OpenAI → Groq with free `llama-3.3-70b-versatile` model
- **Project renamed**: "TotalRecall" → "DearDiary" everywhere
- **Strict anti-hallucination prompt**: Grounded, factual summarization only
- **Migrations**: Proper Prisma migration system (data-safe updates)
- **Docker**: Non-destructive database migrations via `prisma migrate deploy`
## [0.2.0] - 2026-03-26 ### Fixed
- Groq API endpoint: `https://api.groq.com/openai/v1/chat/completions`
- Ollama baseUrl no longer leaks to other providers
- Database schema references corrected
## [0.0.1] - 2026-03-26
Initial release
### Added ### Added
- **Task System**: AI journal generation now creates tasks that track: - **Task System**: AI journal generation now creates tasks that track:

View File

@@ -1,6 +1,6 @@
# Android App # Android App
Native Android app using Kotlin and Jetpack Compose that connects to the same TotalRecall API. Native Android app using Kotlin and Jetpack Compose that connects to the same DearDiary API.
## Requirements ## Requirements
@@ -49,7 +49,7 @@ buildConfigField("String", "API_BASE_URL", "\"http://your-server:3000/api/v1/\""
``` ```
android/ android/
├── app/src/main/java/com/totalrecall/ ├── app/src/main/java/com/deardiary/
│ ├── api/ # API client │ ├── api/ # API client
│ ├── model/ # Data models │ ├── model/ # Data models
│ ├── repository/ # Repository pattern │ ├── repository/ # Repository pattern

View File

@@ -9,14 +9,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.deardiary.ui.AppNavigation import com.deardiary.ui.AppNavigation
import com.deardiary.ui.TotalRecallTheme import com.deardiary.ui.DearDiaryTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
TotalRecallTheme { DearDiaryTheme {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background

View File

@@ -8,7 +8,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.deardiary.api.* import com.deardiary.api.*
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "totalrecall") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "deardiary")
class Repository(context: Context, private val baseUrl: String) { class Repository(context: Context, private val baseUrl: String) {
private val api = ApiClient(baseUrl) private val api = ApiClient(baseUrl)

View File

@@ -18,7 +18,7 @@ private val DarkColorScheme = darkColorScheme(
) )
@Composable @Composable
fun TotalRecallTheme( fun DearDiaryTheme(
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
MaterialTheme( MaterialTheme(

View File

@@ -36,7 +36,7 @@ fun AuthScreen(
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text( Text(
text = "TotalRecall", text = "DearDiary",
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )

View File

@@ -14,5 +14,5 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "TotalRecall" rootProject.name = "DearDiary"
include(":app") include(":app")

View File

@@ -1,6 +1,6 @@
{ {
"name": "totalrecall-backend", "name": "deardiary-backend",
"version": "1.0.0", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bun --watch src/index.ts", "dev": "bun --watch src/index.ts",

View File

@@ -0,0 +1,103 @@
-- CreateInitialSchema
CREATE TABLE IF NOT EXISTS "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "User_email_key" ON "User"("email");
CREATE TABLE IF NOT EXISTS "ApiKey" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"keyHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"lastUsedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS "ApiKey_userId_key" ON "ApiKey"("userId");
CREATE UNIQUE INDEX IF NOT EXISTS "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
CREATE TABLE IF NOT EXISTS "Entry" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"date" TEXT NOT NULL,
"type" TEXT NOT NULL,
"content" TEXT NOT NULL,
"mediaPath" TEXT,
"metadata" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Entry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS "Entry_userId_date_key" ON "Entry"("userId", "date");
CREATE INDEX IF NOT EXISTS "Entry_date_key" ON "Entry"("date");
CREATE TABLE IF NOT EXISTS "Journal" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"date" TEXT NOT NULL,
"content" TEXT NOT NULL,
"entryCount" INTEGER NOT NULL,
"generatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Journal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "Journal_userId_date_key" ON "Journal"("userId", "date");
CREATE INDEX IF NOT EXISTS "Journal_userId_key" ON "Journal"("userId");
CREATE TABLE IF NOT EXISTS "Task" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"journalId" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'journal_generate',
"status" TEXT NOT NULL DEFAULT 'pending',
"provider" TEXT NOT NULL,
"model" TEXT,
"prompt" TEXT NOT NULL,
"request" TEXT,
"response" TEXT,
"error" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" DATETIME,
CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Task_journalId_fkey" FOREIGN KEY ("journalId") REFERENCES "Journal" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS "Task_userId_key" ON "Task"("userId");
CREATE INDEX IF NOT EXISTS "Task_journalId_key" ON "Task"("journalId");
CREATE TABLE IF NOT EXISTS "Settings" (
"userId" TEXT NOT NULL PRIMARY KEY,
"aiProvider" TEXT NOT NULL DEFAULT 'groq',
"aiApiKey" TEXT,
"aiModel" TEXT NOT NULL DEFAULT 'llama-3.3-70b-versatile',
"aiBaseUrl" TEXT,
"journalPrompt" TEXT NOT NULL 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',
"language" TEXT NOT NULL DEFAULT 'en',
"timezone" TEXT NOT NULL DEFAULT 'UTC',
"providerSettings" TEXT,
"journalContextDays" INTEGER NOT NULL DEFAULT 10,
CONSTRAINT "Settings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- Create _prisma_migrations table for Prisma migrate tracking
CREATE TABLE IF NOT EXISTS "_prisma_migrations" (
"id" TEXT NOT NULL PRIMARY KEY,
"checksum" TEXT NOT NULL,
"finished_at" DATETIME,
"migration_name" TEXT NOT NULL,
"logs" TEXT,
"rolled_back_at" DATETIME,
"started_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"applied_steps_count" INTEGER NOT NULL DEFAULT 0
);
-- Record this migration
INSERT INTO "_prisma_migrations" ("id", "checksum", "finished_at", "migration_name", "applied_steps_count")
VALUES (lower(hex(randomblob(16))), 'init', datetime('now'), '00000000000000_init', 1);

View File

@@ -89,13 +89,16 @@ model Task {
} }
model Settings { model Settings {
userId String @id userId String @id
aiProvider String @default("openai") aiProvider String @default("groq")
aiApiKey String? aiApiKey String?
aiModel String @default("gpt-4") aiModel String @default("llama-3.3-70b-versatile")
aiBaseUrl String? 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 @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")
language String @default("en") language String @default("en")
timezone String @default("UTC")
providerSettings String?
journalContextDays Int @default(10)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }

View File

@@ -7,6 +7,7 @@ import bcrypt from 'bcryptjs';
import { createHash, randomBytes } from 'crypto'; import { createHash, randomBytes } from 'crypto';
import * as jose from 'jose'; import * as jose from 'jose';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { createAIProvider } from './services/ai/provider';
const app = new Hono(); const app = new Hono();
@@ -19,7 +20,7 @@ app.use('*', cors({
})); }));
const prisma = new PrismaClient({ const prisma = new PrismaClient({
datasourceUrl: envVars.DATABASE_URL || 'file:./data/totalrecall.db', datasourceUrl: envVars.DATABASE_URL || 'file:./data/deardiary.db',
}); });
app.use('*', async (c, next) => { app.use('*', async (c, next) => {
@@ -285,9 +286,9 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
return c.json({ data: null, error: { code: 'NO_ENTRIES', message: 'No entries found for this date' } }, 400); return c.json({ data: null, error: { code: 'NO_ENTRIES', message: 'No entries found for this date' } }, 400);
} }
const provider = settings?.aiProvider || 'openai'; const provider = settings?.aiProvider || 'groq';
if ((provider === 'openai' || provider === 'anthropic') && !settings?.aiApiKey) { if ((provider === 'openai' || provider === 'anthropic' || provider === 'groq') && !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); return c.json({ data: null, error: { code: 'NO_AI_CONFIG', message: 'AI not configured. Please set up your API key in settings.' } }, 400);
} }
@@ -303,14 +304,43 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
return text; return text;
}).join('\n\n'); }).join('\n\n');
// Get previous journals for context
const contextDays = settings?.journalContextDays || 0;
let previousJournalsText = '';
if (contextDays > 0) {
const contextStartDate = new Date(date);
contextStartDate.setDate(contextStartDate.getDate() - contextDays);
const previousJournals = await prisma.journal.findMany({
where: {
userId,
date: {
gte: contextStartDate.toISOString().split('T')[0],
lt: date,
},
},
orderBy: { date: 'desc' },
select: { date: true, content: true, generatedAt: true },
});
if (previousJournals.length > 0) {
previousJournalsText = `\n\nPREVIOUS JOURNAL SUMMARY (last ${contextDays} days for context):\n${previousJournals.map(j =>
`[${j.date}]\n${j.content}`
).join('\n\n')}\n`;
}
}
const systemPrompt = settings?.journalPrompt || 'You are a thoughtful journal writer.'; 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. const userPrompt = `${previousJournalsText}The following entries were captured throughout the day (${date}). Write a thoughtful, reflective journal entry.
ENTRIES: ENTRIES:
${entriesText} ${entriesText}
JOURNAL:`; JOURNAL:`;
console.log(`[Journal Generate] Date: ${date}, Context days: ${contextDays}, Entries: ${entries.length}`);
// Create placeholder journal and task // Create placeholder journal and task
const placeholderJournal = await prisma.journal.create({ const placeholderJournal = await prisma.journal.create({
data: { userId, date, content: 'Generating...', entryCount: entries.length }, data: { userId, date, content: 'Generating...', entryCount: entries.length },
@@ -324,7 +354,8 @@ JOURNAL:`;
status: 'pending', status: 'pending',
provider, provider,
model: settings?.aiModel, model: settings?.aiModel,
prompt: `${systemPrompt}\n\n${userPrompt}`, prompt: userPrompt,
request: systemPrompt,
}, },
}); });
@@ -334,128 +365,34 @@ JOURNAL:`;
data: { id: placeholderJournal.id }, data: { id: placeholderJournal.id },
}); });
let requestBody: any = null;
let responseBody: any = null;
let content = ''; let content = '';
try { try {
if (provider === 'openai') { console.log(`[Journal Generate] Using provider: ${provider}`);
requestBody = {
model: settings?.aiModel || 'gpt-4', const aiProvider = createAIProvider({
messages: [ provider: provider as 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq',
{ role: 'system', content: systemPrompt }, apiKey: settings?.aiApiKey || '',
{ role: 'user', content: userPrompt } model: settings?.aiModel || undefined,
], baseUrl: (provider === 'ollama' || provider === 'lmstudio') ? settings?.aiBaseUrl || undefined : undefined,
temperature: 0.7, });
max_tokens: 2000,
}; console.log(`[Journal Generate] AI Provider created: ${aiProvider.provider}`);
const response = await fetch('https://api.openai.com/v1/chat/completions', { content = await aiProvider.generate(userPrompt, systemPrompt);
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) { if (!content) {
throw new Error('No content generated from AI'); throw new Error('No content generated from AI');
} }
// Update task with success console.log(`[Journal Generate] Success! Content length: ${content.length}`);
// Update task with success - store full prompt and response
await prisma.task.update({ await prisma.task.update({
where: { id: task.id }, where: { id: task.id },
data: { data: {
status: 'completed', status: 'completed',
request: JSON.stringify(requestBody, null, 2), response: content,
response: JSON.stringify(responseBody, null, 2),
completedAt: new Date(), completedAt: new Date(),
}, },
}); });
@@ -554,15 +491,17 @@ app.put('/api/v1/settings', async (c) => {
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const body = await c.req.json(); const body = await c.req.json();
const { aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language } = body; const { aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language, providerSettings, journalContextDays } = body;
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
if (aiProvider !== undefined) data.aiProvider = aiProvider; if (aiProvider !== undefined) data.aiProvider = aiProvider;
if (aiApiKey !== undefined) data.aiApiKey = aiApiKey; if (aiApiKey !== undefined) data.aiApiKey = aiApiKey;
if (aiModel !== undefined) data.aiModel = aiModel || 'gpt-4'; if (aiModel !== undefined) data.aiModel = aiModel;
if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl; if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl;
if (journalPrompt !== undefined) data.journalPrompt = journalPrompt; if (journalPrompt !== undefined) data.journalPrompt = journalPrompt;
if (language !== undefined) data.language = language; if (language !== undefined) data.language = language;
if (providerSettings !== undefined) data.providerSettings = JSON.stringify(providerSettings);
if (journalContextDays !== undefined) data.journalContextDays = journalContextDays;
const settings = await prisma.settings.upsert({ const settings = await prisma.settings.upsert({
where: { userId }, where: { userId },
@@ -570,9 +509,57 @@ app.put('/api/v1/settings', async (c) => {
update: data, update: data,
}); });
if (settings.providerSettings) {
try {
(settings as any).providerSettings = JSON.parse(settings.providerSettings as string);
} catch {
(settings as any).providerSettings = {};
}
}
return c.json({ data: settings, error: null }); return c.json({ data: settings, error: null });
}); });
app.post('/api/v1/ai/test', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { provider, apiKey, model, baseUrl } = await c.req.json();
console.log(`[AI Test] Provider: ${provider}, Model: ${model || 'default'}, BaseURL: ${baseUrl || 'default'}`);
console.log(`[AI Test] API Key set: ${!!apiKey}, Length: ${apiKey?.length || 0}`);
if (!provider) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'provider is required' } }, 400);
}
try {
const aiProvider = createAIProvider({
provider,
apiKey: apiKey || '',
model: model || undefined,
baseUrl: baseUrl || undefined,
});
console.log(`[AI Test] Creating provider: ${aiProvider.provider}`);
const result = await aiProvider.generate('Say "OK" if you can read this.', 'You are a test assistant. Respond with just "OK".');
console.log(`[AI Test] Success! Response length: ${result.length}`);
if (result.toLowerCase().includes('ok')) {
return c.json({ data: { valid: true, message: 'Connection successful!' }, error: null });
} else {
return c.json({ data: { valid: false }, error: { code: 'TEST_FAILED', message: 'Model responded but with unexpected output' } });
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Connection failed';
console.error(`[AI Test] Error: ${message}`);
console.error(`[AI Test] Stack: ${err instanceof Error ? err.stack : 'N/A'}`);
return c.json({ data: { valid: false }, error: { code: 'TEST_FAILED', message } });
}
});
app.notFound((c) => c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Not found' } }, 404)); app.notFound((c) => c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Not found' } }, 404));
app.onError((err, c) => { app.onError((err, c) => {
console.error('Unhandled error:', err); console.error('Unhandled error:', err);

View File

@@ -6,6 +6,7 @@ import * as jose from 'jose';
function createAuthRoutes() { function createAuthRoutes() {
const app = new Hono<HonoEnv>(); const app = new Hono<HonoEnv>();
const authRoutes = app;
authRoutes.post('/register', async (c) => { authRoutes.post('/register', async (c) => {
const { email, password } = await c.req.json(); const { email, password } = await c.req.json();
@@ -113,3 +114,8 @@ authRoutes.post('/api-key', async (c) => {
return c.json({ data: { apiKey, id: keyRecord.id, name: keyRecord.name }, error: null }, 201); return c.json({ data: { apiKey, id: keyRecord.id, name: keyRecord.name }, error: null }, 201);
}); });
return app;
}
export default createAuthRoutes;

View File

@@ -31,7 +31,7 @@ settingsRoutes.put('/', async (c) => {
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
if (aiProvider !== undefined) data.aiProvider = aiProvider; if (aiProvider !== undefined) data.aiProvider = aiProvider;
if (aiApiKey !== undefined) data.aiApiKey = aiApiKey; if (aiApiKey !== undefined) data.aiApiKey = aiApiKey;
if (aiModel !== undefined) data.aiModel = aiModel || 'gpt-4'; if (aiModel !== undefined) data.aiModel = aiModel;
if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl; if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl;
if (journalPrompt !== undefined) data.journalPrompt = journalPrompt; if (journalPrompt !== undefined) data.journalPrompt = journalPrompt;
if (language !== undefined) data.language = language; if (language !== undefined) data.language = language;
@@ -67,3 +67,34 @@ settingsRoutes.post('/validate-key', async (c) => {
return c.json({ data: { valid: false }, error: null }); return c.json({ data: { valid: false }, error: null });
} }
}); });
settingsRoutes.post('/test', async (c) => {
const body = await c.req.json();
const { provider, apiKey, model, baseUrl } = body;
if (!provider) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'provider is required' } }, 400);
}
const { createAIProvider } = await import('../services/ai/provider');
try {
const aiProvider = createAIProvider({
provider,
apiKey: apiKey || '',
model: model || undefined,
baseUrl: baseUrl || undefined,
});
const result = await aiProvider.generate('Say "OK" if you can read this.', 'You are a test assistant. Respond with just "OK".');
if (result.toLowerCase().includes('ok')) {
return c.json({ data: { valid: true, message: 'Connection successful!' }, error: null });
} else {
return c.json({ data: { valid: false }, error: { code: 'TEST_FAILED', message: 'Model responded but with unexpected output' } });
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Connection failed';
return c.json({ data: { valid: false }, error: { code: 'TEST_FAILED', message } });
}
});

View File

@@ -0,0 +1,66 @@
import type { AIProvider, AIProviderConfig } from './provider';
export class GroqProvider implements AIProvider {
provider = 'groq' as const;
private apiKey: string;
private model: string;
private baseUrl: string;
constructor(config: AIProviderConfig) {
this.apiKey = config.apiKey;
this.model = config.model || 'llama-3.3-70b-versatile';
this.baseUrl = config.baseUrl || 'https://api.groq.com/openai/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(`Groq 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}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.model,
messages: [{ role: 'user', content: 'test' }],
max_tokens: 5,
}),
});
return response.ok || response.status === 400;
} catch {
return false;
}
}
}

View File

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

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1e293b" /> <meta name="theme-color" content="#1e293b" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<title>TotalRecall - AI Journal</title> <title>DearDiary - AI Journal</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,11 +1,11 @@
{ {
"name": "totalrecall-frontend", "name": "deardiary-frontend",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "totalrecall-frontend", "name": "deardiary-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",

View File

@@ -1,7 +1,7 @@
{ {
"name": "totalrecall-frontend", "name": "deardiary-frontend",
"private": true, "private": true,
"version": "1.0.0", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,6 +1,6 @@
{ {
"name": "TotalRecall", "name": "DearDiary",
"short_name": "TotalRecall", "short_name": "DearDiary",
"description": "AI-powered daily journal that captures life through multiple input methods", "description": "AI-powered daily journal that captures life through multiple input methods",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",

View File

@@ -6,6 +6,7 @@ import Home from './pages/Home';
import History from './pages/History'; import History from './pages/History';
import Day from './pages/Day'; import Day from './pages/Day';
import Journal from './pages/Journal'; import Journal from './pages/Journal';
import Tasks from './pages/Tasks';
import Settings from './pages/Settings'; import Settings from './pages/Settings';
import { useTheme } from './lib/ThemeContext'; import { useTheme } from './lib/ThemeContext';
@@ -88,6 +89,9 @@ function App() {
<Route path="/journal/:date" element={ <Route path="/journal/:date" element={
<PrivateRoute><Journal /></PrivateRoute> <PrivateRoute><Journal /></PrivateRoute>
} /> } />
<Route path="/tasks/:date" element={
<PrivateRoute><Tasks /></PrivateRoute>
} />
<Route path="/settings" element={ <Route path="/settings" element={
<PrivateRoute><Settings /></PrivateRoute> <PrivateRoute><Settings /></PrivateRoute>
} /> } />

View File

@@ -184,12 +184,19 @@ export interface Task {
} }
export interface Settings { export interface Settings {
aiProvider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio'; aiProvider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq';
aiApiKey?: string; aiApiKey?: string;
aiModel: string; aiModel: string;
aiBaseUrl?: string; aiBaseUrl?: string;
journalPrompt: string; journalPrompt: string;
language: string; language: string;
timezone: string;
journalContextDays: number;
providerSettings?: Record<string, {
apiKey?: string;
model?: string;
baseUrl?: string;
}>;
} }
export const api = new ApiClient(); export const api = new ApiClient();

View File

@@ -44,7 +44,7 @@ export default function Auth({ onAuth }: { onAuth: () => void }) {
return ( return (
<div className="min-h-screen flex items-center justify-center p-4"> <div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<h1 className="text-3xl font-bold text-center mb-8 text-slate-100">TotalRecall</h1> <h1 className="text-3xl font-bold text-center mb-8 text-slate-100">DearDiary</h1>
<div className="bg-slate-900 rounded-xl p-6 border border-slate-800"> <div className="bg-slate-900 rounded-xl p-6 border border-slate-800">
<div className="flex gap-4 mb-6"> <div className="flex gap-4 mb-6">

View File

@@ -1,6 +1,124 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { api, Journal, Task } from '../lib/api'; import { api, Journal, Task } from '../lib/api';
import { useTheme } from '../lib/ThemeContext';
const detailsStyles = `
details[open] .details-arrow {
transform: rotate(90deg);
}
details summary::-webkit-details-marker {
display: none;
}
`;
function GeneratingModal({
isOpen,
provider,
model,
step,
response
}: {
isOpen: boolean;
provider: string;
model?: string;
step: number;
response?: string;
}) {
const { resolvedTheme } = useTheme();
if (!isOpen) return null;
const steps = [
{ label: 'Sending entries to AI...', done: step > 0 },
{ label: 'Waiting for response...', done: step > 1 },
{ label: 'Processing & formatting...', done: step > 2 },
];
const formatResponse = (str: string | undefined): string | null => {
if (!str) return null;
try {
const parsed = JSON.parse(str);
if (parsed.content?.[0]?.text) {
return parsed.content[0].text;
}
if (parsed.choices?.[0]?.message?.content) {
return parsed.choices[0].message.content;
}
return JSON.stringify(parsed, null, 2);
} catch {
return str;
}
};
const output = formatResponse(response);
return (
<>
<style>{detailsStyles}</style>
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div className={`relative ${resolvedTheme === 'dark' ? 'bg-slate-800' : 'bg-white'} rounded-2xl p-8 shadow-2xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto`}>
<div className="text-center mb-6">
<div className="w-16 h-16 mx-auto mb-6 relative">
<div className="absolute inset-0 border-4 border-slate-600 rounded-full" />
<div className="absolute inset-0 border-4 border-purple-500 rounded-full border-t-transparent animate-spin" />
</div>
<h3 className="text-xl font-semibold mb-4">Generating Journal</h3>
<div className="space-y-3 text-sm text-left">
{steps.map((s, i) => (
<div key={i} className="flex items-center gap-3">
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${
s.done
? 'bg-green-500 text-white'
: step === i + 1
? 'bg-yellow-500 text-white animate-pulse'
: 'bg-slate-600 text-slate-400'
}`}>
{s.done ? '✓' : i + 1}
</div>
<span className={s.done ? 'text-slate-300' : step === i + 1 ? 'text-yellow-300' : 'text-slate-500'}>
{s.label}
</span>
</div>
))}
</div>
<div className="mt-4 w-full bg-slate-700 rounded-full h-2">
<div
className="h-2 bg-purple-500 rounded-full transition-all duration-300"
style={{ width: `${(step / 3) * 100}%` }}
/>
</div>
</div>
<details className="mt-4 border border-slate-600 rounded-lg">
<summary className="px-4 py-3 cursor-pointer text-sm text-slate-400 hover:text-slate-300 flex items-center gap-2">
<span className="transform transition-transform details-arrow"></span>
Details (AI Output)
</summary>
<div className="px-4 pb-4">
{output ? (
<pre className="mt-2 p-3 bg-slate-900 rounded-lg text-xs text-slate-300 max-h-48 overflow-auto whitespace-pre-wrap">
{output}
</pre>
) : (
<p className="mt-2 text-xs text-slate-500 italic">Waiting for response...</p>
)}
</div>
</details>
<div className="mt-4 text-xs text-slate-500 text-center">
{provider.charAt(0).toUpperCase() + provider.slice(1)}
{model && `${model}`}
</div>
</div>
</div>
</>
);
}
export default function JournalPage() { export default function JournalPage() {
const { date } = useParams<{ date: string }>(); const { date } = useParams<{ date: string }>();
@@ -8,6 +126,10 @@ export default function JournalPage() {
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [currentProvider, setCurrentProvider] = useState('');
const [currentModel, setCurrentModel] = useState('');
const [generatingStep, setGeneratingStep] = useState(0);
const [currentResponse, setCurrentResponse] = useState<string | undefined>();
useEffect(() => { useEffect(() => {
if (date) { if (date) {
@@ -36,13 +158,23 @@ export default function JournalPage() {
const handleGenerate = async () => { const handleGenerate = async () => {
if (!date) return; if (!date) return;
setGenerating(true); setGenerating(true);
setGeneratingStep(1);
setCurrentProvider('ai');
setCurrentModel('');
setCurrentResponse(undefined);
const res = await api.generateJournal(date); const res = await api.generateJournal(date);
setGeneratingStep(3);
if (res.data) { if (res.data) {
setCurrentResponse(res.data.task.response);
setJournal(res.data.journal); setJournal(res.data.journal);
setCurrentProvider(res.data.task.provider);
setCurrentModel(res.data.task.model || '');
setTasks(prev => [res.data!.task, ...prev]); setTasks(prev => [res.data!.task, ...prev]);
} }
setGenerating(false); setTimeout(() => setGenerating(false), 500);
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
@@ -53,104 +185,100 @@ export default function JournalPage() {
if (!date) return null; if (!date) return null;
return ( return (
<div className="max-w-4xl mx-auto p-4"> <>
<div className="mb-6"> <GeneratingModal
<a href={`/day/${date}`} className="text-slate-400 hover:text-white text-sm mb-1 inline-block"> Back to day</a> isOpen={generating}
<h1 className="text-2xl font-bold">Journal</h1> provider={currentProvider}
<p className="text-slate-400">{formatDate(date)}</p> model={currentModel}
</div> step={generatingStep}
response={currentResponse}
{loading ? ( />
<div className="text-center py-12 text-slate-400">Loading...</div>
) : !journal ? ( <div className="max-w-4xl mx-auto p-4">
<div className="text-center py-12"> <div className="mb-6">
<p className="text-slate-400 mb-4">No journal generated yet</p> <a href={`/day/${date}`} className="text-slate-400 hover:text-white text-sm mb-1 inline-block"> Back to day</a>
<button <h1 className="text-2xl font-bold">Journal</h1>
onClick={handleGenerate} <p className="text-slate-400">{formatDate(date)}</p>
disabled={generating}
className="px-6 py-3 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50"
>
{generating ? 'Generating...' : 'Generate Journal'}
</button>
</div> </div>
) : (
<div> {loading ? (
<div className="text-sm text-slate-400 mb-4"> <div className="text-center py-12 text-slate-400">Loading...</div>
Generated {new Date(journal.generatedAt).toLocaleString()} {journal.entryCount} entries ) : !journal || journal.content === 'Generating...' ? (
</div> <div className="text-center py-12">
<div className="prose prose-invert prose-slate max-w-none"> <p className="text-slate-400 mb-4">No journal generated yet</p>
<div className="bg-slate-900 rounded-xl p-6 border border-slate-800 whitespace-pre-wrap leading-relaxed">
{journal.content}
</div>
</div>
<div className="mt-6 flex gap-4">
<button <button
onClick={handleGenerate} onClick={handleGenerate}
disabled={generating} disabled={generating}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition disabled:opacity-50" className="px-6 py-3 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50"
> >
Regenerate Generate Journal
</button> </button>
</div> </div>
</div> ) : (
)} <div>
<div className="text-sm text-slate-400 mb-4">
{tasks.length > 0 && ( Generated {new Date(journal.generatedAt).toLocaleString()} {journal.entryCount} entries
<div className="mt-8"> </div>
<h2 className="text-lg font-medium mb-4">Generation History</h2> <div className="prose prose-invert prose-slate max-w-none">
<div className="space-y-3"> <div className="bg-slate-900 rounded-xl p-6 border border-slate-800 whitespace-pre-wrap leading-relaxed">
{tasks.map(task => ( {journal.content}
<details key={task.id} className="bg-slate-900 rounded-lg border border-slate-800"> </div>
<summary className="p-4 cursor-pointer flex items-center justify-between"> </div>
<div className="flex items-center gap-3"> <div className="mt-6 flex gap-4">
<span className={`w-2 h-2 rounded-full ${ <button
task.status === 'completed' ? 'bg-green-500' : onClick={handleGenerate}
task.status === 'failed' ? 'bg-red-500' : 'bg-yellow-500' disabled={generating}
}`} /> className="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition disabled:opacity-50"
<span className="font-medium"> >
{task.provider.charAt(0).toUpperCase() + task.provider.slice(1)} Regenerate
{task.model && ` - ${task.model}`} </button>
</span> <a
<span className="text-sm text-slate-400"> href={`/tasks/${date}`}
{new Date(task.createdAt).toLocaleString()} className="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition"
</span> >
</div> View Tasks
<span className={`text-xs px-2 py-1 rounded ${ </a>
task.status === 'completed' ? 'bg-green-500/20 text-green-400' : </div>
task.status === 'failed' ? 'bg-red-500/20 text-red-400' : 'bg-yellow-500/20 text-yellow-400'
}`}>
{task.status}
</span>
</summary>
<div className="px-4 pb-4 space-y-3">
{task.error && (
<div className="bg-red-500/10 border border-red-500/20 rounded p-3">
<p className="text-sm text-red-400 font-medium mb-1">Error:</p>
<pre className="text-sm text-red-300 whitespace-pre-wrap">{task.error}</pre>
</div>
)}
{task.request && (
<div>
<p className="text-sm text-slate-400 font-medium mb-1">Request:</p>
<pre className="bg-slate-950 rounded p-3 text-xs text-slate-300 overflow-x-auto max-h-64">
{JSON.stringify(JSON.parse(task.request), null, 2)}
</pre>
</div>
)}
{task.response && (
<div>
<p className="text-sm text-slate-400 font-medium mb-1">Response:</p>
<pre className="bg-slate-950 rounded p-3 text-xs text-slate-300 overflow-x-auto max-h-64">
{JSON.stringify(JSON.parse(task.response), null, 2)}
</pre>
</div>
)}
</div>
</details>
))}
</div> </div>
</div> )}
)}
</div> {tasks.length > 0 && (
<div className="mt-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium">Generation Tasks</h2>
<a
href={`/tasks/${date}`}
className="text-sm text-purple-400 hover:text-purple-300"
>
View all
</a>
</div>
<div className="space-y-2">
{tasks.slice(0, 3).map(task => (
<details key={task.id} className="bg-slate-900 rounded-lg border border-slate-800">
<summary className="p-3 cursor-pointer flex items-center justify-between text-sm">
<div className="flex items-center gap-3">
<span className={`w-2 h-2 rounded-full ${
task.status === 'completed' ? 'bg-green-500' :
task.status === 'failed' ? 'bg-red-500' : 'bg-yellow-500'
}`} />
<span className="font-medium">
{task.provider.charAt(0).toUpperCase() + task.provider.slice(1)}
</span>
</div>
<span className={`text-xs px-2 py-1 rounded ${
task.status === 'completed' ? 'bg-green-500/20 text-green-400' :
task.status === 'failed' ? 'bg-red-500/20 text-red-400' : 'bg-yellow-500/20 text-yellow-400'
}`}>
{task.status}
</span>
</summary>
</details>
))}
</div>
</div>
)}
</div>
</>
); );
} }

View File

@@ -2,11 +2,43 @@ import { useState, useEffect } from 'react';
import { api, Settings } from '../lib/api'; import { api, Settings } from '../lib/api';
import { useTheme } from '../lib/ThemeContext'; import { useTheme } from '../lib/ThemeContext';
interface ProviderSettings {
apiKey?: string;
model?: string;
baseUrl?: string;
}
interface FullSettings extends Settings {
providerSettings?: Record<string, ProviderSettings>;
}
const DEFAULT_MODELS: Record<string, string> = {
groq: 'llama-3.3-70b-versatile',
openai: 'gpt-4o',
anthropic: 'claude-3-5-sonnet-20241022',
ollama: 'llama3.2',
lmstudio: 'local-model',
};
const DEFAULT_BASE_URLS: Record<string, string> = {
ollama: 'http://localhost:11434',
lmstudio: 'http://localhost:1234/v1',
};
export default function SettingsPage() { export default function SettingsPage() {
const [settings, setSettings] = useState<Partial<Settings>>({}); const [settings, setSettings] = useState<FullSettings>({
aiProvider: 'groq',
aiModel: 'llama-3.3-70b-versatile',
journalPrompt: '',
language: 'en',
timezone: 'UTC',
journalContextDays: 10,
});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
useEffect(() => { useEffect(() => {
@@ -17,25 +49,133 @@ export default function SettingsPage() {
setLoading(true); setLoading(true);
const res = await api.getSettings(); const res = await api.getSettings();
if (res.data) { if (res.data) {
setSettings(res.data); const data = res.data as FullSettings;
if (data.providerSettings && typeof data.providerSettings === 'string') {
try {
data.providerSettings = JSON.parse(data.providerSettings);
} catch {
data.providerSettings = {};
}
}
if (!data.providerSettings) {
data.providerSettings = {};
}
setSettings(data);
} }
setLoading(false); setLoading(false);
}; };
const getCurrentProviderSettings = (): ProviderSettings => {
const provider = settings.aiProvider || 'groq';
return settings.providerSettings?.[provider] || {
apiKey: settings.aiApiKey,
model: settings.aiModel,
baseUrl: settings.aiBaseUrl,
};
};
const updateProviderSettings = (updates: Partial<ProviderSettings>) => {
const provider = settings.aiProvider || 'groq';
const current = getCurrentProviderSettings();
const newProviderSettings = {
...settings.providerSettings,
[provider]: { ...current, ...updates },
};
setSettings({
...settings,
providerSettings: newProviderSettings,
aiProvider: provider,
});
};
const handleProviderChange = (provider: string) => {
const currentProvider = settings.aiProvider || 'groq';
const currentSettings = getCurrentProviderSettings();
const newProviderSettings = {
...settings.providerSettings,
[currentProvider]: currentSettings,
};
const newProviderSettingsData = newProviderSettings[provider] || {};
setSettings({
...settings,
aiProvider: provider as FullSettings['aiProvider'],
aiApiKey: newProviderSettingsData.apiKey,
aiModel: newProviderSettingsData.model || DEFAULT_MODELS[provider] || '',
aiBaseUrl: newProviderSettingsData.baseUrl,
providerSettings: newProviderSettings,
});
setTestResult(null);
};
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
setSaved(false); setSaved(false);
await api.updateSettings(settings);
const provider = settings.aiProvider || 'groq';
const currentSettings = getCurrentProviderSettings();
const newProviderSettings = {
...settings.providerSettings,
[provider]: currentSettings,
};
await api.updateSettings({
...settings,
providerSettings: newProviderSettings,
});
setSaving(false); setSaving(false);
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 2000); setTimeout(() => setSaved(false), 2000);
}; };
const handleTest = async () => {
setTesting(true);
setTestResult(null);
const currentSettings = getCurrentProviderSettings();
const provider = settings.aiProvider || 'groq';
try {
const response = await fetch('/api/v1/ai/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('apiKey')}`,
},
body: JSON.stringify({
provider,
apiKey: currentSettings.apiKey,
model: currentSettings.model || DEFAULT_MODELS[provider],
baseUrl: (provider === 'ollama' || provider === 'lmstudio')
? (currentSettings.baseUrl || DEFAULT_BASE_URLS[provider])
: undefined,
}),
});
const data = await response.json();
if (data.data?.valid) {
setTestResult({ success: true, message: 'Connection successful!' });
} else {
setTestResult({ success: false, message: data.error?.message || 'Connection failed' });
}
} catch (err) {
setTestResult({ success: false, message: 'Connection failed: ' + (err instanceof Error ? err.message : 'Unknown error') });
}
setTesting(false);
};
const handleLogout = () => { const handleLogout = () => {
api.clearApiKey(); api.clearApiKey();
window.location.href = '/login'; window.location.href = '/login';
}; };
const currentProviderSettings = getCurrentProviderSettings();
const provider = settings.aiProvider || 'groq';
if (loading) { if (loading) {
return ( return (
<div className="max-w-2xl mx-auto p-4"> <div className="max-w-2xl mx-auto p-4">
@@ -96,10 +236,11 @@ export default function SettingsPage() {
<div> <div>
<label className="block text-sm text-slate-400 mb-1">Provider</label> <label className="block text-sm text-slate-400 mb-1">Provider</label>
<select <select
value={settings.aiProvider || 'openai'} value={provider}
onChange={(e) => setSettings({ ...settings, aiProvider: e.target.value as Settings['aiProvider'] })} onChange={(e) => handleProviderChange(e.target.value)}
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none" className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none"
> >
<option value="groq">Groq (Free, Fast)</option>
<option value="openai">OpenAI (GPT-4)</option> <option value="openai">OpenAI (GPT-4)</option>
<option value="anthropic">Anthropic (Claude)</option> <option value="anthropic">Anthropic (Claude)</option>
<option value="ollama">Ollama (Local)</option> <option value="ollama">Ollama (Local)</option>
@@ -107,27 +248,27 @@ export default function SettingsPage() {
</select> </select>
</div> </div>
{(settings.aiProvider === 'openai' || settings.aiProvider === 'anthropic') && ( {(provider === 'openai' || provider === 'anthropic' || provider === 'groq') && (
<div> <div>
<label className="block text-sm text-slate-400 mb-1">API Key</label> <label className="block text-sm text-slate-400 mb-1">API Key</label>
<input <input
type="password" type="password"
value={settings.aiApiKey || ''} value={currentProviderSettings.apiKey || ''}
onChange={(e) => setSettings({ ...settings, aiApiKey: e.target.value })} onChange={(e) => updateProviderSettings({ apiKey: e.target.value })}
placeholder="sk-..." placeholder={provider === 'groq' ? 'gsk_...' : 'sk-...'}
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none" className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none"
/> />
</div> </div>
)} )}
{(settings.aiProvider === 'ollama' || settings.aiProvider === 'lmstudio') && ( {(provider === 'ollama' || provider === 'lmstudio') && (
<div> <div>
<label className="block text-sm text-slate-400 mb-1">Base URL</label> <label className="block text-sm text-slate-400 mb-1">Base URL</label>
<input <input
type="text" type="text"
value={settings.aiBaseUrl || ''} value={currentProviderSettings.baseUrl || ''}
onChange={(e) => setSettings({ ...settings, aiBaseUrl: e.target.value })} onChange={(e) => updateProviderSettings({ baseUrl: e.target.value })}
placeholder={settings.aiProvider === 'ollama' ? 'http://localhost:11434' : 'http://localhost:1234/v1'} placeholder={DEFAULT_BASE_URLS[provider]}
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none" className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none"
/> />
</div> </div>
@@ -137,12 +278,28 @@ export default function SettingsPage() {
<label className="block text-sm text-slate-400 mb-1">Model</label> <label className="block text-sm text-slate-400 mb-1">Model</label>
<input <input
type="text" type="text"
value={settings.aiModel || ''} value={currentProviderSettings.model || ''}
onChange={(e) => setSettings({ ...settings, aiModel: e.target.value })} onChange={(e) => updateProviderSettings({ model: e.target.value })}
placeholder={settings.aiProvider === 'openai' ? 'gpt-4' : settings.aiProvider === 'anthropic' ? 'claude-3-sonnet-20240229' : 'llama3.2'} placeholder={DEFAULT_MODELS[provider]}
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none" className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none"
/> />
</div> </div>
<div className="flex gap-3 items-center">
<button
onClick={handleTest}
disabled={testing}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm transition disabled:opacity-50"
>
{testing ? 'Testing...' : 'Test Connection'}
</button>
{testResult && (
<span className={`text-sm ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
{testResult.message}
</span>
)}
</div>
</div> </div>
</section> </section>
@@ -176,6 +333,53 @@ export default function SettingsPage() {
<option value="zh"></option> <option value="zh"></option>
</select> </select>
</div> </div>
<div>
<label className="block text-sm text-slate-400 mb-1">Timezone</label>
<select
value={settings.timezone || 'UTC'}
onChange={(e) => setSettings({ ...settings, timezone: e.target.value })}
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none"
>
<option value="Pacific/Honolulu">Hawaii (HST)</option>
<option value="America/Anchorage">Alaska (AKST)</option>
<option value="America/Los_Angeles">Pacific (PST/PDT)</option>
<option value="America/Denver">Mountain (MST/MDT)</option>
<option value="America/Chicago">Central (CST/CDT)</option>
<option value="America/New_York">Eastern (EST/EDT)</option>
<option value="America/Sao_Paulo">São Paulo (BRT)</option>
<option value="Atlantic/Azores">Azores (AZOT)</option>
<option value="UTC">UTC</option>
<option value="Europe/London">London (GMT/BST)</option>
<option value="Europe/Paris">Paris (CET/CEST)</option>
<option value="Europe/Berlin">Berlin (CET/CEST)</option>
<option value="Africa/Cairo">Cairo (EET)</option>
<option value="Europe/Moscow">Moscow (MSK)</option>
<option value="Asia/Dubai">Dubai (GST)</option>
<option value="Asia/Kolkata">India (IST)</option>
<option value="Asia/Bangkok">Bangkok (ICT)</option>
<option value="Asia/Shanghai">Beijing (CST)</option>
<option value="Asia/Tokyo">Tokyo (JST)</option>
<option value="Australia/Sydney">Sydney (AEST/AEDT)</option>
<option value="Pacific/Auckland">Auckland (NZST)</option>
</select>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">Include Previous Journals in Context</label>
<select
value={settings.journalContextDays || 10}
onChange={(e) => setSettings({ ...settings, journalContextDays: parseInt(e.target.value) })}
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none"
>
<option value="0">None - only today's entries</option>
<option value="3">Last 3 days</option>
<option value="7">Last 7 days</option>
<option value="14">Last 14 days</option>
<option value="30">Last 30 days</option>
</select>
<p className="text-xs text-slate-500 mt-1">AI will see recent journal summaries to maintain context</p>
</div>
</div> </div>
</section> </section>

View File

@@ -0,0 +1,171 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { api, Task } from '../lib/api';
export default function TasksPage() {
const { date } = useParams<{ date: string }>();
const navigate = useNavigate();
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [expandedTask, setExpandedTask] = useState<string | null>(null);
useEffect(() => {
if (date) {
loadTasks();
}
}, [date]);
const loadTasks = async () => {
if (!date) return;
setLoading(true);
const res = await api.getJournalTasks(date);
if (res.data) {
setTasks(res.data);
}
setLoading(false);
};
const formatDate = (dateStr: string) => {
const d = new Date(dateStr + 'T12:00:00');
return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
const prettyJson = (str: string | undefined) => {
if (!str) return null;
try {
return JSON.stringify(JSON.parse(str), null, 2);
} catch {
return str;
}
};
if (!date) return null;
return (
<div className="max-w-4xl mx-auto p-4">
<div className="mb-6">
<button
onClick={() => navigate(`/journal/${date}`)}
className="text-slate-400 hover:text-white text-sm mb-1"
>
Back to journal
</button>
<h1 className="text-2xl font-bold">Generation Tasks</h1>
<p className="text-slate-400">{formatDate(date)}</p>
</div>
{loading ? (
<div className="text-center py-12 text-slate-400">Loading...</div>
) : tasks.length === 0 ? (
<div className="text-center py-12">
<p className="text-slate-400 mb-4">No tasks yet</p>
<button
onClick={() => navigate(`/journal/${date}`)}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm"
>
Generate Journal
</button>
</div>
) : (
<div className="space-y-4">
{tasks.map(task => (
<div
key={task.id}
className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden"
>
<button
onClick={() => setExpandedTask(expandedTask === task.id ? null : task.id)}
className="w-full p-4 flex items-center justify-between text-left"
>
<div className="flex items-center gap-4">
<span className={`w-3 h-3 rounded-full ${
task.status === 'completed' ? 'bg-green-500' :
task.status === 'failed' ? 'bg-red-500' : 'bg-yellow-500 animate-pulse'
}`} />
<div>
<p className="font-medium">
{task.provider.charAt(0).toUpperCase() + task.provider.slice(1)}
{task.model && `${task.model}`}
</p>
<p className="text-sm text-slate-400">
{formatTime(task.createdAt)}
{task.completedAt && `${formatTime(task.completedAt)}`}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`text-xs px-3 py-1 rounded-full ${
task.status === 'completed' ? 'bg-green-500/20 text-green-400' :
task.status === 'failed' ? 'bg-red-500/20 text-red-400' : 'bg-yellow-500/20 text-yellow-400'
}`}>
{task.status}
</span>
<span className="text-slate-400">
{expandedTask === task.id ? '▲' : '▼'}
</span>
</div>
</button>
{expandedTask === task.id && (
<div className="border-t border-slate-800 p-4 space-y-4">
{task.error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4">
<p className="text-sm font-medium text-red-400 mb-2">Error</p>
<pre className="text-sm text-red-300 whitespace-pre-wrap">{task.error}</pre>
</div>
)}
{task.prompt && (
<div>
<p className="text-sm font-medium text-slate-400 mb-2">System Prompt</p>
<div className="bg-slate-950 rounded-lg p-3 max-h-40 overflow-y-auto">
<pre className="text-xs text-slate-300 whitespace-pre-wrap">{task.prompt}</pre>
</div>
</div>
)}
{task.request && (
<div>
<p className="text-sm font-medium text-slate-400 mb-2">Request</p>
<div className="bg-slate-950 rounded-lg p-3 max-h-64 overflow-y-auto">
<pre className="text-xs text-slate-300 whitespace-pre-wrap font-mono">
{prettyJson(task.request)}
</pre>
</div>
</div>
)}
{task.response && (
<div>
<p className="text-sm font-medium text-slate-400 mb-2">Response</p>
<details className="bg-slate-950 rounded-lg">
<summary className="p-3 cursor-pointer text-sm text-slate-400 hover:text-slate-300">
View full response
</summary>
<pre className="px-3 pb-3 text-xs text-slate-300 whitespace-pre-wrap font-mono overflow-x-auto">
{prettyJson(task.response)}
</pre>
</details>
</div>
)}
{!task.error && !task.request && !task.response && (
<p className="text-sm text-slate-500 italic">No details available</p>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/EntryInput.tsx","./src/components/EntryList.tsx","./src/lib/ThemeContext.tsx","./src/lib/api.ts","./src/pages/Auth.tsx","./src/pages/Day.tsx","./src/pages/History.tsx","./src/pages/Home.tsx","./src/pages/Journal.tsx","./src/pages/Settings.tsx"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/EntryInput.tsx","./src/components/EntryList.tsx","./src/lib/ThemeContext.tsx","./src/lib/api.ts","./src/pages/Auth.tsx","./src/pages/Day.tsx","./src/pages/History.tsx","./src/pages/Home.tsx","./src/pages/Journal.tsx","./src/pages/Settings.tsx","./src/pages/Tasks.tsx"],"version":"5.9.3"}

View File

@@ -1,8 +1,40 @@
#!/bin/sh #!/bin/sh
set -e set -e
echo "Running database migrations..." # Check if database exists and has data
bunx prisma db push --accept-data-loss if [ -f /data/deardiary.db ]; then
# Database exists - ensure migration tracking exists
bun -e "
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient({ datasourceUrl: 'file:/data/deardiary.db' });
async function main() {
try {
const tables = await prisma.\$queryRaw\`SELECT name FROM sqlite_master WHERE type='table' AND name='_prisma_migrations'\`;
if (tables.length === 0) {
await prisma.\$executeRaw\`
CREATE TABLE _prisma_migrations (
id TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
finished_at DATETIME,
migration_name TEXT NOT NULL,
logs TEXT,
rolled_back_at DATETIME,
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
applied_steps_count INTEGER NOT NULL DEFAULT 0
)
\`;
await prisma.\$executeRaw\`INSERT INTO _prisma_migrations (id, checksum, finished_at, migration_name, applied_steps_count) VALUES (lower(hex(randomblob(16))), 'baseline', datetime('now'), '00000000000000_init', 1)\`;
console.log('Migration table created');
}
} catch (e) {
console.log('Migration check done');
}
await prisma.\$disconnect();
}
main();
"
fi
echo "Starting server..." echo "Starting server..."
nginx -g 'daemon off;' & nginx -g 'daemon off;' &