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:
27
CHANGELOG.md
27
CHANGELOG.md
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ private val DarkColorScheme = darkColorScheme(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TotalRecallTheme(
|
fun DearDiaryTheme(
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "TotalRecall"
|
rootProject.name = "DearDiary"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
103
backend/prisma/migrations/00000000000000_init.sql
Normal file
103
backend/prisma/migrations/00000000000000_init.sql
Normal 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);
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: systemPrompt },
|
|
||||||
{ role: 'user', content: userPrompt }
|
|
||||||
],
|
|
||||||
temperature: 0.7,
|
|
||||||
max_tokens: 2000,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
const aiProvider = createAIProvider({
|
||||||
method: 'POST',
|
provider: provider as 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq',
|
||||||
headers: {
|
apiKey: settings?.aiApiKey || '',
|
||||||
'Content-Type': 'application/json',
|
model: settings?.aiModel || undefined,
|
||||||
'Authorization': `Bearer ${settings?.aiApiKey}`,
|
baseUrl: (provider === 'ollama' || provider === 'lmstudio') ? settings?.aiBaseUrl || undefined : undefined,
|
||||||
},
|
});
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
});
|
|
||||||
|
|
||||||
responseBody = await response.json();
|
console.log(`[Journal Generate] AI Provider created: ${aiProvider.provider}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
content = await aiProvider.generate(userPrompt, systemPrompt);
|
||||||
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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
66
backend/src/services/ai/groq.ts
Normal file
66
backend/src/services/ai/groq.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
} />
|
} />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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="max-w-4xl mx-auto p-4">
|
||||||
<div className="text-center py-12 text-slate-400">Loading...</div>
|
<div className="mb-6">
|
||||||
) : !journal ? (
|
<a href={`/day/${date}`} className="text-slate-400 hover:text-white text-sm mb-1 inline-block">← Back to day</a>
|
||||||
<div className="text-center py-12">
|
<h1 className="text-2xl font-bold">Journal</h1>
|
||||||
<p className="text-slate-400 mb-4">No journal generated yet</p>
|
<p className="text-slate-400">{formatDate(date)}</p>
|
||||||
<button
|
|
||||||
onClick={handleGenerate}
|
|
||||||
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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
171
frontend/src/pages/Tasks.tsx
Normal file
171
frontend/src/pages/Tasks.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
36
start.sh
36
start.sh
@@ -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;' &
|
||||||
|
|||||||
Reference in New Issue
Block a user