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:
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1e293b" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>TotalRecall - AI Journal</title>
|
||||
<title>DearDiary - AI Journal</title>
|
||||
</head>
|
||||
<body>
|
||||
<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",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "totalrecall-frontend",
|
||||
"name": "deardiary-frontend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "totalrecall-frontend",
|
||||
"name": "deardiary-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "TotalRecall",
|
||||
"short_name": "TotalRecall",
|
||||
"name": "DearDiary",
|
||||
"short_name": "DearDiary",
|
||||
"description": "AI-powered daily journal that captures life through multiple input methods",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
|
||||
@@ -6,6 +6,7 @@ import Home from './pages/Home';
|
||||
import History from './pages/History';
|
||||
import Day from './pages/Day';
|
||||
import Journal from './pages/Journal';
|
||||
import Tasks from './pages/Tasks';
|
||||
import Settings from './pages/Settings';
|
||||
import { useTheme } from './lib/ThemeContext';
|
||||
|
||||
@@ -88,6 +89,9 @@ function App() {
|
||||
<Route path="/journal/:date" element={
|
||||
<PrivateRoute><Journal /></PrivateRoute>
|
||||
} />
|
||||
<Route path="/tasks/:date" element={
|
||||
<PrivateRoute><Tasks /></PrivateRoute>
|
||||
} />
|
||||
<Route path="/settings" element={
|
||||
<PrivateRoute><Settings /></PrivateRoute>
|
||||
} />
|
||||
|
||||
@@ -184,12 +184,19 @@ export interface Task {
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
aiProvider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio';
|
||||
aiProvider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq';
|
||||
aiApiKey?: string;
|
||||
aiModel: string;
|
||||
aiBaseUrl?: string;
|
||||
journalPrompt: string;
|
||||
language: string;
|
||||
timezone: string;
|
||||
journalContextDays: number;
|
||||
providerSettings?: Record<string, {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function Auth({ onAuth }: { onAuth: () => void }) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<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="flex gap-4 mb-6">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,43 @@ import { useState, useEffect } from 'react';
|
||||
import { api, Settings } from '../lib/api';
|
||||
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() {
|
||||
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 [saving, setSaving] = 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();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -17,25 +49,133 @@ export default function SettingsPage() {
|
||||
setLoading(true);
|
||||
const res = await api.getSettings();
|
||||
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);
|
||||
};
|
||||
|
||||
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 () => {
|
||||
setSaving(true);
|
||||
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);
|
||||
setSaved(true);
|
||||
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 = () => {
|
||||
api.clearApiKey();
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
const currentProviderSettings = getCurrentProviderSettings();
|
||||
const provider = settings.aiProvider || 'groq';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-4">
|
||||
@@ -96,10 +236,11 @@ export default function SettingsPage() {
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Provider</label>
|
||||
<select
|
||||
value={settings.aiProvider || 'openai'}
|
||||
onChange={(e) => setSettings({ ...settings, aiProvider: e.target.value as Settings['aiProvider'] })}
|
||||
value={provider}
|
||||
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"
|
||||
>
|
||||
<option value="groq">Groq (Free, Fast)</option>
|
||||
<option value="openai">OpenAI (GPT-4)</option>
|
||||
<option value="anthropic">Anthropic (Claude)</option>
|
||||
<option value="ollama">Ollama (Local)</option>
|
||||
@@ -107,27 +248,27 @@ export default function SettingsPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(settings.aiProvider === 'openai' || settings.aiProvider === 'anthropic') && (
|
||||
{(provider === 'openai' || provider === 'anthropic' || provider === 'groq') && (
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settings.aiApiKey || ''}
|
||||
onChange={(e) => setSettings({ ...settings, aiApiKey: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
value={currentProviderSettings.apiKey || ''}
|
||||
onChange={(e) => updateProviderSettings({ apiKey: e.target.value })}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(settings.aiProvider === 'ollama' || settings.aiProvider === 'lmstudio') && (
|
||||
{(provider === 'ollama' || provider === 'lmstudio') && (
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.aiBaseUrl || ''}
|
||||
onChange={(e) => setSettings({ ...settings, aiBaseUrl: e.target.value })}
|
||||
placeholder={settings.aiProvider === 'ollama' ? 'http://localhost:11434' : 'http://localhost:1234/v1'}
|
||||
value={currentProviderSettings.baseUrl || ''}
|
||||
onChange={(e) => updateProviderSettings({ baseUrl: e.target.value })}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -137,12 +278,28 @@ export default function SettingsPage() {
|
||||
<label className="block text-sm text-slate-400 mb-1">Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.aiModel || ''}
|
||||
onChange={(e) => setSettings({ ...settings, aiModel: e.target.value })}
|
||||
placeholder={settings.aiProvider === 'openai' ? 'gpt-4' : settings.aiProvider === 'anthropic' ? 'claude-3-sonnet-20240229' : 'llama3.2'}
|
||||
value={currentProviderSettings.model || ''}
|
||||
onChange={(e) => updateProviderSettings({ model: e.target.value })}
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -176,6 +333,53 @@ export default function SettingsPage() {
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
</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>
|
||||
</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"}
|
||||
Reference in New Issue
Block a user