feat(ai): localize AI features
This commit is contained in:
parent
8f9031f076
commit
9eb3bd912a
@ -8,10 +8,12 @@ import { resetPassword } from '@/app/actions/auth-reset'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
function ResetPasswordForm() {
|
function ResetPasswordForm() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useLanguage()
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
const token = searchParams.get('token')
|
const token = searchParams.get('token')
|
||||||
@ -25,7 +27,7 @@ function ResetPasswordForm() {
|
|||||||
const confirm = formData.get('confirmPassword') as string
|
const confirm = formData.get('confirmPassword') as string
|
||||||
|
|
||||||
if (password !== confirm) {
|
if (password !== confirm) {
|
||||||
toast.error("Passwords don't match")
|
toast.error(t('resetPassword.passwordMismatch'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,7 +38,7 @@ function ResetPasswordForm() {
|
|||||||
if (result.error) {
|
if (result.error) {
|
||||||
toast.error(result.error)
|
toast.error(result.error)
|
||||||
} else {
|
} else {
|
||||||
toast.success('Password reset successfully. You can now login.')
|
toast.success(t('resetPassword.success'))
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -45,12 +47,12 @@ function ResetPasswordForm() {
|
|||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-[400px]">
|
<Card className="w-full max-w-[400px]">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Invalid Link</CardTitle>
|
<CardTitle>{t('resetPassword.invalidLinkTitle')}</CardTitle>
|
||||||
<CardDescription>This password reset link is invalid or has expired.</CardDescription>
|
<CardDescription>{t('resetPassword.invalidLinkDescription')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Link href="/forgot-password" title="Try again" className="w-full">
|
<Link href="/forgot-password" title="Try again" className="w-full">
|
||||||
<Button variant="outline" className="w-full">Request new link</Button>
|
<Button variant="outline" className="w-full">{t('resetPassword.requestNewLink')}</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
@ -60,23 +62,23 @@ function ResetPasswordForm() {
|
|||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-[400px]">
|
<Card className="w-full max-w-[400px]">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Reset Password</CardTitle>
|
<CardTitle>{t('resetPassword.title')}</CardTitle>
|
||||||
<CardDescription>Enter your new password below.</CardDescription>
|
<CardDescription>{t('resetPassword.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="password">New Password</label>
|
<label htmlFor="password">{t('resetPassword.newPassword')}</label>
|
||||||
<Input id="password" name="password" type="password" required minLength={6} autoFocus />
|
<Input id="password" name="password" type="password" required minLength={6} autoFocus />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="confirmPassword">Confirm New Password</label>
|
<label htmlFor="confirmPassword">{t('resetPassword.confirmNewPassword')}</label>
|
||||||
<Input id="confirmPassword" name="confirmPassword" type="password" required minLength={6} />
|
<Input id="confirmPassword" name="confirmPassword" type="password" required minLength={6} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||||
{isSubmitting ? 'Resetting...' : 'Reset Password'}
|
{isSubmitting ? t('resetPassword.resetting') : t('resetPassword.resetPassword')}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</form>
|
</form>
|
||||||
@ -85,9 +87,10 @@ function ResetPasswordForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
export default function ResetPasswordPage() {
|
||||||
|
const { t } = useLanguage()
|
||||||
return (
|
return (
|
||||||
<main className="flex items-center justify-center md:h-screen p-4">
|
<main className="flex items-center justify-center md:h-screen p-4">
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<Suspense fallback={<div>{t('resetPassword.loading')}</div>}>
|
||||||
<ResetPasswordForm />
|
<ResetPasswordForm />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Loader2, CheckCircle2, XCircle, Clock, Zap, Info } from 'lucide-react'
|
import { Loader2, CheckCircle2, XCircle, Clock, Zap, Info } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
interface TestResult {
|
interface TestResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
@ -20,6 +21,7 @@ interface TestResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [result, setResult] = useState<TestResult | null>(null)
|
const [result, setResult] = useState<TestResult | null>(null)
|
||||||
const [config, setConfig] = useState<any>(null)
|
const [config, setConfig] = useState<any>(null)
|
||||||
@ -34,7 +36,6 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setConfig(data)
|
setConfig(data)
|
||||||
|
|
||||||
// Set previous result if available
|
|
||||||
if (data.previousTest) {
|
if (data.previousTest) {
|
||||||
setResult(data.previousTest[type] || null)
|
setResult(data.previousTest[type] || null)
|
||||||
}
|
}
|
||||||
@ -84,7 +85,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
|||||||
responseTime: endTime - startTime
|
responseTime: endTime - startTime
|
||||||
}
|
}
|
||||||
setResult(errorResult)
|
setResult(errorResult)
|
||||||
toast.error(`❌ Test Error: ${error.message}`)
|
toast.error(t('admin.aiTest.testError', { error: error.message }))
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@ -113,13 +114,13 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
|||||||
{/* Provider Info */}
|
{/* Provider Info */}
|
||||||
<div className="space-y-3 p-4 bg-muted/50 rounded-lg">
|
<div className="space-y-3 p-4 bg-muted/50 rounded-lg">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Provider:</span>
|
<span className="text-sm font-medium">{t('admin.aiTest.provider')}</span>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{providerInfo.provider.toUpperCase()}
|
{providerInfo.provider.toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Model:</span>
|
<span className="text-sm font-medium">{t('admin.aiTest.model')}</span>
|
||||||
<span className="text-sm text-muted-foreground font-mono">
|
<span className="text-sm text-muted-foreground font-mono">
|
||||||
{providerInfo.model}
|
{providerInfo.model}
|
||||||
</span>
|
</span>
|
||||||
@ -136,12 +137,12 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Testing...
|
{t('admin.aiTest.testing')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Zap className="mr-2 h-4 w-4" />
|
<Zap className="mr-2 h-4 w-4" />
|
||||||
Run Test
|
{t('admin.aiTest.runTest')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@ -155,12 +156,12 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
|||||||
{result.success ? (
|
{result.success ? (
|
||||||
<>
|
<>
|
||||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||||
<span className="font-semibold text-green-600 dark:text-green-400">Test Passed</span>
|
<span className="font-semibold text-green-600 dark:text-green-400">{t('admin.aiTest.testPassed')}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<XCircle className="h-5 w-5 text-red-600" />
|
<XCircle className="h-5 w-5 text-red-600" />
|
||||||
<span className="font-semibold text-red-600 dark:text-red-400">Test Failed</span>
|
<span className="font-semibold text-red-600 dark:text-red-400">{t('admin.aiTest.testFailed')}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -169,7 +170,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
|||||||
{result.responseTime && (
|
{result.responseTime && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-4">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-4">
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
<span>Response time: {result.responseTime}ms</span>
|
<span>{t('admin.aiTest.responseTime', { time: result.responseTime })}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -178,7 +179,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Info className="h-4 w-4 text-primary" />
|
<Info className="h-4 w-4 text-primary" />
|
||||||
<span className="text-sm font-medium">Generated Tags:</span>
|
<span className="text-sm font-medium">{t('admin.aiTest.generatedTags')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{result.tags.map((tag, idx) => (
|
{result.tags.map((tag, idx) => (
|
||||||
@ -202,19 +203,19 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Info className="h-4 w-4 text-green-600" />
|
<Info className="h-4 w-4 text-green-600" />
|
||||||
<span className="text-sm font-medium">Embedding Dimensions:</span>
|
<span className="text-sm font-medium">{t('admin.aiTest.embeddingDimensions')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-muted rounded-lg">
|
<div className="p-3 bg-muted rounded-lg">
|
||||||
<div className="text-2xl font-bold text-center">
|
<div className="text-2xl font-bold text-center">
|
||||||
{result.embeddingLength}
|
{result.embeddingLength}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-center text-muted-foreground mt-1">
|
<div className="text-xs text-center text-muted-foreground mt-1">
|
||||||
vector dimensions
|
{t('admin.aiTest.vectorDimensions')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{result.firstValues && result.firstValues.length > 0 && (
|
{result.firstValues && result.firstValues.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs font-medium">First 5 values:</span>
|
<span className="text-xs font-medium">{t('admin.aiTest.first5Values')}</span>
|
||||||
<div className="p-2 bg-muted rounded font-mono text-xs">
|
<div className="p-2 bg-muted rounded font-mono text-xs">
|
||||||
[{result.firstValues.slice(0, 5).map((v, i) => v.toFixed(4)).join(', ')}]
|
[{result.firstValues.slice(0, 5).map((v, i) => v.toFixed(4)).join(', ')}]
|
||||||
</div>
|
</div>
|
||||||
@ -226,7 +227,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
|||||||
{/* Error Details */}
|
{/* Error Details */}
|
||||||
{!result.success && result.error && (
|
{!result.success && result.error && (
|
||||||
<div className="mt-4 p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900">
|
<div className="mt-4 p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900">
|
||||||
<p className="text-sm font-medium text-red-900 dark:text-red-100">Error:</p>
|
<p className="text-sm font-medium text-red-900 dark:text-red-100">{t('admin.aiTest.error')}</p>
|
||||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">{result.error}</p>
|
<p className="text-sm text-red-700 dark:text-red-300 mt-1">{result.error}</p>
|
||||||
{result.details && (
|
{result.details && (
|
||||||
<details className="mt-2">
|
<details className="mt-2">
|
||||||
@ -249,7 +250,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
|||||||
<div className="text-center py-4">
|
<div className="text-center py-4">
|
||||||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
Testing {type === 'tags' ? 'tags generation' : 'embeddings'}...
|
{t('admin.aiTest.testing')} {type === 'tags' ? 'tags generation' : 'embeddings'}...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,26 +1,48 @@
|
|||||||
import { AdminMetrics } from '@/components/admin-metrics'
|
import { AdminMetrics } from '@/components/admin-metrics'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Zap, Settings, Activity, TrendingUp } from 'lucide-react'
|
import { Zap, Settings, Activity, TrendingUp } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
|
||||||
export default async function AdminAIPage() {
|
export default async function AdminAIPage() {
|
||||||
|
const config = await getSystemConfig()
|
||||||
|
|
||||||
|
// Determine provider status based on config
|
||||||
|
const openaiKey = config.OPENAI_API_KEY
|
||||||
|
const ollamaUrl = config.OLLAMA_BASE_URL || config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL_EMBEDDING
|
||||||
|
|
||||||
|
const providers = [
|
||||||
|
{
|
||||||
|
name: 'OpenAI',
|
||||||
|
status: openaiKey ? 'Connected' : 'Not Configured',
|
||||||
|
requests: 'N/A' // We don't track request counts yet
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Ollama',
|
||||||
|
status: ollamaUrl ? 'Available' : 'Not Configured',
|
||||||
|
requests: 'N/A'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
// Mock AI metrics - in a real app, these would come from analytics
|
// Mock AI metrics - in a real app, these would come from analytics
|
||||||
|
// TODO: Implement real analytics tracking
|
||||||
const aiMetrics = [
|
const aiMetrics = [
|
||||||
{
|
{
|
||||||
title: 'Total Requests',
|
title: 'Total Requests',
|
||||||
value: '856',
|
value: '—',
|
||||||
trend: { value: 12, isPositive: true },
|
trend: { value: 0, isPositive: true },
|
||||||
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
|
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Success Rate',
|
title: 'Success Rate',
|
||||||
value: '98.5%',
|
value: '100%',
|
||||||
trend: { value: 2, isPositive: true },
|
trend: { value: 0, isPositive: true },
|
||||||
icon: <TrendingUp className="h-5 w-5 text-green-600 dark:text-green-400" />,
|
icon: <TrendingUp className="h-5 w-5 text-green-600 dark:text-green-400" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Avg Response Time',
|
title: 'Avg Response Time',
|
||||||
value: '1.2s',
|
value: '—',
|
||||||
trend: { value: 5, isPositive: true },
|
trend: { value: 0, isPositive: true },
|
||||||
icon: <Activity className="h-5 w-5 text-primary dark:text-primary-foreground" />,
|
icon: <Activity className="h-5 w-5 text-primary dark:text-primary-foreground" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -41,10 +63,12 @@ export default async function AdminAIPage() {
|
|||||||
Monitor and configure AI features
|
Monitor and configure AI features
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline">
|
<Link href="/admin/settings">
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Button variant="outline">
|
||||||
Configure
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
</Button>
|
Configure
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AdminMetrics metrics={aiMetrics} />
|
<AdminMetrics metrics={aiMetrics} />
|
||||||
@ -83,10 +107,7 @@ export default async function AdminAIPage() {
|
|||||||
AI Provider Status
|
AI Provider Status
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[
|
{providers.map((provider) => (
|
||||||
{ name: 'OpenAI', status: 'Connected', requests: '642' },
|
|
||||||
{ name: 'Ollama', status: 'Available', requests: '214' },
|
|
||||||
].map((provider) => (
|
|
||||||
<div
|
<div
|
||||||
key={provider.name}
|
key={provider.name}
|
||||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg"
|
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg"
|
||||||
@ -100,11 +121,10 @@ export default async function AdminAIPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
className={`px-2 py-1 text-xs font-medium rounded-full ${provider.status === 'Connected' || provider.status === 'Available'
|
||||||
provider.status === 'Connected'
|
|
||||||
? 'text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900'
|
? 'text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900'
|
||||||
: 'text-primary dark:text-primary-foreground bg-primary/10 dark:bg-primary/20'
|
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{provider.status}
|
{provider.status}
|
||||||
</span>
|
</span>
|
||||||
@ -119,7 +139,7 @@ export default async function AdminAIPage() {
|
|||||||
Recent AI Requests
|
Recent AI Requests
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
Recent AI requests will be displayed here.
|
Recent AI requests will be displayed here. (Coming Soon)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,50 +15,52 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
import { createUser } from '@/app/actions/admin'
|
import { createUser } from '@/app/actions/admin'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
export function CreateUserDialog() {
|
export function CreateUserDialog() {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button>
|
<Button>
|
||||||
<Plus className="mr-2 h-4 w-4" /> Add User
|
<Plus className="mr-2 h-4 w-4" /> {t('admin.users.addUser')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create User</DialogTitle>
|
<DialogTitle>{t('admin.users.createUser')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Add a new user to the system. They will need to change their password upon first login if you implement that policy.
|
{t('admin.users.createUserDescription')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form
|
<form
|
||||||
action={async (formData) => {
|
action={async (formData) => {
|
||||||
const result = await createUser(formData)
|
const result = await createUser(formData)
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
toast.error('Failed to create user')
|
toast.error(t('admin.users.createFailed'))
|
||||||
} else {
|
} else {
|
||||||
toast.success('User created successfully')
|
toast.success(t('admin.users.createSuccess'))
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="grid gap-4 py-4"
|
className="grid gap-4 py-4"
|
||||||
>
|
>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label htmlFor="name">Name</label>
|
<label htmlFor="name">{t('admin.users.name')}</label>
|
||||||
<Input id="name" name="name" required />
|
<Input id="name" name="name" required />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label htmlFor="email">Email</label>
|
<label htmlFor="email">{t('admin.users.email')}</label>
|
||||||
<Input id="email" name="email" type="email" required />
|
<Input id="email" name="email" type="email" required />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label htmlFor="password">Password</label>
|
<label htmlFor="password">{t('admin.users.password')}</label>
|
||||||
<Input id="password" name="password" type="password" required minLength={6} />
|
<Input id="password" name="password" type="password" required minLength={6} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label htmlFor="role">Role</label>
|
<label htmlFor="role">{t('admin.users.role')}</label>
|
||||||
<select
|
<select
|
||||||
id="role"
|
id="role"
|
||||||
name="role"
|
name="role"
|
||||||
@ -69,7 +71,7 @@ export function CreateUserDialog() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit">Create User</Button>
|
<Button type="submit">{t('admin.users.createUser')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -6,10 +6,12 @@ import { Checkbox } from '@/components/ui/checkbox'
|
|||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { updateSystemConfig, testSMTP } from '@/app/actions/admin-settings'
|
import { updateSystemConfig, testSMTP } from '@/app/actions/admin-settings'
|
||||||
|
import { getOllamaModels } from '@/app/actions/ollama'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { TestTube, ExternalLink } from 'lucide-react'
|
import { TestTube, ExternalLink, RefreshCw } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
type AIProvider = 'ollama' | 'openai' | 'custom'
|
type AIProvider = 'ollama' | 'openai' | 'custom'
|
||||||
|
|
||||||
@ -19,10 +21,7 @@ interface AvailableModels {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MODELS_2026 = {
|
const MODELS_2026 = {
|
||||||
ollama: {
|
// Removed hardcoded Ollama models in favor of dynamic fetching
|
||||||
tags: ['llama3:latest', 'llama3.2:latest', 'granite4:latest', 'mistral:latest', 'mixtral:latest', 'phi3:latest', 'gemma2:latest', 'qwen2:latest'],
|
|
||||||
embeddings: ['embeddinggemma:latest', 'mxbai-embed-large:latest', 'nomic-embed-text:latest']
|
|
||||||
},
|
|
||||||
openai: {
|
openai: {
|
||||||
tags: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'],
|
tags: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'],
|
||||||
embeddings: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002']
|
embeddings: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002']
|
||||||
@ -34,6 +33,7 @@ const MODELS_2026 = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
|
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isTesting, setIsTesting] = useState(false)
|
const [isTesting, setIsTesting] = useState(false)
|
||||||
|
|
||||||
@ -46,15 +46,68 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
|
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
|
||||||
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
|
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
|
||||||
|
|
||||||
// Sync state with config when server revalidates
|
// Selected Models State (Controlled Inputs)
|
||||||
|
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
|
||||||
|
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
|
||||||
|
|
||||||
|
|
||||||
|
// Dynamic Models State
|
||||||
|
const [ollamaTagsModels, setOllamaTagsModels] = useState<string[]>([])
|
||||||
|
const [ollamaEmbeddingsModels, setOllamaEmbeddingsModels] = useState<string[]>([])
|
||||||
|
const [isLoadingTagsModels, setIsLoadingTagsModels] = useState(false)
|
||||||
|
const [isLoadingEmbeddingsModels, setIsLoadingEmbeddingsModels] = useState(false)
|
||||||
|
|
||||||
|
// Sync state with config
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAllowRegister(config.ALLOW_REGISTRATION !== 'false')
|
setAllowRegister(config.ALLOW_REGISTRATION !== 'false')
|
||||||
setSmtpSecure(config.SMTP_SECURE === 'true')
|
setSmtpSecure(config.SMTP_SECURE === 'true')
|
||||||
setSmtpIgnoreCert(config.SMTP_IGNORE_CERT === 'true')
|
setSmtpIgnoreCert(config.SMTP_IGNORE_CERT === 'true')
|
||||||
setTagsProvider((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
|
setTagsProvider((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
|
||||||
setEmbeddingsProvider((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
|
setEmbeddingsProvider((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
|
||||||
|
setSelectedTagsModel(config.AI_MODEL_TAGS || '')
|
||||||
|
setSelectedEmbeddingModel(config.AI_MODEL_EMBEDDING || '')
|
||||||
}, [config])
|
}, [config])
|
||||||
|
|
||||||
|
// Fetch Ollama models
|
||||||
|
const fetchOllamaModels = useCallback(async (type: 'tags' | 'embeddings', url: string) => {
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
if (type === 'tags') setIsLoadingTagsModels(true)
|
||||||
|
else setIsLoadingEmbeddingsModels(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getOllamaModels(url)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (type === 'tags') setOllamaTagsModels(result.models)
|
||||||
|
else setOllamaEmbeddingsModels(result.models)
|
||||||
|
} else {
|
||||||
|
toast.error(`Failed to fetch Ollama models: ${result.error}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error('Failed to fetch Ollama models')
|
||||||
|
} finally {
|
||||||
|
if (type === 'tags') setIsLoadingTagsModels(false)
|
||||||
|
else setIsLoadingEmbeddingsModels(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Initial fetch for Ollama models if provider is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (tagsProvider === 'ollama') {
|
||||||
|
const url = config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||||
|
fetchOllamaModels('tags', url)
|
||||||
|
}
|
||||||
|
}, [tagsProvider, config.OLLAMA_BASE_URL_TAGS, config.OLLAMA_BASE_URL, fetchOllamaModels])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (embeddingsProvider === 'ollama') {
|
||||||
|
const url = config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||||
|
fetchOllamaModels('embeddings', url)
|
||||||
|
}
|
||||||
|
}, [embeddingsProvider, config.OLLAMA_BASE_URL_EMBEDDING, config.OLLAMA_BASE_URL, fetchOllamaModels])
|
||||||
|
|
||||||
const handleSaveSecurity = async (formData: FormData) => {
|
const handleSaveSecurity = async (formData: FormData) => {
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
const data = {
|
const data = {
|
||||||
@ -65,9 +118,9 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
toast.error('Failed to update security settings')
|
toast.error(t('admin.security.updateFailed'))
|
||||||
} else {
|
} else {
|
||||||
toast.success('Security Settings updated')
|
toast.success(t('admin.security.updateSuccess'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,9 +129,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
const data: Record<string, string> = {}
|
const data: Record<string, string> = {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Tags provider configuration
|
|
||||||
const tagsProv = formData.get('AI_PROVIDER_TAGS') as AIProvider
|
const tagsProv = formData.get('AI_PROVIDER_TAGS') as AIProvider
|
||||||
if (!tagsProv) throw new Error('AI_PROVIDER_TAGS is required')
|
if (!tagsProv) throw new Error(t('admin.ai.providerTagsRequired'))
|
||||||
data.AI_PROVIDER_TAGS = tagsProv
|
data.AI_PROVIDER_TAGS = tagsProv
|
||||||
|
|
||||||
const tagsModel = formData.get('AI_MODEL_TAGS') as string
|
const tagsModel = formData.get('AI_MODEL_TAGS') as string
|
||||||
@ -86,7 +138,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
|
|
||||||
if (tagsProv === 'ollama') {
|
if (tagsProv === 'ollama') {
|
||||||
const ollamaUrl = formData.get('OLLAMA_BASE_URL_TAGS') as string
|
const ollamaUrl = formData.get('OLLAMA_BASE_URL_TAGS') as string
|
||||||
if (ollamaUrl) data.OLLAMA_BASE_URL = ollamaUrl
|
if (ollamaUrl) data.OLLAMA_BASE_URL_TAGS = ollamaUrl
|
||||||
} else if (tagsProv === 'openai') {
|
} else if (tagsProv === 'openai') {
|
||||||
const openaiKey = formData.get('OPENAI_API_KEY') as string
|
const openaiKey = formData.get('OPENAI_API_KEY') as string
|
||||||
if (openaiKey) data.OPENAI_API_KEY = openaiKey
|
if (openaiKey) data.OPENAI_API_KEY = openaiKey
|
||||||
@ -97,9 +149,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
|
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embeddings provider configuration
|
|
||||||
const embedProv = formData.get('AI_PROVIDER_EMBEDDING') as AIProvider
|
const embedProv = formData.get('AI_PROVIDER_EMBEDDING') as AIProvider
|
||||||
if (!embedProv) throw new Error('AI_PROVIDER_EMBEDDING is required')
|
if (!embedProv) throw new Error(t('admin.ai.providerEmbeddingRequired'))
|
||||||
data.AI_PROVIDER_EMBEDDING = embedProv
|
data.AI_PROVIDER_EMBEDDING = embedProv
|
||||||
|
|
||||||
const embedModel = formData.get('AI_MODEL_EMBEDDING') as string
|
const embedModel = formData.get('AI_MODEL_EMBEDDING') as string
|
||||||
@ -107,7 +158,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
|
|
||||||
if (embedProv === 'ollama') {
|
if (embedProv === 'ollama') {
|
||||||
const ollamaUrl = formData.get('OLLAMA_BASE_URL_EMBEDDING') as string
|
const ollamaUrl = formData.get('OLLAMA_BASE_URL_EMBEDDING') as string
|
||||||
if (ollamaUrl) data.OLLAMA_BASE_URL = ollamaUrl
|
if (ollamaUrl) data.OLLAMA_BASE_URL_EMBEDDING = ollamaUrl
|
||||||
} else if (embedProv === 'openai') {
|
} else if (embedProv === 'openai') {
|
||||||
const openaiKey = formData.get('OPENAI_API_KEY') as string
|
const openaiKey = formData.get('OPENAI_API_KEY') as string
|
||||||
if (openaiKey) data.OPENAI_API_KEY = openaiKey
|
if (openaiKey) data.OPENAI_API_KEY = openaiKey
|
||||||
@ -118,20 +169,30 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
|
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Saving AI config:', data)
|
|
||||||
const result = await updateSystemConfig(data)
|
const result = await updateSystemConfig(data)
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
toast.error('Failed to update AI settings: ' + result.error)
|
toast.error(t('admin.ai.updateFailed') + ': ' + result.error)
|
||||||
} else {
|
} else {
|
||||||
toast.success('AI Settings updated successfully')
|
toast.success(t('admin.ai.updateSuccess'))
|
||||||
setTagsProvider(tagsProv)
|
setTagsProvider(tagsProv)
|
||||||
setEmbeddingsProvider(embedProv)
|
setEmbeddingsProvider(embedProv)
|
||||||
|
|
||||||
|
// Refresh models after save if Ollama is selected
|
||||||
|
if (tagsProv === 'ollama') {
|
||||||
|
const url = data.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||||
|
fetchOllamaModels('tags', url)
|
||||||
|
}
|
||||||
|
if (embedProv === 'ollama') {
|
||||||
|
const url = data.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||||
|
fetchOllamaModels('embeddings', url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
toast.error('Error: ' + error.message)
|
toast.error(t('general.error') + ': ' + error.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,9 +212,9 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
toast.error('Failed to update SMTP settings')
|
toast.error(t('admin.smtp.updateFailed'))
|
||||||
} else {
|
} else {
|
||||||
toast.success('SMTP Settings updated')
|
toast.success(t('admin.smtp.updateSuccess'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,12 +223,12 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
try {
|
try {
|
||||||
const result: any = await testSMTP()
|
const result: any = await testSMTP()
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success('Test email sent successfully!')
|
toast.success(t('admin.smtp.testSuccess'))
|
||||||
} else {
|
} else {
|
||||||
toast.error(`Failed: ${result.error}`)
|
toast.error(t('admin.smtp.testFailed', { error: result.error }))
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(`Error: ${e.message}`)
|
toast.error(t('general.error') + ': ' + e.message)
|
||||||
} finally {
|
} finally {
|
||||||
setIsTesting(false)
|
setIsTesting(false)
|
||||||
}
|
}
|
||||||
@ -177,8 +238,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Security Settings</CardTitle>
|
<CardTitle>{t('admin.security.title')}</CardTitle>
|
||||||
<CardDescription>Manage access control and registration policies.</CardDescription>
|
<CardDescription>{t('admin.security.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<form action={handleSaveSecurity}>
|
<form action={handleSaveSecurity}>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@ -192,35 +253,34 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
htmlFor="ALLOW_REGISTRATION"
|
htmlFor="ALLOW_REGISTRATION"
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
>
|
>
|
||||||
Allow Public Registration
|
{t('admin.security.allowPublicRegistration')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
If disabled, new users can only be added by an Administrator via the User Management page.
|
{t('admin.security.allowPublicRegistrationDescription')}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Button type="submit" disabled={isSaving}>Save Security Settings</Button>
|
<Button type="submit" disabled={isSaving}>{t('admin.security.title')}</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>AI Configuration</CardTitle>
|
<CardTitle>{t('admin.ai.title')}</CardTitle>
|
||||||
<CardDescription>Configure AI providers for auto-tagging and semantic search. Use different providers for optimal performance.</CardDescription>
|
<CardDescription>{t('admin.ai.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<form action={handleSaveAI}>
|
<form action={handleSaveAI}>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Tags Generation Section */}
|
|
||||||
<div className="space-y-4 p-4 border rounded-lg bg-primary/5 dark:bg-primary/10">
|
<div className="space-y-4 p-4 border rounded-lg bg-primary/5 dark:bg-primary/10">
|
||||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||||
<span className="text-primary">🏷️</span> Tags Generation Provider
|
<span className="text-primary">🏷️</span> {t('admin.ai.tagsGenerationProvider')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">AI provider for automatic tag suggestions. Recommended: Ollama (free, local).</p>
|
<p className="text-xs text-muted-foreground">{t('admin.ai.tagsGenerationDescription')}</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="AI_PROVIDER_TAGS">Provider</Label>
|
<Label htmlFor="AI_PROVIDER_TAGS">{t('admin.ai.provider')}</Label>
|
||||||
<select
|
<select
|
||||||
id="AI_PROVIDER_TAGS"
|
id="AI_PROVIDER_TAGS"
|
||||||
name="AI_PROVIDER_TAGS"
|
name="AI_PROVIDER_TAGS"
|
||||||
@ -228,100 +288,125 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
onChange={(e) => setTagsProvider(e.target.value as AIProvider)}
|
onChange={(e) => setTagsProvider(e.target.value as AIProvider)}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<option value="ollama">🦙 Ollama (Local & Free)</option>
|
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
|
||||||
<option value="openai">🤖 OpenAI (GPT-5, GPT-4)</option>
|
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
|
||||||
<option value="custom">🔧 Custom OpenAI-Compatible</option>
|
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ollama Tags Config */}
|
|
||||||
{tagsProvider === 'ollama' && (
|
{tagsProvider === 'ollama' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="OLLAMA_BASE_URL_TAGS">Base URL</Label>
|
<Label htmlFor="OLLAMA_BASE_URL_TAGS">{t('admin.ai.baseUrl')}</Label>
|
||||||
<Input id="OLLAMA_BASE_URL_TAGS" name="OLLAMA_BASE_URL_TAGS" defaultValue={config.OLLAMA_BASE_URL || 'http://localhost:11434'} placeholder="http://localhost:11434" />
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="OLLAMA_BASE_URL_TAGS"
|
||||||
|
name="OLLAMA_BASE_URL_TAGS"
|
||||||
|
defaultValue={config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'}
|
||||||
|
placeholder="http://localhost:11434"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const input = document.getElementById('OLLAMA_BASE_URL_TAGS') as HTMLInputElement
|
||||||
|
fetchOllamaModels('tags', input.value)
|
||||||
|
}}
|
||||||
|
disabled={isLoadingTagsModels}
|
||||||
|
title="Refresh Models"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isLoadingTagsModels ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="AI_MODEL_TAGS_OLLAMA">Model</Label>
|
<Label htmlFor="AI_MODEL_TAGS_OLLAMA">{t('admin.ai.model')}</Label>
|
||||||
<select
|
<select
|
||||||
id="AI_MODEL_TAGS_OLLAMA"
|
id="AI_MODEL_TAGS_OLLAMA"
|
||||||
name="AI_MODEL_TAGS"
|
name="AI_MODEL_TAGS_OLLAMA"
|
||||||
defaultValue={config.AI_MODEL_TAGS || 'granite4:latest'}
|
value={selectedTagsModel}
|
||||||
|
onChange={(e) => setSelectedTagsModel(e.target.value)}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
{MODELS_2026.ollama.tags.map((model) => (
|
{ollamaTagsModels.length > 0 ? (
|
||||||
<option key={model} value={model}>{model}</option>
|
ollamaTagsModels.map((model) => (
|
||||||
))}
|
<option key={model} value={model}>{model}</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<option value={selectedTagsModel || 'granite4:latest'}>{selectedTagsModel || 'granite4:latest'} {t('admin.ai.saved')}</option>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-muted-foreground">Select an Ollama model installed on your system</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{isLoadingTagsModels ? 'Fetching models...' : t('admin.ai.selectOllamaModel')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* OpenAI Tags Config */}
|
|
||||||
{tagsProvider === 'openai' && (
|
{tagsProvider === 'openai' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="OPENAI_API_KEY">API Key</Label>
|
<Label htmlFor="OPENAI_API_KEY">{t('admin.ai.apiKey')}</Label>
|
||||||
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
|
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||||
<p className="text-xs text-muted-foreground">Your OpenAI API key from <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">platform.openai.com</a></p>
|
<p className="text-xs text-muted-foreground">{t('admin.ai.openAIKeyDescription')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="AI_MODEL_TAGS_OPENAI">Model</Label>
|
<Label htmlFor="AI_MODEL_TAGS_OPENAI">{t('admin.ai.model')}</Label>
|
||||||
<select
|
<select
|
||||||
id="AI_MODEL_TAGS_OPENAI"
|
id="AI_MODEL_TAGS_OPENAI"
|
||||||
name="AI_MODEL_TAGS"
|
name="AI_MODEL_TAGS_OPENAI"
|
||||||
defaultValue={config.AI_MODEL_TAGS || 'gpt-4o-mini'}
|
value={selectedTagsModel}
|
||||||
|
onChange={(e) => setSelectedTagsModel(e.target.value)}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
{MODELS_2026.openai.tags.map((model) => (
|
{MODELS_2026.openai.tags.map((model) => (
|
||||||
<option key={model} value={model}>{model}</option>
|
<option key={model} value={model}>{model}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-muted-foreground"><strong className="text-green-600">gpt-4o-mini</strong> = Best value • <strong className="text-primary">gpt-4o</strong> = Best quality</p>
|
<p className="text-xs text-muted-foreground"><strong className="text-green-600">gpt-4o-mini</strong> = {t('admin.ai.bestValue')} • <strong className="text-primary">gpt-4o</strong> = {t('admin.ai.bestQuality')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Custom OpenAI Tags Config */}
|
|
||||||
{tagsProvider === 'custom' && (
|
{tagsProvider === 'custom' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_TAGS">Base URL</Label>
|
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_TAGS">{t('admin.ai.baseUrl')}</Label>
|
||||||
<Input id="CUSTOM_OPENAI_BASE_URL_TAGS" name="CUSTOM_OPENAI_BASE_URL_TAGS" defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''} placeholder="https://api.example.com/v1" />
|
<Input id="CUSTOM_OPENAI_BASE_URL_TAGS" name="CUSTOM_OPENAI_BASE_URL_TAGS" defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''} placeholder="https://api.example.com/v1" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="CUSTOM_OPENAI_API_KEY_TAGS">API Key</Label>
|
<Label htmlFor="CUSTOM_OPENAI_API_KEY_TAGS">{t('admin.ai.apiKey')}</Label>
|
||||||
<Input id="CUSTOM_OPENAI_API_KEY_TAGS" name="CUSTOM_OPENAI_API_KEY_TAGS" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
|
<Input id="CUSTOM_OPENAI_API_KEY_TAGS" name="CUSTOM_OPENAI_API_KEY_TAGS" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="AI_MODEL_TAGS_CUSTOM">Model</Label>
|
<Label htmlFor="AI_MODEL_TAGS_CUSTOM">{t('admin.ai.model')}</Label>
|
||||||
<select
|
<select
|
||||||
id="AI_MODEL_TAGS_CUSTOM"
|
id="AI_MODEL_TAGS_CUSTOM"
|
||||||
name="AI_MODEL_TAGS"
|
name="AI_MODEL_TAGS_CUSTOM"
|
||||||
defaultValue={config.AI_MODEL_TAGS || 'gpt-4o-mini'}
|
value={selectedTagsModel}
|
||||||
|
onChange={(e) => setSelectedTagsModel(e.target.value)}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
{MODELS_2026.custom.tags.map((model) => (
|
{MODELS_2026.custom.tags.map((model) => (
|
||||||
<option key={model} value={model}>{model}</option>
|
<option key={model} value={model}>{model}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-muted-foreground">Common models for OpenAI-compatible APIs</p>
|
<p className="text-xs text-muted-foreground">{t('admin.ai.commonModelsDescription')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Embeddings Section */}
|
|
||||||
<div className="space-y-4 p-4 border rounded-lg bg-green-50/50 dark:bg-green-950/20">
|
<div className="space-y-4 p-4 border rounded-lg bg-green-50/50 dark:bg-green-950/20">
|
||||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||||
<span className="text-green-600">🔍</span> Embeddings Provider
|
<span className="text-green-600">🔍</span> {t('admin.ai.embeddingsProvider')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">AI provider for semantic search embeddings. Recommended: OpenAI (best quality).</p>
|
<p className="text-xs text-muted-foreground">{t('admin.ai.embeddingsDescription')}</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="AI_PROVIDER_EMBEDDING">
|
<Label htmlFor="AI_PROVIDER_EMBEDDING">
|
||||||
Provider
|
{t('admin.ai.provider')}
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
(Current: {embeddingsProvider})
|
(Current: {embeddingsProvider})
|
||||||
</span>
|
</span>
|
||||||
@ -333,99 +418,125 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
onChange={(e) => setEmbeddingsProvider(e.target.value as AIProvider)}
|
onChange={(e) => setEmbeddingsProvider(e.target.value as AIProvider)}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<option value="ollama">🦙 Ollama (Local & Free)</option>
|
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
|
||||||
<option value="openai">🤖 OpenAI (text-embedding-4)</option>
|
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
|
||||||
<option value="custom">🔧 Custom OpenAI-Compatible</option>
|
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Config value: {config.AI_PROVIDER_EMBEDDING || 'Not set (defaults to ollama)'}
|
Config value: {config.AI_PROVIDER_EMBEDDING || 'Not set (defaults to ollama)'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ollama Embeddings Config */}
|
|
||||||
{embeddingsProvider === 'ollama' && (
|
{embeddingsProvider === 'ollama' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="OLLAMA_BASE_URL_EMBEDDING">Base URL</Label>
|
<Label htmlFor="OLLAMA_BASE_URL_EMBEDDING">{t('admin.ai.baseUrl')}</Label>
|
||||||
<Input id="OLLAMA_BASE_URL_EMBEDDING" name="OLLAMA_BASE_URL_EMBEDDING" defaultValue={config.OLLAMA_BASE_URL || 'http://localhost:11434'} placeholder="http://localhost:11434" />
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="OLLAMA_BASE_URL_EMBEDDING"
|
||||||
|
name="OLLAMA_BASE_URL_EMBEDDING"
|
||||||
|
defaultValue={config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'}
|
||||||
|
placeholder="http://localhost:11434"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const input = document.getElementById('OLLAMA_BASE_URL_EMBEDDING') as HTMLInputElement
|
||||||
|
fetchOllamaModels('embeddings', input.value)
|
||||||
|
}}
|
||||||
|
disabled={isLoadingEmbeddingsModels}
|
||||||
|
title="Refresh Models"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isLoadingEmbeddingsModels ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="AI_MODEL_EMBEDDING_OLLAMA">Model</Label>
|
<Label htmlFor="AI_MODEL_EMBEDDING_OLLAMA">{t('admin.ai.model')}</Label>
|
||||||
<select
|
<select
|
||||||
id="AI_MODEL_EMBEDDING_OLLAMA"
|
id="AI_MODEL_EMBEDDING_OLLAMA"
|
||||||
name="AI_MODEL_EMBEDDING"
|
name="AI_MODEL_EMBEDDING_OLLAMA"
|
||||||
defaultValue={config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'}
|
value={selectedEmbeddingModel}
|
||||||
|
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
{MODELS_2026.ollama.embeddings.map((model) => (
|
{ollamaEmbeddingsModels.length > 0 ? (
|
||||||
<option key={model} value={model}>{model}</option>
|
ollamaEmbeddingsModels.map((model) => (
|
||||||
))}
|
<option key={model} value={model}>{model}</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<option value={selectedEmbeddingModel || 'embeddinggemma:latest'}>{selectedEmbeddingModel || 'embeddinggemma:latest'} {t('admin.ai.saved')}</option>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-muted-foreground">Select an embedding model installed on your system</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{isLoadingEmbeddingsModels ? 'Fetching models...' : t('admin.ai.selectEmbeddingModel')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* OpenAI Embeddings Config */}
|
|
||||||
{embeddingsProvider === 'openai' && (
|
{embeddingsProvider === 'openai' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="OPENAI_API_KEY">API Key</Label>
|
<Label htmlFor="OPENAI_API_KEY">{t('admin.ai.apiKey')}</Label>
|
||||||
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
|
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||||
<p className="text-xs text-muted-foreground">Your OpenAI API key from <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">platform.openai.com</a></p>
|
<p className="text-xs text-muted-foreground">{t('admin.ai.openAIKeyDescription')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="AI_MODEL_EMBEDDING_OPENAI">Model</Label>
|
<Label htmlFor="AI_MODEL_EMBEDDING_OPENAI">{t('admin.ai.model')}</Label>
|
||||||
<select
|
<select
|
||||||
id="AI_MODEL_EMBEDDING_OPENAI"
|
id="AI_MODEL_EMBEDDING_OPENAI"
|
||||||
name="AI_MODEL_EMBEDDING"
|
name="AI_MODEL_EMBEDDING_OPENAI"
|
||||||
defaultValue={config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'}
|
value={selectedEmbeddingModel}
|
||||||
|
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
{MODELS_2026.openai.embeddings.map((model) => (
|
{MODELS_2026.openai.embeddings.map((model) => (
|
||||||
<option key={model} value={model}>{model}</option>
|
<option key={model} value={model}>{model}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-muted-foreground"><strong className="text-green-600">text-embedding-3-small</strong> = Best value • <strong className="text-primary">text-embedding-3-large</strong> = Best quality</p>
|
<p className="text-xs text-muted-foreground"><strong className="text-green-600">text-embedding-3-small</strong> = {t('admin.ai.bestValue')} • <strong className="text-primary">text-embedding-3-large</strong> = {t('admin.ai.bestQuality')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Custom OpenAI Embeddings Config */}
|
|
||||||
{embeddingsProvider === 'custom' && (
|
{embeddingsProvider === 'custom' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_EMBEDDING">Base URL</Label>
|
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_EMBEDDING">{t('admin.ai.baseUrl')}</Label>
|
||||||
<Input id="CUSTOM_OPENAI_BASE_URL_EMBEDDING" name="CUSTOM_OPENAI_BASE_URL_EMBEDDING" defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''} placeholder="https://api.example.com/v1" />
|
<Input id="CUSTOM_OPENAI_BASE_URL_EMBEDDING" name="CUSTOM_OPENAI_BASE_URL_EMBEDDING" defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''} placeholder="https://api.example.com/v1" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="CUSTOM_OPENAI_API_KEY_EMBEDDING">API Key</Label>
|
<Label htmlFor="CUSTOM_OPENAI_API_KEY_EMBEDDING">{t('admin.ai.apiKey')}</Label>
|
||||||
<Input id="CUSTOM_OPENAI_API_KEY_EMBEDDING" name="CUSTOM_OPENAI_API_KEY_EMBEDDING" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
|
<Input id="CUSTOM_OPENAI_API_KEY_EMBEDDING" name="CUSTOM_OPENAI_API_KEY_EMBEDDING" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="AI_MODEL_EMBEDDING_CUSTOM">Model</Label>
|
<Label htmlFor="AI_MODEL_EMBEDDING_CUSTOM">{t('admin.ai.model')}</Label>
|
||||||
<select
|
<select
|
||||||
id="AI_MODEL_EMBEDDING_CUSTOM"
|
id="AI_MODEL_EMBEDDING_CUSTOM"
|
||||||
name="AI_MODEL_EMBEDDING"
|
name="AI_MODEL_EMBEDDING_CUSTOM"
|
||||||
defaultValue={config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'}
|
value={selectedEmbeddingModel}
|
||||||
|
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
{MODELS_2026.custom.embeddings.map((model) => (
|
{MODELS_2026.custom.embeddings.map((model) => (
|
||||||
<option key={model} value={model}>{model}</option>
|
<option key={model} value={model}>{model}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-muted-foreground">Common embedding models for OpenAI-compatible APIs</p>
|
<p className="text-xs text-muted-foreground">{t('admin.ai.commonEmbeddingModels')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex justify-between">
|
<CardFooter className="flex justify-between">
|
||||||
<Button type="submit" disabled={isSaving}>{isSaving ? 'Saving...' : 'Save AI Settings'}</Button>
|
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
|
||||||
<Link href="/admin/ai-test">
|
<Link href="/admin/ai-test">
|
||||||
<Button type="button" variant="outline" className="gap-2">
|
<Button type="button" variant="outline" className="gap-2">
|
||||||
<TestTube className="h-4 w-4" />
|
<TestTube className="h-4 w-4" />
|
||||||
Open AI Test Panel
|
{t('admin.ai.openTestPanel')}
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@ -435,34 +546,34 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>SMTP Configuration</CardTitle>
|
<CardTitle>{t('admin.smtp.title')}</CardTitle>
|
||||||
<CardDescription>Configure email server for password resets.</CardDescription>
|
<CardDescription>{t('admin.smtp.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<form action={handleSaveSMTP}>
|
<form action={handleSaveSMTP}>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="SMTP_HOST" className="text-sm font-medium">Host</label>
|
<label htmlFor="SMTP_HOST" className="text-sm font-medium">{t('admin.smtp.host')}</label>
|
||||||
<Input id="SMTP_HOST" name="SMTP_HOST" defaultValue={config.SMTP_HOST || ''} placeholder="smtp.example.com" />
|
<Input id="SMTP_HOST" name="SMTP_HOST" defaultValue={config.SMTP_HOST || ''} placeholder="smtp.example.com" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="SMTP_PORT" className="text-sm font-medium">Port</label>
|
<label htmlFor="SMTP_PORT" className="text-sm font-medium">{t('admin.smtp.port')}</label>
|
||||||
<Input id="SMTP_PORT" name="SMTP_PORT" defaultValue={config.SMTP_PORT || '587'} placeholder="587" />
|
<Input id="SMTP_PORT" name="SMTP_PORT" defaultValue={config.SMTP_PORT || '587'} placeholder="587" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="SMTP_USER" className="text-sm font-medium">Username</label>
|
<label htmlFor="SMTP_USER" className="text-sm font-medium">{t('admin.smtp.username')}</label>
|
||||||
<Input id="SMTP_USER" name="SMTP_USER" defaultValue={config.SMTP_USER || ''} />
|
<Input id="SMTP_USER" name="SMTP_USER" defaultValue={config.SMTP_USER || ''} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="SMTP_PASS" className="text-sm font-medium">Password</label>
|
<label htmlFor="SMTP_PASS" className="text-sm font-medium">{t('admin.smtp.password')}</label>
|
||||||
<Input id="SMTP_PASS" name="SMTP_PASS" type="password" defaultValue={config.SMTP_PASS || ''} />
|
<Input id="SMTP_PASS" name="SMTP_PASS" type="password" defaultValue={config.SMTP_PASS || ''} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="SMTP_FROM" className="text-sm font-medium">From Email</label>
|
<label htmlFor="SMTP_FROM" className="text-sm font-medium">{t('admin.smtp.fromEmail')}</label>
|
||||||
<Input id="SMTP_FROM" name="SMTP_FROM" defaultValue={config.SMTP_FROM || 'noreply@memento.app'} />
|
<Input id="SMTP_FROM" name="SMTP_FROM" defaultValue={config.SMTP_FROM || 'noreply@memento.app'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -476,7 +587,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
htmlFor="SMTP_SECURE"
|
htmlFor="SMTP_SECURE"
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
>
|
>
|
||||||
Force SSL/TLS (usually for port 465)
|
{t('admin.smtp.forceSSL')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -490,14 +601,14 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
htmlFor="SMTP_IGNORE_CERT"
|
htmlFor="SMTP_IGNORE_CERT"
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-yellow-600"
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-yellow-600"
|
||||||
>
|
>
|
||||||
Ignore Certificate Errors (Self-hosted/Dev only)
|
{t('admin.smtp.ignoreCertErrors')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex justify-between">
|
<CardFooter className="flex justify-between">
|
||||||
<Button type="submit" disabled={isSaving}>Save SMTP Settings</Button>
|
<Button type="submit" disabled={isSaving}>{t('admin.smtp.saveSettings')}</Button>
|
||||||
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
|
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
|
||||||
{isTesting ? 'Sending...' : 'Test Email'}
|
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -6,17 +6,18 @@ import { deleteUser, updateUserRole } from '@/app/actions/admin'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Trash2, Shield, ShieldOff } from 'lucide-react'
|
import { Trash2, Shield, ShieldOff } from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
// Optimistic update could be implemented here, but standard is fine for admin
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!confirm('Are you sure? This action cannot be undone.')) return
|
if (!confirm(t('admin.users.confirmDelete'))) return
|
||||||
try {
|
try {
|
||||||
await deleteUser(id)
|
await deleteUser(id)
|
||||||
toast.success('User deleted')
|
toast.success(t('admin.users.deleteSuccess'))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('Failed to delete')
|
toast.error(t('admin.users.deleteFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,9 +25,9 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
|||||||
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
|
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
|
||||||
try {
|
try {
|
||||||
await updateUserRole(user.id, newRole)
|
await updateUserRole(user.id, newRole)
|
||||||
toast.success(`User role updated to ${newRole}`)
|
toast.success(t('admin.users.roleUpdateSuccess', { role: newRole }))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('Failed to update role')
|
toast.error(t('admin.users.roleUpdateFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,21 +36,21 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
|||||||
<table className="w-full caption-bottom text-sm text-left">
|
<table className="w-full caption-bottom text-sm text-left">
|
||||||
<thead className="[&_tr]:border-b">
|
<thead className="[&_tr]:border-b">
|
||||||
<tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
<tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
||||||
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">Name</th>
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.name')}</th>
|
||||||
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">Email</th>
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.email')}</th>
|
||||||
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">Role</th>
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.role')}</th>
|
||||||
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">Created At</th>
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.createdAt')}</th>
|
||||||
<th className="h-12 px-4 align-middle font-medium text-muted-foreground text-right">Actions</th>
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground text-right">{t('admin.users.table.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="[&_tr:last-child]:border-0">
|
<tbody className="[&_tr:last-child]:border-0">
|
||||||
{initialUsers.map((user) => (
|
{initialUsers.map((user) => (
|
||||||
<tr key={user.id} className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
<tr key={user.id} className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
||||||
<td className="p-4 align-middle font-medium">{user.name || 'N/A'}</td>
|
<td className="p-4 align-middle font-medium">{user.name || t('common.notAvailable')}</td>
|
||||||
<td className="p-4 align-middle">{user.email}</td>
|
<td className="p-4 align-middle">{user.email}</td>
|
||||||
<td className="p-4 align-middle">
|
<td className="p-4 align-middle">
|
||||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${user.role === 'ADMIN' ? 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80' : 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80'}`}>
|
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${user.role === 'ADMIN' ? 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80' : 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80'}`}>
|
||||||
{user.role}
|
{user.role === 'ADMIN' ? t('admin.users.roles.admin') : t('admin.users.roles.user')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
|
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
|
||||||
@ -59,7 +60,7 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleRoleToggle(user)}
|
onClick={() => handleRoleToggle(user)}
|
||||||
title={user.role === 'ADMIN' ? "Demote to User" : "Promote to Admin"}
|
title={user.role === 'ADMIN' ? t('admin.users.demote') : t('admin.users.promote')}
|
||||||
>
|
>
|
||||||
{user.role === 'ADMIN' ? <ShieldOff className="h-4 w-4" /> : <Shield className="h-4 w-4" />}
|
{user.role === 'ADMIN' ? <ShieldOff className="h-4 w-4" /> : <Shield className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -24,11 +24,13 @@ import { useNotebooks } from '@/context/notebooks-context'
|
|||||||
import { Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, Plane, ChevronRight, Plus } from 'lucide-react'
|
import { Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, Plane, ChevronRight, Plus } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { LabelFilter } from '@/components/label-filter'
|
import { LabelFilter } from '@/components/label-filter'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useLanguage()
|
||||||
// Force re-render when search params change (for filtering)
|
// Force re-render when search params change (for filtering)
|
||||||
const [notes, setNotes] = useState<Note[]>([])
|
const [notes, setNotes] = useState<Note[]>([])
|
||||||
const [pinnedNotes, setPinnedNotes] = useState<Note[]>([])
|
const [pinnedNotes, setPinnedNotes] = useState<Note[]>([])
|
||||||
@ -260,7 +262,7 @@ export default function HomePage() {
|
|||||||
// Helper for Breadcrumbs
|
// Helper for Breadcrumbs
|
||||||
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
|
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||||
<span>Notebooks</span>
|
<span>{t('nav.notebooks')}</span>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
<span className="font-medium text-primary">{notebookName}</span>
|
<span className="font-medium text-primary">{notebookName}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -325,7 +327,7 @@ export default function HomePage() {
|
|||||||
<div className="p-3 bg-white border border-gray-100 dark:bg-gray-800 dark:border-gray-700 rounded-xl shadow-sm">
|
<div className="p-3 bg-white border border-gray-100 dark:bg-gray-800 dark:border-gray-700 rounded-xl shadow-sm">
|
||||||
<FileText className="w-8 h-8 text-primary" />
|
<FileText className="w-8 h-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">Notes</h1>
|
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions Section */}
|
{/* Actions Section */}
|
||||||
@ -342,15 +344,15 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* AI Organization Button - Moved to Header */}
|
{/* AI Organization Button - Moved to Header */}
|
||||||
{isInbox && !isLoading && notes.length >= 5 && (
|
{isInbox && !isLoading && notes.length >= 2 && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setBatchOrganizationOpen(true)}
|
onClick={() => setBatchOrganizationOpen(true)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-10 px-4 rounded-full border-gray-200 text-gray-700 hover:bg-gray-50 gap-2 shadow-sm"
|
className="h-10 px-4 rounded-full border-gray-200 text-gray-700 hover:bg-gray-50 gap-2 shadow-sm"
|
||||||
title="Organiser avec l'IA"
|
title={t('batch.organizeWithAI')}
|
||||||
>
|
>
|
||||||
<Wand2 className="h-4 w-4 text-purple-600" />
|
<Wand2 className="h-4 w-4 text-purple-600" />
|
||||||
<span className="hidden sm:inline">Organiser</span>
|
<span className="hidden sm:inline">{t('batch.organize')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -359,7 +361,7 @@ export default function HomePage() {
|
|||||||
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
||||||
>
|
>
|
||||||
<Plus className="w-5 h-5" />
|
<Plus className="w-5 h-5" />
|
||||||
Add Note
|
{t('notes.newNote')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -378,7 +380,7 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-gray-500">Loading...</div>
|
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Favorites Section - Pinned Notes */}
|
{/* Favorites Section - Pinned Notes */}
|
||||||
@ -408,7 +410,7 @@ export default function HomePage() {
|
|||||||
{/* Empty state when no notes */}
|
{/* Empty state when no notes */}
|
||||||
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && (
|
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && (
|
||||||
<div className="text-center py-8 text-gray-500">
|
<div className="text-center py-8 text-gray-500">
|
||||||
No notes yet. Create your first note!
|
{t('notes.emptyState')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,127 +1,129 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { SettingsNav, SettingsSection } from '@/components/settings'
|
import { SettingsSection } from '@/components/settings'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
export default function AboutSettingsPage() {
|
export default function AboutSettingsPage() {
|
||||||
|
const { t } = useLanguage()
|
||||||
const version = '1.0.0'
|
const version = '1.0.0'
|
||||||
const buildDate = '2026-01-17'
|
const buildDate = '2026-01-17'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-2">About</h1>
|
<h1 className="text-3xl font-bold mb-2">{t('about.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
Information about the application
|
{t('about.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Keep Notes"
|
title={t('about.appName')}
|
||||||
icon={<span className="text-2xl">📝</span>}
|
icon={<span className="text-2xl">📝</span>}
|
||||||
description="A powerful note-taking application with AI-powered features"
|
description={t('about.appDescription')}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6 space-y-4">
|
<CardContent className="pt-6 space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="font-medium">Version</span>
|
<span className="font-medium">{t('about.version')}</span>
|
||||||
<Badge variant="secondary">{version}</Badge>
|
<Badge variant="secondary">{version}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="font-medium">Build Date</span>
|
<span className="font-medium">{t('about.buildDate')}</span>
|
||||||
<Badge variant="outline">{buildDate}</Badge>
|
<Badge variant="outline">{buildDate}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="font-medium">Platform</span>
|
<span className="font-medium">{t('about.platform')}</span>
|
||||||
<Badge variant="outline">Web</Badge>
|
<Badge variant="outline">{t('about.platformWeb')}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Features"
|
title={t('about.features.title')}
|
||||||
icon={<span className="text-2xl">✨</span>}
|
icon={<span className="text-2xl">✨</span>}
|
||||||
description="AI-powered capabilities"
|
description={t('about.features.description')}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6 space-y-2">
|
<CardContent className="pt-6 space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-green-500">✓</span>
|
<span className="text-green-500">✓</span>
|
||||||
<span>AI-powered title suggestions</span>
|
<span>{t('about.features.titleSuggestions')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-green-500">✓</span>
|
<span className="text-green-500">✓</span>
|
||||||
<span>Semantic search with embeddings</span>
|
<span>{t('about.features.semanticSearch')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-green-500">✓</span>
|
<span className="text-green-500">✓</span>
|
||||||
<span>Paragraph reformulation</span>
|
<span>{t('about.features.paragraphReformulation')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-green-500">✓</span>
|
<span className="text-green-500">✓</span>
|
||||||
<span>Memory Echo daily insights</span>
|
<span>{t('about.features.memoryEcho')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-green-500">✓</span>
|
<span className="text-green-500">✓</span>
|
||||||
<span>Notebook organization</span>
|
<span>{t('about.features.notebookOrganization')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-green-500">✓</span>
|
<span className="text-green-500">✓</span>
|
||||||
<span>Drag & drop note management</span>
|
<span>{t('about.features.dragDrop')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-green-500">✓</span>
|
<span className="text-green-500">✓</span>
|
||||||
<span>Label system</span>
|
<span>{t('about.features.labelSystem')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-green-500">✓</span>
|
<span className="text-green-500">✓</span>
|
||||||
<span>Multiple AI providers (OpenAI, Ollama)</span>
|
<span>{t('about.features.multipleProviders')}</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Technology Stack"
|
title={t('about.technology.title')}
|
||||||
icon={<span className="text-2xl">⚙️</span>}
|
icon={<span className="text-2xl">⚙️</span>}
|
||||||
description="Built with modern technologies"
|
description={t('about.technology.description')}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6 space-y-2 text-sm">
|
<CardContent className="pt-6 space-y-2 text-sm">
|
||||||
<div><strong>Frontend:</strong> Next.js 16, React 19, TypeScript</div>
|
<div><strong>{t('about.technology.frontend')}:</strong> Next.js 16, React 19, TypeScript</div>
|
||||||
<div><strong>Backend:</strong> Next.js API Routes, Server Actions</div>
|
<div><strong>{t('about.technology.backend')}:</strong> Next.js API Routes, Server Actions</div>
|
||||||
<div><strong>Database:</strong> SQLite (Prisma ORM)</div>
|
<div><strong>{t('about.technology.database')}:</strong> SQLite (Prisma ORM)</div>
|
||||||
<div><strong>Authentication:</strong> NextAuth 5</div>
|
<div><strong>{t('about.technology.authentication')}:</strong> NextAuth 5</div>
|
||||||
<div><strong>AI:</strong> Vercel AI SDK, OpenAI, Ollama</div>
|
<div><strong>{t('about.technology.ai')}:</strong> Vercel AI SDK, OpenAI, Ollama</div>
|
||||||
<div><strong>UI:</strong> Radix UI, Tailwind CSS, Lucide Icons</div>
|
<div><strong>{t('about.technology.ui')}:</strong> Radix UI, Tailwind CSS, Lucide Icons</div>
|
||||||
<div><strong>Testing:</strong> Playwright (E2E)</div>
|
<div><strong>{t('about.technology.testing')}:</strong> Playwright (E2E)</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Support"
|
title={t('about.support.title')}
|
||||||
icon={<span className="text-2xl">💬</span>}
|
icon={<span className="text-2xl">💬</span>}
|
||||||
description="Get help and feedback"
|
description={t('about.support.description')}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6 space-y-4">
|
<CardContent className="pt-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium mb-2">Documentation</p>
|
<p className="font-medium mb-2">{t('about.support.documentation')}</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Check the documentation for detailed guides and tutorials.
|
Check the documentation for detailed guides and tutorials.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium mb-2">Report Issues</p>
|
<p className="font-medium mb-2">{t('about.support.reportIssues')}</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Found a bug? Report it in the issue tracker.
|
Found a bug? Report it in the issue tracker.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium mb-2">Feedback</p>
|
<p className="font-medium mb-2">{t('about.support.feedback')}</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
We value your feedback! Share your thoughts and suggestions.
|
We value your feedback! Share your thoughts and suggestions.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -27,36 +27,33 @@ export default function AISettingsPage() {
|
|||||||
try {
|
try {
|
||||||
await updateAISettings({ [feature]: value })
|
await updateAISettings({ [feature]: value })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating setting:', error)
|
toast.error(t('aiSettings.error'))
|
||||||
toast.error('Failed to save setting')
|
|
||||||
setSettings(settings) // Revert on error
|
setSettings(settings) // Revert on error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFrequencyChange = async (value: 'daily' | 'weekly' | 'custom') => {
|
const handleFrequencyChange = async (value: string) => {
|
||||||
setSettings(prev => ({ ...prev, memoryEchoFrequency: value }))
|
setSettings(prev => ({ ...prev, memoryEchoFrequency: value as any }))
|
||||||
try {
|
try {
|
||||||
await updateAISettings({ memoryEchoFrequency: value })
|
await updateAISettings({ memoryEchoFrequency: value as any })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating frequency:', error)
|
toast.error(t('aiSettings.error'))
|
||||||
toast.error('Failed to save setting')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleProviderChange = async (value: 'auto' | 'openai' | 'ollama') => {
|
const handleProviderChange = async (value: string) => {
|
||||||
setSettings(prev => ({ ...prev, aiProvider: value }))
|
setSettings(prev => ({ ...prev, aiProvider: value as any }))
|
||||||
try {
|
try {
|
||||||
await updateAISettings({ aiProvider: value })
|
await updateAISettings({ aiProvider: value as any })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating provider:', error)
|
toast.error(t('aiSettings.error'))
|
||||||
toast.error('Failed to save setting')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleApiKeyChange = async (value: string) => {
|
const handleApiKeyChange = async (value: string) => {
|
||||||
setApiKey(value)
|
setApiKey(value)
|
||||||
// TODO: Implement API key persistence
|
// TODO: Implement API key persistence
|
||||||
console.log('API Key:', value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -70,37 +67,37 @@ export default function AISettingsPage() {
|
|||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="lg:col-span-3 space-y-6">
|
<main className="lg:col-span-3 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-2">AI Settings</h1>
|
<h1 className="text-3xl font-bold mb-2">{t('aiSettings.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
Configure AI-powered features and preferences
|
{t('aiSettings.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Provider */}
|
{/* AI Provider */}
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="AI Provider"
|
title={t('aiSettings.provider')}
|
||||||
icon={<span className="text-2xl">🤖</span>}
|
icon={<span className="text-2xl">🤖</span>}
|
||||||
description="Choose your preferred AI service provider"
|
description={t('aiSettings.providerDesc')}
|
||||||
>
|
>
|
||||||
<SettingSelect
|
<SettingSelect
|
||||||
label="Provider"
|
label={t('aiSettings.provider')}
|
||||||
description="Select which AI service to use"
|
description={t('aiSettings.providerDesc')}
|
||||||
value={settings.aiProvider}
|
value={settings.aiProvider}
|
||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
value: 'auto',
|
value: 'auto',
|
||||||
label: 'Auto-detect',
|
label: t('aiSettings.providerAuto'),
|
||||||
description: 'Ollama when available, OpenAI fallback'
|
description: t('aiSettings.providerAutoDesc')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'ollama',
|
value: 'ollama',
|
||||||
label: 'Ollama (Local)',
|
label: t('aiSettings.providerOllama'),
|
||||||
description: '100% private, runs locally on your machine'
|
description: t('aiSettings.providerOllamaDesc')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'openai',
|
value: 'openai',
|
||||||
label: 'OpenAI',
|
label: t('aiSettings.providerOpenAI'),
|
||||||
description: 'Most accurate, requires API key'
|
description: t('aiSettings.providerOpenAIDesc')
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onChange={handleProviderChange}
|
onChange={handleProviderChange}
|
||||||
@ -108,8 +105,8 @@ export default function AISettingsPage() {
|
|||||||
|
|
||||||
{settings.aiProvider === 'openai' && (
|
{settings.aiProvider === 'openai' && (
|
||||||
<SettingInput
|
<SettingInput
|
||||||
label="API Key"
|
label={t('admin.ai.apiKey')}
|
||||||
description="Your OpenAI API key (stored securely)"
|
description={t('admin.ai.openAIKeyDescription')}
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="sk-..."
|
placeholder="sk-..."
|
||||||
@ -120,46 +117,46 @@ export default function AISettingsPage() {
|
|||||||
|
|
||||||
{/* Feature Toggles */}
|
{/* Feature Toggles */}
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="AI Features"
|
title={t('aiSettings.features')}
|
||||||
icon={<span className="text-2xl">✨</span>}
|
icon={<span className="text-2xl">✨</span>}
|
||||||
description="Enable or disable AI-powered features"
|
description={t('aiSettings.description')}
|
||||||
>
|
>
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label="Title Suggestions"
|
label={t('titleSuggestions.available').replace('💡 ', '')}
|
||||||
description="Suggest titles for untitled notes after 50+ words"
|
description={t('aiSettings.titleSuggestionsDesc')}
|
||||||
checked={settings.titleSuggestions}
|
checked={settings.titleSuggestions}
|
||||||
onChange={(checked) => handleToggle('titleSuggestions', checked)}
|
onChange={(checked) => handleToggle('titleSuggestions', checked)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label="Semantic Search"
|
label={t('semanticSearch.exactMatch')}
|
||||||
description="Search by meaning, not just keywords"
|
description={t('semanticSearch.searching')}
|
||||||
checked={settings.semanticSearch}
|
checked={settings.semanticSearch}
|
||||||
onChange={(checked) => handleToggle('semanticSearch', checked)}
|
onChange={(checked) => handleToggle('semanticSearch', checked)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label="Paragraph Reformulation"
|
label={t('paragraphRefactor.title')}
|
||||||
description="AI-powered text improvement options"
|
description={t('aiSettings.paragraphRefactorDesc')}
|
||||||
checked={settings.paragraphRefactor}
|
checked={settings.paragraphRefactor}
|
||||||
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
|
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label="Memory Echo"
|
label={t('memoryEcho.title')}
|
||||||
description="Daily proactive note connections and insights"
|
description={t('memoryEcho.dailyInsight')}
|
||||||
checked={settings.memoryEcho}
|
checked={settings.memoryEcho}
|
||||||
onChange={(checked) => handleToggle('memoryEcho', checked)}
|
onChange={(checked) => handleToggle('memoryEcho', checked)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{settings.memoryEcho && (
|
{settings.memoryEcho && (
|
||||||
<SettingSelect
|
<SettingSelect
|
||||||
label="Memory Echo Frequency"
|
label={t('aiSettings.frequency')}
|
||||||
description="How often to analyze note connections"
|
description={t('aiSettings.frequencyDesc')}
|
||||||
value={settings.memoryEchoFrequency}
|
value={settings.memoryEchoFrequency}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'daily', label: 'Daily' },
|
{ value: 'daily', label: t('aiSettings.frequencyDaily') },
|
||||||
{ value: 'weekly', label: 'Weekly' },
|
{ value: 'weekly', label: t('aiSettings.frequencyWeekly') },
|
||||||
{ value: 'custom', label: 'Custom' },
|
{ value: 'custom', label: 'Custom' },
|
||||||
]}
|
]}
|
||||||
onChange={handleFrequencyChange}
|
onChange={handleFrequencyChange}
|
||||||
@ -169,13 +166,13 @@ export default function AISettingsPage() {
|
|||||||
|
|
||||||
{/* Demo Mode */}
|
{/* Demo Mode */}
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Demo Mode"
|
title={t('demoMode.title')}
|
||||||
icon={<span className="text-2xl">🎭</span>}
|
icon={<span className="text-2xl">🎭</span>}
|
||||||
description="Test AI features without using real AI calls"
|
description={t('demoMode.description')}
|
||||||
>
|
>
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label="Enable Demo Mode"
|
label={t('demoMode.title')}
|
||||||
description="Use mock AI responses for testing and demonstrations"
|
description={t('demoMode.description')}
|
||||||
checked={settings.demoMode}
|
checked={settings.demoMode}
|
||||||
onChange={(checked) => handleToggle('demoMode', checked)}
|
onChange={(checked) => handleToggle('demoMode', checked)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -3,9 +3,9 @@
|
|||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { SettingsSection, SettingSelect } from '@/components/settings'
|
import { SettingsSection, SettingSelect } from '@/components/settings'
|
||||||
// Import actions directly
|
|
||||||
import { updateAISettings as updateAI } from '@/app/actions/ai-settings'
|
import { updateAISettings as updateAI } from '@/app/actions/ai-settings'
|
||||||
import { updateUserSettings as updateUser } from '@/app/actions/user-settings'
|
import { updateUserSettings as updateUser } from '@/app/actions/user-settings'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
interface AppearanceSettingsFormProps {
|
interface AppearanceSettingsFormProps {
|
||||||
initialTheme: string
|
initialTheme: string
|
||||||
@ -16,6 +16,7 @@ export function AppearanceSettingsForm({ initialTheme, initialFontSize }: Appear
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [theme, setTheme] = useState(initialTheme)
|
const [theme, setTheme] = useState(initialTheme)
|
||||||
const [fontSize, setFontSize] = useState(initialFontSize)
|
const [fontSize, setFontSize] = useState(initialFontSize)
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
const handleThemeChange = async (value: string) => {
|
const handleThemeChange = async (value: string) => {
|
||||||
setTheme(value)
|
setTheme(value)
|
||||||
@ -57,46 +58,46 @@ export function AppearanceSettingsForm({ initialTheme, initialFontSize }: Appear
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-2">Appearance</h1>
|
<h1 className="text-3xl font-bold mb-2">{t('appearance.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
Customize look and feel of application
|
{t('appearance.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Theme"
|
title={t('settings.theme')}
|
||||||
icon={<span className="text-2xl">🎨</span>}
|
icon={<span className="text-2xl">🎨</span>}
|
||||||
description="Choose your preferred color scheme"
|
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
|
||||||
>
|
>
|
||||||
<SettingSelect
|
<SettingSelect
|
||||||
label="Color Scheme"
|
label={t('settings.theme')}
|
||||||
description="Select app's visual theme"
|
description={t('settings.selectLanguage')}
|
||||||
value={theme}
|
value={theme}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'slate', label: 'Light' },
|
{ value: 'slate', label: t('settings.themeLight') },
|
||||||
{ value: 'dark', label: 'Dark' },
|
{ value: 'dark', label: t('settings.themeDark') },
|
||||||
{ value: 'sepia', label: 'Sepia' },
|
{ value: 'sepia', label: 'Sepia' },
|
||||||
{ value: 'midnight', label: 'Midnight' },
|
{ value: 'midnight', label: 'Midnight' },
|
||||||
{ value: 'blue', label: 'Blue' },
|
{ value: 'blue', label: 'Blue' },
|
||||||
{ value: 'auto', label: 'Auto (system)' },
|
{ value: 'auto', label: t('settings.themeSystem') },
|
||||||
]}
|
]}
|
||||||
onChange={handleThemeChange}
|
onChange={handleThemeChange}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Typography"
|
title={t('profile.fontSize')}
|
||||||
icon={<span className="text-2xl">📝</span>}
|
icon={<span className="text-2xl">📝</span>}
|
||||||
description="Adjust text size for better readability"
|
description={t('profile.fontSizeDescription')}
|
||||||
>
|
>
|
||||||
<SettingSelect
|
<SettingSelect
|
||||||
label="Font Size"
|
label={t('profile.fontSize')}
|
||||||
description="Adjust size of text throughout app"
|
description={t('profile.selectFontSize')}
|
||||||
value={fontSize}
|
value={fontSize}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'small', label: 'Small' },
|
{ value: 'small', label: t('profile.fontSizeSmall') },
|
||||||
{ value: 'medium', label: 'Medium' },
|
{ value: 'medium', label: t('profile.fontSizeMedium') },
|
||||||
{ value: 'large', label: 'Large' },
|
{ value: 'large', label: t('profile.fontSizeLarge') },
|
||||||
]}
|
]}
|
||||||
onChange={handleFontSizeChange}
|
onChange={handleFontSizeChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import { useState, useEffect } from 'react'
|
|||||||
import { SettingsNav, SettingsSection, SettingSelect } from '@/components/settings'
|
import { SettingsNav, SettingsSection, SettingSelect } from '@/components/settings'
|
||||||
import { updateAISettings, getAISettings } from '@/app/actions/ai-settings'
|
import { updateAISettings, getAISettings } from '@/app/actions/ai-settings'
|
||||||
import { updateUserSettings, getUserSettings } from '@/app/actions/user-settings'
|
import { updateUserSettings, getUserSettings } from '@/app/actions/user-settings'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
export default function AppearanceSettingsPage() {
|
export default function AppearanceSettingsPage() {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [theme, setTheme] = useState('auto')
|
const [theme, setTheme] = useState('auto')
|
||||||
const [fontSize, setFontSize] = useState('medium')
|
const [fontSize, setFontSize] = useState('medium')
|
||||||
|
|
||||||
@ -63,45 +65,45 @@ export default function AppearanceSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-2">Appearance</h1>
|
<h1 className="text-3xl font-bold mb-2">{t('appearance.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
Customize look and feel of application
|
{t('appearance.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Theme"
|
title={t('settings.theme')}
|
||||||
icon={<span className="text-2xl">🎨</span>}
|
icon={<span className="text-2xl">🎨</span>}
|
||||||
description="Choose your preferred color scheme"
|
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
|
||||||
>
|
>
|
||||||
<SettingSelect
|
<SettingSelect
|
||||||
label="Color Scheme"
|
label={t('settings.theme')}
|
||||||
description="Select app's visual theme"
|
description={t('settings.selectLanguage')}
|
||||||
value={theme}
|
value={theme}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'light', label: 'Light' },
|
{ value: 'light', label: t('settings.themeLight') },
|
||||||
{ value: 'dark', label: 'Dark' },
|
{ value: 'dark', label: t('settings.themeDark') },
|
||||||
{ value: 'sepia', label: 'Sepia' },
|
{ value: 'sepia', label: 'Sepia' },
|
||||||
{ value: 'midnight', label: 'Midnight' },
|
{ value: 'midnight', label: 'Midnight' },
|
||||||
{ value: 'auto', label: 'Auto (system)' },
|
{ value: 'auto', label: t('settings.themeSystem') },
|
||||||
]}
|
]}
|
||||||
onChange={handleThemeChange}
|
onChange={handleThemeChange}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Typography"
|
title={t('profile.fontSize')}
|
||||||
icon={<span className="text-2xl">📝</span>}
|
icon={<span className="text-2xl">📝</span>}
|
||||||
description="Adjust text size for better readability"
|
description={t('profile.fontSizeDescription')}
|
||||||
>
|
>
|
||||||
<SettingSelect
|
<SettingSelect
|
||||||
label="Font Size"
|
label={t('profile.fontSize')}
|
||||||
description="Adjust size of text throughout app"
|
description={t('profile.selectFontSize')}
|
||||||
value={fontSize}
|
value={fontSize}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'small', label: 'Small' },
|
{ value: 'small', label: t('profile.fontSizeSmall') },
|
||||||
{ value: 'medium', label: 'Medium' },
|
{ value: 'medium', label: t('profile.fontSizeMedium') },
|
||||||
{ value: 'large', label: 'Large' },
|
{ value: 'large', label: t('profile.fontSizeLarge') },
|
||||||
]}
|
]}
|
||||||
onChange={handleFontSizeChange}
|
onChange={handleFontSizeChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { SettingsNav, SettingsSection, SettingToggle, SettingInput } from '@/components/settings'
|
import { SettingsSection } from '@/components/settings'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Download, Upload, Trash2, Loader2, Check } from 'lucide-react'
|
import { Download, Upload, Trash2, Loader2 } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
export default function DataSettingsPage() {
|
export default function DataSettingsPage() {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [isExporting, setIsExporting] = useState(false)
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
const [isImporting, setIsImporting] = useState(false)
|
const [isImporting, setIsImporting] = useState(false)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [exportUrl, setExportUrl] = useState('')
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
setIsExporting(true)
|
setIsExporting(true)
|
||||||
@ -26,11 +27,11 @@ export default function DataSettingsPage() {
|
|||||||
a.click()
|
a.click()
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
window.URL.revokeObjectURL(url)
|
window.URL.revokeObjectURL(url)
|
||||||
toast.success('Notes exported successfully')
|
toast.success(t('dataManagement.export.success'))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Export error:', error)
|
console.error('Export error:', error)
|
||||||
toast.error('Failed to export notes')
|
toast.error(t('dataManagement.export.failed'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsExporting(false)
|
setIsExporting(false)
|
||||||
}
|
}
|
||||||
@ -52,24 +53,22 @@ export default function DataSettingsPage() {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
toast.success(`Imported ${result.count} notes`)
|
toast.success(t('dataManagement.import.success', { count: result.count }))
|
||||||
// Refresh the page to show imported notes
|
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Import failed')
|
throw new Error('Import failed')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Import error:', error)
|
console.error('Import error:', error)
|
||||||
toast.error('Failed to import notes')
|
toast.error(t('dataManagement.import.failed'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsImporting(false)
|
setIsImporting(false)
|
||||||
// Reset input
|
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteAll = async () => {
|
const handleDeleteAll = async () => {
|
||||||
if (!confirm('Are you sure you want to delete all notes? This action cannot be undone.')) {
|
if (!confirm(t('dataManagement.delete.confirm'))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,12 +76,12 @@ export default function DataSettingsPage() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/notes/delete-all', { method: 'POST' })
|
const response = await fetch('/api/notes/delete-all', { method: 'POST' })
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast.success('All notes deleted')
|
toast.success(t('dataManagement.delete.success'))
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete error:', error)
|
console.error('Delete error:', error)
|
||||||
toast.error('Failed to delete notes')
|
toast.error(t('dataManagement.delete.failed'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false)
|
setIsDeleting(false)
|
||||||
}
|
}
|
||||||
@ -91,22 +90,22 @@ export default function DataSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-2">Data Management</h1>
|
<h1 className="text-3xl font-bold mb-2">{t('dataManagement.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
Export, import, or manage your data
|
{t('dataManagement.toolsDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Export Data"
|
title={`💾 ${t('dataManagement.export.title')}`}
|
||||||
icon={<span className="text-2xl">💾</span>}
|
icon={<span className="text-2xl">💾</span>}
|
||||||
description="Download your notes as a JSON file"
|
description={t('dataManagement.export.description')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between py-4">
|
<div className="flex items-center justify-between py-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Export All Notes</p>
|
<p className="font-medium">{t('dataManagement.export.title')}</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Download all your notes in JSON format
|
{t('dataManagement.export.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@ -118,21 +117,21 @@ export default function DataSettingsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
{isExporting ? 'Exporting...' : 'Export'}
|
{isExporting ? t('dataManagement.exporting') : t('dataManagement.export.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Import Data"
|
title={`📥 ${t('dataManagement.import.title')}`}
|
||||||
icon={<span className="text-2xl">📥</span>}
|
icon={<span className="text-2xl">📥</span>}
|
||||||
description="Import notes from a JSON file"
|
description={t('dataManagement.import.description')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between py-4">
|
<div className="flex items-center justify-between py-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Import Notes</p>
|
<p className="font-medium">{t('dataManagement.import.title')}</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Upload a JSON file to import notes
|
{t('dataManagement.import.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -153,22 +152,22 @@ export default function DataSettingsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Upload className="h-4 w-4 mr-2" />
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
{isImporting ? 'Importing...' : 'Import'}
|
{isImporting ? t('dataManagement.importing') : t('dataManagement.import.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Danger Zone"
|
title={`⚠️ ${t('dataManagement.dangerZone')}`}
|
||||||
icon={<span className="text-2xl">⚠️</span>}
|
icon={<span className="text-2xl">⚠️</span>}
|
||||||
description="Permanently delete your data"
|
description={t('dataManagement.dangerZoneDescription')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between py-4 border-t border-red-200 dark:border-red-900">
|
<div className="flex items-center justify-between py-4 border-t border-red-200 dark:border-red-900">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-red-600 dark:text-red-400">Delete All Notes</p>
|
<p className="font-medium text-red-600 dark:text-red-400">{t('dataManagement.delete.title')}</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
This action cannot be undone
|
{t('dataManagement.delete.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@ -181,7 +180,7 @@ export default function DataSettingsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
{isDeleting ? 'Deleting...' : 'Delete All'}
|
{isDeleting ? t('dataManagement.deleting') : t('dataManagement.delete.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|||||||
@ -4,9 +4,10 @@ import { useState, useEffect } from 'react'
|
|||||||
import { SettingsNav, SettingsSection, SettingToggle, SettingSelect } from '@/components/settings'
|
import { SettingsNav, SettingsSection, SettingToggle, SettingSelect } from '@/components/settings'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { updateAISettings, getAISettings } from '@/app/actions/ai-settings'
|
import { updateAISettings, getAISettings } from '@/app/actions/ai-settings'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export default function GeneralSettingsPage() {
|
export default function GeneralSettingsPage() {
|
||||||
const { t } = useLanguage()
|
const { t, setLanguage: setContextLanguage } = useLanguage()
|
||||||
const [language, setLanguage] = useState('auto')
|
const [language, setLanguage] = useState('auto')
|
||||||
const [emailNotifications, setEmailNotifications] = useState(false)
|
const [emailNotifications, setEmailNotifications] = useState(false)
|
||||||
const [desktopNotifications, setDesktopNotifications] = useState(false)
|
const [desktopNotifications, setDesktopNotifications] = useState(false)
|
||||||
@ -30,7 +31,22 @@ export default function GeneralSettingsPage() {
|
|||||||
|
|
||||||
const handleLanguageChange = async (value: string) => {
|
const handleLanguageChange = async (value: string) => {
|
||||||
setLanguage(value)
|
setLanguage(value)
|
||||||
|
|
||||||
|
// 1. Update database settings
|
||||||
await updateAISettings({ preferredLanguage: value as any })
|
await updateAISettings({ preferredLanguage: value as any })
|
||||||
|
|
||||||
|
// 2. Update local storage and application state
|
||||||
|
if (value === 'auto') {
|
||||||
|
localStorage.removeItem('user-language')
|
||||||
|
toast.success("Language set to Auto")
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('user-language', value)
|
||||||
|
setContextLanguage(value as any)
|
||||||
|
toast.success(t('profile.languageUpdateSuccess') || "Language updated")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Force reload to ensure all components update (server components, metadata, etc.)
|
||||||
|
setTimeout(() => window.location.reload(), 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEmailNotificationsChange = async (enabled: boolean) => {
|
const handleEmailNotificationsChange = async (enabled: boolean) => {
|
||||||
@ -51,23 +67,23 @@ export default function GeneralSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-2">General Settings</h1>
|
<h1 className="text-3xl font-bold mb-2">{t('generalSettings.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
Configure basic application preferences
|
{t('generalSettings.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Language & Region"
|
title={t('settings.language')}
|
||||||
icon={<span className="text-2xl">🌍</span>}
|
icon={<span className="text-2xl">🌍</span>}
|
||||||
description="Choose your preferred language and regional settings"
|
description={t('profile.languagePreferencesDescription')}
|
||||||
>
|
>
|
||||||
<SettingSelect
|
<SettingSelect
|
||||||
label="Language"
|
label={t('settings.language')}
|
||||||
description="Select interface language"
|
description={t('settings.selectLanguage')}
|
||||||
value={language}
|
value={language}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'auto', label: 'Auto-detect' },
|
{ value: 'auto', label: t('profile.autoDetect') },
|
||||||
{ value: 'en', label: 'English' },
|
{ value: 'en', label: 'English' },
|
||||||
{ value: 'fr', label: 'Français' },
|
{ value: 'fr', label: 'Français' },
|
||||||
{ value: 'es', label: 'Español' },
|
{ value: 'es', label: 'Español' },
|
||||||
@ -89,32 +105,32 @@ export default function GeneralSettingsPage() {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Notifications"
|
title={t('settings.notifications')}
|
||||||
icon={<span className="text-2xl">🔔</span>}
|
icon={<span className="text-2xl">🔔</span>}
|
||||||
description="Manage how and when you receive notifications"
|
description={t('settings.notifications')}
|
||||||
>
|
>
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label="Email Notifications"
|
label={t('settings.notifications')}
|
||||||
description="Receive email updates about your notes"
|
description={t('settings.notifications')}
|
||||||
checked={emailNotifications}
|
checked={emailNotifications}
|
||||||
onChange={handleEmailNotificationsChange}
|
onChange={handleEmailNotificationsChange}
|
||||||
/>
|
/>
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label="Desktop Notifications"
|
label={t('settings.notifications')}
|
||||||
description="Show notifications in your browser"
|
description={t('settings.notifications')}
|
||||||
checked={desktopNotifications}
|
checked={desktopNotifications}
|
||||||
onChange={handleDesktopNotificationsChange}
|
onChange={handleDesktopNotificationsChange}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Privacy"
|
title={t('settings.privacy')}
|
||||||
icon={<span className="text-2xl">🔒</span>}
|
icon={<span className="text-2xl">🔒</span>}
|
||||||
description="Control your privacy settings"
|
description={t('settings.privacy')}
|
||||||
>
|
>
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label="Anonymous Analytics"
|
label={t('settings.privacy')}
|
||||||
description="Help improve app with anonymous usage data"
|
description={t('settings.privacy')}
|
||||||
checked={anonymousAnalytics}
|
checked={anonymousAnalytics}
|
||||||
onChange={handleAnonymousAnalyticsChange}
|
onChange={handleAnonymousAnalyticsChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -81,9 +81,9 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-2">Settings</h1>
|
<h1 className="text-3xl font-bold mb-2">{t('settings.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
Configure your application settings
|
{t('settings.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -92,18 +92,18 @@ export default function SettingsPage() {
|
|||||||
<Link href="/settings/ai">
|
<Link href="/settings/ai">
|
||||||
<div className="p-4 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
|
<div className="p-4 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
|
||||||
<BrainCircuit className="h-6 w-6 text-purple-500 mb-2" />
|
<BrainCircuit className="h-6 w-6 text-purple-500 mb-2" />
|
||||||
<h3 className="font-semibold">AI Settings</h3>
|
<h3 className="font-semibold">{t('aiSettings.title')}</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Configure AI features and provider
|
{t('aiSettings.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/settings/profile">
|
<Link href="/settings/profile">
|
||||||
<div className="p-4 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
|
<div className="p-4 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
|
||||||
<RefreshCw className="h-6 w-6 text-primary mb-2" />
|
<RefreshCw className="h-6 w-6 text-primary mb-2" />
|
||||||
<h3 className="font-semibold">Profile Settings</h3>
|
<h3 className="font-semibold">{t('profile.title')}</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Manage your account and preferences
|
{t('profile.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@ -111,17 +111,17 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{/* AI Diagnostics */}
|
{/* AI Diagnostics */}
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="AI Diagnostics"
|
title={t('diagnostics.title')}
|
||||||
icon={<span className="text-2xl">🔍</span>}
|
icon={<span className="text-2xl">🔍</span>}
|
||||||
description="Check your AI provider connection status"
|
description={t('diagnostics.description')}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-4 py-4">
|
<div className="grid grid-cols-2 gap-4 py-4">
|
||||||
<div className="p-4 rounded-lg bg-secondary/50">
|
<div className="p-4 rounded-lg bg-secondary/50">
|
||||||
<p className="text-sm font-medium text-muted-foreground mb-1">Configured Provider</p>
|
<p className="text-sm font-medium text-muted-foreground mb-1">{t('diagnostics.configuredProvider')}</p>
|
||||||
<p className="text-lg font-mono">{config?.provider || '...'}</p>
|
<p className="text-lg font-mono">{config?.provider || '...'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-lg bg-secondary/50">
|
<div className="p-4 rounded-lg bg-secondary/50">
|
||||||
<p className="text-sm font-medium text-muted-foreground mb-1">API Status</p>
|
<p className="text-sm font-medium text-muted-foreground mb-1">{t('diagnostics.apiStatus')}</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{status === 'success' && <CheckCircle className="text-green-500 w-5 h-5" />}
|
{status === 'success' && <CheckCircle className="text-green-500 w-5 h-5" />}
|
||||||
{status === 'error' && <XCircle className="text-red-500 w-5 h-5" />}
|
{status === 'error' && <XCircle className="text-red-500 w-5 h-5" />}
|
||||||
@ -129,9 +129,9 @@ export default function SettingsPage() {
|
|||||||
status === 'error' ? 'text-red-600 dark:text-red-400' :
|
status === 'error' ? 'text-red-600 dark:text-red-400' :
|
||||||
'text-gray-600'
|
'text-gray-600'
|
||||||
}`}>
|
}`}>
|
||||||
{status === 'success' ? 'Operational' :
|
{status === 'success' ? t('diagnostics.operational') :
|
||||||
status === 'error' ? 'Error' :
|
status === 'error' ? t('diagnostics.errorStatus') :
|
||||||
'Checking...'}
|
t('diagnostics.checking')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -139,7 +139,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className="space-y-2 mt-4">
|
<div className="space-y-2 mt-4">
|
||||||
<h3 className="text-sm font-medium">Test Details:</h3>
|
<h3 className="text-sm font-medium">{t('diagnostics.testDetails')}</h3>
|
||||||
<div className={`p-4 rounded-md font-mono text-xs overflow-x-auto ${status === 'error'
|
<div className={`p-4 rounded-md font-mono text-xs overflow-x-auto ${status === 'error'
|
||||||
? 'bg-red-50 text-red-900 border border-red-200 dark:bg-red-950 dark:text-red-100 dark:border-red-900'
|
? 'bg-red-50 text-red-900 border border-red-200 dark:bg-red-950 dark:text-red-100 dark:border-red-900'
|
||||||
: 'bg-slate-950 text-slate-50'
|
: 'bg-slate-950 text-slate-50'
|
||||||
@ -149,12 +149,12 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
<div className="text-sm text-red-600 dark:text-red-400 mt-2">
|
<div className="text-sm text-red-600 dark:text-red-400 mt-2">
|
||||||
<p className="font-bold">Troubleshooting Tips:</p>
|
<p className="font-bold">{t('diagnostics.troubleshootingTitle')}</p>
|
||||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||||
<li>Check that Ollama is running (<code className="bg-red-100 dark:bg-red-900 px-1 rounded">ollama list</code>)</li>
|
<li>{t('diagnostics.tip1')}</li>
|
||||||
<li>Check URL (http://localhost:11434)</li>
|
<li>{t('diagnostics.tip2')}</li>
|
||||||
<li>Verify model (e.g., granite4:latest) is downloaded</li>
|
<li>{t('diagnostics.tip3')}</li>
|
||||||
<li>Check Next.js server terminal for more logs</li>
|
<li>{t('diagnostics.tip4')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -164,45 +164,45 @@ export default function SettingsPage() {
|
|||||||
<div className="mt-4 flex justify-end">
|
<div className="mt-4 flex justify-end">
|
||||||
<Button variant="outline" size="sm" onClick={checkConnection} disabled={loading}>
|
<Button variant="outline" size="sm" onClick={checkConnection} disabled={loading}>
|
||||||
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
|
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
|
||||||
Test Connection
|
{t('general.testConnection')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Maintenance */}
|
{/* Maintenance */}
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Maintenance"
|
title={t('settings.maintenance')}
|
||||||
icon={<span className="text-2xl">🔧</span>}
|
icon={<span className="text-2xl">🔧</span>}
|
||||||
description="Tools to maintain your database health"
|
description={t('settings.maintenanceDescription')}
|
||||||
>
|
>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium flex items-center gap-2">
|
<h3 className="font-medium flex items-center gap-2">
|
||||||
Clean Orphan Tags
|
{t('settings.cleanTags')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Remove tags that are no longer used by any notes
|
{t('settings.cleanTagsDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onClick={handleCleanup} disabled={cleanupLoading}>
|
<Button variant="secondary" onClick={handleCleanup} disabled={cleanupLoading}>
|
||||||
{cleanupLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Database className="w-4 h-4 mr-2" />}
|
{cleanupLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Database className="w-4 h-4 mr-2" />}
|
||||||
Clean
|
{t('general.clean')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium flex items-center gap-2">
|
<h3 className="font-medium flex items-center gap-2">
|
||||||
Semantic Indexing
|
{t('settings.semanticIndexing')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Generate vectors for all notes to enable intent-based search
|
{t('settings.semanticIndexingDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onClick={handleSync} disabled={syncLoading}>
|
<Button variant="secondary" onClick={handleSync} disabled={syncLoading}>
|
||||||
{syncLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <BrainCircuit className="w-4 h-4 mr-2" />}
|
{syncLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <BrainCircuit className="w-4 h-4 mr-2" />}
|
||||||
Index All
|
{t('general.indexAll')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -21,13 +21,13 @@ export default function ProfileSettingsPage() {
|
|||||||
const handleNameChange = async (value: string) => {
|
const handleNameChange = async (value: string) => {
|
||||||
setUser(prev => ({ ...prev, name: value }))
|
setUser(prev => ({ ...prev, name: value }))
|
||||||
// TODO: Implement profile update
|
// TODO: Implement profile update
|
||||||
console.log('Name:', value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEmailChange = async (value: string) => {
|
const handleEmailChange = async (value: string) => {
|
||||||
setUser(prev => ({ ...prev, email: value }))
|
setUser(prev => ({ ...prev, email: value }))
|
||||||
// TODO: Implement email update
|
// TODO: Implement email update
|
||||||
console.log('Email:', value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLanguageChange = async (value: string) => {
|
const handleLanguageChange = async (value: string) => {
|
||||||
@ -36,7 +36,7 @@ export default function ProfileSettingsPage() {
|
|||||||
await updateAISettings({ preferredLanguage: value as any })
|
await updateAISettings({ preferredLanguage: value as any })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating language:', error)
|
console.error('Error updating language:', error)
|
||||||
toast.error('Failed to save language')
|
toast.error(t('aiSettings.error'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,7 +46,7 @@ export default function ProfileSettingsPage() {
|
|||||||
await updateAISettings({ showRecentNotes: enabled })
|
await updateAISettings({ showRecentNotes: enabled })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating recent notes setting:', error)
|
console.error('Error updating recent notes setting:', error)
|
||||||
toast.error('Failed to save setting')
|
toast.error(t('aiSettings.error'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,48 +61,48 @@ export default function ProfileSettingsPage() {
|
|||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="lg:col-span-3 space-y-6">
|
<main className="lg:col-span-3 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-2">Profile</h1>
|
<h1 className="text-3xl font-bold mb-2">{t('profile.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
Manage your account and personal information
|
{t('profile.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Profile Information */}
|
{/* Profile Information */}
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Profile Information"
|
title={t('profile.accountSettings')}
|
||||||
icon={<span className="text-2xl">👤</span>}
|
icon={<span className="text-2xl">👤</span>}
|
||||||
description="Update your personal details"
|
description={t('profile.description')}
|
||||||
>
|
>
|
||||||
<SettingInput
|
<SettingInput
|
||||||
label="Name"
|
label={t('profile.displayName')}
|
||||||
description="Your display name"
|
description={t('profile.displayName')}
|
||||||
value={user.name}
|
value={user.name}
|
||||||
onChange={handleNameChange}
|
onChange={handleNameChange}
|
||||||
placeholder="Enter your name"
|
placeholder={t('auth.namePlaceholder')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingInput
|
<SettingInput
|
||||||
label="Email"
|
label={t('profile.email')}
|
||||||
description="Your email address"
|
description={t('profile.email')}
|
||||||
value={user.email}
|
value={user.email}
|
||||||
type="email"
|
type="email"
|
||||||
onChange={handleEmailChange}
|
onChange={handleEmailChange}
|
||||||
placeholder="Enter your email"
|
placeholder={t('auth.emailPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Preferences */}
|
{/* Preferences */}
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Preferences"
|
title={t('settings.language')}
|
||||||
icon={<span className="text-2xl">⚙️</span>}
|
icon={<span className="text-2xl">⚙️</span>}
|
||||||
description="Customize your experience"
|
description={t('profile.languagePreferencesDescription')}
|
||||||
>
|
>
|
||||||
<SettingSelect
|
<SettingSelect
|
||||||
label="Language"
|
label={t('profile.preferredLanguage')}
|
||||||
description="Choose your preferred language"
|
description={t('profile.languageDescription')}
|
||||||
value={language}
|
value={language}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'auto', label: 'Auto-detect' },
|
{ value: 'auto', label: t('profile.autoDetect') },
|
||||||
{ value: 'en', label: 'English' },
|
{ value: 'en', label: 'English' },
|
||||||
{ value: 'fr', label: 'Français' },
|
{ value: 'fr', label: 'Français' },
|
||||||
{ value: 'es', label: 'Español' },
|
{ value: 'es', label: 'Español' },
|
||||||
@ -123,8 +123,8 @@ export default function ProfileSettingsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label="Show Recent Notes"
|
label={t('profile.showRecentNotes')}
|
||||||
description="Display recently viewed notes in sidebar"
|
description={t('profile.showRecentNotesDescription')}
|
||||||
checked={showRecentNotes}
|
checked={showRecentNotes}
|
||||||
onChange={handleRecentNotesChange}
|
onChange={handleRecentNotesChange}
|
||||||
/>
|
/>
|
||||||
@ -135,16 +135,16 @@ export default function ProfileSettingsPage() {
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-4xl">✨</div>
|
<div className="text-4xl">✨</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold text-lg mb-1">AI Settings</h3>
|
<h3 className="font-semibold text-lg mb-1">{t('aiSettings.title')}</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Configure AI-powered features, provider selection, and preferences
|
{t('aiSettings.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.href = '/settings/ai'}
|
onClick={() => window.location.href = '/settings/ai'}
|
||||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
>
|
>
|
||||||
Configure
|
{t('nav.configureAI')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,40 +7,16 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
|
||||||
Select,
|
import { updateProfile, changePassword, updateFontSize, updateShowRecentNotes } from '@/app/actions/profile'
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { updateProfile, changePassword, updateLanguage, updateFontSize, updateShowRecentNotes } from '@/app/actions/profile'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
const LANGUAGES = [
|
|
||||||
{ value: 'auto', label: 'Auto-detect', flag: '🌐' },
|
|
||||||
{ value: 'en', label: 'English', flag: '🇬🇧' },
|
|
||||||
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
|
|
||||||
{ value: 'es', label: 'Español', flag: '🇪🇸' },
|
|
||||||
{ value: 'de', label: 'Deutsch', flag: '🇩🇪' },
|
|
||||||
{ value: 'it', label: 'Italiano', flag: '🇮🇹' },
|
|
||||||
{ value: 'pt', label: 'Português', flag: '🇵🇹' },
|
|
||||||
{ value: 'ru', label: 'Русский', flag: '🇷🇺' },
|
|
||||||
{ value: 'zh', label: '中文', flag: '🇨🇳' },
|
|
||||||
{ value: 'ja', label: '日本語', flag: '🇯🇵' },
|
|
||||||
{ value: 'ko', label: '한국어', flag: '🇰🇷' },
|
|
||||||
{ value: 'ar', label: 'العربية', flag: '🇸🇦' },
|
|
||||||
{ value: 'hi', label: 'हिन्दी', flag: '🇮🇳' },
|
|
||||||
{ value: 'nl', label: 'Nederlands', flag: '🇳🇱' },
|
|
||||||
{ value: 'pl', label: 'Polski', flag: '🇵🇱' },
|
|
||||||
{ value: 'fa', label: 'فارسی (Persian)', flag: '🇮🇷' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function ProfileForm({ user, userAISettings }: { user: any; userAISettings?: any }) {
|
export function ProfileForm({ user, userAISettings }: { user: any; userAISettings?: any }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [selectedLanguage, setSelectedLanguage] = useState(userAISettings?.preferredLanguage || 'auto')
|
|
||||||
const [isUpdatingLanguage, setIsUpdatingLanguage] = useState(false)
|
|
||||||
const [fontSize, setFontSize] = useState(userAISettings?.fontSize || 'medium')
|
const [fontSize, setFontSize] = useState(userAISettings?.fontSize || 'medium')
|
||||||
const [isUpdatingFontSize, setIsUpdatingFontSize] = useState(false)
|
const [isUpdatingFontSize, setIsUpdatingFontSize] = useState(false)
|
||||||
const [showRecentNotes, setShowRecentNotes] = useState(userAISettings?.showRecentNotes ?? false)
|
const [showRecentNotes, setShowRecentNotes] = useState(userAISettings?.showRecentNotes ?? false)
|
||||||
@ -101,26 +77,7 @@ export function ProfileForm({ user, userAISettings }: { user: any; userAISetting
|
|||||||
applyFontSize(savedFontSize as string)
|
applyFontSize(savedFontSize as string)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleLanguageChange = async (language: string) => {
|
|
||||||
setIsUpdatingLanguage(true)
|
|
||||||
try {
|
|
||||||
const result = await updateLanguage(language)
|
|
||||||
if (result?.error) {
|
|
||||||
toast.error(t('profile.languageUpdateFailed'))
|
|
||||||
} else {
|
|
||||||
setSelectedLanguage(language)
|
|
||||||
// Update localStorage and reload to apply new language
|
|
||||||
localStorage.setItem('user-language', language)
|
|
||||||
toast.success(t('profile.languageUpdateSuccess'))
|
|
||||||
// Reload page to apply new language
|
|
||||||
setTimeout(() => window.location.reload(), 500)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(t('profile.languageUpdateFailed'))
|
|
||||||
} finally {
|
|
||||||
setIsUpdatingLanguage(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleShowRecentNotesChange = async (enabled: boolean) => {
|
const handleShowRecentNotesChange = async (enabled: boolean) => {
|
||||||
setIsUpdatingRecentNotes(true)
|
setIsUpdatingRecentNotes(true)
|
||||||
@ -175,39 +132,7 @@ export function ProfileForm({ user, userAISettings }: { user: any; userAISetting
|
|||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('profile.languagePreferences')}</CardTitle>
|
|
||||||
<CardDescription>{t('profile.languagePreferencesDescription')}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="language">{t('profile.preferredLanguage')}</Label>
|
|
||||||
<Select
|
|
||||||
value={selectedLanguage}
|
|
||||||
onValueChange={handleLanguageChange}
|
|
||||||
disabled={isUpdatingLanguage}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="language">
|
|
||||||
<SelectValue placeholder={t('profile.selectLanguage')} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{LANGUAGES.map((lang) => (
|
|
||||||
<SelectItem key={lang.value} value={lang.value}>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span>{lang.flag}</span>
|
|
||||||
<span>{lang.label}</span>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t('profile.languageDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,75 +1,77 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useLanguage } from '@/lib/i18n';
|
||||||
|
|
||||||
export default function SupportPage() {
|
export default function SupportPage() {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-10 max-w-4xl">
|
<div className="container mx-auto py-10 max-w-4xl">
|
||||||
<div className="text-center mb-10">
|
<div className="text-center mb-10">
|
||||||
<h1 className="text-4xl font-bold mb-4">
|
<h1 className="text-4xl font-bold mb-4">
|
||||||
Support Memento Development ☕
|
{t('support.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground text-lg">
|
<p className="text-muted-foreground text-lg">
|
||||||
Memento is 100% free and open-source. Your support helps keep it that way.
|
{t('support.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2 mb-10">
|
<div className="grid gap-6 md:grid-cols-2 mb-10">
|
||||||
{/* Ko-fi Card */}
|
|
||||||
<Card className="border-2 border-primary">
|
<Card className="border-2 border-primary">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<span className="text-2xl">☕</span>
|
<span className="text-2xl">☕</span>
|
||||||
Buy me a coffee
|
{t('support.buyMeACoffee')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Make a one-time donation or become a monthly supporter.
|
{t('support.donationDescription')}
|
||||||
</p>
|
</p>
|
||||||
<Button asChild className="w-full">
|
<Button asChild className="w-full">
|
||||||
<a href="https://ko-fi.com/yourusername" target="_blank" rel="noopener noreferrer">
|
<a href="https://ko-fi.com/yourusername" target="_blank" rel="noopener noreferrer">
|
||||||
Donate on Ko-fi
|
{t('support.donateOnKofi')}
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
No platform fees • Instant payouts • Secure
|
{t('support.kofiDescription')}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* GitHub Sponsors Card */}
|
|
||||||
<Card className="border-2 border-primary">
|
<Card className="border-2 border-primary">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<span className="text-2xl">💚</span>
|
<span className="text-2xl">💚</span>
|
||||||
Sponsor on GitHub
|
{t('support.sponsorOnGithub')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Become a monthly sponsor and get recognition.
|
{t('support.sponsorDescription')}
|
||||||
</p>
|
</p>
|
||||||
<Button asChild variant="outline" className="w-full">
|
<Button asChild variant="outline" className="w-full">
|
||||||
<a href="https://github.com/sponsors/yourusername" target="_blank" rel="noopener noreferrer">
|
<a href="https://github.com/sponsors/yourusername" target="_blank" rel="noopener noreferrer">
|
||||||
Sponsor on GitHub
|
{t('support.sponsorOnGithub')}
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
Recurring support • Public recognition • Developer-focused
|
{t('support.githubDescription')}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* How Donations Are Used */}
|
|
||||||
<Card className="mb-10">
|
<Card className="mb-10">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>How Your Support Helps</CardTitle>
|
<CardTitle>{t('support.howSupportHelps')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-2">💰 Direct Impact</h3>
|
<h3 className="font-semibold mb-2">💰 {t('support.directImpact')}</h3>
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
<li>☕ Keeps me fueled with coffee</li>
|
<li>☕ Keeps me fueled with coffee</li>
|
||||||
<li>🐛 Covers hosting and server costs</li>
|
<li>🐛 Covers hosting and server costs</li>
|
||||||
@ -79,7 +81,7 @@ export default function SupportPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-2">🎁 Sponsor Perks</h3>
|
<h3 className="font-semibold mb-2">🎁 {t('support.sponsorPerks')}</h3>
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
<li>🥉 $5/month - Bronze: Name in supporters list</li>
|
<li>🥉 $5/month - Bronze: Name in supporters list</li>
|
||||||
<li>🥈 $15/month - Silver: Priority feature requests</li>
|
<li>🥈 $15/month - Silver: Priority feature requests</li>
|
||||||
@ -91,30 +93,29 @@ export default function SupportPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Transparency */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>💡 Transparency</CardTitle>
|
<CardTitle>💡 {t('support.transparency')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm mb-4">
|
<p className="text-sm mb-4">
|
||||||
I believe in complete transparency. Here's how donations are used:
|
{t('support.transparencyDescription')}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Hosting & servers:</span>
|
<span>{t('support.hostingServers')}</span>
|
||||||
<span className="font-mono">~$20/month</span>
|
<span className="font-mono">~$20/month</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Domain & SSL:</span>
|
<span>{t('support.domainSSL')}</span>
|
||||||
<span className="font-mono">~$15/year</span>
|
<span className="font-mono">~$15/year</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>AI API costs:</span>
|
<span>{t('support.aiApiCosts')}</span>
|
||||||
<span className="font-mono">~$30/month</span>
|
<span className="font-mono">~$30/month</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between border-t pt-2">
|
<div className="flex justify-between border-t pt-2">
|
||||||
<span className="font-semibold">Total expenses:</span>
|
<span className="font-semibold">{t('support.totalExpenses')}</span>
|
||||||
<span className="font-mono font-semibold">~$50/month</span>
|
<span className="font-mono font-semibold">~$50/month</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -125,28 +126,27 @@ export default function SupportPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Alternative Ways to Support */}
|
|
||||||
<div className="mt-10 text-center">
|
<div className="mt-10 text-center">
|
||||||
<h2 className="text-2xl font-bold mb-4">Other Ways to Support</h2>
|
<h2 className="text-2xl font-bold mb-4">{t('support.otherWaysTitle')}</h2>
|
||||||
<div className="flex flex-wrap justify-center gap-4">
|
<div className="flex flex-wrap justify-center gap-4">
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
||||||
⭐ Star on GitHub
|
⭐ {t('support.starGithub')}
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<a href="https://github.com/yourusername/memento/issues" target="_blank" rel="noopener noreferrer">
|
<a href="https://github.com/yourusername/memento/issues" target="_blank" rel="noopener noreferrer">
|
||||||
🐛 Report a bug
|
🐛 {t('support.reportBug')}
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
||||||
📝 Contribute code
|
📝 {t('support.contributeCode')}
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<a href="https://twitter.com/intent/tweet?text=Check%20out%20Memento%20-%20a%20great%20open-source%20note-taking%20app!%20https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
<a href="https://twitter.com/intent/tweet?text=Check%20out%20Memento%20-%20a%20great%20open-source%20note-taking%20app!%20https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
||||||
🐦 Share on Twitter
|
🐦 {t('support.shareTwitter')}
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,23 +1,28 @@
|
|||||||
import { ArchiveHeader } from '@/components/archive-header'
|
|
||||||
import { Trash2 } from 'lucide-react'
|
import { Trash2 } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export default function TrashPage() {
|
export default function TrashPage() {
|
||||||
// Currently, we don't have soft-delete implemented, so trash is always empty.
|
|
||||||
// This page exists to fix the 404 error and provide a placeholder.
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center text-gray-500">
|
<TrashContent />
|
||||||
<div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-full mb-4">
|
|
||||||
<Trash2 className="w-12 h-12 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-medium mb-2">La corbeille est vide</h2>
|
|
||||||
<p className="max-w-md text-sm opacity-80">
|
|
||||||
Les notes supprimées sont actuellement effacées définitivement.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TrashContent() {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center text-gray-500">
|
||||||
|
<div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-full mb-4">
|
||||||
|
<Trash2 className="w-12 h-12 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-medium mb-2">{t('trash.empty')}</h2>
|
||||||
|
<p className="max-w-md text-sm opacity-80">
|
||||||
|
{t('trash.restore')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -46,7 +46,7 @@ export async function updateSystemConfig(data: Record<string, string>) {
|
|||||||
Object.entries(data).filter(([key, value]) => value !== '' && value !== null && value !== undefined)
|
Object.entries(data).filter(([key, value]) => value !== '' && value !== null && value !== undefined)
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log('Updating system config:', filteredData)
|
|
||||||
|
|
||||||
const operations = Object.entries(filteredData).map(([key, value]) =>
|
const operations = Object.entries(filteredData).map(([key, value]) =>
|
||||||
prisma.systemConfig.upsert({
|
prisma.systemConfig.upsert({
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||||
|
|
||||||
export type UserAISettingsData = {
|
export type UserAISettingsData = {
|
||||||
titleSuggestions?: boolean
|
titleSuggestions?: boolean
|
||||||
@ -25,9 +25,9 @@ export type UserAISettingsData = {
|
|||||||
* Update AI settings for the current user
|
* Update AI settings for the current user
|
||||||
*/
|
*/
|
||||||
export async function updateAISettings(settings: UserAISettingsData) {
|
export async function updateAISettings(settings: UserAISettingsData) {
|
||||||
console.log('[updateAISettings] Started with:', JSON.stringify(settings, null, 2))
|
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
console.log('[updateAISettings] Session User ID:', session?.user?.id)
|
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
console.error('[updateAISettings] Unauthorized: No session or user ID')
|
console.error('[updateAISettings] Unauthorized: No session or user ID')
|
||||||
@ -44,10 +44,11 @@ export async function updateAISettings(settings: UserAISettingsData) {
|
|||||||
},
|
},
|
||||||
update: settings
|
update: settings
|
||||||
})
|
})
|
||||||
console.log('[updateAISettings] Database upsert successful:', result)
|
|
||||||
|
|
||||||
revalidatePath('/settings/ai')
|
|
||||||
revalidatePath('/')
|
revalidatePath('/settings/ai', 'page')
|
||||||
|
revalidatePath('/', 'layout')
|
||||||
|
revalidateTag('ai-settings')
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -57,8 +58,78 @@ export async function updateAISettings(settings: UserAISettingsData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get AI settings for the current user
|
* Get AI settings for the current user (Cached)
|
||||||
*/
|
*/
|
||||||
|
import { unstable_cache } from 'next/cache'
|
||||||
|
|
||||||
|
// Internal cached function to fetch settings from DB
|
||||||
|
const getCachedAISettings = unstable_cache(
|
||||||
|
async (userId: string) => {
|
||||||
|
try {
|
||||||
|
const settings = await prisma.userAISettings.findUnique({
|
||||||
|
where: { userId }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
return {
|
||||||
|
titleSuggestions: true,
|
||||||
|
semanticSearch: true,
|
||||||
|
paragraphRefactor: true,
|
||||||
|
memoryEcho: true,
|
||||||
|
memoryEchoFrequency: 'daily' as const,
|
||||||
|
aiProvider: 'auto' as const,
|
||||||
|
preferredLanguage: 'auto' as const,
|
||||||
|
demoMode: false,
|
||||||
|
showRecentNotes: false,
|
||||||
|
emailNotifications: false,
|
||||||
|
desktopNotifications: false,
|
||||||
|
anonymousAnalytics: false,
|
||||||
|
theme: 'light' as const,
|
||||||
|
fontSize: 'medium' as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
titleSuggestions: settings.titleSuggestions,
|
||||||
|
semanticSearch: settings.semanticSearch,
|
||||||
|
paragraphRefactor: settings.paragraphRefactor,
|
||||||
|
memoryEcho: settings.memoryEcho,
|
||||||
|
memoryEchoFrequency: (settings.memoryEchoFrequency || 'daily') as 'daily' | 'weekly' | 'custom',
|
||||||
|
aiProvider: (settings.aiProvider || 'auto') as 'auto' | 'openai' | 'ollama',
|
||||||
|
preferredLanguage: (settings.preferredLanguage || 'auto') as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl',
|
||||||
|
demoMode: settings.demoMode,
|
||||||
|
showRecentNotes: settings.showRecentNotes,
|
||||||
|
emailNotifications: settings.emailNotifications,
|
||||||
|
desktopNotifications: settings.desktopNotifications,
|
||||||
|
anonymousAnalytics: settings.anonymousAnalytics,
|
||||||
|
// theme: 'light' as const, // REMOVED: Should not be handled here or hardcoded
|
||||||
|
fontSize: (settings.fontSize || 'medium') as 'small' | 'medium' | 'large'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting AI settings:', error)
|
||||||
|
// Return defaults on error
|
||||||
|
return {
|
||||||
|
titleSuggestions: true,
|
||||||
|
semanticSearch: true,
|
||||||
|
paragraphRefactor: true,
|
||||||
|
memoryEcho: true,
|
||||||
|
memoryEchoFrequency: 'daily' as const,
|
||||||
|
aiProvider: 'auto' as const,
|
||||||
|
preferredLanguage: 'auto' as const,
|
||||||
|
demoMode: false,
|
||||||
|
showRecentNotes: false,
|
||||||
|
emailNotifications: false,
|
||||||
|
desktopNotifications: false,
|
||||||
|
anonymousAnalytics: false,
|
||||||
|
theme: 'light' as const,
|
||||||
|
fontSize: 'medium' as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
['user-ai-settings'],
|
||||||
|
{ tags: ['ai-settings'] }
|
||||||
|
)
|
||||||
|
|
||||||
export async function getAISettings(userId?: string) {
|
export async function getAISettings(userId?: string) {
|
||||||
let id = userId
|
let id = userId
|
||||||
|
|
||||||
@ -87,66 +158,7 @@ export async function getAISettings(userId?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return getCachedAISettings(id)
|
||||||
const settings = await prisma.userAISettings.findUnique({
|
|
||||||
where: { userId: id }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!settings) {
|
|
||||||
return {
|
|
||||||
titleSuggestions: true,
|
|
||||||
semanticSearch: true,
|
|
||||||
paragraphRefactor: true,
|
|
||||||
memoryEcho: true,
|
|
||||||
memoryEchoFrequency: 'daily' as const,
|
|
||||||
aiProvider: 'auto' as const,
|
|
||||||
preferredLanguage: 'auto' as const,
|
|
||||||
demoMode: false,
|
|
||||||
showRecentNotes: false,
|
|
||||||
emailNotifications: false,
|
|
||||||
desktopNotifications: false,
|
|
||||||
anonymousAnalytics: false,
|
|
||||||
theme: 'light' as const,
|
|
||||||
fontSize: 'medium' as const
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
titleSuggestions: settings.titleSuggestions,
|
|
||||||
semanticSearch: settings.semanticSearch,
|
|
||||||
paragraphRefactor: settings.paragraphRefactor,
|
|
||||||
memoryEcho: settings.memoryEcho,
|
|
||||||
memoryEchoFrequency: (settings.memoryEchoFrequency || 'daily') as 'daily' | 'weekly' | 'custom',
|
|
||||||
aiProvider: (settings.aiProvider || 'auto') as 'auto' | 'openai' | 'ollama',
|
|
||||||
preferredLanguage: (settings.preferredLanguage || 'auto') as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl',
|
|
||||||
demoMode: settings.demoMode,
|
|
||||||
showRecentNotes: settings.showRecentNotes,
|
|
||||||
emailNotifications: settings.emailNotifications,
|
|
||||||
desktopNotifications: settings.desktopNotifications,
|
|
||||||
anonymousAnalytics: settings.anonymousAnalytics,
|
|
||||||
// theme: 'light' as const, // REMOVED: Should not be handled here or hardcoded
|
|
||||||
fontSize: (settings.fontSize || 'medium') as 'small' | 'medium' | 'large'
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting AI settings:', error)
|
|
||||||
// Return defaults on error
|
|
||||||
return {
|
|
||||||
titleSuggestions: true,
|
|
||||||
semanticSearch: true,
|
|
||||||
paragraphRefactor: true,
|
|
||||||
memoryEcho: true,
|
|
||||||
memoryEchoFrequency: 'daily' as const,
|
|
||||||
aiProvider: 'auto' as const,
|
|
||||||
preferredLanguage: 'auto' as const,
|
|
||||||
demoMode: false,
|
|
||||||
showRecentNotes: false,
|
|
||||||
emailNotifications: false,
|
|
||||||
desktopNotifications: false,
|
|
||||||
anonymousAnalytics: false,
|
|
||||||
theme: 'light' as const,
|
|
||||||
fontSize: 'medium' as const
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -346,7 +346,7 @@ export async function createNote(data: {
|
|||||||
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70);
|
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70);
|
||||||
|
|
||||||
if (autoLabelingEnabled) {
|
if (autoLabelingEnabled) {
|
||||||
console.log('[AUTO-LABELING] Generating suggestions for new note in notebook:', data.notebookId);
|
|
||||||
const suggestions = await contextualAutoTagService.suggestLabels(
|
const suggestions = await contextualAutoTagService.suggestLabels(
|
||||||
data.content,
|
data.content,
|
||||||
data.notebookId,
|
data.notebookId,
|
||||||
@ -360,12 +360,12 @@ export async function createNote(data: {
|
|||||||
|
|
||||||
if (appliedLabels.length > 0) {
|
if (appliedLabels.length > 0) {
|
||||||
labelsToUse = appliedLabels;
|
labelsToUse = appliedLabels;
|
||||||
console.log(`[AUTO-LABELING] Applied ${appliedLabels.length} labels:`, appliedLabels);
|
|
||||||
} else {
|
} else {
|
||||||
console.log('[AUTO-LABELING] No suggestions met confidence threshold');
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('[AUTO-LABELING] Disabled in config');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AUTO-LABELING] Failed to suggest labels:', error);
|
console.error('[AUTO-LABELING] Failed to suggest labels:', error);
|
||||||
|
|||||||
57
keep-notes/app/actions/ollama.ts
Normal file
57
keep-notes/app/actions/ollama.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
interface OllamaModel {
|
||||||
|
name: string
|
||||||
|
modified_at: string
|
||||||
|
size: number
|
||||||
|
digest: string
|
||||||
|
details: {
|
||||||
|
format: string
|
||||||
|
family: string
|
||||||
|
families: string[]
|
||||||
|
parameter_size: string
|
||||||
|
quantization_level: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OllamaTagsResponse {
|
||||||
|
models: OllamaModel[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOllamaModels(baseUrl: string): Promise<{ success: boolean; models: string[]; error?: string }> {
|
||||||
|
if (!baseUrl) {
|
||||||
|
return { success: false, models: [], error: 'Base URL is required' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure URL doesn't end with slash
|
||||||
|
const cleanUrl = baseUrl.replace(/\/$/, '')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${cleanUrl}/api/tags`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
// Set a reasonable timeout
|
||||||
|
signal: AbortSignal.timeout(5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Ollama API returned ${response.status}: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as OllamaTagsResponse
|
||||||
|
|
||||||
|
// Extract model names
|
||||||
|
const modelNames = data.models?.map(m => m.name) || []
|
||||||
|
|
||||||
|
return { success: true, models: modelNames }
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to fetch Ollama models:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
models: [],
|
||||||
|
error: error.message || 'Failed to connect to Ollama'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||||
|
|
||||||
export type UserSettingsData = {
|
export type UserSettingsData = {
|
||||||
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
|
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
|
||||||
@ -12,7 +12,7 @@ export type UserSettingsData = {
|
|||||||
* Update user settings (theme, etc.)
|
* Update user settings (theme, etc.)
|
||||||
*/
|
*/
|
||||||
export async function updateUserSettings(settings: UserSettingsData) {
|
export async function updateUserSettings(settings: UserSettingsData) {
|
||||||
console.log('[updateUserSettings] Started with:', settings)
|
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
@ -25,9 +25,10 @@ export async function updateUserSettings(settings: UserSettingsData) {
|
|||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
data: settings
|
data: settings
|
||||||
})
|
})
|
||||||
console.log('[updateUserSettings] Success:', result)
|
|
||||||
|
|
||||||
revalidatePath('/', 'layout')
|
revalidatePath('/', 'layout')
|
||||||
|
revalidateTag('user-settings')
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -37,8 +38,33 @@ export async function updateUserSettings(settings: UserSettingsData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user settings for current user
|
* Get user settings for current user (Cached)
|
||||||
*/
|
*/
|
||||||
|
import { unstable_cache } from 'next/cache'
|
||||||
|
|
||||||
|
// Internal cached function
|
||||||
|
const getCachedUserSettings = unstable_cache(
|
||||||
|
async (userId: string) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { theme: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting user settings:', error)
|
||||||
|
return {
|
||||||
|
theme: 'light' as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
['user-settings'],
|
||||||
|
{ tags: ['user-settings'] }
|
||||||
|
)
|
||||||
|
|
||||||
export async function getUserSettings(userId?: string) {
|
export async function getUserSettings(userId?: string) {
|
||||||
let id = userId
|
let id = userId
|
||||||
|
|
||||||
@ -53,19 +79,5 @@ export async function getUserSettings(userId?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return getCachedUserSettings(id)
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { id },
|
|
||||||
select: { theme: true }
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting user settings:', error)
|
|
||||||
return {
|
|
||||||
theme: 'light' as const
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { notebookId } = body
|
const { notebookId, language = 'en' } = body
|
||||||
|
|
||||||
if (!notebookId || typeof notebookId !== 'string') {
|
if (!notebookId || typeof notebookId !== 'string') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -45,7 +45,8 @@ export async function POST(request: NextRequest) {
|
|||||||
// Get label suggestions
|
// Get label suggestions
|
||||||
const suggestions = await autoLabelCreationService.suggestLabels(
|
const suggestions = await autoLabelCreationService.suggestLabels(
|
||||||
notebookId,
|
notebookId,
|
||||||
session.user.id
|
session.user.id,
|
||||||
|
language
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!suggestions) {
|
if (!suggestions) {
|
||||||
|
|||||||
@ -16,9 +16,25 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get language from request headers or body
|
||||||
|
let language = 'en'
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
if (body.language) {
|
||||||
|
language = body.language
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If no body or invalid json, check headers
|
||||||
|
const acceptLanguage = request.headers.get('accept-language')
|
||||||
|
if (acceptLanguage) {
|
||||||
|
language = acceptLanguage.split(',')[0].split('-')[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create organization plan
|
// Create organization plan
|
||||||
const plan = await batchOrganizationService.createOrganizationPlan(
|
const plan = await batchOrganizationService.createOrganizationPlan(
|
||||||
session.user.id
|
session.user.id,
|
||||||
|
language
|
||||||
)
|
)
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { notebookId } = body
|
const { notebookId, language = 'en' } = body
|
||||||
|
|
||||||
if (!notebookId || typeof notebookId !== 'string') {
|
if (!notebookId || typeof notebookId !== 'string') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -45,7 +45,8 @@ export async function POST(request: NextRequest) {
|
|||||||
// Generate summary
|
// Generate summary
|
||||||
const summary = await notebookSummaryService.generateSummary(
|
const summary = await notebookSummaryService.generateSummary(
|
||||||
notebookId,
|
notebookId,
|
||||||
session.user.id
|
session.user.id,
|
||||||
|
language
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!summary) {
|
if (!summary) {
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
const { noteContent } = body
|
const { noteContent, language = 'en' } = body
|
||||||
|
|
||||||
if (!noteContent || typeof noteContent !== 'string') {
|
if (!noteContent || typeof noteContent !== 'string') {
|
||||||
return NextResponse.json({ error: 'noteContent is required' }, { status: 400 })
|
return NextResponse.json({ error: 'noteContent is required' }, { status: 400 })
|
||||||
@ -29,7 +29,8 @@ export async function POST(req: NextRequest) {
|
|||||||
// Get suggestion from AI service
|
// Get suggestion from AI service
|
||||||
const suggestedNotebook = await notebookSuggestionService.suggestNotebook(
|
const suggestedNotebook = await notebookSuggestionService.suggestNotebook(
|
||||||
noteContent,
|
noteContent,
|
||||||
session.user.id
|
session.user.id,
|
||||||
|
language
|
||||||
)
|
)
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { z } from 'zod';
|
|||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
content: z.string().min(1, "Le contenu ne peut pas être vide"),
|
content: z.string().min(1, "Le contenu ne peut pas être vide"),
|
||||||
notebookId: z.string().optional(),
|
notebookId: z.string().optional(),
|
||||||
|
language: z.string().default('en'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@ -18,14 +19,15 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { content, notebookId } = requestSchema.parse(body);
|
const { content, notebookId, language } = requestSchema.parse(body);
|
||||||
|
|
||||||
// If notebookId is provided, use contextual suggestions (IA2)
|
// If notebookId is provided, use contextual suggestions (IA2)
|
||||||
if (notebookId) {
|
if (notebookId) {
|
||||||
const suggestions = await contextualAutoTagService.suggestLabels(
|
const suggestions = await contextualAutoTagService.suggestLabels(
|
||||||
content,
|
content,
|
||||||
notebookId,
|
notebookId,
|
||||||
session.user.id
|
session.user.id,
|
||||||
|
language
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert label → tag to match TagSuggestion interface
|
// Convert label → tag to match TagSuggestion interface
|
||||||
@ -37,7 +39,7 @@ export async function POST(req: NextRequest) {
|
|||||||
...(s.isNewLabel !== undefined && { isNewLabel: s.isNewLabel })
|
...(s.isNewLabel !== undefined && { isNewLabel: s.isNewLabel })
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json({ tags: convertedTags });
|
return NextResponse.json({ tags: convertedTags });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, use legacy auto-tagging (generates new tags)
|
// Otherwise, use legacy auto-tagging (generates new tags)
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export const viewport: Viewport = {
|
|||||||
themeColor: "#f59e0b",
|
themeColor: "#f59e0b",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
import { getAISettings } from "@/app/actions/ai-settings";
|
import { getAISettings } from "@/app/actions/ai-settings";
|
||||||
import { getUserSettings } from "@/app/actions/user-settings";
|
import { getUserSettings } from "@/app/actions/user-settings";
|
||||||
@ -59,8 +59,7 @@ export default async function RootLayout({
|
|||||||
getUserSettings(userId)
|
getUserSettings(userId)
|
||||||
])
|
])
|
||||||
|
|
||||||
console.log('[RootLayout] Auth user:', userId)
|
|
||||||
console.log('[RootLayout] Server fetched user settings:', userSettings)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html suppressHydrationWarning>
|
<html suppressHydrationWarning>
|
||||||
|
|||||||
@ -127,7 +127,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
|
|
||||||
<FeatureToggle
|
<FeatureToggle
|
||||||
name={t('titleSuggestions.available').replace('💡 ', '')}
|
name={t('titleSuggestions.available').replace('💡 ', '')}
|
||||||
description="Suggest titles for untitled notes after 50+ words"
|
description={t('aiSettings.titleSuggestionsDesc')}
|
||||||
checked={settings.titleSuggestions}
|
checked={settings.titleSuggestions}
|
||||||
onChange={(checked) => handleToggle('titleSuggestions', checked)}
|
onChange={(checked) => handleToggle('titleSuggestions', checked)}
|
||||||
/>
|
/>
|
||||||
@ -141,7 +141,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
|
|
||||||
<FeatureToggle
|
<FeatureToggle
|
||||||
name={t('paragraphRefactor.title')}
|
name={t('paragraphRefactor.title')}
|
||||||
description="AI-powered text improvement options"
|
description={t('aiSettings.paragraphRefactorDesc')}
|
||||||
checked={settings.paragraphRefactor}
|
checked={settings.paragraphRefactor}
|
||||||
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
|
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
|
||||||
/>
|
/>
|
||||||
@ -159,7 +159,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
{t('aiSettings.frequency')}
|
{t('aiSettings.frequency')}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-gray-500 mb-3">
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
How often to analyze note connections
|
{t('aiSettings.frequencyDesc')}
|
||||||
</p>
|
</p>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={settings.memoryEchoFrequency}
|
value={settings.memoryEchoFrequency}
|
||||||
@ -192,7 +192,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<Label className="text-base font-medium mb-1">{t('aiSettings.provider')}</Label>
|
<Label className="text-base font-medium mb-1">{t('aiSettings.provider')}</Label>
|
||||||
<p className="text-sm text-gray-500 mb-4">
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
Choose your preferred AI provider
|
{t('aiSettings.providerDesc')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
@ -206,7 +206,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
{t('aiSettings.providerAuto')}
|
{t('aiSettings.providerAuto')}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Ollama when available, OpenAI fallback
|
{t('aiSettings.providerAutoDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -218,7 +218,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
{t('aiSettings.providerOllama')}
|
{t('aiSettings.providerOllama')}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
100% private, runs locally on your machine
|
{t('aiSettings.providerOllamaDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -230,7 +230,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
{t('aiSettings.providerOpenAI')}
|
{t('aiSettings.providerOpenAI')}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Most accurate, requires API key
|
{t('aiSettings.providerOpenAIDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -55,7 +55,10 @@ export function AutoLabelSuggestionDialog({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ notebookId }),
|
body: JSON.stringify({
|
||||||
|
notebookId,
|
||||||
|
language: document.documentElement.lang || 'en',
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
@ -68,7 +71,7 @@ export function AutoLabelSuggestionDialog({
|
|||||||
} else {
|
} else {
|
||||||
// No suggestions is not an error - just close the dialog
|
// No suggestions is not an error - just close the dialog
|
||||||
if (data.message) {
|
if (data.message) {
|
||||||
}
|
}
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -113,7 +116,7 @@ export function AutoLabelSuggestionDialog({
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
toast.success(
|
toast.success(
|
||||||
t('ai.autoLabels.created', { count: data.data.createdCount }) ||
|
t('ai.autoLabels.created', { count: data.data.createdCount }) ||
|
||||||
`${data.data.createdCount} labels created successfully`
|
`${data.data.createdCount} labels created successfully`
|
||||||
)
|
)
|
||||||
onLabelsCreated()
|
onLabelsCreated()
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
|
|||||||
@ -38,7 +38,11 @@ export function BatchOrganizationDialog({
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/ai/batch-organize', {
|
const response = await fetch('/api/ai/batch-organize', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
language: document.documentElement.lang || 'en'
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
@ -125,7 +129,7 @@ export function BatchOrganizationDialog({
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
toast.success(
|
toast.success(
|
||||||
t('ai.batchOrganization.success', { count: data.data.movedCount }) ||
|
t('ai.batchOrganization.success', { count: data.data.movedCount }) ||
|
||||||
`${data.data.movedCount} notes moved successfully`
|
`${data.data.movedCount} notes moved successfully`
|
||||||
)
|
)
|
||||||
onNotesMoved()
|
onNotesMoved()
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
@ -306,7 +310,7 @@ export function BatchOrganizationDialog({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||||
{t('ai.batchOrganization.apply')}
|
{t('ai.batchOrganization.apply', { count: selectedNotes.size })}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -21,13 +21,13 @@ export function DemoModeToggle({ demoMode, onToggle }: DemoModeToggleProps) {
|
|||||||
try {
|
try {
|
||||||
await onToggle(checked)
|
await onToggle(checked)
|
||||||
if (checked) {
|
if (checked) {
|
||||||
toast.success('🧪 Demo Mode activated! Memory Echo will now work instantly.')
|
toast.success(t('demoMode.activated'))
|
||||||
} else {
|
} else {
|
||||||
toast.success('Demo Mode disabled. Normal parameters restored.')
|
toast.success(t('demoMode.deactivated'))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling demo mode:', error)
|
console.error('Error toggling demo mode:', error)
|
||||||
toast.error('Failed to toggle demo mode')
|
toast.error(t('demoMode.toggleFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsPending(false)
|
setIsPending(false)
|
||||||
}
|
}
|
||||||
@ -53,14 +53,11 @@ export function DemoModeToggle({ demoMode, onToggle }: DemoModeToggleProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
🧪 Demo Mode
|
🧪 {t('demoMode.title')}
|
||||||
{demoMode && <Zap className="h-4 w-4 text-amber-500 animate-pulse" />}
|
{demoMode && <Zap className="h-4 w-4 text-amber-500 animate-pulse" />}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs mt-1">
|
<CardDescription className="text-xs mt-1">
|
||||||
{demoMode
|
{t('demoMode.description')}
|
||||||
? 'Test Memory Echo instantly with relaxed parameters'
|
|
||||||
: 'Enable instant testing of Memory Echo feature'
|
|
||||||
}
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -77,31 +74,25 @@ export function DemoModeToggle({ demoMode, onToggle }: DemoModeToggleProps) {
|
|||||||
<CardContent className="pt-0 space-y-2">
|
<CardContent className="pt-0 space-y-2">
|
||||||
<div className="rounded-lg bg-white dark:bg-zinc-900 border border-amber-200 dark:border-amber-900/30 p-3">
|
<div className="rounded-lg bg-white dark:bg-zinc-900 border border-amber-200 dark:border-amber-900/30 p-3">
|
||||||
<p className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
<p className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
⚡ Demo parameters active:
|
{t('demoMode.parametersActive')}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
|
<div className="space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Target className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
|
<Target className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
|
||||||
<span>
|
<span>{t('demoMode.similarityThreshold')}</span>
|
||||||
<strong>50% similarity</strong> threshold (normally 75%)
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Zap className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
|
<Zap className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
|
||||||
<span>
|
<span>{t('demoMode.delayBetweenNotes')}</span>
|
||||||
<strong>0-day delay</strong> between notes (normally 7 days)
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Lightbulb className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
|
<Lightbulb className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
|
||||||
<span>
|
<span>{t('demoMode.unlimitedInsights')}</span>
|
||||||
<strong>Unlimited insights</strong> (no frequency limits)
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-amber-700 dark:text-amber-400 text-center">
|
<p className="text-xs text-amber-700 dark:text-amber-400 text-center">
|
||||||
💡 Create 2+ similar notes and see Memory Echo in action!
|
💡 {t('demoMode.createNotesTip')}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export function FavoritesSection({ pinnedNotes, onEdit, isLoading }: FavoritesSe
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-2xl">📌</span>
|
<span className="text-2xl">📌</span>
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
Pinned Notes
|
{t('notes.pinnedNotes')}
|
||||||
<span className="text-sm font-medium text-muted-foreground ml-2">
|
<span className="text-sm font-medium text-muted-foreground ml-2">
|
||||||
({pinnedNotes.length})
|
({pinnedNotes.length})
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -77,7 +77,7 @@ export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, on
|
|||||||
onSelectTag(suggestion.tag);
|
onSelectTag(suggestion.tag);
|
||||||
}}
|
}}
|
||||||
className={cn("flex items-center px-3 py-1 text-xs font-medium", colorClasses.text)}
|
className={cn("flex items-center px-3 py-1 text-xs font-medium", colorClasses.text)}
|
||||||
title={isNewLabel ? "Créer ce nouveau label et l'ajouter" : t('ai.clickToAddTag')}
|
title={isNewLabel ? t('ai.autoLabels.createNewLabel') : t('ai.clickToAddTag')}
|
||||||
>
|
>
|
||||||
{isNewLabel && <Plus className="w-3 h-3 mr-1" />}
|
{isNewLabel && <Plus className="w-3 h-3 mr-1" />}
|
||||||
{!isNewLabel && <Sparkles className="w-3 h-3 mr-1.5 opacity-50" />}
|
{!isNewLabel && <Sparkles className="w-3 h-3 mr-1.5 opacity-50" />}
|
||||||
|
|||||||
@ -355,7 +355,7 @@ export function Header({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => router.push('/admin')} className="cursor-pointer">
|
<DropdownMenuItem onClick={() => router.push('/admin')} className="cursor-pointer">
|
||||||
<Shield className="mr-2 h-4 w-4" />
|
<Shield className="mr-2 h-4 w-4" />
|
||||||
<span>Admin</span>
|
<span>{t('nav.adminDashboard')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => signOut()} className="cursor-pointer text-red-600 focus:text-red-600">
|
<DropdownMenuItem onClick={() => signOut()} className="cursor-pointer text-red-600 focus:text-red-600">
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@ -33,6 +34,7 @@ interface MemoryEchoNotificationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationProps) {
|
export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [insight, setInsight] = useState<MemoryEchoInsight | null>(null)
|
const [insight, setInsight] = useState<MemoryEchoInsight | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [isDismissed, setIsDismissed] = useState(false)
|
const [isDismissed, setIsDismissed] = useState(false)
|
||||||
@ -137,9 +139,9 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
|||||||
<Sparkles className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
<Sparkles className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">💡 Memory Echo Discovery</h2>
|
<h2 className="text-xl font-semibold">{t('memoryEcho.title')}</h2>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
These notes are connected by {similarityPercentage}% similarity
|
{t('connection.similarityInfo', { similarity: similarityPercentage })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -179,7 +181,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
|||||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-4">
|
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-4">
|
||||||
{insight.note1.content}
|
{insight.note1.content}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 mt-2">Click to view note →</p>
|
<p className="text-xs text-gray-500 mt-2">{t('memoryEcho.clickToView')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Note 2 */}
|
{/* Note 2 */}
|
||||||
@ -198,37 +200,35 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
|||||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-4">
|
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-4">
|
||||||
{insight.note2.content}
|
{insight.note2.content}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 mt-2">Click to view note →</p>
|
<p className="text-xs text-gray-500 mt-2">{t('memoryEcho.clickToView')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feedback Section */}
|
{/* Feedback Section */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
|
<div className="flex items-center justify-between px-6 py-4 border-t dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Is this connection helpful?
|
{t('connection.isHelpful')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleFeedback('thumbs_up')}
|
onClick={() => handleFeedback('thumbs_up')}
|
||||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${
|
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${insight.feedback === 'thumbs_up'
|
||||||
insight.feedback === 'thumbs_up'
|
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
: 'hover:bg-green-50 text-green-600 dark:hover:bg-green-950/20'
|
||||||
: 'hover:bg-green-50 text-green-600 dark:hover:bg-green-950/20'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<ThumbsUp className="h-4 w-4" />
|
<ThumbsUp className="h-4 w-4" />
|
||||||
Helpful
|
{t('memoryEcho.helpful')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleFeedback('thumbs_down')}
|
onClick={() => handleFeedback('thumbs_down')}
|
||||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${
|
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${insight.feedback === 'thumbs_down'
|
||||||
insight.feedback === 'thumbs_down'
|
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
: 'hover:bg-red-50 text-red-600 dark:hover:bg-red-950/20'
|
||||||
: 'hover:bg-red-50 text-red-600 dark:hover:bg-red-950/20'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<ThumbsDown className="h-4 w-4" />
|
<ThumbsDown className="h-4 w-4" />
|
||||||
Not Helpful
|
{t('memoryEcho.notHelpful')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -248,11 +248,11 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
💡 I noticed something...
|
{t('memoryEcho.title')}
|
||||||
<Sparkles className="h-4 w-4 text-amber-500" />
|
<Sparkles className="h-4 w-4 text-amber-500" />
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs mt-1">
|
<CardDescription className="text-xs mt-1">
|
||||||
Proactive connections between your notes
|
{t('memoryEcho.description')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -298,7 +298,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
|||||||
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
|
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
|
||||||
onClick={handleView}
|
onClick={handleView}
|
||||||
>
|
>
|
||||||
View Connection
|
{t('memoryEcho.viewConnection')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 border-l pl-2">
|
<div className="flex items-center gap-1 border-l pl-2">
|
||||||
@ -307,7 +307,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
|
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
|
||||||
onClick={() => handleFeedback('thumbs_up')}
|
onClick={() => handleFeedback('thumbs_up')}
|
||||||
title="Helpful"
|
title={t('memoryEcho.helpful')}
|
||||||
>
|
>
|
||||||
<ThumbsUp className="h-4 w-4" />
|
<ThumbsUp className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -316,7 +316,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
|
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||||
onClick={() => handleFeedback('thumbs_down')}
|
onClick={() => handleFeedback('thumbs_down')}
|
||||||
title="Not Helpful"
|
title={t('memoryEcho.notHelpful')}
|
||||||
>
|
>
|
||||||
<ThumbsDown className="h-4 w-4" />
|
<ThumbsDown className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -328,7 +328,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
|||||||
className="w-full text-center text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 py-1"
|
className="w-full text-center text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 py-1"
|
||||||
onClick={handleDismiss}
|
onClick={handleDismiss}
|
||||||
>
|
>
|
||||||
Dismiss for now
|
{t('memoryEcho.dismiss')}
|
||||||
</button>
|
</button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -1122,7 +1122,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success('Notes fusionnées avec succès !')
|
toast.success(t('toast.notesFusionSuccess'))
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -57,7 +57,10 @@ export function NotebookSuggestionToast({
|
|||||||
const response = await fetch('/api/ai/suggest-notebook', {
|
const response = await fetch('/api/ai/suggest-notebook', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ noteContent })
|
body: JSON.stringify({
|
||||||
|
noteContent,
|
||||||
|
language: document.documentElement.lang || 'en',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|||||||
@ -52,7 +52,10 @@ export function NotebookSummaryDialog({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ notebookId }),
|
body: JSON.stringify({
|
||||||
|
notebookId,
|
||||||
|
language: document.documentElement.lang || 'en',
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
@ -82,6 +85,10 @@ export function NotebookSummaryDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{t('notebook.generating')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('notebook.generatingDescription') || 'Please wait...'}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
<p className="mt-4 text-sm text-muted-foreground">
|
<p className="mt-4 text-sm text-muted-foreground">
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Note } from '@/lib/types'
|
import { Note } from '@/lib/types'
|
||||||
import { Clock, FileText, Tag } from 'lucide-react'
|
import { Clock } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
@ -11,27 +11,24 @@ interface RecentNotesSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RecentNotesSection({ recentNotes, onEdit }: RecentNotesSectionProps) {
|
export function RecentNotesSection({ recentNotes, onEdit }: RecentNotesSectionProps) {
|
||||||
const { language } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
// Show only the 3 most recent notes
|
|
||||||
const topThree = recentNotes.slice(0, 3)
|
const topThree = recentNotes.slice(0, 3)
|
||||||
|
|
||||||
if (topThree.length === 0) return null
|
if (topThree.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section data-testid="recent-notes-section" className="mb-6">
|
<section data-testid="recent-notes-section" className="mb-6">
|
||||||
{/* Minimalist header - matching your app style */}
|
|
||||||
<div className="flex items-center gap-2 mb-3 px-1">
|
<div className="flex items-center gap-2 mb-3 px-1">
|
||||||
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
|
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
{language === 'fr' ? 'Récent' : 'Recent'}
|
{t('notes.recent')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
· {topThree.length}
|
· {topThree.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compact 3-card row */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
{topThree.map((note, index) => (
|
{topThree.map((note, index) => (
|
||||||
<CompactCard
|
<CompactCard
|
||||||
@ -46,7 +43,6 @@ export function RecentNotesSection({ recentNotes, onEdit }: RecentNotesSectionPr
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact card - matching your app's clean design
|
|
||||||
function CompactCard({
|
function CompactCard({
|
||||||
note,
|
note,
|
||||||
index,
|
index,
|
||||||
@ -56,9 +52,8 @@ function CompactCard({
|
|||||||
index: number
|
index: number
|
||||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const { language } = useLanguage()
|
const { t } = useLanguage()
|
||||||
// Use contentUpdatedAt - only reflects actual content changes, not property changes (size, color, etc.)
|
const timeAgo = getCompactTime(note.contentUpdatedAt || note.updatedAt, t)
|
||||||
const timeAgo = getCompactTime(note.contentUpdatedAt || note.updatedAt, language)
|
|
||||||
const isFirstNote = index === 0
|
const isFirstNote = index === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -69,7 +64,6 @@ function CompactCard({
|
|||||||
isFirstNote && "ring-2 ring-primary/20"
|
isFirstNote && "ring-2 ring-primary/20"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Subtle left accent - colored based on recency */}
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"absolute left-0 top-0 bottom-0 w-1 rounded-l-xl",
|
"absolute left-0 top-0 bottom-0 w-1 rounded-l-xl",
|
||||||
isFirstNote
|
isFirstNote
|
||||||
@ -79,74 +73,54 @@ function CompactCard({
|
|||||||
: "bg-muted dark:bg-muted/60"
|
: "bg-muted dark:bg-muted/60"
|
||||||
)} />
|
)} />
|
||||||
|
|
||||||
{/* Content with left padding for accent line */}
|
|
||||||
<div className="pl-2">
|
<div className="pl-2">
|
||||||
{/* Title */}
|
|
||||||
<h3 className="text-sm font-semibold text-foreground line-clamp-1 mb-2">
|
<h3 className="text-sm font-semibold text-foreground line-clamp-1 mb-2">
|
||||||
{note.title || (language === 'fr' ? 'Sans titre' : 'Untitled')}
|
{note.title || t('notes.untitled')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Preview - 2 lines max */}
|
|
||||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-3 min-h-[2.5rem]">
|
<p className="text-xs text-muted-foreground line-clamp-2 mb-3 min-h-[2.5rem]">
|
||||||
{note.content?.substring(0, 80) || ''}
|
{note.content?.substring(0, 80) || ''}
|
||||||
{note.content && note.content.length > 80 && '...'}
|
{note.content && note.content.length > 80 && '...'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Footer with time and indicators */}
|
|
||||||
<div className="flex items-center justify-between pt-2 border-t border-border">
|
<div className="flex items-center justify-between pt-2 border-t border-border">
|
||||||
{/* Time - left */}
|
|
||||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
<span className="font-medium">{timeAgo}</span>
|
<span className="font-medium">{timeAgo}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Indicators - right */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{/* Notebook indicator */}
|
|
||||||
{note.notebookId && (
|
{note.notebookId && (
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-primary dark:bg-primary/70" title="In notebook" />
|
<div className="w-1.5 h-1.5 rounded-full bg-primary dark:bg-primary/70" title={t('notes.inNotebook')} />
|
||||||
)}
|
)}
|
||||||
{/* Labels indicator */}
|
|
||||||
{note.labels && note.labels.length > 0 && (
|
{note.labels && note.labels.length > 0 && (
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400" title={`${note.labels.length} ${language === 'fr' ? 'étiquettes' : 'labels'}`} />
|
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400" title={t('labels.count', { count: note.labels.length })} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hover indicator - top right */}
|
|
||||||
<div className="absolute top-3 right-3 w-2 h-2 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
|
<div className="absolute top-3 right-3 w-2 h-2 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact time display - matching your app's style
|
function getCompactTime(date: Date | string, t: (key: string, params?: Record<string, any>) => string): string {
|
||||||
// NOTE: Ensure dates are properly parsed from database (may come as strings)
|
|
||||||
function getCompactTime(date: Date | string, language: string): string {
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const then = date instanceof Date ? date : new Date(date)
|
const then = date instanceof Date ? date : new Date(date)
|
||||||
|
|
||||||
// Validate date
|
|
||||||
if (isNaN(then.getTime())) {
|
if (isNaN(then.getTime())) {
|
||||||
console.warn('Invalid date provided to getCompactTime:', date)
|
console.warn('Invalid date provided to getCompactTime:', date)
|
||||||
return language === 'fr' ? 'date invalide' : 'invalid date'
|
return t('common.error')
|
||||||
}
|
}
|
||||||
|
|
||||||
const seconds = Math.floor((now.getTime() - then.getTime()) / 1000)
|
const seconds = Math.floor((now.getTime() - then.getTime()) / 1000)
|
||||||
const minutes = Math.floor(seconds / 60)
|
const minutes = Math.floor(seconds / 60)
|
||||||
const hours = Math.floor(minutes / 60)
|
const hours = Math.floor(minutes / 60)
|
||||||
|
|
||||||
if (language === 'fr') {
|
if (seconds < 60) return t('time.justNow')
|
||||||
if (seconds < 60) return 'à l\'instant'
|
if (minutes < 60) return t('time.minutesAgo', { count: minutes })
|
||||||
if (minutes < 60) return `il y a ${minutes}m`
|
if (hours < 24) return t('time.hoursAgo', { count: hours })
|
||||||
if (hours < 24) return `il y a ${hours}h`
|
const days = Math.floor(hours / 24)
|
||||||
const days = Math.floor(hours / 24)
|
return t('time.daysAgo', { count: days })
|
||||||
return `il y a ${days}j`
|
|
||||||
} else {
|
|
||||||
if (seconds < 60) return 'just now'
|
|
||||||
if (minutes < 60) return `${minutes}m ago`
|
|
||||||
if (hours < 24) return `${hours}h ago`
|
|
||||||
const days = Math.floor(hours / 24)
|
|
||||||
return `${days}d ago`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
interface ReminderDialogProps {
|
interface ReminderDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@ -18,6 +21,7 @@ export function ReminderDialog({
|
|||||||
onSave,
|
onSave,
|
||||||
onRemove
|
onRemove
|
||||||
}: ReminderDialogProps) {
|
}: ReminderDialogProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [reminderDate, setReminderDate] = useState('')
|
const [reminderDate, setReminderDate] = useState('')
|
||||||
const [reminderTime, setReminderTime] = useState('')
|
const [reminderTime, setReminderTime] = useState('')
|
||||||
|
|
||||||
@ -51,7 +55,6 @@ export function ReminderDialog({
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
onInteractOutside={(event) => {
|
onInteractOutside={(event) => {
|
||||||
// Prevent dialog from closing when interacting with Sonner toasts
|
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
const isSonnerElement =
|
const isSonnerElement =
|
||||||
@ -75,12 +78,12 @@ export function ReminderDialog({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Set Reminder</DialogTitle>
|
<DialogTitle>{t('reminder.setReminder')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="reminder-date" className="text-sm font-medium">
|
<label htmlFor="reminder-date" className="text-sm font-medium">
|
||||||
Date
|
{t('reminder.reminderDate')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="reminder-date"
|
id="reminder-date"
|
||||||
@ -92,7 +95,7 @@ export function ReminderDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="reminder-time" className="text-sm font-medium">
|
<label htmlFor="reminder-time" className="text-sm font-medium">
|
||||||
Time
|
{t('reminder.reminderTime')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="reminder-time"
|
id="reminder-time"
|
||||||
@ -107,16 +110,16 @@ export function ReminderDialog({
|
|||||||
<div>
|
<div>
|
||||||
{currentReminder && (
|
{currentReminder && (
|
||||||
<Button variant="outline" onClick={() => { onRemove(); onOpenChange(false); }}>
|
<Button variant="outline" onClick={() => { onRemove(); onOpenChange(false); }}>
|
||||||
Remove Reminder
|
{t('reminder.removeReminder')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
{t('reminder.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave}>
|
<Button onClick={handleSave}>
|
||||||
Set Reminder
|
{t('reminder.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Loader2, Check } from 'lucide-react'
|
import { Loader2, Check } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
interface SettingInputProps {
|
interface SettingInputProps {
|
||||||
label: string
|
label: string
|
||||||
@ -25,6 +26,7 @@ export function SettingInput({
|
|||||||
placeholder,
|
placeholder,
|
||||||
disabled
|
disabled
|
||||||
}: SettingInputProps) {
|
}: SettingInputProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [isSaved, setIsSaved] = useState(false)
|
const [isSaved, setIsSaved] = useState(false)
|
||||||
|
|
||||||
@ -35,15 +37,12 @@ export function SettingInput({
|
|||||||
try {
|
try {
|
||||||
await onChange(newValue)
|
await onChange(newValue)
|
||||||
setIsSaved(true)
|
setIsSaved(true)
|
||||||
toast.success('Setting saved')
|
toast.success(t('toast.saved'))
|
||||||
|
|
||||||
// Clear saved indicator after 2 seconds
|
|
||||||
setTimeout(() => setIsSaved(false), 2000)
|
setTimeout(() => setIsSaved(false), 2000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error updating setting:', err)
|
console.error('Error updating setting:', err)
|
||||||
toast.error('Failed to save setting', {
|
toast.error(t('toast.saveFailed'))
|
||||||
description: 'Please try again'
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
interface SelectOption {
|
interface SelectOption {
|
||||||
value: string
|
value: string
|
||||||
@ -29,6 +30,7 @@ export function SettingSelect({
|
|||||||
onChange,
|
onChange,
|
||||||
disabled
|
disabled
|
||||||
}: SettingSelectProps) {
|
}: SettingSelectProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const handleChange = async (newValue: string) => {
|
const handleChange = async (newValue: string) => {
|
||||||
@ -36,14 +38,10 @@ export function SettingSelect({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await onChange(newValue)
|
await onChange(newValue)
|
||||||
toast.success('Setting saved', {
|
toast.success(t('toast.saved'))
|
||||||
description: `${label} has been updated`
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error updating setting:', err)
|
console.error('Error updating setting:', err)
|
||||||
toast.error('Failed to save setting', {
|
toast.error(t('toast.saveFailed'))
|
||||||
description: 'Please try again'
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Loader2, Check, X } from 'lucide-react'
|
import { Loader2, Check, X } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
interface SettingToggleProps {
|
interface SettingToggleProps {
|
||||||
label: string
|
label: string
|
||||||
@ -22,6 +23,7 @@ export function SettingToggle({
|
|||||||
onChange,
|
onChange,
|
||||||
disabled
|
disabled
|
||||||
}: SettingToggleProps) {
|
}: SettingToggleProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
@ -31,15 +33,11 @@ export function SettingToggle({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await onChange(newChecked)
|
await onChange(newChecked)
|
||||||
toast.success('Setting saved', {
|
toast.success(t('toast.saved'))
|
||||||
description: `${label} has been ${newChecked ? 'enabled' : 'disabled'}`
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error updating setting:', err)
|
console.error('Error updating setting:', err)
|
||||||
setError(true)
|
setError(true)
|
||||||
toast.error('Failed to save setting', {
|
toast.error(t('toast.saveFailed'))
|
||||||
description: 'Please try again'
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import Link from 'next/link'
|
|||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { Settings, Sparkles, Palette, User, Database, Info, Check } from 'lucide-react'
|
import { Settings, Sparkles, Palette, User, Database, Info, Check } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
interface SettingsSection {
|
interface SettingsSection {
|
||||||
id: string
|
id: string
|
||||||
@ -18,41 +19,42 @@ interface SettingsNavProps {
|
|||||||
|
|
||||||
export function SettingsNav({ className }: SettingsNavProps) {
|
export function SettingsNav({ className }: SettingsNavProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
const sections: SettingsSection[] = [
|
const sections: SettingsSection[] = [
|
||||||
{
|
{
|
||||||
id: 'general',
|
id: 'general',
|
||||||
label: 'General',
|
label: t('generalSettings.title'),
|
||||||
icon: <Settings className="h-5 w-5" />,
|
icon: <Settings className="h-5 w-5" />,
|
||||||
href: '/settings/general'
|
href: '/settings/general'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ai',
|
id: 'ai',
|
||||||
label: 'AI',
|
label: t('aiSettings.title'),
|
||||||
icon: <Sparkles className="h-5 w-5" />,
|
icon: <Sparkles className="h-5 w-5" />,
|
||||||
href: '/settings/ai'
|
href: '/settings/ai'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'appearance',
|
id: 'appearance',
|
||||||
label: 'Appearance',
|
label: t('appearance.title'),
|
||||||
icon: <Palette className="h-5 w-5" />,
|
icon: <Palette className="h-5 w-5" />,
|
||||||
href: '/settings/appearance'
|
href: '/settings/appearance'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'profile',
|
id: 'profile',
|
||||||
label: 'Profile',
|
label: t('profile.title'),
|
||||||
icon: <User className="h-5 w-5" />,
|
icon: <User className="h-5 w-5" />,
|
||||||
href: '/settings/profile'
|
href: '/settings/profile'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'data',
|
id: 'data',
|
||||||
label: 'Data',
|
label: t('dataManagement.title'),
|
||||||
icon: <Database className="h-5 w-5" />,
|
icon: <Database className="h-5 w-5" />,
|
||||||
href: '/settings/data'
|
href: '/settings/data'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'about',
|
id: 'about',
|
||||||
label: 'About',
|
label: t('about.title'),
|
||||||
icon: <Info className="h-5 w-5" />,
|
icon: <Info className="h-5 w-5" />,
|
||||||
href: '/settings/about'
|
href: '/settings/about'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -109,11 +109,11 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
|
|||||||
{/* Footer / Copyright / Terms */}
|
{/* Footer / Copyright / Terms */}
|
||||||
<div className="mt-auto px-6 py-4 text-[10px] text-gray-400">
|
<div className="mt-auto px-6 py-4 text-[10px] text-gray-400">
|
||||||
<div className="flex gap-2 mb-1">
|
<div className="flex gap-2 mb-1">
|
||||||
<Link href="#" className="hover:underline">Confidentialité</Link>
|
<Link href="#" className="hover:underline">{t('footer.privacy')}</Link>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<Link href="#" className="hover:underline">Conditions</Link>
|
<Link href="#" className="hover:underline">{t('footer.terms')}</Link>
|
||||||
</div>
|
</div>
|
||||||
<p>Open Source Clone</p>
|
<p>{t('footer.openSource')}</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -14,10 +14,12 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|||||||
import { useSession, signOut } from 'next-auth/react'
|
import { useSession, signOut } from 'next-auth/react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { LogOut, Settings, User, Shield } from 'lucide-react'
|
import { LogOut, Settings, User, Shield } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
export function UserNav({ user }: { user?: any }) {
|
export function UserNav({ user }: { user?: any }) {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
const currentUser = user || session?.user
|
const currentUser = user || session?.user
|
||||||
|
|
||||||
@ -51,23 +53,23 @@ export function UserNav({ user }: { user?: any }) {
|
|||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem onClick={() => router.push('/settings/profile')}>
|
<DropdownMenuItem onClick={() => router.push('/settings/profile')}>
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
<span>Profile</span>
|
<span>{t('nav.profile')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{userRole === 'ADMIN' && (
|
{userRole === 'ADMIN' && (
|
||||||
<DropdownMenuItem onClick={() => router.push('/admin')}>
|
<DropdownMenuItem onClick={() => router.push('/admin')}>
|
||||||
<Shield className="mr-2 h-4 w-4" />
|
<Shield className="mr-2 h-4 w-4" />
|
||||||
<span>Admin Dashboard</span>
|
<span>{t('nav.adminDashboard')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem onClick={() => router.push('/settings')}>
|
<DropdownMenuItem onClick={() => router.push('/settings')}>
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
<span>Diagnostics</span>
|
<span>{t('nav.diagnostics')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => signOut({ callbackUrl: '/login' })}>
|
<DropdownMenuItem onClick={() => signOut({ callbackUrl: '/login' })}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
<span>Log out</span>
|
<span>{t('nav.logout')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@ -41,7 +41,8 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
content: contentToAnalyze,
|
content: contentToAnalyze,
|
||||||
notebookId: notebookId || undefined, // Pass notebookId for contextual suggestions (IA2)
|
notebookId: notebookId || undefined,
|
||||||
|
language: document.documentElement.lang || 'en',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export class AutoLabelCreationService {
|
|||||||
* @param userId - User ID (for authorization)
|
* @param userId - User ID (for authorization)
|
||||||
* @returns Suggested labels or null if not enough notes/no patterns found
|
* @returns Suggested labels or null if not enough notes/no patterns found
|
||||||
*/
|
*/
|
||||||
async suggestLabels(notebookId: string, userId: string): Promise<AutoLabelSuggestion | null> {
|
async suggestLabels(notebookId: string, userId: string, language: string = 'en'): Promise<AutoLabelSuggestion | null> {
|
||||||
// 1. Get notebook with existing labels
|
// 1. Get notebook with existing labels
|
||||||
const notebook = await prisma.notebook.findFirst({
|
const notebook = await prisma.notebook.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@ -84,7 +84,7 @@ export class AutoLabelCreationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Use AI to detect recurring themes
|
// 2. Use AI to detect recurring themes
|
||||||
const suggestions = await this.detectRecurringThemes(notes, notebook)
|
const suggestions = await this.detectRecurringThemes(notes, notebook, language)
|
||||||
|
|
||||||
return suggestions
|
return suggestions
|
||||||
}
|
}
|
||||||
@ -94,13 +94,14 @@ export class AutoLabelCreationService {
|
|||||||
*/
|
*/
|
||||||
private async detectRecurringThemes(
|
private async detectRecurringThemes(
|
||||||
notes: any[],
|
notes: any[],
|
||||||
notebook: any
|
notebook: any,
|
||||||
|
language: string
|
||||||
): Promise<AutoLabelSuggestion | null> {
|
): Promise<AutoLabelSuggestion | null> {
|
||||||
const existingLabelNames = new Set<string>(
|
const existingLabelNames = new Set<string>(
|
||||||
notebook.labels.map((l: any) => l.name.toLowerCase())
|
notebook.labels.map((l: any) => l.name.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
const prompt = this.buildPrompt(notes, existingLabelNames)
|
const prompt = this.buildPrompt(notes, existingLabelNames, language)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
@ -128,9 +129,9 @@ export class AutoLabelCreationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build prompt for AI (always in French - interface language)
|
* Build prompt for AI (localized)
|
||||||
*/
|
*/
|
||||||
private buildPrompt(notes: any[], existingLabelNames: Set<string>): string {
|
private buildPrompt(notes: any[], existingLabelNames: Set<string>, language: string = 'en'): string {
|
||||||
const notesSummary = notes
|
const notesSummary = notes
|
||||||
.map((note, index) => {
|
.map((note, index) => {
|
||||||
const title = note.title || 'Sans titre'
|
const title = note.title || 'Sans titre'
|
||||||
@ -141,7 +142,8 @@ export class AutoLabelCreationService {
|
|||||||
|
|
||||||
const existingLabels = Array.from(existingLabelNames).join(', ')
|
const existingLabels = Array.from(existingLabelNames).join(', ')
|
||||||
|
|
||||||
return `
|
const instructions: Record<string, string> = {
|
||||||
|
fr: `
|
||||||
Tu es un assistant qui détecte les thèmes récurrents dans des notes pour suggérer de nouvelles étiquettes.
|
Tu es un assistant qui détecte les thèmes récurrents dans des notes pour suggérer de nouvelles étiquettes.
|
||||||
|
|
||||||
CARNET ANALYSÉ :
|
CARNET ANALYSÉ :
|
||||||
@ -182,7 +184,178 @@ Exemples de bonnes étiquettes :
|
|||||||
- "marie", "jean", "équipe" (personnes)
|
- "marie", "jean", "équipe" (personnes)
|
||||||
|
|
||||||
Ta réponse (JSON seulement) :
|
Ta réponse (JSON seulement) :
|
||||||
|
`.trim(),
|
||||||
|
en: `
|
||||||
|
You are an assistant that detects recurring themes in notes to suggest new labels.
|
||||||
|
|
||||||
|
ANALYZED NOTEBOOK:
|
||||||
|
${notes.length} notes
|
||||||
|
|
||||||
|
EXISTING LABELS (do not suggest these):
|
||||||
|
${existingLabels || 'None'}
|
||||||
|
|
||||||
|
NOTEBOOK NOTES:
|
||||||
|
${notesSummary}
|
||||||
|
|
||||||
|
TASK:
|
||||||
|
Analyze the notes and detect recurring themes (keywords, subjects, places, people).
|
||||||
|
A theme must appear in at least 5 different notes to be suggested.
|
||||||
|
|
||||||
|
RESPONSE FORMAT (JSON):
|
||||||
|
{
|
||||||
|
"labels": [
|
||||||
|
{
|
||||||
|
"nom": "label_name",
|
||||||
|
"note_indices": [0, 5, 12, 23, 45],
|
||||||
|
"confiance": 0.85
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Label name must be short (max 1-2 words)
|
||||||
|
- A theme must appear in 5+ notes to be suggested
|
||||||
|
- Confidence must be > 0.60
|
||||||
|
- Do not suggest labels that already exist
|
||||||
|
- Prioritize places, people, clear categories
|
||||||
|
- Maximum 5 suggestions
|
||||||
|
|
||||||
|
Examples of good labels:
|
||||||
|
- "tokyo", "kyoto", "osaka" (places)
|
||||||
|
- "hotels", "restaurants", "flights" (categories)
|
||||||
|
- "mary", "john", "team" (people)
|
||||||
|
|
||||||
|
Your response (JSON only):
|
||||||
|
`.trim(),
|
||||||
|
fa: `
|
||||||
|
شما یک دستیار هستید که تمهای تکرارشونده در یادداشتها را برای پیشنهاد برچسبهای جدید شناسایی میکنید.
|
||||||
|
|
||||||
|
دفترچه تحلیل شده:
|
||||||
|
${notes.length} یادداشت
|
||||||
|
|
||||||
|
برچسبهای موجود (اینها را پیشنهاد ندهید):
|
||||||
|
${existingLabels || 'هیچ'}
|
||||||
|
|
||||||
|
یادداشتهای دفترچه:
|
||||||
|
${notesSummary}
|
||||||
|
|
||||||
|
وظیفه:
|
||||||
|
یادداشتها را تحلیل کنید و تمهای تکرارشونده (کلمات کلیدی، موضوعات، مکانها، افراد) را شناسایی کنید.
|
||||||
|
یک تم باید حداقل در ۵ یادداشت مختلف ظاهر شود تا پیشنهاد داده شود.
|
||||||
|
|
||||||
|
فرمت پاسخ (JSON):
|
||||||
|
{
|
||||||
|
"labels": [
|
||||||
|
{
|
||||||
|
"nom": "نام_برچسب",
|
||||||
|
"note_indices": [0, 5, 12, 23, 45],
|
||||||
|
"confiance": 0.85
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
قوانین:
|
||||||
|
- نام برچسب باید کوتاه باشد (حداکثر ۱-۲ کلمه)
|
||||||
|
- یک تم باید در ۵+ یادداشت ظاهر شود تا پیشنهاد داده شود
|
||||||
|
- اطمینان باید > 0.60 باشد
|
||||||
|
- برچسبهایی که قبلاً وجود دارند را پیشنهاد ندهید
|
||||||
|
- اولویت با مکانها، افراد، دستهبندیهای واضح است
|
||||||
|
- حداکثر ۵ پیشنهاد
|
||||||
|
|
||||||
|
مثالهای برچسب خوب:
|
||||||
|
- "توکیو"، "کیوتو"، "اوزاکا" (مکانها)
|
||||||
|
- "هتلها"، "رستورانها"، "پروازها" (دستهبندیها)
|
||||||
|
- "مریم"، "علی"، "تیم" (افراد)
|
||||||
|
|
||||||
|
پاسخ شما (فقط JSON):
|
||||||
|
`.trim(),
|
||||||
|
es: `
|
||||||
|
Eres un asistente que detecta temas recurrentes en notas para sugerir nuevas etiquetas.
|
||||||
|
|
||||||
|
CUADERNO ANALIZADO:
|
||||||
|
${notes.length} notas
|
||||||
|
|
||||||
|
ETIQUETAS EXISTENTES (no sugerir estas):
|
||||||
|
${existingLabels || 'Ninguna'}
|
||||||
|
|
||||||
|
NOTAS DEL CUADERNO:
|
||||||
|
${notesSummary}
|
||||||
|
|
||||||
|
TAREA:
|
||||||
|
Analiza las notas y detecta temas recurrentes (palabras clave, temas, lugares, personas).
|
||||||
|
Un tema debe aparecer en al menos 5 notas diferentes para ser sugerido.
|
||||||
|
|
||||||
|
FORMATO DE RESPUESTA (JSON):
|
||||||
|
{
|
||||||
|
"labels": [
|
||||||
|
{
|
||||||
|
"nom": "nombre_etiqueta",
|
||||||
|
"note_indices": [0, 5, 12, 23, 45],
|
||||||
|
"confiance": 0.85
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
REGLAS:
|
||||||
|
- El nombre de la etiqueta debe ser corto (máx 1-2 palabras)
|
||||||
|
- Un tema debe aparecer en 5+ notas para ser sugerido
|
||||||
|
- La confianza debe ser > 0.60
|
||||||
|
- No sugieras etiquetas que ya existen
|
||||||
|
- Prioriza lugares, personas, categorías claras
|
||||||
|
- Máximo 5 sugerencias
|
||||||
|
|
||||||
|
Ejemplos de buenas etiquetas:
|
||||||
|
- "tokio", "kyoto", "osaka" (lugares)
|
||||||
|
- "hoteles", "restaurantes", "vuelos" (categorías)
|
||||||
|
- "maría", "juan", "equipo" (personas)
|
||||||
|
|
||||||
|
Tu respuesta (solo JSON):
|
||||||
|
`.trim(),
|
||||||
|
de: `
|
||||||
|
Du bist ein Assistent, der wiederkehrende Themen in Notizen erkennt, um neue Labels vorzuschlagen.
|
||||||
|
|
||||||
|
ANALYSIERTES NOTIZBUCH:
|
||||||
|
${notes.length} Notizen
|
||||||
|
|
||||||
|
VORHANDENE LABELS (schlage diese nicht vor):
|
||||||
|
${existingLabels || 'Keine'}
|
||||||
|
|
||||||
|
NOTIZBUCH-NOTIZEN:
|
||||||
|
${notesSummary}
|
||||||
|
|
||||||
|
AUFGABE:
|
||||||
|
Analysiere die Notizen und erkenne wiederkehrende Themen (Schlüsselwörter, Themen, Orte, Personen).
|
||||||
|
Ein Thema muss in mindestens 5 verschiedenen Notizen erscheinen, um vorgeschlagen zu werden.
|
||||||
|
|
||||||
|
ANTWORTFORMAT (JSON):
|
||||||
|
{
|
||||||
|
"labels": [
|
||||||
|
{
|
||||||
|
"nom": "label_name",
|
||||||
|
"note_indices": [0, 5, 12, 23, 45],
|
||||||
|
"confiance": 0.85
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
REGELN:
|
||||||
|
- Der Labelname muss kurz sein (max 1-2 Wörter)
|
||||||
|
- Ein Thema muss in 5+ Notizen erscheinen, um vorgeschlagen zu werden
|
||||||
|
- Konfidenz muss > 0.60 sein
|
||||||
|
- Schlage keine Labels vor, die bereits existieren
|
||||||
|
- Priorisiere Orte, Personen, klare Kategorien
|
||||||
|
- Maximal 5 Vorschläge
|
||||||
|
|
||||||
|
Beispiele für gute Labels:
|
||||||
|
- "tokio", "kyoto", "osaka" (Orte)
|
||||||
|
- "hotels", "restaurants", "flüge" (Kategorien)
|
||||||
|
- "maria", "johannes", "team" (Personen)
|
||||||
|
|
||||||
|
Deine Antwort (nur JSON):
|
||||||
`.trim()
|
`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return instructions[language] || instructions['en'] || instructions['fr']
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -36,9 +36,10 @@ export class BatchOrganizationService {
|
|||||||
/**
|
/**
|
||||||
* Analyze all notes in "Notes générales" and create an organization plan
|
* Analyze all notes in "Notes générales" and create an organization plan
|
||||||
* @param userId - User ID
|
* @param userId - User ID
|
||||||
|
* @param language - User's preferred language (default: 'en')
|
||||||
* @returns Organization plan with notebook assignments
|
* @returns Organization plan with notebook assignments
|
||||||
*/
|
*/
|
||||||
async createOrganizationPlan(userId: string): Promise<OrganizationPlan> {
|
async createOrganizationPlan(userId: string, language: string = 'en'): Promise<OrganizationPlan> {
|
||||||
// 1. Get all notes without notebook (Inbox/Notes générales)
|
// 1. Get all notes without notebook (Inbox/Notes générales)
|
||||||
const notesWithoutNotebook = await prisma.note.findMany({
|
const notesWithoutNotebook = await prisma.note.findMany({
|
||||||
where: {
|
where: {
|
||||||
@ -86,7 +87,7 @@ export class BatchOrganizationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Call AI to create organization plan
|
// 3. Call AI to create organization plan
|
||||||
const plan = await this.aiOrganizeNotes(notesWithoutNotebook, notebooks)
|
const plan = await this.aiOrganizeNotes(notesWithoutNotebook, notebooks, language)
|
||||||
|
|
||||||
return plan
|
return plan
|
||||||
}
|
}
|
||||||
@ -96,9 +97,10 @@ export class BatchOrganizationService {
|
|||||||
*/
|
*/
|
||||||
private async aiOrganizeNotes(
|
private async aiOrganizeNotes(
|
||||||
notes: NoteForOrganization[],
|
notes: NoteForOrganization[],
|
||||||
notebooks: any[]
|
notebooks: any[],
|
||||||
|
language: string
|
||||||
): Promise<OrganizationPlan> {
|
): Promise<OrganizationPlan> {
|
||||||
const prompt = this.buildPrompt(notes, notebooks)
|
const prompt = this.buildPrompt(notes, notebooks, language)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
@ -121,9 +123,9 @@ export class BatchOrganizationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build prompt for AI (always in French - interface language)
|
* Build prompt for AI (localized)
|
||||||
*/
|
*/
|
||||||
private buildPrompt(notes: NoteForOrganization[], notebooks: any[]): string {
|
private buildPrompt(notes: NoteForOrganization[], notebooks: any[], language: string = 'en'): string {
|
||||||
const notebookList = notebooks
|
const notebookList = notebooks
|
||||||
.map(nb => {
|
.map(nb => {
|
||||||
const labels = nb.labels.map((l: any) => l.name).join(', ')
|
const labels = nb.labels.map((l: any) => l.name).join(', ')
|
||||||
@ -140,7 +142,9 @@ export class BatchOrganizationService {
|
|||||||
})
|
})
|
||||||
.join('\n')
|
.join('\n')
|
||||||
|
|
||||||
return `
|
// System instructions based on language
|
||||||
|
const instructions: Record<string, string> = {
|
||||||
|
fr: `
|
||||||
Tu es un assistant qui organise des notes en les regroupant par thématique dans des carnets.
|
Tu es un assistant qui organise des notes en les regroupant par thématique dans des carnets.
|
||||||
|
|
||||||
CARNETS DISPONIBLES :
|
CARNETS DISPONIBLES :
|
||||||
@ -175,7 +179,7 @@ Pour chaque carnet, liste les notes qui lui appartiennent :
|
|||||||
{
|
{
|
||||||
"index": 0,
|
"index": 0,
|
||||||
"confiance": 0.95,
|
"confiance": 0.95,
|
||||||
"raison": "Courte explication"
|
"raison": "Courte explication en français"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -186,9 +190,684 @@ RÈGLES :
|
|||||||
- Seules les notes avec confiance > 0.60 doivent être assignées
|
- Seules les notes avec confiance > 0.60 doivent être assignées
|
||||||
- Si une note est trop générique, ne l'assigne pas
|
- Si une note est trop générique, ne l'assigne pas
|
||||||
- Sois précis dans tes regroupements thématiques
|
- Sois précis dans tes regroupements thématiques
|
||||||
|
- Ta réponse doit être uniquement un JSON valide
|
||||||
|
`.trim(),
|
||||||
|
en: `
|
||||||
|
You are an assistant that organizes notes by grouping them into notebooks based on their theme.
|
||||||
|
|
||||||
Ta réponse (JSON seulement) :
|
AVAILABLE NOTEBOOKS:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
NOTES TO ORGANIZE (Inbox):
|
||||||
|
${notesList}
|
||||||
|
|
||||||
|
TASK:
|
||||||
|
Analyze each note and suggest the MOST appropriate notebook.
|
||||||
|
Consider:
|
||||||
|
1. The subject/theme of the note (MOST IMPORTANT)
|
||||||
|
2. Existing labels in each notebook
|
||||||
|
3. Thematic consistency between notes in the same notebook
|
||||||
|
|
||||||
|
CLASSIFICATION GUIDE:
|
||||||
|
- SPORT/EXERCISE/SHOPPING/GROCERIES → Personal Notebook
|
||||||
|
- HOBBIES/PASSIONS/OUTINGS → Personal Notebook
|
||||||
|
- HEALTH/FITNESS/DOCTOR → Personal Notebook or Health
|
||||||
|
- FAMILY/FRIENDS → Personal Notebook
|
||||||
|
- WORK/MEETINGS/PROJECTS/CLIENTS → Work Notebook
|
||||||
|
- CODING/TECH/DEVELOPMENT → Work Notebook or Code
|
||||||
|
- FINANCE/BILLS/BANKING → Personal Notebook or Finance
|
||||||
|
|
||||||
|
RESPONSE FORMAT (JSON):
|
||||||
|
For each notebook, list the notes that belong to it:
|
||||||
|
{
|
||||||
|
"carnets": [
|
||||||
|
{
|
||||||
|
"nom": "Notebook Name",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"confiance": 0.95,
|
||||||
|
"raison": "Short explanation in English"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Only assign notes with confidence > 0.60
|
||||||
|
- If a note is too generic, do not assign it
|
||||||
|
- Be precise in your thematic groupings
|
||||||
|
- Your response must be valid JSON only
|
||||||
|
`.trim(),
|
||||||
|
es: `
|
||||||
|
Eres un asistente que organiza notas agrupándolas por temática en cuadernos.
|
||||||
|
|
||||||
|
CUADERNOS DISPONIBLES:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
NOTAS A ORGANIZAR (Bandeja de entrada):
|
||||||
|
${notesList}
|
||||||
|
|
||||||
|
TAREA:
|
||||||
|
Analiza cada nota y sugiere el cuaderno MÁS apropiado.
|
||||||
|
Considera:
|
||||||
|
1. El tema/asunto de la nota (LO MÁS IMPORTANTE)
|
||||||
|
2. Etiquetas existentes en cada cuaderno
|
||||||
|
3. Coherencia temática entre notas del mismo cuaderno
|
||||||
|
|
||||||
|
GUÍA DE CLASIFICACIÓN:
|
||||||
|
- DEPORTE/EJERCICIO/COMPRAS → Cuaderno Personal
|
||||||
|
- HOBBIES/PASIONES/SALIDAS → Cuaderno Personal
|
||||||
|
- SALUD/FITNESS/DOCTOR → Cuaderno Personal o Salud
|
||||||
|
- FAMILIA/AMIGOS → Cuaderno Personal
|
||||||
|
- TRABAJO/REUNIONES/PROYECTOS → Cuaderno Trabajo
|
||||||
|
- CODING/TECH/DESARROLLO → Cuaderno Trabajo o Código
|
||||||
|
- FINANZAS/FACTURAS/BANCO → Cuaderno Personal o Finanzas
|
||||||
|
|
||||||
|
FORMATO DE RESPUESTA (JSON):
|
||||||
|
Para cada cuaderno, lista las notas que le pertenecen:
|
||||||
|
{
|
||||||
|
"carnets": [
|
||||||
|
{
|
||||||
|
"nom": "Nombre del cuaderno",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"confiance": 0.95,
|
||||||
|
"raison": "Breve explicación en español"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
REGLAS:
|
||||||
|
- Solo asigna notas con confianza > 0.60
|
||||||
|
- Si una nota es demasiado genérica, no la asignes
|
||||||
|
- Sé preciso en tus agrupaciones temáticas
|
||||||
|
- Tu respuesta debe ser únicamente un JSON válido
|
||||||
|
`.trim(),
|
||||||
|
de: `
|
||||||
|
Du bist ein Assistent, der Notizen organisiert, indem er sie thematisch in Notizbücher gruppiert.
|
||||||
|
|
||||||
|
VERFÜGBARE NOTIZBÜCHER:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
ZU ORGANISIERENDE NOTIZEN (Eingang):
|
||||||
|
${notesList}
|
||||||
|
|
||||||
|
AUFGABE:
|
||||||
|
Analysiere jede Notiz und schlage das AM BESTEN geeignete Notizbuch vor.
|
||||||
|
Berücksichtige:
|
||||||
|
1. Das Thema/den Inhalt der Notiz (AM WICHTIGSTEN)
|
||||||
|
2. Vorhandene Labels in jedem Notizbuch
|
||||||
|
3. Thematische Konsistenz zwischen Notizen im selben Notizbuch
|
||||||
|
|
||||||
|
KLASSIFIZIERUNGSLEITFADEN:
|
||||||
|
- SPORT/ÜBUNG/EINKAUFEN → Persönliches Notizbuch
|
||||||
|
- HOBBYS/LEIDENSCHAFTEN → Persönliches Notizbuch
|
||||||
|
- GESUNDHEIT/FITNESS/ARZT → Persönliches Notizbuch oder Gesundheit
|
||||||
|
- FAMILIE/FREUNDE → Persönliches Notizbuch
|
||||||
|
- ARBEIT/MEETINGS/PROJEKTE → Arbeitsnotizbuch
|
||||||
|
- CODING/TECH/ENTWICKLUNG → Arbeitsnotizbuch oder Code
|
||||||
|
- FINANZEN/RECHNUNGEN/BANK → Persönliches Notizbuch oder Finanzen
|
||||||
|
|
||||||
|
ANTWORTFORMAT (JSON):
|
||||||
|
Für jedes Notizbuch, liste die zugehörigen Notizen auf:
|
||||||
|
{
|
||||||
|
"carnets": [
|
||||||
|
{
|
||||||
|
"nom": "Name des Notizbuchs",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"confiance": 0.95,
|
||||||
|
"raison": "Kurze Erklärung auf Deutsch"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
REGELN:
|
||||||
|
- Ordne nur Notizen mit Konfidenz > 0.60
|
||||||
|
- Wenn eine Notiz zu allgemein ist, ordne sie nicht zu
|
||||||
|
- Sei präzise in deinen thematischen Gruppierungen
|
||||||
|
- Deine Antwort muss ein gültiges JSON sein
|
||||||
|
`.trim(),
|
||||||
|
it: `
|
||||||
|
Sei un assistente che organizza le note raggruppandole per tema nei taccuini.
|
||||||
|
|
||||||
|
TACCUINI DISPONIBILI:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
NOTE DA ORGANIZZARE (In arrivo):
|
||||||
|
${notesList}
|
||||||
|
|
||||||
|
COMPITO:
|
||||||
|
Analizza ogni nota e suggerisci il taccuino PIÙ appropriato.
|
||||||
|
Considera:
|
||||||
|
1. L'argomento/tema della nota (PIÙ IMPORTANTE)
|
||||||
|
2. Etichette esistenti in ogni taccuino
|
||||||
|
3. Coerenza tematica tra le note dello stesso taccuino
|
||||||
|
|
||||||
|
GUIDA ALLA CLASSIFICAZIONE:
|
||||||
|
- SPORT/ESERCIZIO/SHOPPING → Taccuino Personale
|
||||||
|
- HOBBY/PASSIONI/USCITE → Taccuino Personale
|
||||||
|
- SALUTE/FITNESS/DOTTORE → Taccuino Personale o Salute
|
||||||
|
- FAMIGLIA/AMIGOS → Taccuino Personale
|
||||||
|
- LAVORO/RIUNIONI/PROGETTI → Taccuino Lavoro
|
||||||
|
- CODING/TECH/SVILUPPO → Taccuino Lavoro o Codice
|
||||||
|
- FINANZA/BILLS/BANCA → Taccuino Personale o Finanza
|
||||||
|
|
||||||
|
FORMATO RISPOSTA (JSON):
|
||||||
|
Per ogni taccuino, elenca le note che appartengono ad esso:
|
||||||
|
{
|
||||||
|
"carnets": [
|
||||||
|
{
|
||||||
|
"nom": "Nome del taccuino",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"confiance": 0.95,
|
||||||
|
"raison": "Breve spiegazione in italiano"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
REGOLE:
|
||||||
|
- Assegna solo note con confidenza > 0.60
|
||||||
|
- Se una nota è troppo generica, non assegnarla
|
||||||
|
- Sii preciso nei tuoi raggruppamenti tematici
|
||||||
|
- La tua risposta deve essere solo un JSON valido
|
||||||
|
`.trim(),
|
||||||
|
pt: `
|
||||||
|
Você é um assistente que organiza notas agrupando-as por tema em cadernos.
|
||||||
|
|
||||||
|
CADERNOS DISPONÍVEIS:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
NOTAS A ORGANIZAR (Caixa de entrada):
|
||||||
|
${notesList}
|
||||||
|
|
||||||
|
TAREFA:
|
||||||
|
Analise cada nota e sugira o caderno MAIS apropriado.
|
||||||
|
Considere:
|
||||||
|
1. O assunto/tema da nota (MAIS IMPORTANTE)
|
||||||
|
2. Etiquetas existentes em cada caderno
|
||||||
|
3. Coerência temática entre notas do mesmo caderno
|
||||||
|
|
||||||
|
GUIA DE CLASSIFICAÇÃO:
|
||||||
|
- ESPORTE/EXERCÍCIO/COMPRAS → Caderno Pessoal
|
||||||
|
- HOBBIES/PAIXÕES/SAÍDAS → Caderno Pessoal
|
||||||
|
- SAÚDE/FITNESS/MÉDICO → Caderno Pessoal ou Saúde
|
||||||
|
- FAMÍLIA/AMIGOS → Caderno Pessoal
|
||||||
|
- TRABALHO/REUNIÕES/PROJETOS → Caderno Trabalho
|
||||||
|
- CODING/TECH/DESENVOLVIMENTO → Caderno Trabalho ou Código
|
||||||
|
- FINANÇAS/CONTAS/BANCO → Caderno Pessoal ou Finanças
|
||||||
|
|
||||||
|
FORMATO DE RESPOSTA (JSON):
|
||||||
|
Para cada caderno, liste as notas que pertencem a ele:
|
||||||
|
{
|
||||||
|
"carnets": [
|
||||||
|
{
|
||||||
|
"nom": "Nome do caderno",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"confiance": 0.95,
|
||||||
|
"raison": "Breve explicação em português"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
REGRAS:
|
||||||
|
- Apenas atribua notas com confiança > 0.60
|
||||||
|
- Se uma nota for muito genérica, não a atribua
|
||||||
|
- Seja preciso em seus agrupamentos temáticos
|
||||||
|
- Sua resposta deve ser apenas um JSON válido
|
||||||
|
`.trim(),
|
||||||
|
nl: `
|
||||||
|
Je bent een assistent die notities organiseert door ze thematisch in notitieboekjes te groeperen.
|
||||||
|
|
||||||
|
BESCHIKBARE NOTITIEBOEKJES:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
TE ORGANISEREN NOTITIES (Inbox):
|
||||||
|
${notesList}
|
||||||
|
|
||||||
|
TAAK:
|
||||||
|
Analyseer elke notitie en stel het MEEST geschikte notitieboek voor.
|
||||||
|
Overweeg:
|
||||||
|
1. Het onderwerp/thema van de notitie (BELANGRIJKST)
|
||||||
|
2. Bestaande labels in elk notitieboekje
|
||||||
|
3. Thematische consistentie tussen notities in hetzelfde notitieboekje
|
||||||
|
|
||||||
|
CLASSIFICATIEGIDS:
|
||||||
|
- SPORT/OEFENING/WINKELEN → Persoonlijk Notitieboek
|
||||||
|
- HOBBIES/PASSIES/UITJES → Persoonlijk Notitieboek
|
||||||
|
- GEZONDHEID/FITNESS/DOKTER → Persoonlijk Notitieboek of Gezondheid
|
||||||
|
- FAMILIE/VRIENDEN → Persoonlijk Notitieboek
|
||||||
|
- WERK/VERGADERINGEN/PROJECTEN → Werk Notitieboek
|
||||||
|
- CODING/TECH/ONTWIKKELING → Werk Notitieboek of Code
|
||||||
|
- FINANCIËN/REKENINGEN/BANK → Persoonlijk Notitieboek of Financiën
|
||||||
|
|
||||||
|
ANTWOORDFORMAAT (JSON):
|
||||||
|
Voor elk notitieboekje, lijst de notities op die erbij horen:
|
||||||
|
{
|
||||||
|
"carnets": [
|
||||||
|
{
|
||||||
|
"nom": "Naam van het notitieboekje",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"confiance": 0.95,
|
||||||
|
"raison": "Korte uitleg in het Nederlands"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
REGELS:
|
||||||
|
- Wijs alleen notities toe met vertrouwen > 0.60
|
||||||
|
- Als een notitie te generiek is, wijs deze dan niet toe
|
||||||
|
- Wees nauwkeurig in je thematische groeperingen
|
||||||
|
- Je antwoord moet alleen een geldige JSON zijn
|
||||||
|
`.trim(),
|
||||||
|
pl: `
|
||||||
|
Jesteś asystentem, który organizuje notatki, grupując je tematycznie w notatnikach.
|
||||||
|
|
||||||
|
DOSTĘPNE NOTATNIKI:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
NOTATKI DO ZORGANIZOWANIA (Skrzynka odbiorcza):
|
||||||
|
${notesList}
|
||||||
|
|
||||||
|
ZADANIE:
|
||||||
|
Przeanalizuj każdą notatkę i zasugeruj NAJBARDZIEJ odpowiedni notatnik.
|
||||||
|
Rozważ:
|
||||||
|
1. Temat/treść notatki (NAJWAŻNIEJSZE)
|
||||||
|
2. Istniejące etykiety w każdym notatniku
|
||||||
|
3. Spójność tematyczna między notatkami w tym samym notatniku
|
||||||
|
|
||||||
|
PRZEWODNIK KLASYFIKACJI:
|
||||||
|
- SPORT/ĆWICZENIA/ZAKUPY → Notatnik Osobisty
|
||||||
|
- HOBBY/PASJE/WYJŚCIA → Notatnik Osobisty
|
||||||
|
- ZDROWIE/FITNESS/LEKARZ → Notatnik Osobisty lub Zdrowie
|
||||||
|
- RODZINA/PRZYJACIELE → Notatnik Osobisty
|
||||||
|
- PRACA/SPOTKANIA/PROJEKTY → Notatnik Praca
|
||||||
|
- KODOWANIE/TECH/ROZWÓJ → Notatnik Praca lub Kod
|
||||||
|
- FINANSE/RACHUNKI/BANK → Notatnik Osobisty lub Finanse
|
||||||
|
|
||||||
|
FORMAT ODPOWIEDZI (JSON):
|
||||||
|
Dla każdego notatnika wymień należące do niego notatki:
|
||||||
|
{
|
||||||
|
"carnets": [
|
||||||
|
{
|
||||||
|
"nom": "Nazwa notatnika",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"confiance": 0.95,
|
||||||
|
"raison": "Krótkie wyjaśnienie po polsku"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
ZASADY:
|
||||||
|
- Przypisuj tylko notatki z pewnością > 0.60
|
||||||
|
- Jeśli notatka jest zbyt ogólna, nie przypisuj jej
|
||||||
|
- Bądź precyzyjny w swoich grupach tematycznych
|
||||||
|
- Twoja odpowiedź musi być tylko prawidłowym JSON
|
||||||
|
`.trim(),
|
||||||
|
ru: `
|
||||||
|
Вы помощник, который организует заметки, группируя их по темам в блокноты.
|
||||||
|
|
||||||
|
ДОСТУПНЫЕ БЛОКНОТЫ:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
ЗАМЕТКИ ДЛЯ ОРГАНИЗАЦИИ (Входящие):
|
||||||
|
${notesList}
|
||||||
|
|
||||||
|
ЗАДАЧА:
|
||||||
|
Проанализируйте каждую заметку и предложите САМЫЙ подходящий блокнот.
|
||||||
|
Учитывайте:
|
||||||
|
1. Тему/предмет заметки (САМОЕ ВАЖНОЕ)
|
||||||
|
2. Существующие метки в каждом блокноте
|
||||||
|
3. Тематическую согласованность между заметками в одном блокноте
|
||||||
|
|
||||||
|
РУКОВОДСТВО ПО КЛАССИФИКАЦИИ:
|
||||||
|
- СПОРТ/УПРАЖНЕНИЯ/ПОКУПКИ → Личный блокнот
|
||||||
|
- ХОББИ/УВЛЕЧЕНИЯ/ВЫХОДЫ → Личный блокнот
|
||||||
|
- ЗДОРОВЬЕ/ФИТНЕС/ВРАЧ → Личный блокнот или Здоровье
|
||||||
|
- СЕМЬЯ/ДРУЗЬЯ → Личный блокнот
|
||||||
|
- РАБОТА/СОВЕЩАНИЯ/ПРОЕКТЫ → Рабочий блокнот
|
||||||
|
- КОДИНГ/ТЕХНОЛОГИИ/РАЗРАБОТКА → Рабочий блокнот или Код
|
||||||
|
- ФИНАНСЫ/СЧЕТА/БАНК → Личный блокнот или Финансы
|
||||||
|
|
||||||
|
ФОРМАТ ОТВЕТА (JSON):
|
||||||
|
Для каждого блокнота перечислите заметки, которые к нему относятся:
|
||||||
|
{
|
||||||
|
"carnets": [
|
||||||
|
{
|
||||||
|
"nom": "Название блокнота",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"confiance": 0.95,
|
||||||
|
"raison": "Краткое объяснение на русском"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
ПРАВИЛА:
|
||||||
|
- Назначайте только заметки с уверенностью > 0.60
|
||||||
|
- Если заметка слишком общая, не назначайте ее
|
||||||
|
- Будьте точны в своих тематических группировках
|
||||||
|
- Ваш ответ должен быть только валидным JSON
|
||||||
|
`.trim(),
|
||||||
|
ja: `
|
||||||
|
あなたは、テーマごとにノートを分類してノートブックに整理するアシスタントです。
|
||||||
|
|
||||||
|
利用可能なノートブック:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
整理するノート (受信トレイ):
|
||||||
|
${notesList}
|
||||||
|
|
||||||
|
タスク:
|
||||||
|
各ノートを分析し、最も適切なノートブックを提案してください。
|
||||||
|
考慮事項:
|
||||||
|
1. ノートの主題/テーマ (最も重要)
|
||||||
|
2. 各ノートブックの既存のラベル
|
||||||
|
3. 同じノートブック内のノート間のテーマの一貫性
|
||||||
|
|
||||||
|
分類ガイド:
|
||||||
|
- スポーツ/運動/買い物 → 個人用ノートブック
|
||||||
|
- 趣味/情熱/外出 → 個人用ノートブック
|
||||||
|
- 健康/フィットネス/医師 → 個人用ノートブックまたは健康
|
||||||
|
- 家族/友人 → 個人用ノートブック
|
||||||
|
- 仕事/会議/プロジェクト → 仕事用ノートブック
|
||||||
|
- コーディング/技術/開発 → 仕事用ノートブックまたはコード
|
||||||
|
- 金融/請求書/銀行 → 個人用ノートブックまたは金融
|
||||||
|
|
||||||
|
回答形式 (JSON):
|
||||||
|
各ノートブックについて、それに属するノートをリストアップしてください:
|
||||||
|
{
|
||||||
|
"carnets": [
|
||||||
|
{
|
||||||
|
"nom": "ノートブック名",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"confiance": 0.95,
|
||||||
|
"raison": "日本語での短い説明"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
ルール:
|
||||||
|
- 信頼度が 0.60 を超えるノートのみを割り当ててください
|
||||||
|
- ノートが一般的すぎる場合は、割り当てないでください
|
||||||
|
- テーマ別のグループ化において正確であってください
|
||||||
|
- 回答は有効な JSON のみにしてください
|
||||||
|
`.trim(),
|
||||||
|
ko: `
|
||||||
|
당신은 주제별로 노트를 분류하여 노트북으로 정리하는 도우미입니다.
|
||||||
|
|
||||||
|
사용 가능한 노트북:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
정리할 노트 (받은 편지함):
|
||||||
|
${notesList}
|
||||||
|
|
||||||
|
작업:
|
||||||
|
각 노트를 분석하고 가장 적절한 노트북을 제안하십시오.
|
||||||
|
고려 사항:
|
||||||
|
1. 노트의 주제/테마 (가장 중요)
|
||||||
|
2. 각 노트북의 기존 라벨
|
||||||
|
3. 동일한 노트북 내 노트 간의 주제별 일관성
|
||||||
|
|
||||||
|
분류 가이드:
|
||||||
|
- 스포츠/운동/쇼핑 → 개인 노트북
|
||||||
|
- 취미/열정/외출 → 개인 노트북
|
||||||
|
- 건강/피트니스/의사 → 개인 노트북 또는 건강
|
||||||
|
- 가족/친구 → 개인 노트북
|
||||||
|
- 업무/회의/프로젝트 → 업무 노트북
|
||||||
|
- 코딩/기술/개발 → 업무 노트북 또는 코드
|
||||||
|
- 금융/청구서/은행 → 개인 노트북 또는 금융
|
||||||
|
|
||||||
|
응답 형식 (JSON):
|
||||||
|
각 노트북에 대해 속한 노트를 나열하십시오:
|
||||||
|
{
|
||||||
|
"carnets": [
|
||||||
|
{
|
||||||
|
"nom": "노트북 이름",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"confiance": 0.95,
|
||||||
|
"raison": "한국어로 된 짧은 설명"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
- 신뢰도가 0.60을 초과하는 노트만 할당하십시오
|
||||||
|
- 노트가 너무 일반적인 경우 할당하지 마십시오
|
||||||
|
- 주제별 그룹화에서 정확해야 합니다
|
||||||
|
- 응답은 유효한 JSON이어야 합니다
|
||||||
|
`.trim(),
|
||||||
|
zh: `
|
||||||
|
你是一个助手,负责通过按主题将笔记分组到笔记本中来整理笔记。
|
||||||
|
|
||||||
|
可用笔记本:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
待整理笔记(收件箱):
|
||||||
|
${notesList}
|
||||||
|
|
||||||
|
任务:
|
||||||
|
分析每个笔记并建议最合适的笔记本。
|
||||||
|
考虑:
|
||||||
|
1. 笔记的主题/题材(最重要)
|
||||||
|
2. 每个笔记本中的现有标签
|
||||||
|
3. 同一笔记本中笔记之间的主题一致性
|
||||||
|
|
||||||
|
分类指南:
|
||||||
|
- 运动/锻炼/购物 → 个人笔记本
|
||||||
|
- 爱好/激情/郊游 → 个人笔记本
|
||||||
|
- 健康/健身/医生 → 个人笔记本或健康
|
||||||
|
- 家庭/朋友 → 个人笔记本
|
||||||
|
- 工作/会议/项目 → 工作笔记本
|
||||||
|
- 编码/技术/开发 → 工作笔记本或代码
|
||||||
|
- 金融/账单/银行 → 个人笔记本或金融
|
||||||
|
|
||||||
|
响应格式 (JSON):
|
||||||
|
对于每个笔记本,列出属于它的笔记:
|
||||||
|
{
|
||||||
|
"carnets": [
|
||||||
|
{
|
||||||
|
"nom": "笔记本名称",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"confiance": 0.95,
|
||||||
|
"raison": "中文简短说明"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 仅分配置信度 > 0.60 的笔记
|
||||||
|
- 如果笔记太普通,请勿分配
|
||||||
|
- 在主题分组中要精确
|
||||||
|
- 您的响应必须仅为有效的 JSON
|
||||||
|
`.trim(),
|
||||||
|
ar: `
|
||||||
|
أنت مساعد يقوم بتنظيم الملاحظات عن طريق تجميعها حسب الموضوع في دفاتر ملاحظات.
|
||||||
|
|
||||||
|
دفاتر الملاحظات المتاحة:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
ملاحظات للتنظيم (صندوق الوارد):
|
||||||
|
${notesList}
|
||||||
|
|
||||||
|
المهمة:
|
||||||
|
حلل كل ملاحظة واقترح دفتر الملاحظات الأكثر ملاءمة.
|
||||||
|
اعتبار:
|
||||||
|
1. موضوع/مادة الملاحظة (الأهم)
|
||||||
|
2. التسميات الموجودة في كل دفتر ملاحظات
|
||||||
|
3. الاتساق الموضوعي بين الملاحظات في نفس دفتر الملاحظات
|
||||||
|
|
||||||
|
دليل التصنيف:
|
||||||
|
- الرياضة/التمرين/التسوق → دفتر ملاحظات شخصي
|
||||||
|
- الهوايات/الشغف/النزهات → دفتر ملاحظات شخصي
|
||||||
|
- الصحة/اللياقة البدنية/الطبيب → دفتر ملاحظات شخصي أو صحة
|
||||||
|
- العائلة/الأصدقاء → دفتر ملاحظات شخصي
|
||||||
|
- العمل/الاجتماعات/المشاريع → دفتر ملاحظات العمل
|
||||||
|
- البرمجة/التقنية/التطوير → دفتر ملاحظات العمل أو الكود
|
||||||
|
- المالية/الفواتير/البنك → دفتر ملاحظات شخصي أو مالية
|
||||||
|
|
||||||
|
تنسيق الاستجابة (JSON):
|
||||||
|
لكل دفتر ملاحظات، ضع قائمة بالملاحظات التي تنتمي إليه:
|
||||||
|
{
|
||||||
|
"carnets": [
|
||||||
|
{
|
||||||
|
"nom": "اسم دفتر الملاحظات",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"confiance": 0.95,
|
||||||
|
"raison": "شرح قصير باللغة العربية"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
القواعد:
|
||||||
|
- عيّن فقط الملاحظات ذات الثقة > 0.60
|
||||||
|
- إذا كانت الملاحظة عامة جدًا، فلا تقم بتعيينها
|
||||||
|
- كن دقيقًا في مجموعاتك الموضوعية
|
||||||
|
- يجب أن تكون إجابتك بتنسيق JSON صالح فقط
|
||||||
|
`.trim(),
|
||||||
|
hi: `
|
||||||
|
आप एक सहायक हैं जो नोटों को विषय के आधार पर नोटबुक में समूहित करके व्यवस्थित करते हैं।
|
||||||
|
|
||||||
|
उपलब्ध नोटबुक:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
व्यवस्थित करने के लिए नोट्स (इनबॉक्स):
|
||||||
|
${notesList}
|
||||||
|
|
||||||
|
कार्य:
|
||||||
|
प्रत्येक नोट का विश्लेषण करें और सबसे उपयुक्त नोटबुक का सुझाव दें।
|
||||||
|
विचार करें:
|
||||||
|
1. नोट का विषय/थीम (सबसे महत्वपूर्ण)
|
||||||
|
2. प्रत्येक नोटबुक में मौजूदा लेबल
|
||||||
|
3. एक ही नोटबुक में नोटों के बीच विषयगत स्थिरता
|
||||||
|
|
||||||
|
वर्गीकरण गाइड:
|
||||||
|
- खेल/व्यायाम/खरीदारी → व्यक्तिगत नोटबुक
|
||||||
|
- शौक/जुनून/बाहर जाना → व्यक्तिगत नोटबुक
|
||||||
|
- स्वास्थ्य/फिटनेस/डॉक्टर → व्यक्तिगत नोटबुक या स्वास्थ्य
|
||||||
|
- परिवार/मित्र → व्यक्तिगत नोटबुक
|
||||||
|
- कार्य/बैठकें/परियोजनाएं → कार्य नोटबुक
|
||||||
|
- कोडिंग/तकनीक/विकास → कार्य नोटबुक या कोड
|
||||||
|
- वित्त/बिल/बैंक → व्यक्तिगत नोटबुक या वित्त
|
||||||
|
|
||||||
|
प्रतिक्रिया प्रारूप (JSON):
|
||||||
|
प्रत्येक नोटबुक के लिए, उन नोटों को सूचीबद्ध करें जो उससे संबंधित हैं:
|
||||||
|
{
|
||||||
|
"carnets": [
|
||||||
|
{
|
||||||
|
"nom": "नोटबुक का नाम",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"confiance": 0.95,
|
||||||
|
"raison": "हिंदी में संक्षिप्त स्पष्टीकरण"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
नियम:
|
||||||
|
- केवल > 0.60 आत्मविश्वास वाले नोट्स असाइन करें
|
||||||
|
- यदि कोई नोट बहुत सामान्य है, तो उसे असाइन न करें
|
||||||
|
- अपने विषयगत समूहों में सटीक रहें
|
||||||
|
- आपकी प्रतिक्रिया केवल वैध JSON होनी चाहिए
|
||||||
|
`.trim(),
|
||||||
|
fa: `
|
||||||
|
شما دستیاری هستید که یادداشتها را با گروهبندی موضوعی در دفترچهها سازماندهی میکنید.
|
||||||
|
|
||||||
|
دفترچههای موجود:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
یادداشتهای برای سازماندهی (صندوق ورودی):
|
||||||
|
${notesList}
|
||||||
|
|
||||||
|
وظیفه:
|
||||||
|
هر یادداشت را تحلیل کنید و مناسبترین دفترچه را پیشنهاد دهید.
|
||||||
|
در نظر بگیرید:
|
||||||
|
1. موضوع/تم یادداشت (مهمترین)
|
||||||
|
2. برچسبهای موجود در هر دفترچه
|
||||||
|
3. سازگاری موضوعی بین یادداشتها در همان دفترچه
|
||||||
|
|
||||||
|
راهنمای طبقهبندی:
|
||||||
|
- ورزش/تمرین/خرید → دفترچه شخصی
|
||||||
|
- سرگرمیها/علایق/گردش → دفترچه شخصی
|
||||||
|
- سلامت/تناسب اندام/پزشک → دفترچه شخصی یا سلامت
|
||||||
|
- خانواده/دوستان → دفترچه شخصی
|
||||||
|
- کار/جلسات/پروژهها → دفترچه کار
|
||||||
|
- کدنویسی/تکنولوژی/توسعه → دفترچه کار یا کد
|
||||||
|
- مالی/قبضها/بانک → دفترچه شخصی یا مالی
|
||||||
|
|
||||||
|
فرمت پاسخ (JSON):
|
||||||
|
برای هر دفترچه، یادداشتهایی که به آن تعلق دارند را لیست کنید:
|
||||||
|
{
|
||||||
|
"carnets": [
|
||||||
|
{
|
||||||
|
"nom": "نام دفترچه",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"confiance": 0.95,
|
||||||
|
"raison": "توضیح کوتاه به فارسی"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
قوانین:
|
||||||
|
- فقط یادداشتهای با اطمینان > 0.60 را اختصاص دهید
|
||||||
|
- اگر یادداشتی خیلی کلی است، آن را اختصاص ندهید
|
||||||
|
- در گروهبندیهای موضوعی خود دقیق باشید
|
||||||
|
- پاسخ شما باید فقط یک JSON معتبر باشد
|
||||||
`.trim()
|
`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return instruction for requested language, fallback to English
|
||||||
|
return instructions[language] || instructions['en'] || instructions['fr']
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -26,7 +26,8 @@ export class ContextualAutoTagService {
|
|||||||
async suggestLabels(
|
async suggestLabels(
|
||||||
noteContent: string,
|
noteContent: string,
|
||||||
notebookId: string | null,
|
notebookId: string | null,
|
||||||
userId: string
|
userId: string,
|
||||||
|
language: string = 'en'
|
||||||
): Promise<LabelSuggestion[]> {
|
): Promise<LabelSuggestion[]> {
|
||||||
// If no notebook, return empty (no context)
|
// If no notebook, return empty (no context)
|
||||||
if (!notebookId) {
|
if (!notebookId) {
|
||||||
@ -54,11 +55,11 @@ export class ContextualAutoTagService {
|
|||||||
|
|
||||||
// CASE 1: Notebook has existing labels → suggest from them (IA2)
|
// CASE 1: Notebook has existing labels → suggest from them (IA2)
|
||||||
if (notebook.labels.length > 0) {
|
if (notebook.labels.length > 0) {
|
||||||
return await this.suggestFromExistingLabels(noteContent, notebook)
|
return await this.suggestFromExistingLabels(noteContent, notebook, language)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CASE 2: Notebook has NO labels → suggest NEW labels to create
|
// CASE 2: Notebook has NO labels → suggest NEW labels to create
|
||||||
return await this.suggestNewLabels(noteContent, notebook)
|
return await this.suggestNewLabels(noteContent, notebook, language)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -66,12 +67,13 @@ export class ContextualAutoTagService {
|
|||||||
*/
|
*/
|
||||||
private async suggestFromExistingLabels(
|
private async suggestFromExistingLabels(
|
||||||
noteContent: string,
|
noteContent: string,
|
||||||
notebook: any
|
notebook: any,
|
||||||
|
language: string
|
||||||
): Promise<LabelSuggestion[]> {
|
): Promise<LabelSuggestion[]> {
|
||||||
const availableLabels = notebook.labels.map((l: any) => l.name)
|
const availableLabels = notebook.labels.map((l: any) => l.name)
|
||||||
|
|
||||||
// Build prompt with available labels
|
// Build prompt with available labels
|
||||||
const prompt = this.buildPrompt(noteContent, notebook.name, availableLabels)
|
const prompt = this.buildPrompt(noteContent, notebook.name, availableLabels, language)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
@ -151,10 +153,11 @@ export class ContextualAutoTagService {
|
|||||||
*/
|
*/
|
||||||
private async suggestNewLabels(
|
private async suggestNewLabels(
|
||||||
noteContent: string,
|
noteContent: string,
|
||||||
notebook: any
|
notebook: any,
|
||||||
|
language: string
|
||||||
): Promise<LabelSuggestion[]> {
|
): Promise<LabelSuggestion[]> {
|
||||||
// Build prompt to suggest NEW labels based on content
|
// Build prompt to suggest NEW labels based on content
|
||||||
const prompt = this.buildNewLabelsPrompt(noteContent, notebook.name)
|
const prompt = this.buildNewLabelsPrompt(noteContent, notebook.name, language)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
@ -229,12 +232,13 @@ export class ContextualAutoTagService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the AI prompt for contextual label suggestion
|
* Build the AI prompt for contextual label suggestion (localized)
|
||||||
*/
|
*/
|
||||||
private buildPrompt(noteContent: string, notebookName: string, availableLabels: string[]): string {
|
private buildPrompt(noteContent: string, notebookName: string, availableLabels: string[], language: string = 'en'): string {
|
||||||
const labelList = availableLabels.map(l => `- ${l}`).join('\n')
|
const labelList = availableLabels.map(l => `- ${l}`).join('\n')
|
||||||
|
|
||||||
return `
|
const instructions: Record<string, string> = {
|
||||||
|
fr: `
|
||||||
Tu es un assistant qui suggère les labels les plus appropriés pour une note.
|
Tu es un assistant qui suggère les labels les plus appropriés pour une note.
|
||||||
|
|
||||||
CONTENU DE LA NOTE :
|
CONTENU DE LA NOTE :
|
||||||
@ -267,14 +271,154 @@ FORMAT DE RÉPONSE (JSON uniquement) :
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ta réponse :
|
Ta réponse :
|
||||||
|
`.trim(),
|
||||||
|
en: `
|
||||||
|
You are an assistant that suggests the most appropriate labels for a note.
|
||||||
|
|
||||||
|
NOTE CONTENT:
|
||||||
|
${noteContent.substring(0, 1000)}
|
||||||
|
|
||||||
|
CURRENT NOTEBOOK:
|
||||||
|
${notebookName}
|
||||||
|
|
||||||
|
AVAILABLE LABELS IN THIS NOTEBOOK:
|
||||||
|
${labelList}
|
||||||
|
|
||||||
|
TASK:
|
||||||
|
Analyze the note content and suggest the MOST appropriate labels from the available labels above.
|
||||||
|
Consider:
|
||||||
|
1. Label relevance to content
|
||||||
|
2. Number of labels (maximum 3 suggestions)
|
||||||
|
3. Confidence (minimum threshold: 0.6)
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Suggest ONLY labels that are in the available labels list
|
||||||
|
- Return maximum 3 suggestions
|
||||||
|
- Each suggestion must have confidence > 0.6
|
||||||
|
- If no label is relevant, return an empty array
|
||||||
|
|
||||||
|
RESPONSE FORMAT (JSON only):
|
||||||
|
{
|
||||||
|
"suggestions": [
|
||||||
|
{ "label": "label_name", "confidence": 0.85, "reasoning": "Why this label is relevant" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Your response:
|
||||||
|
`.trim(),
|
||||||
|
fa: `
|
||||||
|
شما یک دستیار هستید که مناسبترین برچسبها را برای یک یادداشت پیشنهاد میدهید.
|
||||||
|
|
||||||
|
محتوای یادداشت:
|
||||||
|
${noteContent.substring(0, 1000)}
|
||||||
|
|
||||||
|
دفترچه فعلی:
|
||||||
|
${notebookName}
|
||||||
|
|
||||||
|
برچسبهای موجود در این دفترچه:
|
||||||
|
${labelList}
|
||||||
|
|
||||||
|
وظیفه:
|
||||||
|
محتوای یادداشت را تحلیل کنید و مناسبترین برچسبها را از لیست برچسبهای موجود در بالا پیشنهاد دهید.
|
||||||
|
در نظر بگیرید:
|
||||||
|
1. ارتباط برچسب با محتوا
|
||||||
|
2. تعداد برچسبها (حداکثر ۳ پیشنهاد)
|
||||||
|
3. اطمینان (حداقل آستانه: 0.6)
|
||||||
|
|
||||||
|
قوانین:
|
||||||
|
- فقط برچسبهایی را پیشنهاد دهید که در لیست برچسبهای موجود هستند
|
||||||
|
- حداکثر ۳ پیشنهاد برگردانید
|
||||||
|
- هر پیشنهاد باید دارای اطمینان > 0.6 باشد
|
||||||
|
- اگر هیچ برچسبی مرتبط نیست، یک اینرایه خالی برگردانید
|
||||||
|
|
||||||
|
فرمت پاسخ (فقط JSON):
|
||||||
|
{
|
||||||
|
"suggestions": [
|
||||||
|
{ "label": "نام_برچسب", "confidence": 0.85, "reasoning": "چرا این برچسب مرتبط است" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
پاسخ شما:
|
||||||
|
`.trim(),
|
||||||
|
es: `
|
||||||
|
Eres un asistente que sugiere las etiquetas más apropiadas para una nota.
|
||||||
|
|
||||||
|
CONTENIDO DE LA NOTA:
|
||||||
|
${noteContent.substring(0, 1000)}
|
||||||
|
|
||||||
|
CUADERNO ACTUAL:
|
||||||
|
${notebookName}
|
||||||
|
|
||||||
|
ETIQUETAS DISPONIBLES EN ESTE CUADERNO:
|
||||||
|
${labelList}
|
||||||
|
|
||||||
|
TAREA:
|
||||||
|
Analiza el contenido de la nota y sugiere las etiquetas MÁS apropiadas de las etiquetas disponibles arriba.
|
||||||
|
Considera:
|
||||||
|
1. Relevancia de la etiqueta para el contenido
|
||||||
|
2. Número de etiquetas (máximo 3 sugerencias)
|
||||||
|
3. Confianza (umbral mínimo: 0.6)
|
||||||
|
|
||||||
|
REGLAS:
|
||||||
|
- Sugiere SOLO etiquetas que estén en la lista de etiquetas disponibles
|
||||||
|
- Devuelve máximo 3 sugerencias
|
||||||
|
- Cada sugerencia debe tener confianza > 0.6
|
||||||
|
- Si ninguna etiqueta es relevante, devuelve un array vacío
|
||||||
|
|
||||||
|
FORMATO DE RESPUESTA (solo JSON):
|
||||||
|
{
|
||||||
|
"suggestions": [
|
||||||
|
{ "label": "nombre_etiqueta", "confidence": 0.85, "reasoning": "Por qué esta etiqueta es relevante" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Tu respuesta:
|
||||||
|
`.trim(),
|
||||||
|
de: `
|
||||||
|
Du bist ein Assistent, der die passendsten Labels für eine Notiz vorschlägt.
|
||||||
|
|
||||||
|
NOTIZINHALT:
|
||||||
|
${noteContent.substring(0, 1000)}
|
||||||
|
|
||||||
|
AKTUELLES NOTIZBUCH:
|
||||||
|
${notebookName}
|
||||||
|
|
||||||
|
VERFÜGBARE LABELS IN DIESEM NOTIZBUCH:
|
||||||
|
${labelList}
|
||||||
|
|
||||||
|
AUFGABE:
|
||||||
|
Analysiere den Notizinhalt und schlage die AM BESTEN geeigneten Labels aus den oben verfügbaren Labels vor.
|
||||||
|
Berücksichtige:
|
||||||
|
1. Relevanz des Labels für den Inhalt
|
||||||
|
2. Anzahl der Labels (maximal 3 Vorschläge)
|
||||||
|
3. Konfidenz (Mindestschwellenwert: 0.6)
|
||||||
|
|
||||||
|
REGELN:
|
||||||
|
- Schlage NUR Labels vor, die in der Liste der verfügbaren Labels sind
|
||||||
|
- Gib maximal 3 Vorschläge zurück
|
||||||
|
- Jeder Vorschlag muss eine Konfidenz > 0.6 haben
|
||||||
|
- Wenn kein Label relevant ist, gib ein leeres Array zurück
|
||||||
|
|
||||||
|
ANTWORTFORMAT (nur JSON):
|
||||||
|
{
|
||||||
|
"suggestions": [
|
||||||
|
{ "label": "label_name", "confidence": 0.85, "reasoning": "Warum dieses Label relevant ist" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Deine Antwort:
|
||||||
`.trim()
|
`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return instructions[language] || instructions['en'] || instructions['fr']
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the AI prompt for NEW label suggestions (when notebook is empty)
|
* Build the AI prompt for NEW label suggestions (when notebook is empty) (localized)
|
||||||
*/
|
*/
|
||||||
private buildNewLabelsPrompt(noteContent: string, notebookName: string): string {
|
private buildNewLabelsPrompt(noteContent: string, notebookName: string, language: string = 'en'): string {
|
||||||
return `
|
const instructions: Record<string, string> = {
|
||||||
|
fr: `
|
||||||
Tu es un assistant qui suggère de nouveaux labels pour organiser une note.
|
Tu es un assistant qui suggère de nouveaux labels pour organiser une note.
|
||||||
|
|
||||||
CONTENU DE LA NOTE :
|
CONTENU DE LA NOTE :
|
||||||
@ -306,7 +450,141 @@ FORMAT DE RÉPONSE (JSON brut, sans markdown) :
|
|||||||
{"suggestions":[{"label":"nom_du_label","confidence":0.85,"reasoning":"Pourquoi ce label est pertinent"}]}
|
{"suggestions":[{"label":"nom_du_label","confidence":0.85,"reasoning":"Pourquoi ce label est pertinent"}]}
|
||||||
|
|
||||||
Ta réponse (JSON brut uniquement) :
|
Ta réponse (JSON brut uniquement) :
|
||||||
|
`.trim(),
|
||||||
|
en: `
|
||||||
|
You are an assistant that suggests new labels to organize a note.
|
||||||
|
|
||||||
|
NOTE CONTENT:
|
||||||
|
${noteContent.substring(0, 1000)}
|
||||||
|
|
||||||
|
CURRENT NOTEBOOK:
|
||||||
|
${notebookName}
|
||||||
|
|
||||||
|
CONTEXT:
|
||||||
|
This notebook has no labels yet. You must suggest the FIRST appropriate labels for this note.
|
||||||
|
|
||||||
|
TASK:
|
||||||
|
Analyze the note content and suggest 1-3 labels that would be relevant to organize this note.
|
||||||
|
Consider:
|
||||||
|
1. Topics or themes covered
|
||||||
|
2. Content type (idea, task, reference, etc.)
|
||||||
|
3. Context of the notebook "${notebookName}"
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Labels must be SHORT (max 1-2 words)
|
||||||
|
- Labels must be lowercase
|
||||||
|
- Avoid accents if possible
|
||||||
|
- Return maximum 3 suggestions
|
||||||
|
- Each suggestion must have confidence > 0.6
|
||||||
|
|
||||||
|
IMPORTANT: Respond ONLY with valid JSON, without text before or after. No markdown, no code blocks.
|
||||||
|
|
||||||
|
RESPONSE FORMAT (raw JSON, no markdown):
|
||||||
|
{"suggestions":[{"label":"label_name","confidence":0.85,"reasoning":"Why this label is relevant"}]}
|
||||||
|
|
||||||
|
Your response (raw JSON only):
|
||||||
|
`.trim(),
|
||||||
|
fa: `
|
||||||
|
شما یک دستیار هستید که برچسبهای جدیدی برای سازماندهی یک یادداشت پیشنهاد میدهید.
|
||||||
|
|
||||||
|
محتوای یادداشت:
|
||||||
|
${noteContent.substring(0, 1000)}
|
||||||
|
|
||||||
|
دفترچه فعلی:
|
||||||
|
${notebookName}
|
||||||
|
|
||||||
|
زمینه:
|
||||||
|
این دفترچه هنوز هیچ برچسبی ندارد. شما باید اولین برچسبهای مناسب را برای این یادداشت پیشنهاد دهید.
|
||||||
|
|
||||||
|
وظیفه:
|
||||||
|
محتوای یادداشت را تحلیل کنید و ۱-۳ برچسب پیشنهاد دهید که برای سازماندهی این یادداشت مرتبط باشند.
|
||||||
|
در نظر بگیرید:
|
||||||
|
1. موضوعات یا تمهای پوشش داده شده
|
||||||
|
2. نوع محتوا (ایده، وظیفه، مرجع و غیره)
|
||||||
|
3. زمینه دفترچه "${notebookName}"
|
||||||
|
|
||||||
|
قوانین:
|
||||||
|
- برچسبها باید کوتاه باشند (حداکثر ۱-۲ کلمه)
|
||||||
|
- برچسبها باید با حروف کوچک باشند
|
||||||
|
- حداکثر ۳ پیشنهاد برگردانید
|
||||||
|
- هر پیشنهاد باید دارای اطمینان > 0.6 باشد
|
||||||
|
|
||||||
|
مهم: فقط با یک JSON معتبر پاسخ دهید، بدون متن قبل یا بعد. بدون مارکداون، بدون بلوک کد.
|
||||||
|
|
||||||
|
فرمت پاسخ (JSON خام، بدون مارکداون):
|
||||||
|
{"suggestions":[{"label":"نام_برچسب","confidence":0.85,"reasoning":"چرا این برچسب مرتبط است"}]}
|
||||||
|
|
||||||
|
پاسخ شما (فقط JSON خام):
|
||||||
|
`.trim(),
|
||||||
|
es: `
|
||||||
|
Eres un asistente que sugiere nuevas etiquetas para organizar una nota.
|
||||||
|
|
||||||
|
CONTENIDO DE LA NOTA:
|
||||||
|
${noteContent.substring(0, 1000)}
|
||||||
|
|
||||||
|
CUADERNO ACTUAL:
|
||||||
|
${notebookName}
|
||||||
|
|
||||||
|
CONTEXTO:
|
||||||
|
Este cuaderno aún no tiene etiquetas. Debes sugerir las PRIMERAS etiquetas apropiadas para esta nota.
|
||||||
|
|
||||||
|
TAREA:
|
||||||
|
Analiza el contenido de la nota y sugiere 1-3 etiquetas que serían relevantes para organizar esta nota.
|
||||||
|
Considera:
|
||||||
|
1. Temas o tópicos cubiertos
|
||||||
|
2. Tipo de contenido (idea, tarea, referencia, etc.)
|
||||||
|
3. Contexto del cuaderno "${notebookName}"
|
||||||
|
|
||||||
|
REGLAS:
|
||||||
|
- Las etiquetas deben ser CORTAS (máx 1-2 palabras)
|
||||||
|
- Las etiquetas deben estar en minúsculas
|
||||||
|
- Evita acentos si es posible
|
||||||
|
- Devuelve máximo 3 sugerencias
|
||||||
|
- Cada sugerencia debe tener confianza > 0.6
|
||||||
|
|
||||||
|
IMPORTANTE: Responde SOLO con JSON válido, sin texto antes o después. Sin markdown, sin bloques de código.
|
||||||
|
|
||||||
|
FORMATO DE RESPUESTA (JSON crudo, sin markdown):
|
||||||
|
{"suggestions":[{"label":"nombre_etiqueta","confidence":0.85,"reasoning":"Por qué esta etiqueta es relevante"}]}
|
||||||
|
|
||||||
|
Tu respuesta (solo JSON crudo):
|
||||||
|
`.trim(),
|
||||||
|
de: `
|
||||||
|
Du bist ein Assistent, der neue Labels vorschlägt, um eine Notiz zu organisieren.
|
||||||
|
|
||||||
|
NOTIZINHALT:
|
||||||
|
${noteContent.substring(0, 1000)}
|
||||||
|
|
||||||
|
AKTUELLES NOTIZBUCH:
|
||||||
|
${notebookName}
|
||||||
|
|
||||||
|
KONTEXT:
|
||||||
|
Dieses Notizbuch hat noch keine Labels. Du musst die ERSTEN passenden Labels für diese Notiz vorschlagen.
|
||||||
|
|
||||||
|
AUFGABE:
|
||||||
|
Analysiere den Notizinhalt und schlage 1-3 Labels vor, die relevant wären, um diese Notiz zu organisieren.
|
||||||
|
Berücksichtige:
|
||||||
|
1. Abgedeckte Themen oder Bereiche
|
||||||
|
2. Inhaltstyp (Idee, Aufgabe, Referenz, usw.)
|
||||||
|
3. Kontext des Notizbuchs "${notebookName}"
|
||||||
|
|
||||||
|
REGELN:
|
||||||
|
- Labels müssen KURZ sein (max 1-2 Wörter)
|
||||||
|
- Labels müssen kleingeschrieben sein
|
||||||
|
- Vermeide Akzente wenn möglich
|
||||||
|
- Gib maximal 3 Vorschläge zurück
|
||||||
|
- Jeder Vorschlag muss eine Konfidenz > 0.6 haben
|
||||||
|
|
||||||
|
WICHTIG: Antworte NUR mit gültigem JSON, ohne Text davor oder danach. Kein Markdown, keine Code-Blöcke.
|
||||||
|
|
||||||
|
ANTWORTFORMAT (rohes JSON, kein Markdown):
|
||||||
|
{"suggestions":[{"label":"label_name","confidence":0.85,"reasoning":"Warum dieses Label relevant ist"}]}
|
||||||
|
|
||||||
|
Deine Antwort (nur rohes JSON):
|
||||||
`.trim()
|
`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return instructions[language] || instructions['en'] || instructions['fr']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export class NotebookSuggestionService {
|
|||||||
* @param userId - User ID (for fetching user's notebooks)
|
* @param userId - User ID (for fetching user's notebooks)
|
||||||
* @returns Suggested notebook or null (if no good match)
|
* @returns Suggested notebook or null (if no good match)
|
||||||
*/
|
*/
|
||||||
async suggestNotebook(noteContent: string, userId: string): Promise<Notebook | null> {
|
async suggestNotebook(noteContent: string, userId: string, language: string = 'en'): Promise<Notebook | null> {
|
||||||
// 1. Get all notebooks for this user
|
// 1. Get all notebooks for this user
|
||||||
const notebooks = await prisma.notebook.findMany({
|
const notebooks = await prisma.notebook.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@ -28,7 +28,7 @@ export class NotebookSuggestionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Build prompt for AI (always in French - interface language)
|
// 2. Build prompt for AI (always in French - interface language)
|
||||||
const prompt = this.buildPrompt(noteContent, notebooks)
|
const prompt = this.buildPrompt(noteContent, notebooks, language)
|
||||||
|
|
||||||
// 3. Call AI
|
// 3. Call AI
|
||||||
try {
|
try {
|
||||||
@ -57,9 +57,9 @@ export class NotebookSuggestionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the AI prompt for notebook suggestion (always in French - interface language)
|
* Build the AI prompt for notebook suggestion (localized)
|
||||||
*/
|
*/
|
||||||
private buildPrompt(noteContent: string, notebooks: any[]): string {
|
private buildPrompt(noteContent: string, notebooks: any[], language: string = 'en'): string {
|
||||||
const notebookList = notebooks
|
const notebookList = notebooks
|
||||||
.map(nb => {
|
.map(nb => {
|
||||||
const labels = nb.labels.map((l: any) => l.name).join(', ')
|
const labels = nb.labels.map((l: any) => l.name).join(', ')
|
||||||
@ -68,7 +68,8 @@ export class NotebookSuggestionService {
|
|||||||
})
|
})
|
||||||
.join('\n')
|
.join('\n')
|
||||||
|
|
||||||
return `
|
const instructions: Record<string, string> = {
|
||||||
|
fr: `
|
||||||
Tu es un assistant qui suggère à quel carnet une note devrait appartenir.
|
Tu es un assistant qui suggère à quel carnet une note devrait appartenir.
|
||||||
|
|
||||||
CONTENU DE LA NOTE :
|
CONTENU DE LA NOTE :
|
||||||
@ -107,7 +108,148 @@ Exemples :
|
|||||||
- "Achat d'une chemise et d'un jean" → carnet "Personnel"
|
- "Achat d'une chemise et d'un jean" → carnet "Personnel"
|
||||||
|
|
||||||
Ta suggestion :
|
Ta suggestion :
|
||||||
|
`.trim(),
|
||||||
|
en: `
|
||||||
|
You are an assistant that suggests which notebook a note should belong to.
|
||||||
|
|
||||||
|
NOTE CONTENT:
|
||||||
|
${noteContent.substring(0, 500)}
|
||||||
|
|
||||||
|
AVAILABLE NOTEBOOKS:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
TASK:
|
||||||
|
Analyze the note content (regardless of language) and suggest the MOST appropriate notebook for this note.
|
||||||
|
Consider:
|
||||||
|
1. The subject/theme of the note (MOST IMPORTANT)
|
||||||
|
2. Existing labels in each notebook
|
||||||
|
3. The number of notes (prefer notebooks with related content)
|
||||||
|
|
||||||
|
CLASSIFICATION GUIDE:
|
||||||
|
- SPORT/EXERCISE/SHOPPING/GROCERIES → Personal Notebook
|
||||||
|
- HOBBIES/PASSIONS/OUTINGS → Personal Notebook
|
||||||
|
- HEALTH/FITNESS/DOCTOR → Personal Notebook or Health
|
||||||
|
- FAMILY/FRIENDS → Personal Notebook
|
||||||
|
- WORK/MEETINGS/PROJECTS/CLIENTS → Work Notebook
|
||||||
|
- CODING/TECH/DEVELOPMENT → Work Notebook or Code
|
||||||
|
- FINANCE/BILLS/BANKING → Personal Notebook or Finance
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Return ONLY the notebook name, EXACTLY as listed above (case insensitive)
|
||||||
|
- If no good match exists, return "NONE"
|
||||||
|
- If the note is too generic/vague, return "NONE"
|
||||||
|
- Do not include explanations or extra text
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "Meeting with John about project planning" → notebook "Work"
|
||||||
|
- "Grocery list or buying clothes" → notebook "Personal"
|
||||||
|
- "Python script for data analysis" → notebook "Code"
|
||||||
|
- "Gym session or fitness" → notebook "Personal"
|
||||||
|
|
||||||
|
Your suggestion:
|
||||||
|
`.trim(),
|
||||||
|
fa: `
|
||||||
|
شما یک دستیار هستید که پیشنهاد میدهد یک یادداشت به کدام دفترچه تعلق داشته باشد.
|
||||||
|
|
||||||
|
محتوای یادداشت:
|
||||||
|
${noteContent.substring(0, 500)}
|
||||||
|
|
||||||
|
دفترچههای موجود:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
وظیفه:
|
||||||
|
محتوای یادداشت را تحلیل کنید (صرف نظر از زبان) و مناسبترین دفترچه را برای این یادداشت پیشنهاد دهید.
|
||||||
|
در نظر بگیرید:
|
||||||
|
1. موضوع/تم یادداشت (مهمترین)
|
||||||
|
2. برچسبهای موجود در هر دفترچه
|
||||||
|
3. تعداد یادداشتها (دفترچههای با محتوای مرتبط را ترجیح دهید)
|
||||||
|
|
||||||
|
راهنمای طبقهبندی:
|
||||||
|
- ورزش/تمرین/خرید → دفترچه شخصی
|
||||||
|
- سرگرمیها/علایق/گردش → دفترچه شخصی
|
||||||
|
- سلامت/تناسب اندام/پزشک → دفترچه شخصی یا سلامت
|
||||||
|
- خانواده/دوستان → دفترچه شخصی
|
||||||
|
- کار/جلسات/پروژهها/مشتریان → دفترچه کار
|
||||||
|
- کدنویسی/تکنولوژی/توسعه → دفترچه کار یا کد
|
||||||
|
- مالی/قبضها/بانک → دفترچه شخصی یا مالی
|
||||||
|
|
||||||
|
قوانین:
|
||||||
|
- فقط نام دفترچه را برگردانید، دقیقاً همانطور که در بالا ذکر شده است (بدون حساسیت به حروف بزرگ و کوچک)
|
||||||
|
- اگر تطابق خوبی وجود ندارد، "NONE" را برگردانید
|
||||||
|
- اگر یادداشت خیلی کلی/مبهم است، "NONE" را برگردانید
|
||||||
|
- توضیحات یا متن اضافی را شامل نکنید
|
||||||
|
|
||||||
|
پیشناد شما:
|
||||||
|
`.trim(),
|
||||||
|
es: `
|
||||||
|
Eres un asistente que sugiere a qué cuaderno debería pertenecer una nota.
|
||||||
|
|
||||||
|
CONTENIDO DE LA NOTA:
|
||||||
|
${noteContent.substring(0, 500)}
|
||||||
|
|
||||||
|
CUADERNOS DISPONIBLES:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
TAREA:
|
||||||
|
Analiza el contenido de la nota (independientemente del idioma) y sugiere el cuaderno MÁS apropiado para esta nota.
|
||||||
|
Considera:
|
||||||
|
1. El tema/asunto de la nota (LO MÁS IMPORTANTE)
|
||||||
|
2. Etiquetas existentes en cada cuaderno
|
||||||
|
3. El número de notas (prefiere cuadernos con contenido relacionado)
|
||||||
|
|
||||||
|
GUÍA DE CLASIFICACIÓN:
|
||||||
|
- DEPORTE/EJERCICIO/COMPRAS → Cuaderno Personal
|
||||||
|
- HOBBIES/PASIONES/SALIDAS → Cuaderno Personal
|
||||||
|
- SALUD/FITNESS/DOCTOR → Cuaderno Personal o Salud
|
||||||
|
- FAMILIA/AMIGOS → Cuaderno Personal
|
||||||
|
- TRABAJO/REUNIONES/PROYECTOS → Cuaderno Trabajo
|
||||||
|
- CODING/TECH/DESARROLLO → Cuaderno Trabajo o Código
|
||||||
|
- FINANZAS/FACTURAS/BANCO → Cuaderno Personal o Finanzas
|
||||||
|
|
||||||
|
REGLAS:
|
||||||
|
- Devuelve SOLO el nombre del cuaderno, EXACTAMENTE como se lista arriba (insensible a mayúsculas/minúsculas)
|
||||||
|
- Si no existe una buena coincidencia, devuelve "NONE"
|
||||||
|
- Si la nota es demasiado genérica/vaga, devuelve "NONE"
|
||||||
|
- No incluyas explicaciones o texto extra
|
||||||
|
|
||||||
|
Tu sugerencia:
|
||||||
|
`.trim(),
|
||||||
|
de: `
|
||||||
|
Du bist ein Assistent, der vorschlägt, zu welchem Notizbuch eine Notiz gehören sollte.
|
||||||
|
|
||||||
|
NOTIZINHALT:
|
||||||
|
${noteContent.substring(0, 500)}
|
||||||
|
|
||||||
|
VERFÜGBARE NOTIZBÜCHER:
|
||||||
|
${notebookList}
|
||||||
|
|
||||||
|
AUFGABE:
|
||||||
|
Analysiere den Notizinhalt (unabhängig von der Sprache) und schlage das AM BESTEN geeignete Notizbuch für diese Notiz vor.
|
||||||
|
Berücksichtige:
|
||||||
|
1. Das Thema/den Inhalt der Notiz (AM WICHTIGSTEN)
|
||||||
|
2. Vorhandene Labels in jedem Notizbuch
|
||||||
|
3. Die Anzahl der Notizen (bevorzuge Notizbücher mit verwandtem Inhalt)
|
||||||
|
|
||||||
|
KLASSIFIZIERUNGSLEITFADEN:
|
||||||
|
- SPORT/ÜBUNG/EINKAUFEN → Persönliches Notizbuch
|
||||||
|
- HOBBYS/LEIDENSCHAFTEN → Persönliches Notizbuch
|
||||||
|
- GESUNDHEIT/FITNESS/ARZT → Persönliches Notizbuch oder Gesundheit
|
||||||
|
- FAMILIE/FREUNDE → Persönliches Notizbuch
|
||||||
|
- ARBEIT/MEETINGS/PROJEKTE → Arbeitsnotizbuch
|
||||||
|
- CODING/TECH/ENTWICKLUNG → Arbeitsnotizbuch oder Code
|
||||||
|
- FINANZEN/RECHNUNGEN/BANK → Persönliches Notizbuch oder Finanzen
|
||||||
|
|
||||||
|
REGELN:
|
||||||
|
- Gib NUR den Namen des Notizbuchs zurück, GENAU wie oben aufgeführt (Groß-/Kleinschreibung egal)
|
||||||
|
- Wenn keine gute Übereinstimmung existiert, gib "NONE" zurück
|
||||||
|
- Wenn die Notiz zu allgemein/vage ist, gib "NONE" zurück
|
||||||
|
- Füge keine Erklärungen oder zusätzlichen Text hinzu
|
||||||
|
|
||||||
|
Dein Vorschlag:
|
||||||
`.trim()
|
`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return instructions[language] || instructions['en'] || instructions['fr']
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -118,14 +260,15 @@ Ta suggestion :
|
|||||||
*/
|
*/
|
||||||
async suggestNotebooksBatch(
|
async suggestNotebooksBatch(
|
||||||
noteContents: string[],
|
noteContents: string[],
|
||||||
userId: string
|
userId: string,
|
||||||
|
language: string = 'en'
|
||||||
): Promise<Map<number, Notebook | null>> {
|
): Promise<Map<number, Notebook | null>> {
|
||||||
const results = new Map<number, Notebook | null>()
|
const results = new Map<number, Notebook | null>()
|
||||||
|
|
||||||
// For efficiency, we could batch this into a single AI call
|
// For efficiency, we could batch this into a single AI call
|
||||||
// For now, process sequentially (could be parallelized)
|
// For now, process sequentially (could be parallelized)
|
||||||
for (let i = 0; i < noteContents.length; i++) {
|
for (let i = 0; i < noteContents.length; i++) {
|
||||||
const suggestion = await this.suggestNotebook(noteContents[i], userId)
|
const suggestion = await this.suggestNotebook(noteContents[i], userId, language)
|
||||||
results.set(i, suggestion)
|
results.set(i, suggestion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,7 @@ export class NotebookSummaryService {
|
|||||||
* @param userId - User ID (for authorization)
|
* @param userId - User ID (for authorization)
|
||||||
* @returns Notebook summary or null
|
* @returns Notebook summary or null
|
||||||
*/
|
*/
|
||||||
async generateSummary(notebookId: string, userId: string): Promise<NotebookSummary | null> {
|
async generateSummary(notebookId: string, userId: string, language: string = 'en'): Promise<NotebookSummary | null> {
|
||||||
// 1. Get notebook with notes and labels
|
// 1. Get notebook with notes and labels
|
||||||
const notebook = await prisma.notebook.findFirst({
|
const notebook = await prisma.notebook.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@ -79,7 +79,7 @@ export class NotebookSummaryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Generate summary using AI
|
// 2. Generate summary using AI
|
||||||
const summary = await this.generateAISummary(notes, notebook)
|
const summary = await this.generateAISummary(notes, notebook, language)
|
||||||
|
|
||||||
// 3. Get labels used in this notebook
|
// 3. Get labels used in this notebook
|
||||||
const labelsUsed = Array.from(
|
const labelsUsed = Array.from(
|
||||||
@ -107,7 +107,7 @@ export class NotebookSummaryService {
|
|||||||
/**
|
/**
|
||||||
* Use AI to generate notebook summary
|
* Use AI to generate notebook summary
|
||||||
*/
|
*/
|
||||||
private async generateAISummary(notes: any[], notebook: any): Promise<string> {
|
private async generateAISummary(notes: any[], notebook: any, language: string): Promise<string> {
|
||||||
// Build notes summary for AI
|
// Build notes summary for AI
|
||||||
const notesSummary = notes
|
const notesSummary = notes
|
||||||
.map((note, index) => {
|
.map((note, index) => {
|
||||||
@ -122,7 +122,7 @@ ${content}...`
|
|||||||
})
|
})
|
||||||
.join('\n\n')
|
.join('\n\n')
|
||||||
|
|
||||||
const prompt = this.buildPrompt(notesSummary, notebook.name)
|
const prompt = this.buildPrompt(notesSummary, notebook.name, language)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
@ -136,10 +136,11 @@ ${content}...`
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build prompt for AI (always in French - interface language)
|
* Build prompt for AI (localized)
|
||||||
*/
|
*/
|
||||||
private buildPrompt(notesSummary: string, notebookName: string): string {
|
private buildPrompt(notesSummary: string, notebookName: string, language: string = 'en'): string {
|
||||||
return `
|
const instructions: Record<string, string> = {
|
||||||
|
fr: `
|
||||||
Tu es un assistant qui génère des synthèses structurées de carnets de notes.
|
Tu es un assistant qui génère des synthèses structurées de carnets de notes.
|
||||||
|
|
||||||
CARNET: ${notebookName}
|
CARNET: ${notebookName}
|
||||||
@ -181,9 +182,197 @@ RÈGLES:
|
|||||||
- Identifie les vraies tendances, ne pas inventer d'informations
|
- Identifie les vraies tendances, ne pas inventer d'informations
|
||||||
- Si une section n'est pas pertinente, utilise "N/A" ou omets-la
|
- Si une section n'est pas pertinente, utilise "N/A" ou omets-la
|
||||||
- Ton: professionnel mais accessible
|
- Ton: professionnel mais accessible
|
||||||
|
- TA RÉPONSE DOIT ÊTRE EN FRANÇAIS
|
||||||
|
|
||||||
Ta réponse :
|
Ta réponse :
|
||||||
|
`.trim(),
|
||||||
|
en: `
|
||||||
|
You are an assistant that generates structured summaries of notebooks.
|
||||||
|
|
||||||
|
NOTEBOOK: ${notebookName}
|
||||||
|
|
||||||
|
NOTEBOOK NOTES:
|
||||||
|
${notesSummary}
|
||||||
|
|
||||||
|
TASK:
|
||||||
|
Generate a structured and organized summary of this notebook by analyzing all notes.
|
||||||
|
|
||||||
|
RESPONSE FORMAT (Markdown with emojis):
|
||||||
|
|
||||||
|
# 📊 Summary of Notebook ${notebookName}
|
||||||
|
|
||||||
|
## 🌍 Main Themes
|
||||||
|
• Identify 3-5 recurring themes or topics covered
|
||||||
|
|
||||||
|
## 📝 Statistics
|
||||||
|
• Total number of notes analyzed
|
||||||
|
• Main content categories
|
||||||
|
|
||||||
|
## 📅 Temporal Elements
|
||||||
|
• Important dates or periods mentioned
|
||||||
|
• Planned vs past events
|
||||||
|
|
||||||
|
## ⚠️ Action Items / Attention Points
|
||||||
|
• Tasks or actions identified in notes
|
||||||
|
• Important reminders or deadlines
|
||||||
|
• Items requiring special attention
|
||||||
|
|
||||||
|
## 💡 Key Insights
|
||||||
|
• Summary of most important information
|
||||||
|
• Observed trends or patterns
|
||||||
|
• Connections between different notes
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Use Markdown format with emojis as in the example
|
||||||
|
- Be concise and organize information clearly
|
||||||
|
- Identify real trends, do not invent information
|
||||||
|
- If a section is not relevant, use "N/A" or omit it
|
||||||
|
- Tone: professional but accessible
|
||||||
|
- YOUR RESPONSE MUST BE IN ENGLISH
|
||||||
|
|
||||||
|
Your response:
|
||||||
|
`.trim(),
|
||||||
|
fa: `
|
||||||
|
شما یک دستیار هستید که خلاصههای ساختاریافته از دفترچههای یادداشت تولید میکنید.
|
||||||
|
|
||||||
|
دفترچه: ${notebookName}
|
||||||
|
|
||||||
|
یادداشتهای دفترچه:
|
||||||
|
${notesSummary}
|
||||||
|
|
||||||
|
وظیفه:
|
||||||
|
یک خلاصه ساختاریافته و منظم از این دفترچه با تحلیل تمام یادداشتها تولید کنید.
|
||||||
|
|
||||||
|
فرمت پاسخ (مارکداون با ایموجی):
|
||||||
|
|
||||||
|
# 📊 خلاصه دفترچه ${notebookName}
|
||||||
|
|
||||||
|
## 🌍 موضوعات اصلی
|
||||||
|
• ۳-۵ موضوع تکرارشونده یا مبحث پوشش داده شده را شناسایی کنید
|
||||||
|
|
||||||
|
## 📝 آمار
|
||||||
|
• تعداد کل یادداشتهای تحلیل شده
|
||||||
|
• دستهبندیهای اصلی محتوا
|
||||||
|
|
||||||
|
## 📅 عناصر زمانی
|
||||||
|
• تاریخها یا دورههای مهم ذکر شده
|
||||||
|
• رویدادهای برنامهریزی شده در مقابل گذشته
|
||||||
|
|
||||||
|
## ⚠️ موارد اقدام / نقاط توجه
|
||||||
|
• وظایف یا اقدامات شناسایی شده در یادداشتها
|
||||||
|
• یادآوریها یا مهلتهای مهم
|
||||||
|
• مواردی که نیاز به توجه ویژه دارند
|
||||||
|
|
||||||
|
## 💡 بینشهای کلیدی
|
||||||
|
• خلاصه مهمترین اطلاعات
|
||||||
|
• روندها یا الگوهای مشاهده شده
|
||||||
|
• ارتباطات بین یادداشتهای مختلف
|
||||||
|
|
||||||
|
قوانین:
|
||||||
|
- از فرمت مارکداون با ایموجی مانند مثال استفاده کنید
|
||||||
|
- مختصر باشید و اطلاعات را به وضوح سازماندهی کنید
|
||||||
|
- روندهای واقعی را شناسایی کنید، اطلاعات اختراع نکنید
|
||||||
|
- اگر بخش مرتبط نیست، از "N/A" استفاده کنید یا آن را حذف کنید
|
||||||
|
- لحن: حرفهای اما قابل دسترس
|
||||||
|
- پاسخ شما باید به زبان فارسی باشد
|
||||||
|
|
||||||
|
پاسخ شما:
|
||||||
|
`.trim(),
|
||||||
|
es: `
|
||||||
|
Eres un asistente que genera resúmenes estructurados de cuadernos de notas.
|
||||||
|
|
||||||
|
CUADERNO: ${notebookName}
|
||||||
|
|
||||||
|
NOTAS DEL CUADERNO:
|
||||||
|
${notesSummary}
|
||||||
|
|
||||||
|
TAREA:
|
||||||
|
Genera un resumen estructurado y organizado de este cuaderno analizando todas las notas.
|
||||||
|
|
||||||
|
FORMATO DE RESPUESTA (Markdown con emojis):
|
||||||
|
|
||||||
|
# 📊 Resumen del Cuaderno ${notebookName}
|
||||||
|
|
||||||
|
## 🌍 Temas Principales
|
||||||
|
• Identifica 3-5 temas recurrentes o tópicos cubiertos
|
||||||
|
|
||||||
|
## 📝 Estadísticas
|
||||||
|
• Número total de notas analizadas
|
||||||
|
• Categorías principales de contenido
|
||||||
|
|
||||||
|
## 📅 Elementos Temporales
|
||||||
|
• Fechas o periodos importantes mencionados
|
||||||
|
• Eventos planificados vs pasados
|
||||||
|
|
||||||
|
## ⚠️ Puntos de Atención / Acciones Requeridas
|
||||||
|
• Tareas o acciones identificadas en las notas
|
||||||
|
• Recordatorios o plazos importantes
|
||||||
|
• Elementos que requieren atención especial
|
||||||
|
|
||||||
|
## 💡 Insights Clave
|
||||||
|
• Resumen de la información más importante
|
||||||
|
• Tendencias o patrones observados
|
||||||
|
• Conexiones entre las diferentes notas
|
||||||
|
|
||||||
|
REGLAS:
|
||||||
|
- Usa formato Markdown con emojis como en el ejemplo
|
||||||
|
- Sé conciso y organiza la información claramente
|
||||||
|
- Identifica tendencias reales, no inventes información
|
||||||
|
- Si una sección no es relevante, usa "N/A" u omítela
|
||||||
|
- Tono: profesional pero accesible
|
||||||
|
- TU RESPUESTA DEBE SER EN ESPAÑOL
|
||||||
|
|
||||||
|
Tu respuesta:
|
||||||
|
`.trim(),
|
||||||
|
de: `
|
||||||
|
Du bist ein Assistent, der strukturierte Zusammenfassungen von Notizbüchern erstellt.
|
||||||
|
|
||||||
|
NOTIZBUCH: ${notebookName}
|
||||||
|
|
||||||
|
NOTIZBUCH-NOTIZEN:
|
||||||
|
${notesSummary}
|
||||||
|
|
||||||
|
AUFGABE:
|
||||||
|
Erstelle eine strukturierte und organisierte Zusammenfassung dieses Notizbuchs, indem du alle Notizen analysierst.
|
||||||
|
|
||||||
|
ANTWORTFORMAT (Markdown mit Emojis):
|
||||||
|
|
||||||
|
# 📊 Zusammenfassung des Notizbuchs ${notebookName}
|
||||||
|
|
||||||
|
## 🌍 Hauptthemen
|
||||||
|
• Identifiziere 3-5 wiederkehrende Themen
|
||||||
|
|
||||||
|
## 📝 Statistiken
|
||||||
|
• Gesamtzahl der analysierten Notizen
|
||||||
|
• Hauptinhaltkategorien
|
||||||
|
|
||||||
|
## 📅 Zeitliche Elemente
|
||||||
|
• Wichtige erwähnte Daten oder Zeiträume
|
||||||
|
• Geplante vs. vergangene Ereignisse
|
||||||
|
|
||||||
|
## ⚠️ Handlungspunkte / Aufmerksamkeitspunkte
|
||||||
|
• In Notizen identifizierte Aufgaben oder Aktionen
|
||||||
|
• Wichtige Erinnerungen oder Fristen
|
||||||
|
• Elemente, die besondere Aufmerksamkeit erfordern
|
||||||
|
|
||||||
|
## 💡 Wichtige Erkenntnisse
|
||||||
|
• Zusammenfassung der wichtigsten Informationen
|
||||||
|
• Beobachtete Trends oder Muster
|
||||||
|
• Verbindungen zwischen verschiedenen Notizen
|
||||||
|
|
||||||
|
REGELN:
|
||||||
|
- Verwende Markdown-Format mit Emojis wie im Beispiel
|
||||||
|
- Sei prägnant und organisiere Informationen klar
|
||||||
|
- Identifiziere echte Trends, erfinde keine Informationen
|
||||||
|
- Wenn ein Abschnitt nicht relevant ist, verwende "N/A" oder lass ihn weg
|
||||||
|
- Ton: professionell aber zugänglich
|
||||||
|
- DEINE ANTWORT MUSS AUF DEUTSCH SEIN
|
||||||
|
|
||||||
|
Deine Antwort:
|
||||||
`.trim()
|
`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return instructions[language] || instructions['en'] || instructions['fr']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,15 @@ export interface Translations {
|
|||||||
createAccount: string
|
createAccount: string
|
||||||
rememberMe: string
|
rememberMe: string
|
||||||
orContinueWith: string
|
orContinueWith: string
|
||||||
|
checkYourEmail: string
|
||||||
|
resetEmailSent: string
|
||||||
|
returnToLogin: string
|
||||||
|
forgotPasswordTitle: string
|
||||||
|
forgotPasswordDescription: string
|
||||||
|
sending: string
|
||||||
|
sendResetLink: string
|
||||||
|
backToLogin: string
|
||||||
|
signOut: string
|
||||||
}
|
}
|
||||||
sidebar: {
|
sidebar: {
|
||||||
notes: string
|
notes: string
|
||||||
@ -65,6 +74,8 @@ export interface Translations {
|
|||||||
invalidDateTime: string
|
invalidDateTime: string
|
||||||
reminderMustBeFuture: string
|
reminderMustBeFuture: string
|
||||||
reminderSet: string
|
reminderSet: string
|
||||||
|
reminderPastError: string
|
||||||
|
reminderRemoved: string
|
||||||
addImage: string
|
addImage: string
|
||||||
addLink: string
|
addLink: string
|
||||||
linkAdded: string
|
linkAdded: string
|
||||||
@ -92,6 +103,34 @@ export interface Translations {
|
|||||||
noNotes: string
|
noNotes: string
|
||||||
noNotesFound: string
|
noNotesFound: string
|
||||||
createFirstNote: string
|
createFirstNote: string
|
||||||
|
size: string
|
||||||
|
small: string
|
||||||
|
medium: string
|
||||||
|
large: string
|
||||||
|
shareWithCollaborators: string
|
||||||
|
view: string
|
||||||
|
edit: string
|
||||||
|
readOnly: string
|
||||||
|
preview: string
|
||||||
|
noContent: string
|
||||||
|
takeNote: string
|
||||||
|
takeNoteMarkdown: string
|
||||||
|
addItem: string
|
||||||
|
sharedReadOnly: string
|
||||||
|
makeCopy: string
|
||||||
|
saving: string
|
||||||
|
copySuccess: string
|
||||||
|
copyFailed: string
|
||||||
|
copy: string
|
||||||
|
markdownOn: string
|
||||||
|
markdownOff: string
|
||||||
|
undo: string
|
||||||
|
redo: string
|
||||||
|
}
|
||||||
|
pagination: {
|
||||||
|
previous: string
|
||||||
|
pageInfo: string
|
||||||
|
next: string
|
||||||
}
|
}
|
||||||
labels: {
|
labels: {
|
||||||
title: string
|
title: string
|
||||||
@ -110,9 +149,19 @@ export interface Translations {
|
|||||||
labelName: string
|
labelName: string
|
||||||
labelColor: string
|
labelColor: string
|
||||||
manageLabels: string
|
manageLabels: string
|
||||||
|
manageLabelsDescription: string
|
||||||
|
selectedLabels: string
|
||||||
|
allLabels: string
|
||||||
clearAll: string
|
clearAll: string
|
||||||
filterByLabel: string
|
filterByLabel: string
|
||||||
tagAdded: string
|
tagAdded: string
|
||||||
|
showLess: string
|
||||||
|
showMore: string
|
||||||
|
editLabels: string
|
||||||
|
editLabelsDescription: string
|
||||||
|
noLabelsFound: string
|
||||||
|
loading: string
|
||||||
|
notebookRequired: string
|
||||||
}
|
}
|
||||||
search: {
|
search: {
|
||||||
placeholder: string
|
placeholder: string
|
||||||
@ -133,6 +182,27 @@ export interface Translations {
|
|||||||
canEdit: string
|
canEdit: string
|
||||||
canView: string
|
canView: string
|
||||||
shareNote: string
|
shareNote: string
|
||||||
|
shareWithCollaborators: string
|
||||||
|
addCollaboratorDescription: string
|
||||||
|
viewerDescription: string
|
||||||
|
emailAddress: string
|
||||||
|
enterEmailAddress: string
|
||||||
|
invite: string
|
||||||
|
peopleWithAccess: string
|
||||||
|
noCollaborators: string
|
||||||
|
noCollaboratorsViewer: string
|
||||||
|
pendingInvite: string
|
||||||
|
pending: string
|
||||||
|
remove: string
|
||||||
|
unnamedUser: string
|
||||||
|
done: string
|
||||||
|
willBeAdded: string
|
||||||
|
alreadyInList: string
|
||||||
|
nowHasAccess: string
|
||||||
|
accessRevoked: string
|
||||||
|
errorLoading: string
|
||||||
|
failedToAdd: string
|
||||||
|
failedToRemove: string
|
||||||
}
|
}
|
||||||
ai: {
|
ai: {
|
||||||
analyzing: string
|
analyzing: string
|
||||||
@ -143,6 +213,64 @@ export interface Translations {
|
|||||||
poweredByAI: string
|
poweredByAI: string
|
||||||
languageDetected: string
|
languageDetected: string
|
||||||
processing: string
|
processing: string
|
||||||
|
tagAdded: string
|
||||||
|
titleGenerating: string
|
||||||
|
titleGenerateWithAI: string
|
||||||
|
titleGenerationMinWords: string
|
||||||
|
titleGenerationError: string
|
||||||
|
titlesGenerated: string
|
||||||
|
titleGenerationFailed: string
|
||||||
|
titleApplied: string
|
||||||
|
reformulationNoText: string
|
||||||
|
reformulationSelectionTooShort: string
|
||||||
|
reformulationMinWords: string
|
||||||
|
reformulationMaxWords: string
|
||||||
|
reformulationError: string
|
||||||
|
reformulationFailed: string
|
||||||
|
reformulationApplied: string
|
||||||
|
transformMarkdown: string
|
||||||
|
transforming: string
|
||||||
|
transformSuccess: string
|
||||||
|
transformError: string
|
||||||
|
assistant: string
|
||||||
|
generating: string
|
||||||
|
generateTitles: string
|
||||||
|
reformulateText: string
|
||||||
|
reformulating: string
|
||||||
|
clarify: string
|
||||||
|
shorten: string
|
||||||
|
improveStyle: string
|
||||||
|
reformulationComparison: string
|
||||||
|
original: string
|
||||||
|
reformulated: string
|
||||||
|
}
|
||||||
|
batchOrganization: {
|
||||||
|
error: string
|
||||||
|
noNotesSelected: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
analyzing: string
|
||||||
|
notesToOrganize: string
|
||||||
|
selected: string
|
||||||
|
noNotebooks: string
|
||||||
|
noSuggestions: string
|
||||||
|
confidence: string
|
||||||
|
unorganized: string
|
||||||
|
applying: string
|
||||||
|
apply: string
|
||||||
|
}
|
||||||
|
autoLabels: {
|
||||||
|
error: string
|
||||||
|
noLabelsSelected: string
|
||||||
|
created: string
|
||||||
|
analyzing: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
note: string
|
||||||
|
notes: string
|
||||||
|
typeContent: string
|
||||||
|
createNewLabel: string
|
||||||
|
new: string
|
||||||
}
|
}
|
||||||
titleSuggestions: {
|
titleSuggestions: {
|
||||||
available: string
|
available: string
|
||||||
@ -169,6 +297,71 @@ export interface Translations {
|
|||||||
description: string
|
description: string
|
||||||
dailyInsight: string
|
dailyInsight: string
|
||||||
insightReady: string
|
insightReady: string
|
||||||
|
viewConnection: string
|
||||||
|
helpful: string
|
||||||
|
notHelpful: string
|
||||||
|
dismiss: string
|
||||||
|
thanksFeedback: string
|
||||||
|
thanksFeedbackImproving: string
|
||||||
|
connections: string
|
||||||
|
connection: string
|
||||||
|
connectionsBadge: string
|
||||||
|
fused: string
|
||||||
|
overlay: {
|
||||||
|
title: string
|
||||||
|
searchPlaceholder: string
|
||||||
|
sortBy: string
|
||||||
|
sortSimilarity: string
|
||||||
|
sortRecent: string
|
||||||
|
sortOldest: string
|
||||||
|
viewAll: string
|
||||||
|
loading: string
|
||||||
|
noConnections: string
|
||||||
|
}
|
||||||
|
comparison: {
|
||||||
|
title: string
|
||||||
|
similarityInfo: string
|
||||||
|
highSimilarityInsight: string
|
||||||
|
untitled: string
|
||||||
|
clickToView: string
|
||||||
|
helpfulQuestion: string
|
||||||
|
helpful: string
|
||||||
|
notHelpful: string
|
||||||
|
}
|
||||||
|
editorSection: {
|
||||||
|
title: string
|
||||||
|
loading: string
|
||||||
|
view: string
|
||||||
|
compare: string
|
||||||
|
merge: string
|
||||||
|
compareAll: string
|
||||||
|
mergeAll: string
|
||||||
|
}
|
||||||
|
fusion: {
|
||||||
|
title: string
|
||||||
|
mergeNotes: string
|
||||||
|
notesToMerge: string
|
||||||
|
optionalPrompt: string
|
||||||
|
promptPlaceholder: string
|
||||||
|
generateFusion: string
|
||||||
|
generating: string
|
||||||
|
previewTitle: string
|
||||||
|
edit: string
|
||||||
|
modify: string
|
||||||
|
finishEditing: string
|
||||||
|
optionsTitle: string
|
||||||
|
archiveOriginals: string
|
||||||
|
keepAllTags: string
|
||||||
|
useLatestTitle: string
|
||||||
|
createBacklinks: string
|
||||||
|
cancel: string
|
||||||
|
confirmFusion: string
|
||||||
|
success: string
|
||||||
|
error: string
|
||||||
|
generateError: string
|
||||||
|
noContentReturned: string
|
||||||
|
unknownDate: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
nav: {
|
nav: {
|
||||||
home: string
|
home: string
|
||||||
@ -181,6 +374,29 @@ export interface Translations {
|
|||||||
aiSettings: string
|
aiSettings: string
|
||||||
logout: string
|
logout: string
|
||||||
login: string
|
login: string
|
||||||
|
adminDashboard: string
|
||||||
|
diagnostics: string
|
||||||
|
trash: string
|
||||||
|
support: string
|
||||||
|
reminders: string
|
||||||
|
userManagement: string
|
||||||
|
accountSettings: string
|
||||||
|
manageAISettings: string
|
||||||
|
configureAI: string
|
||||||
|
supportDevelopment: string
|
||||||
|
supportDescription: string
|
||||||
|
buyMeACoffee: string
|
||||||
|
donationDescription: string
|
||||||
|
donateOnKofi: string
|
||||||
|
donationNote: string
|
||||||
|
sponsorOnGithub: string
|
||||||
|
sponsorDescription: string
|
||||||
|
workspace: string
|
||||||
|
quickAccess: string
|
||||||
|
myLibrary: string
|
||||||
|
favorites: string
|
||||||
|
recent: string
|
||||||
|
proPlan: string
|
||||||
}
|
}
|
||||||
settings: {
|
settings: {
|
||||||
title: string
|
title: string
|
||||||
@ -230,6 +446,21 @@ export interface Translations {
|
|||||||
profileError: string
|
profileError: string
|
||||||
accountSettings: string
|
accountSettings: string
|
||||||
manageAISettings: string
|
manageAISettings: string
|
||||||
|
displaySettings: string
|
||||||
|
displaySettingsDescription: string
|
||||||
|
fontSize: string
|
||||||
|
selectFontSize: string
|
||||||
|
fontSizeSmall: string
|
||||||
|
fontSizeMedium: string
|
||||||
|
fontSizeLarge: string
|
||||||
|
fontSizeExtraLarge: string
|
||||||
|
fontSizeDescription: string
|
||||||
|
fontSizeUpdateSuccess: string
|
||||||
|
fontSizeUpdateFailed: string
|
||||||
|
showRecentNotes: string
|
||||||
|
showRecentNotesDescription: string
|
||||||
|
recentNotesUpdateSuccess: string
|
||||||
|
recentNotesUpdateFailed: string
|
||||||
}
|
}
|
||||||
aiSettings: {
|
aiSettings: {
|
||||||
title: string
|
title: string
|
||||||
@ -287,6 +518,25 @@ export interface Translations {
|
|||||||
save: string
|
save: string
|
||||||
cancel: string
|
cancel: string
|
||||||
}
|
}
|
||||||
|
notebook: {
|
||||||
|
create: string
|
||||||
|
createNew: string
|
||||||
|
createDescription: string
|
||||||
|
name: string
|
||||||
|
selectIcon: string
|
||||||
|
selectColor: string
|
||||||
|
cancel: string
|
||||||
|
creating: string
|
||||||
|
edit: string
|
||||||
|
editDescription: string
|
||||||
|
delete: string
|
||||||
|
deleteWarning: string
|
||||||
|
deleteConfirm: string
|
||||||
|
summary: string
|
||||||
|
summaryDescription: string
|
||||||
|
generating: string
|
||||||
|
summaryError: string
|
||||||
|
}
|
||||||
notebookSuggestion: {
|
notebookSuggestion: {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
@ -296,6 +546,354 @@ export interface Translations {
|
|||||||
moveToNotebook: string
|
moveToNotebook: string
|
||||||
generalNotes: string
|
generalNotes: string
|
||||||
}
|
}
|
||||||
|
admin: {
|
||||||
|
title: string
|
||||||
|
userManagement: string
|
||||||
|
aiTesting: string
|
||||||
|
settings: string
|
||||||
|
security: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
allowPublicRegistration: string
|
||||||
|
allowPublicRegistrationDescription: string
|
||||||
|
updateSuccess: string
|
||||||
|
updateFailed: string
|
||||||
|
}
|
||||||
|
ai: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
tagsGenerationProvider: string
|
||||||
|
tagsGenerationDescription: string
|
||||||
|
embeddingsProvider: string
|
||||||
|
embeddingsDescription: string
|
||||||
|
provider: string
|
||||||
|
baseUrl: string
|
||||||
|
model: string
|
||||||
|
apiKey: string
|
||||||
|
selectOllamaModel: string
|
||||||
|
openAIKeyDescription: string
|
||||||
|
modelRecommendations: string
|
||||||
|
commonModelsDescription: string
|
||||||
|
selectEmbeddingModel: string
|
||||||
|
commonEmbeddingModels: string
|
||||||
|
saving: string
|
||||||
|
saveSettings: string
|
||||||
|
openTestPanel: string
|
||||||
|
updateSuccess: string
|
||||||
|
updateFailed: string
|
||||||
|
providerTagsRequired: string
|
||||||
|
providerEmbeddingRequired: string
|
||||||
|
}
|
||||||
|
smtp: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
host: string
|
||||||
|
port: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
fromEmail: string
|
||||||
|
forceSSL: string
|
||||||
|
ignoreCertErrors: string
|
||||||
|
saveSettings: string
|
||||||
|
sending: string
|
||||||
|
testEmail: string
|
||||||
|
updateSuccess: string
|
||||||
|
updateFailed: string
|
||||||
|
testSuccess: string
|
||||||
|
testFailed: string
|
||||||
|
}
|
||||||
|
users: {
|
||||||
|
createUser: string
|
||||||
|
addUser: string
|
||||||
|
createUserDescription: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
role: string
|
||||||
|
createSuccess: string
|
||||||
|
createFailed: string
|
||||||
|
deleteSuccess: string
|
||||||
|
deleteFailed: string
|
||||||
|
roleUpdateSuccess: string
|
||||||
|
roleUpdateFailed: string
|
||||||
|
table: {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
role: string
|
||||||
|
createdAt: string
|
||||||
|
actions: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aiTest: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
tagsTestTitle: string
|
||||||
|
tagsTestDescription: string
|
||||||
|
embeddingsTestTitle: string
|
||||||
|
embeddingsTestDescription: string
|
||||||
|
howItWorksTitle: string
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
testing: string
|
||||||
|
runTest: string
|
||||||
|
testPassed: string
|
||||||
|
testFailed: string
|
||||||
|
responseTime: string
|
||||||
|
generatedTags: string
|
||||||
|
embeddingDimensions: string
|
||||||
|
vectorDimensions: string
|
||||||
|
first5Values: string
|
||||||
|
error: string
|
||||||
|
testError: string
|
||||||
|
tipTitle: string
|
||||||
|
tipDescription: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
about: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
appName: string
|
||||||
|
appDescription: string
|
||||||
|
version: string
|
||||||
|
buildDate: string
|
||||||
|
platform: string
|
||||||
|
platformWeb: string
|
||||||
|
features: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
titleSuggestions: string
|
||||||
|
semanticSearch: string
|
||||||
|
paragraphReformulation: string
|
||||||
|
memoryEcho: string
|
||||||
|
notebookOrganization: string
|
||||||
|
dragDrop: string
|
||||||
|
labelSystem: string
|
||||||
|
multipleProviders: string
|
||||||
|
}
|
||||||
|
technology: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
frontend: string
|
||||||
|
backend: string
|
||||||
|
database: string
|
||||||
|
authentication: string
|
||||||
|
ai: string
|
||||||
|
ui: string
|
||||||
|
testing: string
|
||||||
|
}
|
||||||
|
support: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
documentation: string
|
||||||
|
reportIssues: string
|
||||||
|
feedback: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
support: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
buyMeACoffee: string
|
||||||
|
donationDescription: string
|
||||||
|
donateOnKofi: string
|
||||||
|
kofiDescription: string
|
||||||
|
sponsorOnGithub: string
|
||||||
|
sponsorDescription: string
|
||||||
|
githubDescription: string
|
||||||
|
howSupportHelps: string
|
||||||
|
directImpact: string
|
||||||
|
sponsorPerks: string
|
||||||
|
transparency: string
|
||||||
|
transparencyDescription: string
|
||||||
|
hostingServers: string
|
||||||
|
domainSSL: string
|
||||||
|
aiApiCosts: string
|
||||||
|
totalExpenses: string
|
||||||
|
otherWaysTitle: string
|
||||||
|
starGithub: string
|
||||||
|
reportBug: string
|
||||||
|
contributeCode: string
|
||||||
|
shareTwitter: string
|
||||||
|
}
|
||||||
|
demoMode: {
|
||||||
|
title: string
|
||||||
|
activated: string
|
||||||
|
deactivated: string
|
||||||
|
toggleFailed: string
|
||||||
|
description: string
|
||||||
|
parametersActive: string
|
||||||
|
similarityThreshold: string
|
||||||
|
delayBetweenNotes: string
|
||||||
|
unlimitedInsights: string
|
||||||
|
createNotesTip: string
|
||||||
|
}
|
||||||
|
resetPassword: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
invalidLinkTitle: string
|
||||||
|
invalidLinkDescription: string
|
||||||
|
requestNewLink: string
|
||||||
|
newPassword: string
|
||||||
|
confirmNewPassword: string
|
||||||
|
resetting: string
|
||||||
|
resetPassword: string
|
||||||
|
passwordMismatch: string
|
||||||
|
success: string
|
||||||
|
loading: string
|
||||||
|
}
|
||||||
|
dataManagement: {
|
||||||
|
title: string
|
||||||
|
toolsDescription: string
|
||||||
|
export: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
button: string
|
||||||
|
success: string
|
||||||
|
failed: string
|
||||||
|
}
|
||||||
|
import: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
button: string
|
||||||
|
success: string
|
||||||
|
failed: string
|
||||||
|
}
|
||||||
|
delete: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
button: string
|
||||||
|
confirm: string
|
||||||
|
success: string
|
||||||
|
failed: string
|
||||||
|
}
|
||||||
|
indexing: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
button: string
|
||||||
|
success: string
|
||||||
|
failed: string
|
||||||
|
}
|
||||||
|
cleanup: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
button: string
|
||||||
|
failed: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appearance: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
generalSettings: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
toast: {
|
||||||
|
saved: string
|
||||||
|
saveFailed: string
|
||||||
|
operationSuccess: string
|
||||||
|
operationFailed: string
|
||||||
|
openingConnection: string
|
||||||
|
openConnectionFailed: string
|
||||||
|
thanksFeedback: string
|
||||||
|
thanksFeedbackImproving: string
|
||||||
|
feedbackFailed: string
|
||||||
|
notesFusionSuccess: string
|
||||||
|
}
|
||||||
|
testPages: {
|
||||||
|
titleSuggestions: {
|
||||||
|
title: string
|
||||||
|
contentLabel: string
|
||||||
|
placeholder: string
|
||||||
|
wordCount: string
|
||||||
|
status: string
|
||||||
|
analyzing: string
|
||||||
|
idle: string
|
||||||
|
error: string
|
||||||
|
suggestions: string
|
||||||
|
noSuggestions: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trash: {
|
||||||
|
title: string
|
||||||
|
empty: string
|
||||||
|
restore: string
|
||||||
|
deletePermanently: string
|
||||||
|
}
|
||||||
|
footer: {
|
||||||
|
privacy: string
|
||||||
|
terms: string
|
||||||
|
openSource: string
|
||||||
|
}
|
||||||
|
connection: {
|
||||||
|
similarityInfo: string
|
||||||
|
clickToView: string
|
||||||
|
isHelpful: string
|
||||||
|
helpful: string
|
||||||
|
notHelpful: string
|
||||||
|
memoryEchoDiscovery: string
|
||||||
|
}
|
||||||
|
diagnostics: {
|
||||||
|
title: string
|
||||||
|
configuredProvider: string
|
||||||
|
apiStatus: string
|
||||||
|
testDetails: string
|
||||||
|
troubleshootingTitle: string
|
||||||
|
tip1: string
|
||||||
|
tip2: string
|
||||||
|
tip3: string
|
||||||
|
tip4: string
|
||||||
|
}
|
||||||
|
batch: {
|
||||||
|
organizeWithAI: string
|
||||||
|
organize: string
|
||||||
|
}
|
||||||
|
common: {
|
||||||
|
unknown: string
|
||||||
|
notAvailable: string
|
||||||
|
loading: string
|
||||||
|
error: string
|
||||||
|
success: string
|
||||||
|
confirm: string
|
||||||
|
cancel: string
|
||||||
|
close: string
|
||||||
|
save: string
|
||||||
|
delete: string
|
||||||
|
edit: string
|
||||||
|
add: string
|
||||||
|
remove: string
|
||||||
|
search: string
|
||||||
|
noResults: string
|
||||||
|
required: string
|
||||||
|
optional: string
|
||||||
|
}
|
||||||
|
time: {
|
||||||
|
justNow: string
|
||||||
|
minutesAgo: string
|
||||||
|
hoursAgo: string
|
||||||
|
daysAgo: string
|
||||||
|
yesterday: string
|
||||||
|
today: string
|
||||||
|
tomorrow: string
|
||||||
|
}
|
||||||
|
favorites: {
|
||||||
|
title: string
|
||||||
|
toggleSection: string
|
||||||
|
noFavorites: string
|
||||||
|
pinToFavorite: string
|
||||||
|
}
|
||||||
|
notebooks: {
|
||||||
|
create: string
|
||||||
|
allNotebooks: string
|
||||||
|
noNotebooks: string
|
||||||
|
createFirst: string
|
||||||
|
}
|
||||||
|
ui: {
|
||||||
|
close: string
|
||||||
|
open: string
|
||||||
|
expand: string
|
||||||
|
collapse: string
|
||||||
|
}
|
||||||
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -304,12 +902,11 @@ export interface Translations {
|
|||||||
export async function loadTranslations(language: SupportedLanguage): Promise<Translations> {
|
export async function loadTranslations(language: SupportedLanguage): Promise<Translations> {
|
||||||
try {
|
try {
|
||||||
const translations = await import(`@/locales/${language}.json`)
|
const translations = await import(`@/locales/${language}.json`)
|
||||||
return translations.default as Translations
|
return translations.default as unknown as Translations
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to load translations for ${language}:`, error)
|
console.error(`Failed to load translations for ${language}:`, error)
|
||||||
// Fallback to English
|
|
||||||
const enTranslations = await import(`@/locales/en.json`)
|
const enTranslations = await import(`@/locales/en.json`)
|
||||||
return enTranslations.default as Translations
|
return enTranslations.default as unknown as Translations
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -25,7 +25,8 @@
|
|||||||
"forgotPasswordDescription": "Enter your email address and we'll send you a link to reset your password.",
|
"forgotPasswordDescription": "Enter your email address and we'll send you a link to reset your password.",
|
||||||
"sending": "Sending...",
|
"sending": "Sending...",
|
||||||
"sendResetLink": "Send Reset Link",
|
"sendResetLink": "Send Reset Link",
|
||||||
"backToLogin": "Back to login"
|
"backToLogin": "Back to login",
|
||||||
|
"signOut": "Sign out"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
@ -118,7 +119,24 @@
|
|||||||
"markdownOn": "Markdown ON",
|
"markdownOn": "Markdown ON",
|
||||||
"markdownOff": "Markdown OFF",
|
"markdownOff": "Markdown OFF",
|
||||||
"undo": "Undo (Ctrl+Z)",
|
"undo": "Undo (Ctrl+Z)",
|
||||||
"redo": "Redo (Ctrl+Y)"
|
"redo": "Redo (Ctrl+Y)",
|
||||||
|
"pinnedNotes": "Pinned Notes",
|
||||||
|
"recent": "Recent",
|
||||||
|
"addNote": "Add Note",
|
||||||
|
"remove": "Remove",
|
||||||
|
"dragToReorder": "Drag to reorder",
|
||||||
|
"more": "More options",
|
||||||
|
"emptyState": "No notes yet. Create your first note!",
|
||||||
|
"inNotebook": "In notebook",
|
||||||
|
"moveFailed": "Failed to move note. Please try again.",
|
||||||
|
"clarifyFailed": "Failed to clarify text",
|
||||||
|
"shortenFailed": "Failed to shorten text",
|
||||||
|
"improveFailed": "Failed to improve text",
|
||||||
|
"transformFailed": "Failed to transform text",
|
||||||
|
"markdown": "Markdown",
|
||||||
|
"unpinned": "Unpinned",
|
||||||
|
"redoShortcut": "Redo (Ctrl+Y)",
|
||||||
|
"undoShortcut": "Undo (Ctrl+Z)"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "←",
|
"previous": "←",
|
||||||
@ -154,7 +172,9 @@
|
|||||||
"editLabelsDescription": "Create, edit colors, or delete labels.",
|
"editLabelsDescription": "Create, edit colors, or delete labels.",
|
||||||
"noLabelsFound": "No labels found.",
|
"noLabelsFound": "No labels found.",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"notebookRequired": "⚠️ Labels are only available in notebooks. Move this note to a notebook first."
|
"notebookRequired": "⚠️ Labels are only available in notebooks. Move this note to a notebook first.",
|
||||||
|
"count": "{count} labels",
|
||||||
|
"noLabels": "No labels"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Search",
|
"placeholder": "Search",
|
||||||
@ -235,7 +255,41 @@
|
|||||||
"improveStyle": "Improve style",
|
"improveStyle": "Improve style",
|
||||||
"reformulationComparison": "Reformulation Comparison",
|
"reformulationComparison": "Reformulation Comparison",
|
||||||
"original": "Original",
|
"original": "Original",
|
||||||
"reformulated": "Reformulated"
|
"reformulated": "Reformulated",
|
||||||
|
"autoLabels": {
|
||||||
|
"error": "Failed to fetch label suggestions",
|
||||||
|
"noLabelsSelected": "No labels selected",
|
||||||
|
"created": "{count} labels created successfully",
|
||||||
|
"analyzing": "Analyzing your notes...",
|
||||||
|
"title": "New Label Suggestions",
|
||||||
|
"description": "I've detected recurring themes in \"{notebookName}\" ({totalNotes} notes). Create labels for them?",
|
||||||
|
"note": "note",
|
||||||
|
"notes": "notes",
|
||||||
|
"typeContent": "Type content to get label suggestions...",
|
||||||
|
"createNewLabel": "Create this new label and add it",
|
||||||
|
"new": "(new)",
|
||||||
|
"create": "Create",
|
||||||
|
"creating": "Creating labels..."
|
||||||
|
},
|
||||||
|
"batchOrganization": {
|
||||||
|
"title": "Organize with AI",
|
||||||
|
"description": "AI will analyze your notes and suggest organizing them into notebooks.",
|
||||||
|
"analyzing": "Analyzing your notes...",
|
||||||
|
"noNotebooks": "No notebooks available. Create notebooks first to organize your notes.",
|
||||||
|
"noSuggestions": "AI could not find a good way to organize these notes.",
|
||||||
|
"apply": "Apply",
|
||||||
|
"applying": "Applying...",
|
||||||
|
"success": "{count} notes moved successfully",
|
||||||
|
"error": "Failed to create organization plan",
|
||||||
|
"noNotesSelected": "No notes selected",
|
||||||
|
"applyFailed": "Failed to apply organization plan",
|
||||||
|
"selectAllIn": "Select all notes in {notebook}",
|
||||||
|
"selectNote": "Select note: {title}"
|
||||||
|
},
|
||||||
|
"notebookSummary": {
|
||||||
|
"regenerate": "Regenerate Summary",
|
||||||
|
"regenerating": "Regenerating summary..."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"batchOrganization": {
|
"batchOrganization": {
|
||||||
"error": "Failed to create organization plan",
|
"error": "Failed to create organization plan",
|
||||||
@ -300,6 +354,7 @@
|
|||||||
"connection": "connection",
|
"connection": "connection",
|
||||||
"connectionsBadge": "{count} connection{plural}",
|
"connectionsBadge": "{count} connection{plural}",
|
||||||
"fused": "Fused",
|
"fused": "Fused",
|
||||||
|
"clickToView": "Click to view note →",
|
||||||
"overlay": {
|
"overlay": {
|
||||||
"title": "Connected Notes",
|
"title": "Connected Notes",
|
||||||
"searchPlaceholder": "Search connections...",
|
"searchPlaceholder": "Search connections...",
|
||||||
@ -309,7 +364,8 @@
|
|||||||
"sortOldest": "Oldest",
|
"sortOldest": "Oldest",
|
||||||
"viewAll": "View all side by side",
|
"viewAll": "View all side by side",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"noConnections": "No connections found"
|
"noConnections": "No connections found",
|
||||||
|
"error": "Failed to load connections"
|
||||||
},
|
},
|
||||||
"comparison": {
|
"comparison": {
|
||||||
"title": "💡 Note Comparison",
|
"title": "💡 Note Comparison",
|
||||||
@ -328,7 +384,8 @@
|
|||||||
"compare": "Compare",
|
"compare": "Compare",
|
||||||
"merge": "Merge",
|
"merge": "Merge",
|
||||||
"compareAll": "Compare all",
|
"compareAll": "Compare all",
|
||||||
"mergeAll": "Merge all"
|
"mergeAll": "Merge all",
|
||||||
|
"close": "Close"
|
||||||
},
|
},
|
||||||
"fusion": {
|
"fusion": {
|
||||||
"title": "🔗 Intelligent Fusion",
|
"title": "🔗 Intelligent Fusion",
|
||||||
@ -408,7 +465,16 @@
|
|||||||
"about": "About",
|
"about": "About",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"settingsSaved": "Settings saved",
|
"settingsSaved": "Settings saved",
|
||||||
"settingsError": "Error saving settings"
|
"settingsError": "Error saving settings",
|
||||||
|
"maintenance": "Maintenance",
|
||||||
|
"maintenanceDescription": "Tools to maintain your database health",
|
||||||
|
"cleanTags": "Clean Orphan Tags",
|
||||||
|
"cleanTagsDescription": "Remove tags that are no longer used by any notes",
|
||||||
|
"semanticIndexing": "Semantic Indexing",
|
||||||
|
"semanticIndexingDescription": "Generate vectors for all notes to enable intent-based search",
|
||||||
|
"profile": "Profile",
|
||||||
|
"searchNoResults": "No settings found",
|
||||||
|
"languageAuto": "Language set to Auto"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Profile",
|
"title": "Profile",
|
||||||
@ -468,7 +534,14 @@
|
|||||||
"frequencyWeekly": "Weekly",
|
"frequencyWeekly": "Weekly",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"saved": "Setting updated",
|
"saved": "Setting updated",
|
||||||
"error": "Failed to update setting"
|
"error": "Failed to update setting",
|
||||||
|
"titleSuggestionsDesc": "Suggest titles for untitled notes after 50+ words",
|
||||||
|
"paragraphRefactorDesc": "AI-powered text improvement options",
|
||||||
|
"frequencyDesc": "How often to analyze note connections",
|
||||||
|
"providerDesc": "Choose your preferred AI provider",
|
||||||
|
"providerAutoDesc": "Ollama when available, OpenAI fallback",
|
||||||
|
"providerOllamaDesc": "100% private, runs locally on your machine",
|
||||||
|
"providerOpenAIDesc": "Most accurate, requires API key"
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@ -489,7 +562,11 @@
|
|||||||
"tryAgain": "Please try again",
|
"tryAgain": "Please try again",
|
||||||
"error": "An error occurred",
|
"error": "An error occurred",
|
||||||
"operationSuccess": "Operation successful",
|
"operationSuccess": "Operation successful",
|
||||||
"operationFailed": "Operation failed"
|
"operationFailed": "Operation failed",
|
||||||
|
"testConnection": "Test Connection",
|
||||||
|
"clean": "Clean",
|
||||||
|
"indexAll": "Index All",
|
||||||
|
"preview": "Preview"
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
@ -528,7 +605,9 @@
|
|||||||
"summary": "Notebook Summary",
|
"summary": "Notebook Summary",
|
||||||
"summaryDescription": "Generate an AI-powered summary of all notes in this notebook.",
|
"summaryDescription": "Generate an AI-powered summary of all notes in this notebook.",
|
||||||
"generating": "Generating summary...",
|
"generating": "Generating summary...",
|
||||||
"summaryError": "Error generating summary"
|
"summaryError": "Error generating summary",
|
||||||
|
"labels": "Labels:",
|
||||||
|
"noLabels": "No labels"
|
||||||
},
|
},
|
||||||
"notebookSuggestion": {
|
"notebookSuggestion": {
|
||||||
"title": "Move to {icon} {name}?",
|
"title": "Move to {icon} {name}?",
|
||||||
@ -538,5 +617,378 @@
|
|||||||
"dismissIn": "Dismiss (closes in {timeLeft}s)",
|
"dismissIn": "Dismiss (closes in {timeLeft}s)",
|
||||||
"moveToNotebook": "Move to notebook",
|
"moveToNotebook": "Move to notebook",
|
||||||
"generalNotes": "General Notes"
|
"generalNotes": "General Notes"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Admin Dashboard",
|
||||||
|
"userManagement": "User Management",
|
||||||
|
"aiTesting": "AI Testing",
|
||||||
|
"settings": "Admin Settings",
|
||||||
|
"security": {
|
||||||
|
"title": "Security Settings",
|
||||||
|
"description": "Manage access control and registration policies.",
|
||||||
|
"allowPublicRegistration": "Allow Public Registration",
|
||||||
|
"allowPublicRegistrationDescription": "If disabled, new users can only be added by an Administrator via the User Management page.",
|
||||||
|
"updateSuccess": "Security Settings updated",
|
||||||
|
"updateFailed": "Failed to update security settings"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"title": "AI Configuration",
|
||||||
|
"description": "Configure AI providers for auto-tagging and semantic search. Use different providers for optimal performance.",
|
||||||
|
"tagsGenerationProvider": "Tags Generation Provider",
|
||||||
|
"tagsGenerationDescription": "AI provider for automatic tag suggestions. Recommended: Ollama (free, local).",
|
||||||
|
"embeddingsProvider": "Embeddings Provider",
|
||||||
|
"embeddingsDescription": "AI provider for semantic search embeddings. Recommended: OpenAI (best quality).",
|
||||||
|
"provider": "Provider",
|
||||||
|
"baseUrl": "Base URL",
|
||||||
|
"model": "Model",
|
||||||
|
"apiKey": "API Key",
|
||||||
|
"selectOllamaModel": "Select an Ollama model installed on your system",
|
||||||
|
"openAIKeyDescription": "Your OpenAI API key from platform.openai.com",
|
||||||
|
"modelRecommendations": "gpt-4o-mini = Best value • gpt-4o = Best quality",
|
||||||
|
"commonModelsDescription": "Common models for OpenAI-compatible APIs",
|
||||||
|
"selectEmbeddingModel": "Select an embedding model installed on your system",
|
||||||
|
"commonEmbeddingModels": "Common embedding models for OpenAI-compatible APIs",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"saveSettings": "Save AI Settings",
|
||||||
|
"openTestPanel": "Open AI Test Panel",
|
||||||
|
"updateSuccess": "AI Settings updated successfully",
|
||||||
|
"updateFailed": "Failed to update AI settings",
|
||||||
|
"providerTagsRequired": "AI_PROVIDER_TAGS is required",
|
||||||
|
"providerEmbeddingRequired": "AI_PROVIDER_EMBEDDING is required",
|
||||||
|
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
||||||
|
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||||
|
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
||||||
|
"bestValue": "Best value",
|
||||||
|
"bestQuality": "Best quality",
|
||||||
|
"saved": "(Saved)"
|
||||||
|
},
|
||||||
|
"smtp": {
|
||||||
|
"title": "SMTP Configuration",
|
||||||
|
"description": "Configure email server for password resets.",
|
||||||
|
"host": "Host",
|
||||||
|
"port": "Port",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"fromEmail": "From Email",
|
||||||
|
"forceSSL": "Force SSL/TLS (usually for port 465)",
|
||||||
|
"ignoreCertErrors": "Ignore Certificate Errors (Self-hosted/Dev only)",
|
||||||
|
"saveSettings": "Save SMTP Settings",
|
||||||
|
"sending": "Sending...",
|
||||||
|
"testEmail": "Test Email",
|
||||||
|
"updateSuccess": "SMTP Settings updated",
|
||||||
|
"updateFailed": "Failed to update SMTP settings",
|
||||||
|
"testSuccess": "Test email sent successfully!",
|
||||||
|
"testFailed": "Failed: {error}"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"createUser": "Create User",
|
||||||
|
"addUser": "Add User",
|
||||||
|
"createUserDescription": "Add a new user to the system.",
|
||||||
|
"name": "Name",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"role": "Role",
|
||||||
|
"createSuccess": "User created successfully",
|
||||||
|
"createFailed": "Failed to create user",
|
||||||
|
"deleteSuccess": "User deleted",
|
||||||
|
"deleteFailed": "Failed to delete",
|
||||||
|
"roleUpdateSuccess": "User role updated to {role}",
|
||||||
|
"roleUpdateFailed": "Failed to update role",
|
||||||
|
"demote": "Demote to User",
|
||||||
|
"promote": "Promote to Admin",
|
||||||
|
"confirmDelete": "Are you sure? This action cannot be undone.",
|
||||||
|
"table": {
|
||||||
|
"name": "Name",
|
||||||
|
"email": "Email",
|
||||||
|
"role": "Role",
|
||||||
|
"createdAt": "Created At",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"user": "User",
|
||||||
|
"admin": "Admin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"aiTest": {
|
||||||
|
"title": "AI Provider Testing",
|
||||||
|
"description": "Test your AI providers for tag generation and semantic search embeddings",
|
||||||
|
"tagsTestTitle": "Tags Generation Test",
|
||||||
|
"tagsTestDescription": "Test the AI provider responsible for automatic tag suggestions",
|
||||||
|
"embeddingsTestTitle": "Embeddings Test",
|
||||||
|
"embeddingsTestDescription": "Test the AI provider responsible for semantic search embeddings",
|
||||||
|
"howItWorksTitle": "How Testing Works",
|
||||||
|
"provider": "Provider:",
|
||||||
|
"model": "Model:",
|
||||||
|
"testing": "Testing...",
|
||||||
|
"runTest": "Run Test",
|
||||||
|
"testPassed": "Test Passed",
|
||||||
|
"testFailed": "Test Failed",
|
||||||
|
"responseTime": "Response time: {time}ms",
|
||||||
|
"generatedTags": "Generated Tags:",
|
||||||
|
"embeddingDimensions": "Embedding Dimensions:",
|
||||||
|
"vectorDimensions": "vector dimensions",
|
||||||
|
"first5Values": "First 5 values:",
|
||||||
|
"error": "Error:",
|
||||||
|
"testError": "Test Error: {error}",
|
||||||
|
"tipTitle": "Tip:",
|
||||||
|
"tipDescription": "Use the AI Test Panel to diagnose configuration issues before testing."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "About",
|
||||||
|
"description": "Information about the application",
|
||||||
|
"appName": "Keep Notes",
|
||||||
|
"appDescription": "A powerful note-taking application with AI-powered features",
|
||||||
|
"version": "Version",
|
||||||
|
"buildDate": "Build Date",
|
||||||
|
"platform": "Platform",
|
||||||
|
"platformWeb": "Web",
|
||||||
|
"features": {
|
||||||
|
"title": "Features",
|
||||||
|
"description": "AI-powered capabilities",
|
||||||
|
"titleSuggestions": "AI-powered title suggestions",
|
||||||
|
"semanticSearch": "Semantic search with embeddings",
|
||||||
|
"paragraphReformulation": "Paragraph reformulation",
|
||||||
|
"memoryEcho": "Memory Echo daily insights",
|
||||||
|
"notebookOrganization": "Notebook organization",
|
||||||
|
"dragDrop": "Drag & drop note management",
|
||||||
|
"labelSystem": "Label system",
|
||||||
|
"multipleProviders": "Multiple AI providers (OpenAI, Ollama)"
|
||||||
|
},
|
||||||
|
"technology": {
|
||||||
|
"title": "Technology Stack",
|
||||||
|
"description": "Built with modern technologies",
|
||||||
|
"frontend": "Frontend",
|
||||||
|
"backend": "Backend",
|
||||||
|
"database": "Database",
|
||||||
|
"authentication": "Authentication",
|
||||||
|
"ai": "AI",
|
||||||
|
"ui": "UI",
|
||||||
|
"testing": "Testing"
|
||||||
|
},
|
||||||
|
"support": {
|
||||||
|
"title": "Support",
|
||||||
|
"description": "Get help and feedback",
|
||||||
|
"documentation": "Documentation",
|
||||||
|
"reportIssues": "Report Issues",
|
||||||
|
"feedback": "Feedback"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"support": {
|
||||||
|
"title": "Support Memento Development",
|
||||||
|
"description": "Memento is 100% free and open-source. Your support helps keep it that way.",
|
||||||
|
"buyMeACoffee": "Buy me a coffee",
|
||||||
|
"donationDescription": "Make a one-time donation or become a monthly supporter.",
|
||||||
|
"donateOnKofi": "Donate on Ko-fi",
|
||||||
|
"kofiDescription": "No platform fees • Instant payouts • Secure",
|
||||||
|
"sponsorOnGithub": "Sponsor on GitHub",
|
||||||
|
"sponsorDescription": "Become a monthly sponsor and get recognition.",
|
||||||
|
"githubDescription": "Recurring support • Public recognition • Developer-focused",
|
||||||
|
"howSupportHelps": "How Your Support Helps",
|
||||||
|
"directImpact": "Direct Impact",
|
||||||
|
"sponsorPerks": "Sponsor Perks",
|
||||||
|
"transparency": "Transparency",
|
||||||
|
"transparencyDescription": "I believe in complete transparency. Here's how donations are used:",
|
||||||
|
"hostingServers": "Hosting & servers:",
|
||||||
|
"domainSSL": "Domain & SSL:",
|
||||||
|
"aiApiCosts": "AI API costs:",
|
||||||
|
"totalExpenses": "Total expenses:",
|
||||||
|
"otherWaysTitle": "Other Ways to Support",
|
||||||
|
"starGithub": "Star on GitHub",
|
||||||
|
"reportBug": "Report a bug",
|
||||||
|
"contributeCode": "Contribute code",
|
||||||
|
"shareTwitter": "Share on Twitter"
|
||||||
|
},
|
||||||
|
"demoMode": {
|
||||||
|
"title": "Demo Mode",
|
||||||
|
"activated": "Demo Mode activated! Memory Echo will now work instantly.",
|
||||||
|
"deactivated": "Demo Mode disabled. Normal parameters restored.",
|
||||||
|
"toggleFailed": "Failed to toggle demo mode",
|
||||||
|
"description": "Speeds up Memory Echo for testing. Connections appear instantly.",
|
||||||
|
"parametersActive": "Demo parameters active:",
|
||||||
|
"similarityThreshold": "50% similarity threshold (normally 75%)",
|
||||||
|
"delayBetweenNotes": "0-day delay between notes (normally 7 days)",
|
||||||
|
"unlimitedInsights": "Unlimited insights (no frequency limits)",
|
||||||
|
"createNotesTip": "Create 2+ similar notes and see Memory Echo in action!"
|
||||||
|
},
|
||||||
|
"resetPassword": {
|
||||||
|
"title": "Reset Password",
|
||||||
|
"description": "Enter your new password below.",
|
||||||
|
"invalidLinkTitle": "Invalid Link",
|
||||||
|
"invalidLinkDescription": "This password reset link is invalid or has expired.",
|
||||||
|
"requestNewLink": "Request new link",
|
||||||
|
"newPassword": "New Password",
|
||||||
|
"confirmNewPassword": "Confirm New Password",
|
||||||
|
"resetting": "Resetting...",
|
||||||
|
"resetPassword": "Reset Password",
|
||||||
|
"passwordMismatch": "Passwords don't match",
|
||||||
|
"success": "Password reset successfully. You can now login.",
|
||||||
|
"loading": "Loading..."
|
||||||
|
},
|
||||||
|
"dataManagement": {
|
||||||
|
"title": "Data Management",
|
||||||
|
"toolsDescription": "Tools to maintain your database health",
|
||||||
|
"exporting": "Exporting...",
|
||||||
|
"importing": "Importing...",
|
||||||
|
"deleting": "Deleting...",
|
||||||
|
"dangerZone": "Danger Zone",
|
||||||
|
"dangerZoneDescription": "Permanently delete your data",
|
||||||
|
"indexingComplete": "Indexing complete: {count} notes processed",
|
||||||
|
"indexingError": "Error during indexing",
|
||||||
|
"cleanupComplete": "Cleanup complete: {created} created, {deleted} removed",
|
||||||
|
"cleanupError": "Error during cleanup",
|
||||||
|
"export": {
|
||||||
|
"title": "Export All Notes",
|
||||||
|
"description": "Download all your notes as a JSON file. This includes all content, labels, and metadata.",
|
||||||
|
"button": "Export Notes",
|
||||||
|
"success": "Notes exported successfully",
|
||||||
|
"failed": "Failed to export notes"
|
||||||
|
},
|
||||||
|
"import": {
|
||||||
|
"title": "Import Notes",
|
||||||
|
"description": "Upload a JSON file to import notes. This will add to your existing notes, not replace them.",
|
||||||
|
"button": "Import Notes",
|
||||||
|
"success": "Imported {count} notes",
|
||||||
|
"failed": "Failed to import notes"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "Delete All Notes",
|
||||||
|
"description": "Permanently delete all your notes. This action cannot be undone.",
|
||||||
|
"button": "Delete All Notes",
|
||||||
|
"confirm": "Are you sure? This will permanently delete all your notes.",
|
||||||
|
"success": "All notes deleted",
|
||||||
|
"failed": "Failed to delete notes"
|
||||||
|
},
|
||||||
|
"indexing": {
|
||||||
|
"title": "Rebuild Search Index",
|
||||||
|
"description": "Regenerate embeddings for all notes to improve semantic search.",
|
||||||
|
"button": "Rebuild Index",
|
||||||
|
"success": "Indexing complete: {count} notes processed",
|
||||||
|
"failed": "Error during indexing"
|
||||||
|
},
|
||||||
|
"cleanup": {
|
||||||
|
"title": "Cleanup Orphaned Data",
|
||||||
|
"description": "Remove labels and connections that reference deleted notes.",
|
||||||
|
"button": "Cleanup",
|
||||||
|
"failed": "Error during cleanup"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"title": "Appearance",
|
||||||
|
"description": "Customize how the app looks"
|
||||||
|
},
|
||||||
|
"generalSettings": {
|
||||||
|
"title": "General Settings",
|
||||||
|
"description": "General application settings"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"saved": "Setting saved",
|
||||||
|
"saveFailed": "Failed to save setting",
|
||||||
|
"operationSuccess": "Operation successful",
|
||||||
|
"operationFailed": "Operation failed",
|
||||||
|
"openingConnection": "Opening connection...",
|
||||||
|
"openConnectionFailed": "Failed to open connection",
|
||||||
|
"thanksFeedback": "Thanks for your feedback!",
|
||||||
|
"thanksFeedbackImproving": "Thanks! We'll use this to improve.",
|
||||||
|
"feedbackFailed": "Failed to submit feedback",
|
||||||
|
"notesFusionSuccess": "Notes merged successfully!"
|
||||||
|
},
|
||||||
|
"testPages": {
|
||||||
|
"titleSuggestions": {
|
||||||
|
"title": "Test Title Suggestions",
|
||||||
|
"contentLabel": "Content (need 50+ words):",
|
||||||
|
"placeholder": "Type at least 50 words here...",
|
||||||
|
"wordCount": "Word count:",
|
||||||
|
"status": "Status:",
|
||||||
|
"analyzing": "Analyzing...",
|
||||||
|
"idle": "Idle",
|
||||||
|
"error": "Error:",
|
||||||
|
"suggestions": "Suggestions ({count}):",
|
||||||
|
"noSuggestions": "No suggestions yet. Type 50+ words and wait 2 seconds."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trash": {
|
||||||
|
"title": "Trash",
|
||||||
|
"empty": "The trash is empty",
|
||||||
|
"restore": "Restore",
|
||||||
|
"deletePermanently": "Delete Permanently"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"privacy": "Privacy",
|
||||||
|
"terms": "Terms",
|
||||||
|
"openSource": "Open Source Clone"
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"similarityInfo": "These notes are connected by {similarity}% similarity",
|
||||||
|
"clickToView": "Click to view note",
|
||||||
|
"isHelpful": "Is this connection helpful?",
|
||||||
|
"helpful": "Helpful",
|
||||||
|
"notHelpful": "Not Helpful",
|
||||||
|
"memoryEchoDiscovery": "Memory Echo Discovery"
|
||||||
|
},
|
||||||
|
"diagnostics": {
|
||||||
|
"title": "Diagnostics",
|
||||||
|
"description": "Check your AI provider connection status",
|
||||||
|
"configuredProvider": "Configured Provider",
|
||||||
|
"apiStatus": "API Status",
|
||||||
|
"operational": "Operational",
|
||||||
|
"errorStatus": "Error",
|
||||||
|
"checking": "Checking...",
|
||||||
|
"testDetails": "Test Details:",
|
||||||
|
"troubleshootingTitle": "Troubleshooting Tips:",
|
||||||
|
"tip1": "Make sure Ollama is running (ollama serve)",
|
||||||
|
"tip2": "Check that the model is installed (ollama pull llama3)",
|
||||||
|
"tip3": "Verify your API key for OpenAI",
|
||||||
|
"tip4": "Check network connectivity"
|
||||||
|
},
|
||||||
|
"batch": {
|
||||||
|
"organizeWithAI": "Organize with AI",
|
||||||
|
"organize": "Organize"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"unknown": "Unknown",
|
||||||
|
"notAvailable": "N/A",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"success": "Success",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"close": "Close",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove",
|
||||||
|
"search": "Search",
|
||||||
|
"noResults": "No results",
|
||||||
|
"required": "Required",
|
||||||
|
"optional": "Optional"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"justNow": "just now",
|
||||||
|
"minutesAgo": "{count}m ago",
|
||||||
|
"hoursAgo": "{count}h ago",
|
||||||
|
"daysAgo": "{count}d ago",
|
||||||
|
"yesterday": "Yesterday",
|
||||||
|
"today": "Today",
|
||||||
|
"tomorrow": "Tomorrow"
|
||||||
|
},
|
||||||
|
"favorites": {
|
||||||
|
"title": "Favorites",
|
||||||
|
"toggleSection": "Toggle pinned notes section",
|
||||||
|
"noFavorites": "No pinned notes yet",
|
||||||
|
"pinToFavorite": "Pin a note to add it to favorites"
|
||||||
|
},
|
||||||
|
"notebooks": {
|
||||||
|
"create": "Create notebook",
|
||||||
|
"allNotebooks": "All Notebooks",
|
||||||
|
"noNotebooks": "No notebooks yet",
|
||||||
|
"createFirst": "Create your first notebook"
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"close": "Close",
|
||||||
|
"open": "Open",
|
||||||
|
"expand": "Expand",
|
||||||
|
"collapse": "Collapse"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
22
keep-notes/scripts/check-config.ts
Normal file
22
keep-notes/scripts/check-config.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const configs = await prisma.systemConfig.findMany()
|
||||||
|
console.log('--- System Config ---')
|
||||||
|
configs.forEach(c => {
|
||||||
|
if (c.key.startsWith('AI_') || c.key.startsWith('OLLAMA_')) {
|
||||||
|
console.log(`${c.key}: ${c.value}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(e => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user