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:
@@ -1,6 +1,124 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
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() {
|
||||
const { date } = useParams<{ date: string }>();
|
||||
@@ -8,6 +126,10 @@ export default function JournalPage() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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(() => {
|
||||
if (date) {
|
||||
@@ -36,13 +158,23 @@ export default function JournalPage() {
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!date) return;
|
||||
|
||||
setGenerating(true);
|
||||
setGeneratingStep(1);
|
||||
setCurrentProvider('ai');
|
||||
setCurrentModel('');
|
||||
setCurrentResponse(undefined);
|
||||
|
||||
const res = await api.generateJournal(date);
|
||||
setGeneratingStep(3);
|
||||
if (res.data) {
|
||||
setCurrentResponse(res.data.task.response);
|
||||
setJournal(res.data.journal);
|
||||
setCurrentProvider(res.data.task.provider);
|
||||
setCurrentModel(res.data.task.model || '');
|
||||
setTasks(prev => [res.data!.task, ...prev]);
|
||||
}
|
||||
setGenerating(false);
|
||||
setTimeout(() => setGenerating(false), 500);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
@@ -53,104 +185,100 @@ export default function JournalPage() {
|
||||
if (!date) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-4">
|
||||
<div className="mb-6">
|
||||
<a href={`/day/${date}`} className="text-slate-400 hover:text-white text-sm mb-1 inline-block">← Back to day</a>
|
||||
<h1 className="text-2xl font-bold">Journal</h1>
|
||||
<p className="text-slate-400">{formatDate(date)}</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">Loading...</div>
|
||||
) : !journal ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-400 mb-4">No journal generated yet</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>
|
||||
<>
|
||||
<GeneratingModal
|
||||
isOpen={generating}
|
||||
provider={currentProvider}
|
||||
model={currentModel}
|
||||
step={generatingStep}
|
||||
response={currentResponse}
|
||||
/>
|
||||
|
||||
<div className="max-w-4xl mx-auto p-4">
|
||||
<div className="mb-6">
|
||||
<a href={`/day/${date}`} className="text-slate-400 hover:text-white text-sm mb-1 inline-block">← Back to day</a>
|
||||
<h1 className="text-2xl font-bold">Journal</h1>
|
||||
<p className="text-slate-400">{formatDate(date)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-sm text-slate-400 mb-4">
|
||||
Generated {new Date(journal.generatedAt).toLocaleString()} • {journal.entryCount} entries
|
||||
</div>
|
||||
<div className="prose prose-invert prose-slate max-w-none">
|
||||
<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">
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">Loading...</div>
|
||||
) : !journal || journal.content === 'Generating...' ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-400 mb-4">No journal generated yet</p>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tasks.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-medium mb-4">Generation History</h2>
|
||||
<div className="space-y-3">
|
||||
{tasks.map(task => (
|
||||
<details key={task.id} className="bg-slate-900 rounded-lg border border-slate-800">
|
||||
<summary className="p-4 cursor-pointer flex items-center justify-between">
|
||||
<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)}
|
||||
{task.model && ` - ${task.model}`}
|
||||
</span>
|
||||
<span className="text-sm text-slate-400">
|
||||
{new Date(task.createdAt).toLocaleString()}
|
||||
</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>
|
||||
<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 className="text-sm text-slate-400 mb-4">
|
||||
Generated {new Date(journal.generatedAt).toLocaleString()} • {journal.entryCount} entries
|
||||
</div>
|
||||
<div className="prose prose-invert prose-slate max-w-none">
|
||||
<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
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition disabled:opacity-50"
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
<a
|
||||
href={`/tasks/${date}`}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition"
|
||||
>
|
||||
View Tasks
|
||||
</a>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user