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 { useSearchParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
function ResetPasswordForm() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const token = searchParams.get('token')
|
||||
@ -25,7 +27,7 @@ function ResetPasswordForm() {
|
||||
const confirm = formData.get('confirmPassword') as string
|
||||
|
||||
if (password !== confirm) {
|
||||
toast.error("Passwords don't match")
|
||||
toast.error(t('resetPassword.passwordMismatch'))
|
||||
return
|
||||
}
|
||||
|
||||
@ -36,7 +38,7 @@ function ResetPasswordForm() {
|
||||
if (result.error) {
|
||||
toast.error(result.error)
|
||||
} else {
|
||||
toast.success('Password reset successfully. You can now login.')
|
||||
toast.success(t('resetPassword.success'))
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
@ -45,12 +47,12 @@ function ResetPasswordForm() {
|
||||
return (
|
||||
<Card className="w-full max-w-[400px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Invalid Link</CardTitle>
|
||||
<CardDescription>This password reset link is invalid or has expired.</CardDescription>
|
||||
<CardTitle>{t('resetPassword.invalidLinkTitle')}</CardTitle>
|
||||
<CardDescription>{t('resetPassword.invalidLinkDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<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>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
@ -60,23 +62,23 @@ function ResetPasswordForm() {
|
||||
return (
|
||||
<Card className="w-full max-w-[400px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Reset Password</CardTitle>
|
||||
<CardDescription>Enter your new password below.</CardDescription>
|
||||
<CardTitle>{t('resetPassword.title')}</CardTitle>
|
||||
<CardDescription>{t('resetPassword.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
<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 />
|
||||
</div>
|
||||
<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} />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Resetting...' : 'Reset Password'}
|
||||
{isSubmitting ? t('resetPassword.resetting') : t('resetPassword.resetPassword')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
@ -85,9 +87,10 @@ function ResetPasswordForm() {
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<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 />
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2, CheckCircle2, XCircle, Clock, Zap, Info } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface TestResult {
|
||||
success: boolean
|
||||
@ -20,6 +21,7 @@ interface TestResult {
|
||||
}
|
||||
|
||||
export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
const { t } = useLanguage()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [result, setResult] = useState<TestResult | null>(null)
|
||||
const [config, setConfig] = useState<any>(null)
|
||||
@ -34,7 +36,6 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
const data = await response.json()
|
||||
setConfig(data)
|
||||
|
||||
// Set previous result if available
|
||||
if (data.previousTest) {
|
||||
setResult(data.previousTest[type] || null)
|
||||
}
|
||||
@ -84,7 +85,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
responseTime: endTime - startTime
|
||||
}
|
||||
setResult(errorResult)
|
||||
toast.error(`❌ Test Error: ${error.message}`)
|
||||
toast.error(t('admin.aiTest.testError', { error: error.message }))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@ -113,13 +114,13 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
{/* Provider Info */}
|
||||
<div className="space-y-3 p-4 bg-muted/50 rounded-lg">
|
||||
<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">
|
||||
{providerInfo.provider.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<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">
|
||||
{providerInfo.model}
|
||||
</span>
|
||||
@ -136,12 +137,12 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Testing...
|
||||
{t('admin.aiTest.testing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Run Test
|
||||
{t('admin.aiTest.runTest')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@ -155,12 +156,12 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
{result.success ? (
|
||||
<>
|
||||
<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" />
|
||||
<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>
|
||||
@ -169,7 +170,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
{result.responseTime && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-4">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Response time: {result.responseTime}ms</span>
|
||||
<span>{t('admin.aiTest.responseTime', { time: result.responseTime })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -178,7 +179,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<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 className="flex flex-wrap gap-2">
|
||||
{result.tags.map((tag, idx) => (
|
||||
@ -202,19 +203,19 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<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 className="p-3 bg-muted rounded-lg">
|
||||
<div className="text-2xl font-bold text-center">
|
||||
{result.embeddingLength}
|
||||
</div>
|
||||
<div className="text-xs text-center text-muted-foreground mt-1">
|
||||
vector dimensions
|
||||
{t('admin.aiTest.vectorDimensions')}
|
||||
</div>
|
||||
</div>
|
||||
{result.firstValues && result.firstValues.length > 0 && (
|
||||
<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">
|
||||
[{result.firstValues.slice(0, 5).map((v, i) => v.toFixed(4)).join(', ')}]
|
||||
</div>
|
||||
@ -226,7 +227,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
{/* Error Details */}
|
||||
{!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">
|
||||
<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>
|
||||
{result.details && (
|
||||
<details className="mt-2">
|
||||
@ -249,7 +250,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
<div className="text-center py-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,26 +1,48 @@
|
||||
import { AdminMetrics } from '@/components/admin-metrics'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Zap, Settings, Activity, TrendingUp } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
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
|
||||
// TODO: Implement real analytics tracking
|
||||
const aiMetrics = [
|
||||
{
|
||||
title: 'Total Requests',
|
||||
value: '856',
|
||||
trend: { value: 12, isPositive: true },
|
||||
value: '—',
|
||||
trend: { value: 0, isPositive: true },
|
||||
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
|
||||
},
|
||||
{
|
||||
title: 'Success Rate',
|
||||
value: '98.5%',
|
||||
trend: { value: 2, isPositive: true },
|
||||
value: '100%',
|
||||
trend: { value: 0, isPositive: true },
|
||||
icon: <TrendingUp className="h-5 w-5 text-green-600 dark:text-green-400" />,
|
||||
},
|
||||
{
|
||||
title: 'Avg Response Time',
|
||||
value: '1.2s',
|
||||
trend: { value: 5, isPositive: true },
|
||||
value: '—',
|
||||
trend: { value: 0, isPositive: true },
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Configure
|
||||
</Button>
|
||||
<Link href="/admin/settings">
|
||||
<Button variant="outline">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Configure
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<AdminMetrics metrics={aiMetrics} />
|
||||
@ -83,10 +107,7 @@ export default async function AdminAIPage() {
|
||||
AI Provider Status
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ name: 'OpenAI', status: 'Connected', requests: '642' },
|
||||
{ name: 'Ollama', status: 'Available', requests: '214' },
|
||||
].map((provider) => (
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
key={provider.name}
|
||||
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>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
provider.status === 'Connected'
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${provider.status === 'Connected' || provider.status === 'Available'
|
||||
? '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}
|
||||
</span>
|
||||
@ -119,7 +139,7 @@ export default async function AdminAIPage() {
|
||||
Recent AI Requests
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -15,50 +15,52 @@ import { Input } from '@/components/ui/input'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { createUser } from '@/app/actions/admin'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function CreateUserDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add User
|
||||
<Plus className="mr-2 h-4 w-4" /> {t('admin.users.addUser')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create User</DialogTitle>
|
||||
<DialogTitle>{t('admin.users.createUser')}</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
<form
|
||||
action={async (formData) => {
|
||||
const result = await createUser(formData)
|
||||
if (result?.error) {
|
||||
toast.error('Failed to create user')
|
||||
toast.error(t('admin.users.createFailed'))
|
||||
} else {
|
||||
toast.success('User created successfully')
|
||||
toast.success(t('admin.users.createSuccess'))
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
className="grid gap-4 py-4"
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="name">Name</label>
|
||||
<label htmlFor="name">{t('admin.users.name')}</label>
|
||||
<Input id="name" name="name" required />
|
||||
</div>
|
||||
<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 />
|
||||
</div>
|
||||
<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} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="role">Role</label>
|
||||
<label htmlFor="role">{t('admin.users.role')}</label>
|
||||
<select
|
||||
id="role"
|
||||
name="role"
|
||||
@ -69,7 +71,7 @@ export function CreateUserDialog() {
|
||||
</select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">Create User</Button>
|
||||
<Button type="submit">{t('admin.users.createUser')}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@ -6,10 +6,12 @@ import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { updateSystemConfig, testSMTP } from '@/app/actions/admin-settings'
|
||||
import { getOllamaModels } from '@/app/actions/ollama'
|
||||
import { toast } from 'sonner'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
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'
|
||||
|
||||
@ -19,10 +21,7 @@ interface AvailableModels {
|
||||
}
|
||||
|
||||
const MODELS_2026 = {
|
||||
ollama: {
|
||||
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']
|
||||
},
|
||||
// Removed hardcoded Ollama models in favor of dynamic fetching
|
||||
openai: {
|
||||
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']
|
||||
@ -34,6 +33,7 @@ const MODELS_2026 = {
|
||||
}
|
||||
|
||||
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
|
||||
const { t } = useLanguage()
|
||||
const [isSaving, setIsSaving] = 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 [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(() => {
|
||||
setAllowRegister(config.ALLOW_REGISTRATION !== 'false')
|
||||
setSmtpSecure(config.SMTP_SECURE === 'true')
|
||||
setSmtpIgnoreCert(config.SMTP_IGNORE_CERT === 'true')
|
||||
setTagsProvider((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
|
||||
setEmbeddingsProvider((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
|
||||
setSelectedTagsModel(config.AI_MODEL_TAGS || '')
|
||||
setSelectedEmbeddingModel(config.AI_MODEL_EMBEDDING || '')
|
||||
}, [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) => {
|
||||
setIsSaving(true)
|
||||
const data = {
|
||||
@ -65,9 +118,9 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
setIsSaving(false)
|
||||
|
||||
if (result.error) {
|
||||
toast.error('Failed to update security settings')
|
||||
toast.error(t('admin.security.updateFailed'))
|
||||
} 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> = {}
|
||||
|
||||
try {
|
||||
// Tags provider configuration
|
||||
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
|
||||
|
||||
const tagsModel = formData.get('AI_MODEL_TAGS') as string
|
||||
@ -86,7 +138,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
|
||||
if (tagsProv === 'ollama') {
|
||||
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') {
|
||||
const openaiKey = formData.get('OPENAI_API_KEY') as string
|
||||
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
|
||||
}
|
||||
|
||||
// Embeddings provider configuration
|
||||
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
|
||||
|
||||
const embedModel = formData.get('AI_MODEL_EMBEDDING') as string
|
||||
@ -107,7 +158,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
|
||||
if (embedProv === 'ollama') {
|
||||
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') {
|
||||
const openaiKey = formData.get('OPENAI_API_KEY') as string
|
||||
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
|
||||
}
|
||||
|
||||
console.log('Saving AI config:', data)
|
||||
|
||||
const result = await updateSystemConfig(data)
|
||||
setIsSaving(false)
|
||||
|
||||
if (result.error) {
|
||||
toast.error('Failed to update AI settings: ' + result.error)
|
||||
toast.error(t('admin.ai.updateFailed') + ': ' + result.error)
|
||||
} else {
|
||||
toast.success('AI Settings updated successfully')
|
||||
toast.success(t('admin.ai.updateSuccess'))
|
||||
setTagsProvider(tagsProv)
|
||||
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) {
|
||||
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)
|
||||
|
||||
if (result.error) {
|
||||
toast.error('Failed to update SMTP settings')
|
||||
toast.error(t('admin.smtp.updateFailed'))
|
||||
} 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 {
|
||||
const result: any = await testSMTP()
|
||||
if (result.success) {
|
||||
toast.success('Test email sent successfully!')
|
||||
toast.success(t('admin.smtp.testSuccess'))
|
||||
} else {
|
||||
toast.error(`Failed: ${result.error}`)
|
||||
toast.error(t('admin.smtp.testFailed', { error: result.error }))
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(`Error: ${e.message}`)
|
||||
toast.error(t('general.error') + ': ' + e.message)
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
@ -177,8 +238,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security Settings</CardTitle>
|
||||
<CardDescription>Manage access control and registration policies.</CardDescription>
|
||||
<CardTitle>{t('admin.security.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.security.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form action={handleSaveSecurity}>
|
||||
<CardContent className="space-y-4">
|
||||
@ -192,35 +253,34 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
htmlFor="ALLOW_REGISTRATION"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Allow Public Registration
|
||||
{t('admin.security.allowPublicRegistration')}
|
||||
</label>
|
||||
</div>
|
||||
<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>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" disabled={isSaving}>Save Security Settings</Button>
|
||||
<Button type="submit" disabled={isSaving}>{t('admin.security.title')}</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI Configuration</CardTitle>
|
||||
<CardDescription>Configure AI providers for auto-tagging and semantic search. Use different providers for optimal performance.</CardDescription>
|
||||
<CardTitle>{t('admin.ai.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.ai.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form action={handleSaveAI}>
|
||||
<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">
|
||||
<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>
|
||||
<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">
|
||||
<Label htmlFor="AI_PROVIDER_TAGS">Provider</Label>
|
||||
<Label htmlFor="AI_PROVIDER_TAGS">{t('admin.ai.provider')}</Label>
|
||||
<select
|
||||
id="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)}
|
||||
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="openai">🤖 OpenAI (GPT-5, GPT-4)</option>
|
||||
<option value="custom">🔧 Custom OpenAI-Compatible</option>
|
||||
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
|
||||
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
|
||||
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Ollama Tags Config */}
|
||||
{tagsProvider === 'ollama' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="OLLAMA_BASE_URL_TAGS">Base URL</Label>
|
||||
<Input id="OLLAMA_BASE_URL_TAGS" name="OLLAMA_BASE_URL_TAGS" defaultValue={config.OLLAMA_BASE_URL || 'http://localhost:11434'} placeholder="http://localhost:11434" />
|
||||
<Label htmlFor="OLLAMA_BASE_URL_TAGS">{t('admin.ai.baseUrl')}</Label>
|
||||
<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 className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_TAGS_OLLAMA">Model</Label>
|
||||
<Label htmlFor="AI_MODEL_TAGS_OLLAMA">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_TAGS_OLLAMA"
|
||||
name="AI_MODEL_TAGS"
|
||||
defaultValue={config.AI_MODEL_TAGS || 'granite4:latest'}
|
||||
name="AI_MODEL_TAGS_OLLAMA"
|
||||
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"
|
||||
>
|
||||
{MODELS_2026.ollama.tags.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
{ollamaTagsModels.length > 0 ? (
|
||||
ollamaTagsModels.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))
|
||||
) : (
|
||||
<option value={selectedTagsModel || 'granite4:latest'}>{selectedTagsModel || 'granite4:latest'} {t('admin.ai.saved')}</option>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* OpenAI Tags Config */}
|
||||
{tagsProvider === 'openai' && (
|
||||
<div className="space-y-3">
|
||||
<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-..." />
|
||||
<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 className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_TAGS_OPENAI">Model</Label>
|
||||
<Label htmlFor="AI_MODEL_TAGS_OPENAI">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_TAGS_OPENAI"
|
||||
name="AI_MODEL_TAGS"
|
||||
defaultValue={config.AI_MODEL_TAGS || 'gpt-4o-mini'}
|
||||
name="AI_MODEL_TAGS_OPENAI"
|
||||
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"
|
||||
>
|
||||
{MODELS_2026.openai.tags.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Custom OpenAI Tags Config */}
|
||||
{tagsProvider === 'custom' && (
|
||||
<div className="space-y-3">
|
||||
<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" />
|
||||
</div>
|
||||
<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-..." />
|
||||
</div>
|
||||
<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
|
||||
id="AI_MODEL_TAGS_CUSTOM"
|
||||
name="AI_MODEL_TAGS"
|
||||
defaultValue={config.AI_MODEL_TAGS || 'gpt-4o-mini'}
|
||||
name="AI_MODEL_TAGS_CUSTOM"
|
||||
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"
|
||||
>
|
||||
{MODELS_2026.custom.tags.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* Embeddings Section */}
|
||||
<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">
|
||||
<span className="text-green-600">🔍</span> Embeddings Provider
|
||||
<span className="text-green-600">🔍</span> {t('admin.ai.embeddingsProvider')}
|
||||
</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">
|
||||
<Label htmlFor="AI_PROVIDER_EMBEDDING">
|
||||
Provider
|
||||
{t('admin.ai.provider')}
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
(Current: {embeddingsProvider})
|
||||
</span>
|
||||
@ -333,99 +418,125 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
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"
|
||||
>
|
||||
<option value="ollama">🦙 Ollama (Local & Free)</option>
|
||||
<option value="openai">🤖 OpenAI (text-embedding-4)</option>
|
||||
<option value="custom">🔧 Custom OpenAI-Compatible</option>
|
||||
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
|
||||
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
|
||||
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Config value: {config.AI_PROVIDER_EMBEDDING || 'Not set (defaults to ollama)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Ollama Embeddings Config */}
|
||||
{embeddingsProvider === 'ollama' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="OLLAMA_BASE_URL_EMBEDDING">Base URL</Label>
|
||||
<Input id="OLLAMA_BASE_URL_EMBEDDING" name="OLLAMA_BASE_URL_EMBEDDING" defaultValue={config.OLLAMA_BASE_URL || 'http://localhost:11434'} placeholder="http://localhost:11434" />
|
||||
<Label htmlFor="OLLAMA_BASE_URL_EMBEDDING">{t('admin.ai.baseUrl')}</Label>
|
||||
<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 className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_EMBEDDING_OLLAMA">Model</Label>
|
||||
<Label htmlFor="AI_MODEL_EMBEDDING_OLLAMA">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_EMBEDDING_OLLAMA"
|
||||
name="AI_MODEL_EMBEDDING"
|
||||
defaultValue={config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'}
|
||||
name="AI_MODEL_EMBEDDING_OLLAMA"
|
||||
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"
|
||||
>
|
||||
{MODELS_2026.ollama.embeddings.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
{ollamaEmbeddingsModels.length > 0 ? (
|
||||
ollamaEmbeddingsModels.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))
|
||||
) : (
|
||||
<option value={selectedEmbeddingModel || 'embeddinggemma:latest'}>{selectedEmbeddingModel || 'embeddinggemma:latest'} {t('admin.ai.saved')}</option>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* OpenAI Embeddings Config */}
|
||||
{embeddingsProvider === 'openai' && (
|
||||
<div className="space-y-3">
|
||||
<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-..." />
|
||||
<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 className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_EMBEDDING_OPENAI">Model</Label>
|
||||
<Label htmlFor="AI_MODEL_EMBEDDING_OPENAI">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_EMBEDDING_OPENAI"
|
||||
name="AI_MODEL_EMBEDDING"
|
||||
defaultValue={config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'}
|
||||
name="AI_MODEL_EMBEDDING_OPENAI"
|
||||
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"
|
||||
>
|
||||
{MODELS_2026.openai.embeddings.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Custom OpenAI Embeddings Config */}
|
||||
{embeddingsProvider === 'custom' && (
|
||||
<div className="space-y-3">
|
||||
<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" />
|
||||
</div>
|
||||
<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-..." />
|
||||
</div>
|
||||
<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
|
||||
id="AI_MODEL_EMBEDDING_CUSTOM"
|
||||
name="AI_MODEL_EMBEDDING"
|
||||
defaultValue={config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'}
|
||||
name="AI_MODEL_EMBEDDING_CUSTOM"
|
||||
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"
|
||||
>
|
||||
{MODELS_2026.custom.embeddings.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</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>
|
||||
</CardContent>
|
||||
<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">
|
||||
<Button type="button" variant="outline" className="gap-2">
|
||||
<TestTube className="h-4 w-4" />
|
||||
Open AI Test Panel
|
||||
{t('admin.ai.openTestPanel')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
@ -435,34 +546,34 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>SMTP Configuration</CardTitle>
|
||||
<CardDescription>Configure email server for password resets.</CardDescription>
|
||||
<CardTitle>{t('admin.smtp.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.smtp.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form action={handleSaveSMTP}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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" />
|
||||
</div>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 || ''} />
|
||||
</div>
|
||||
|
||||
<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 || ''} />
|
||||
</div>
|
||||
|
||||
<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'} />
|
||||
</div>
|
||||
|
||||
@ -476,7 +587,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
htmlFor="SMTP_SECURE"
|
||||
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>
|
||||
</div>
|
||||
|
||||
@ -490,14 +601,14 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
htmlFor="SMTP_IGNORE_CERT"
|
||||
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>
|
||||
</div>
|
||||
</CardContent>
|
||||
<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}>
|
||||
{isTesting ? 'Sending...' : 'Test Email'}
|
||||
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
|
||||
@ -6,17 +6,18 @@ import { deleteUser, updateUserRole } from '@/app/actions/admin'
|
||||
import { toast } from 'sonner'
|
||||
import { Trash2, Shield, ShieldOff } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
||||
|
||||
// Optimistic update could be implemented here, but standard is fine for admin
|
||||
const { t } = useLanguage()
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure? This action cannot be undone.')) return
|
||||
if (!confirm(t('admin.users.confirmDelete'))) return
|
||||
try {
|
||||
await deleteUser(id)
|
||||
toast.success('User deleted')
|
||||
toast.success(t('admin.users.deleteSuccess'))
|
||||
} 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'
|
||||
try {
|
||||
await updateUserRole(user.id, newRole)
|
||||
toast.success(`User role updated to ${newRole}`)
|
||||
toast.success(t('admin.users.roleUpdateSuccess', { role: newRole }))
|
||||
} 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">
|
||||
<thead className="[&_tr]:border-b">
|
||||
<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">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">Created At</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">{t('admin.users.table.name')}</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">{t('admin.users.table.role')}</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">{t('admin.users.table.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="[&_tr:last-child]:border-0">
|
||||
{initialUsers.map((user) => (
|
||||
<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">
|
||||
<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>
|
||||
</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"
|
||||
size="sm"
|
||||
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" />}
|
||||
</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 { cn } from '@/lib/utils'
|
||||
import { LabelFilter } from '@/components/label-filter'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default function HomePage() {
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
// Force re-render when search params change (for filtering)
|
||||
const [notes, setNotes] = useState<Note[]>([])
|
||||
const [pinnedNotes, setPinnedNotes] = useState<Note[]>([])
|
||||
@ -260,7 +262,7 @@ export default function HomePage() {
|
||||
// Helper for Breadcrumbs
|
||||
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
|
||||
<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" />
|
||||
<span className="font-medium text-primary">{notebookName}</span>
|
||||
</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">
|
||||
<FileText className="w-8 h-8 text-primary" />
|
||||
</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>
|
||||
|
||||
{/* Actions Section */}
|
||||
@ -342,15 +344,15 @@ export default function HomePage() {
|
||||
/>
|
||||
|
||||
{/* AI Organization Button - Moved to Header */}
|
||||
{isInbox && !isLoading && notes.length >= 5 && (
|
||||
{isInbox && !isLoading && notes.length >= 2 && (
|
||||
<Button
|
||||
onClick={() => setBatchOrganizationOpen(true)}
|
||||
variant="outline"
|
||||
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" />
|
||||
<span className="hidden sm:inline">Organiser</span>
|
||||
<span className="hidden sm:inline">{t('batch.organize')}</span>
|
||||
</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"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add Note
|
||||
{t('notes.newNote')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -378,7 +380,7 @@ export default function HomePage() {
|
||||
)}
|
||||
|
||||
{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 */}
|
||||
@ -408,7 +410,7 @@ export default function HomePage() {
|
||||
{/* Empty state when no notes */}
|
||||
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No notes yet. Create your first note!
|
||||
{t('notes.emptyState')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -1,127 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import { SettingsNav, SettingsSection } from '@/components/settings'
|
||||
import { SettingsSection } from '@/components/settings'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default function AboutSettingsPage() {
|
||||
const { t } = useLanguage()
|
||||
const version = '1.0.0'
|
||||
const buildDate = '2026-01-17'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
Information about the application
|
||||
{t('about.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title="Keep Notes"
|
||||
title={t('about.appName')}
|
||||
icon={<span className="text-2xl">📝</span>}
|
||||
description="A powerful note-taking application with AI-powered features"
|
||||
description={t('about.appDescription')}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">Platform</span>
|
||||
<Badge variant="outline">Web</Badge>
|
||||
<span className="font-medium">{t('about.platform')}</span>
|
||||
<Badge variant="outline">{t('about.platformWeb')}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Features"
|
||||
title={t('about.features.title')}
|
||||
icon={<span className="text-2xl">✨</span>}
|
||||
description="AI-powered capabilities"
|
||||
description={t('about.features.description')}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span>AI-powered title suggestions</span>
|
||||
<span>{t('about.features.titleSuggestions')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span>Semantic search with embeddings</span>
|
||||
<span>{t('about.features.semanticSearch')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span>Paragraph reformulation</span>
|
||||
<span>{t('about.features.paragraphReformulation')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span>Memory Echo daily insights</span>
|
||||
<span>{t('about.features.memoryEcho')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span>Notebook organization</span>
|
||||
<span>{t('about.features.notebookOrganization')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span>Drag & drop note management</span>
|
||||
<span>{t('about.features.dragDrop')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span>Label system</span>
|
||||
<span>{t('about.features.labelSystem')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span>Multiple AI providers (OpenAI, Ollama)</span>
|
||||
<span>{t('about.features.multipleProviders')}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Technology Stack"
|
||||
title={t('about.technology.title')}
|
||||
icon={<span className="text-2xl">⚙️</span>}
|
||||
description="Built with modern technologies"
|
||||
description={t('about.technology.description')}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-2 text-sm">
|
||||
<div><strong>Frontend:</strong> Next.js 16, React 19, TypeScript</div>
|
||||
<div><strong>Backend:</strong> Next.js API Routes, Server Actions</div>
|
||||
<div><strong>Database:</strong> SQLite (Prisma ORM)</div>
|
||||
<div><strong>Authentication:</strong> NextAuth 5</div>
|
||||
<div><strong>AI:</strong> Vercel AI SDK, OpenAI, Ollama</div>
|
||||
<div><strong>UI:</strong> Radix UI, Tailwind CSS, Lucide Icons</div>
|
||||
<div><strong>Testing:</strong> Playwright (E2E)</div>
|
||||
<div><strong>{t('about.technology.frontend')}:</strong> Next.js 16, React 19, TypeScript</div>
|
||||
<div><strong>{t('about.technology.backend')}:</strong> Next.js API Routes, Server Actions</div>
|
||||
<div><strong>{t('about.technology.database')}:</strong> SQLite (Prisma ORM)</div>
|
||||
<div><strong>{t('about.technology.authentication')}:</strong> NextAuth 5</div>
|
||||
<div><strong>{t('about.technology.ai')}:</strong> Vercel AI SDK, OpenAI, Ollama</div>
|
||||
<div><strong>{t('about.technology.ui')}:</strong> Radix UI, Tailwind CSS, Lucide Icons</div>
|
||||
<div><strong>{t('about.technology.testing')}:</strong> Playwright (E2E)</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Support"
|
||||
title={t('about.support.title')}
|
||||
icon={<span className="text-2xl">💬</span>}
|
||||
description="Get help and feedback"
|
||||
description={t('about.support.description')}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<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">
|
||||
Check the documentation for detailed guides and tutorials.
|
||||
</p>
|
||||
</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">
|
||||
Found a bug? Report it in the issue tracker.
|
||||
</p>
|
||||
</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">
|
||||
We value your feedback! Share your thoughts and suggestions.
|
||||
</p>
|
||||
|
||||
@ -27,36 +27,33 @@ export default function AISettingsPage() {
|
||||
try {
|
||||
await updateAISettings({ [feature]: value })
|
||||
} catch (error) {
|
||||
console.error('Error updating setting:', error)
|
||||
toast.error('Failed to save setting')
|
||||
toast.error(t('aiSettings.error'))
|
||||
setSettings(settings) // Revert on error
|
||||
}
|
||||
}
|
||||
|
||||
const handleFrequencyChange = async (value: 'daily' | 'weekly' | 'custom') => {
|
||||
setSettings(prev => ({ ...prev, memoryEchoFrequency: value }))
|
||||
const handleFrequencyChange = async (value: string) => {
|
||||
setSettings(prev => ({ ...prev, memoryEchoFrequency: value as any }))
|
||||
try {
|
||||
await updateAISettings({ memoryEchoFrequency: value })
|
||||
await updateAISettings({ memoryEchoFrequency: value as any })
|
||||
} catch (error) {
|
||||
console.error('Error updating frequency:', error)
|
||||
toast.error('Failed to save setting')
|
||||
toast.error(t('aiSettings.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleProviderChange = async (value: 'auto' | 'openai' | 'ollama') => {
|
||||
setSettings(prev => ({ ...prev, aiProvider: value }))
|
||||
const handleProviderChange = async (value: string) => {
|
||||
setSettings(prev => ({ ...prev, aiProvider: value as any }))
|
||||
try {
|
||||
await updateAISettings({ aiProvider: value })
|
||||
await updateAISettings({ aiProvider: value as any })
|
||||
} catch (error) {
|
||||
console.error('Error updating provider:', error)
|
||||
toast.error('Failed to save setting')
|
||||
toast.error(t('aiSettings.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleApiKeyChange = async (value: string) => {
|
||||
setApiKey(value)
|
||||
// TODO: Implement API key persistence
|
||||
console.log('API Key:', value)
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
@ -70,37 +67,37 @@ export default function AISettingsPage() {
|
||||
{/* Main Content */}
|
||||
<main className="lg:col-span-3 space-y-6">
|
||||
<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">
|
||||
Configure AI-powered features and preferences
|
||||
{t('aiSettings.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* AI Provider */}
|
||||
<SettingsSection
|
||||
title="AI Provider"
|
||||
title={t('aiSettings.provider')}
|
||||
icon={<span className="text-2xl">🤖</span>}
|
||||
description="Choose your preferred AI service provider"
|
||||
description={t('aiSettings.providerDesc')}
|
||||
>
|
||||
<SettingSelect
|
||||
label="Provider"
|
||||
description="Select which AI service to use"
|
||||
label={t('aiSettings.provider')}
|
||||
description={t('aiSettings.providerDesc')}
|
||||
value={settings.aiProvider}
|
||||
options={[
|
||||
{
|
||||
value: 'auto',
|
||||
label: 'Auto-detect',
|
||||
description: 'Ollama when available, OpenAI fallback'
|
||||
label: t('aiSettings.providerAuto'),
|
||||
description: t('aiSettings.providerAutoDesc')
|
||||
},
|
||||
{
|
||||
value: 'ollama',
|
||||
label: 'Ollama (Local)',
|
||||
description: '100% private, runs locally on your machine'
|
||||
label: t('aiSettings.providerOllama'),
|
||||
description: t('aiSettings.providerOllamaDesc')
|
||||
},
|
||||
{
|
||||
value: 'openai',
|
||||
label: 'OpenAI',
|
||||
description: 'Most accurate, requires API key'
|
||||
label: t('aiSettings.providerOpenAI'),
|
||||
description: t('aiSettings.providerOpenAIDesc')
|
||||
},
|
||||
]}
|
||||
onChange={handleProviderChange}
|
||||
@ -108,8 +105,8 @@ export default function AISettingsPage() {
|
||||
|
||||
{settings.aiProvider === 'openai' && (
|
||||
<SettingInput
|
||||
label="API Key"
|
||||
description="Your OpenAI API key (stored securely)"
|
||||
label={t('admin.ai.apiKey')}
|
||||
description={t('admin.ai.openAIKeyDescription')}
|
||||
value={apiKey}
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
@ -120,46 +117,46 @@ export default function AISettingsPage() {
|
||||
|
||||
{/* Feature Toggles */}
|
||||
<SettingsSection
|
||||
title="AI Features"
|
||||
title={t('aiSettings.features')}
|
||||
icon={<span className="text-2xl">✨</span>}
|
||||
description="Enable or disable AI-powered features"
|
||||
description={t('aiSettings.description')}
|
||||
>
|
||||
<SettingToggle
|
||||
label="Title Suggestions"
|
||||
description="Suggest titles for untitled notes after 50+ words"
|
||||
label={t('titleSuggestions.available').replace('💡 ', '')}
|
||||
description={t('aiSettings.titleSuggestionsDesc')}
|
||||
checked={settings.titleSuggestions}
|
||||
onChange={(checked) => handleToggle('titleSuggestions', checked)}
|
||||
/>
|
||||
|
||||
<SettingToggle
|
||||
label="Semantic Search"
|
||||
description="Search by meaning, not just keywords"
|
||||
label={t('semanticSearch.exactMatch')}
|
||||
description={t('semanticSearch.searching')}
|
||||
checked={settings.semanticSearch}
|
||||
onChange={(checked) => handleToggle('semanticSearch', checked)}
|
||||
/>
|
||||
|
||||
<SettingToggle
|
||||
label="Paragraph Reformulation"
|
||||
description="AI-powered text improvement options"
|
||||
label={t('paragraphRefactor.title')}
|
||||
description={t('aiSettings.paragraphRefactorDesc')}
|
||||
checked={settings.paragraphRefactor}
|
||||
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
|
||||
/>
|
||||
|
||||
<SettingToggle
|
||||
label="Memory Echo"
|
||||
description="Daily proactive note connections and insights"
|
||||
label={t('memoryEcho.title')}
|
||||
description={t('memoryEcho.dailyInsight')}
|
||||
checked={settings.memoryEcho}
|
||||
onChange={(checked) => handleToggle('memoryEcho', checked)}
|
||||
/>
|
||||
|
||||
{settings.memoryEcho && (
|
||||
<SettingSelect
|
||||
label="Memory Echo Frequency"
|
||||
description="How often to analyze note connections"
|
||||
label={t('aiSettings.frequency')}
|
||||
description={t('aiSettings.frequencyDesc')}
|
||||
value={settings.memoryEchoFrequency}
|
||||
options={[
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'daily', label: t('aiSettings.frequencyDaily') },
|
||||
{ value: 'weekly', label: t('aiSettings.frequencyWeekly') },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
]}
|
||||
onChange={handleFrequencyChange}
|
||||
@ -169,13 +166,13 @@ export default function AISettingsPage() {
|
||||
|
||||
{/* Demo Mode */}
|
||||
<SettingsSection
|
||||
title="Demo Mode"
|
||||
title={t('demoMode.title')}
|
||||
icon={<span className="text-2xl">🎭</span>}
|
||||
description="Test AI features without using real AI calls"
|
||||
description={t('demoMode.description')}
|
||||
>
|
||||
<SettingToggle
|
||||
label="Enable Demo Mode"
|
||||
description="Use mock AI responses for testing and demonstrations"
|
||||
label={t('demoMode.title')}
|
||||
description={t('demoMode.description')}
|
||||
checked={settings.demoMode}
|
||||
onChange={(checked) => handleToggle('demoMode', checked)}
|
||||
/>
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { SettingsSection, SettingSelect } from '@/components/settings'
|
||||
// Import actions directly
|
||||
import { updateAISettings as updateAI } from '@/app/actions/ai-settings'
|
||||
import { updateUserSettings as updateUser } from '@/app/actions/user-settings'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface AppearanceSettingsFormProps {
|
||||
initialTheme: string
|
||||
@ -16,6 +16,7 @@ export function AppearanceSettingsForm({ initialTheme, initialFontSize }: Appear
|
||||
const router = useRouter()
|
||||
const [theme, setTheme] = useState(initialTheme)
|
||||
const [fontSize, setFontSize] = useState(initialFontSize)
|
||||
const { t } = useLanguage()
|
||||
|
||||
const handleThemeChange = async (value: string) => {
|
||||
setTheme(value)
|
||||
@ -57,46 +58,46 @@ export function AppearanceSettingsForm({ initialTheme, initialFontSize }: Appear
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
Customize look and feel of application
|
||||
{t('appearance.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title="Theme"
|
||||
title={t('settings.theme')}
|
||||
icon={<span className="text-2xl">🎨</span>}
|
||||
description="Choose your preferred color scheme"
|
||||
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
|
||||
>
|
||||
<SettingSelect
|
||||
label="Color Scheme"
|
||||
description="Select app's visual theme"
|
||||
label={t('settings.theme')}
|
||||
description={t('settings.selectLanguage')}
|
||||
value={theme}
|
||||
options={[
|
||||
{ value: 'slate', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'slate', label: t('settings.themeLight') },
|
||||
{ value: 'dark', label: t('settings.themeDark') },
|
||||
{ value: 'sepia', label: 'Sepia' },
|
||||
{ value: 'midnight', label: 'Midnight' },
|
||||
{ value: 'blue', label: 'Blue' },
|
||||
{ value: 'auto', label: 'Auto (system)' },
|
||||
{ value: 'auto', label: t('settings.themeSystem') },
|
||||
]}
|
||||
onChange={handleThemeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Typography"
|
||||
title={t('profile.fontSize')}
|
||||
icon={<span className="text-2xl">📝</span>}
|
||||
description="Adjust text size for better readability"
|
||||
description={t('profile.fontSizeDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label="Font Size"
|
||||
description="Adjust size of text throughout app"
|
||||
label={t('profile.fontSize')}
|
||||
description={t('profile.selectFontSize')}
|
||||
value={fontSize}
|
||||
options={[
|
||||
{ value: 'small', label: 'Small' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'large', label: 'Large' },
|
||||
{ value: 'small', label: t('profile.fontSizeSmall') },
|
||||
{ value: 'medium', label: t('profile.fontSizeMedium') },
|
||||
{ value: 'large', label: t('profile.fontSizeLarge') },
|
||||
]}
|
||||
onChange={handleFontSizeChange}
|
||||
/>
|
||||
|
||||
@ -4,8 +4,10 @@ import { useState, useEffect } from 'react'
|
||||
import { SettingsNav, SettingsSection, SettingSelect } from '@/components/settings'
|
||||
import { updateAISettings, getAISettings } from '@/app/actions/ai-settings'
|
||||
import { updateUserSettings, getUserSettings } from '@/app/actions/user-settings'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default function AppearanceSettingsPage() {
|
||||
const { t } = useLanguage()
|
||||
const [theme, setTheme] = useState('auto')
|
||||
const [fontSize, setFontSize] = useState('medium')
|
||||
|
||||
@ -63,45 +65,45 @@ export default function AppearanceSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
Customize look and feel of application
|
||||
{t('appearance.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title="Theme"
|
||||
title={t('settings.theme')}
|
||||
icon={<span className="text-2xl">🎨</span>}
|
||||
description="Choose your preferred color scheme"
|
||||
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
|
||||
>
|
||||
<SettingSelect
|
||||
label="Color Scheme"
|
||||
description="Select app's visual theme"
|
||||
label={t('settings.theme')}
|
||||
description={t('settings.selectLanguage')}
|
||||
value={theme}
|
||||
options={[
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'light', label: t('settings.themeLight') },
|
||||
{ value: 'dark', label: t('settings.themeDark') },
|
||||
{ value: 'sepia', label: 'Sepia' },
|
||||
{ value: 'midnight', label: 'Midnight' },
|
||||
{ value: 'auto', label: 'Auto (system)' },
|
||||
{ value: 'auto', label: t('settings.themeSystem') },
|
||||
]}
|
||||
onChange={handleThemeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Typography"
|
||||
title={t('profile.fontSize')}
|
||||
icon={<span className="text-2xl">📝</span>}
|
||||
description="Adjust text size for better readability"
|
||||
description={t('profile.fontSizeDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label="Font Size"
|
||||
description="Adjust size of text throughout app"
|
||||
label={t('profile.fontSize')}
|
||||
description={t('profile.selectFontSize')}
|
||||
value={fontSize}
|
||||
options={[
|
||||
{ value: 'small', label: 'Small' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'large', label: 'Large' },
|
||||
{ value: 'small', label: t('profile.fontSizeSmall') },
|
||||
{ value: 'medium', label: t('profile.fontSizeMedium') },
|
||||
{ value: 'large', label: t('profile.fontSizeLarge') },
|
||||
]}
|
||||
onChange={handleFontSizeChange}
|
||||
/>
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { SettingsNav, SettingsSection, SettingToggle, SettingInput } from '@/components/settings'
|
||||
import { SettingsSection } from '@/components/settings'
|
||||
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 { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default function DataSettingsPage() {
|
||||
const { t } = useLanguage()
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [exportUrl, setExportUrl] = useState('')
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true)
|
||||
@ -26,11 +27,11 @@ export default function DataSettingsPage() {
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
toast.success('Notes exported successfully')
|
||||
toast.success(t('dataManagement.export.success'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Export error:', error)
|
||||
toast.error('Failed to export notes')
|
||||
toast.error(t('dataManagement.export.failed'))
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
@ -52,24 +53,22 @@ export default function DataSettingsPage() {
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
toast.success(`Imported ${result.count} notes`)
|
||||
// Refresh the page to show imported notes
|
||||
toast.success(t('dataManagement.import.success', { count: result.count }))
|
||||
window.location.reload()
|
||||
} else {
|
||||
throw new Error('Import failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Import error:', error)
|
||||
toast.error('Failed to import notes')
|
||||
toast.error(t('dataManagement.import.failed'))
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
// Reset input
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -77,12 +76,12 @@ export default function DataSettingsPage() {
|
||||
try {
|
||||
const response = await fetch('/api/notes/delete-all', { method: 'POST' })
|
||||
if (response.ok) {
|
||||
toast.success('All notes deleted')
|
||||
toast.success(t('dataManagement.delete.success'))
|
||||
window.location.reload()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error)
|
||||
toast.error('Failed to delete notes')
|
||||
toast.error(t('dataManagement.delete.failed'))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
@ -91,22 +90,22 @@ export default function DataSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
Export, import, or manage your data
|
||||
{t('dataManagement.toolsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title="Export Data"
|
||||
title={`💾 ${t('dataManagement.export.title')}`}
|
||||
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>
|
||||
<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">
|
||||
Download all your notes in JSON format
|
||||
{t('dataManagement.export.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@ -118,21 +117,21 @@ export default function DataSettingsPage() {
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isExporting ? 'Exporting...' : 'Export'}
|
||||
{isExporting ? t('dataManagement.exporting') : t('dataManagement.export.button')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Import Data"
|
||||
title={`📥 ${t('dataManagement.import.title')}`}
|
||||
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>
|
||||
<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">
|
||||
Upload a JSON file to import notes
|
||||
{t('dataManagement.import.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@ -153,22 +152,22 @@ export default function DataSettingsPage() {
|
||||
) : (
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isImporting ? 'Importing...' : 'Import'}
|
||||
{isImporting ? t('dataManagement.importing') : t('dataManagement.import.button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Danger Zone"
|
||||
title={`⚠️ ${t('dataManagement.dangerZone')}`}
|
||||
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>
|
||||
<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">
|
||||
This action cannot be undone
|
||||
{t('dataManagement.delete.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@ -181,7 +180,7 @@ export default function DataSettingsPage() {
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isDeleting ? 'Deleting...' : 'Delete All'}
|
||||
{isDeleting ? t('dataManagement.deleting') : t('dataManagement.delete.button')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
@ -4,9 +4,10 @@ import { useState, useEffect } from 'react'
|
||||
import { SettingsNav, SettingsSection, SettingToggle, SettingSelect } from '@/components/settings'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { updateAISettings, getAISettings } from '@/app/actions/ai-settings'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function GeneralSettingsPage() {
|
||||
const { t } = useLanguage()
|
||||
const { t, setLanguage: setContextLanguage } = useLanguage()
|
||||
const [language, setLanguage] = useState('auto')
|
||||
const [emailNotifications, setEmailNotifications] = useState(false)
|
||||
const [desktopNotifications, setDesktopNotifications] = useState(false)
|
||||
@ -30,7 +31,22 @@ export default function GeneralSettingsPage() {
|
||||
|
||||
const handleLanguageChange = async (value: string) => {
|
||||
setLanguage(value)
|
||||
|
||||
// 1. Update database settings
|
||||
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) => {
|
||||
@ -51,23 +67,23 @@ export default function GeneralSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
Configure basic application preferences
|
||||
{t('generalSettings.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title="Language & Region"
|
||||
title={t('settings.language')}
|
||||
icon={<span className="text-2xl">🌍</span>}
|
||||
description="Choose your preferred language and regional settings"
|
||||
description={t('profile.languagePreferencesDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label="Language"
|
||||
description="Select interface language"
|
||||
label={t('settings.language')}
|
||||
description={t('settings.selectLanguage')}
|
||||
value={language}
|
||||
options={[
|
||||
{ value: 'auto', label: 'Auto-detect' },
|
||||
{ value: 'auto', label: t('profile.autoDetect') },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
@ -89,32 +105,32 @@ export default function GeneralSettingsPage() {
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Notifications"
|
||||
title={t('settings.notifications')}
|
||||
icon={<span className="text-2xl">🔔</span>}
|
||||
description="Manage how and when you receive notifications"
|
||||
description={t('settings.notifications')}
|
||||
>
|
||||
<SettingToggle
|
||||
label="Email Notifications"
|
||||
description="Receive email updates about your notes"
|
||||
label={t('settings.notifications')}
|
||||
description={t('settings.notifications')}
|
||||
checked={emailNotifications}
|
||||
onChange={handleEmailNotificationsChange}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="Desktop Notifications"
|
||||
description="Show notifications in your browser"
|
||||
label={t('settings.notifications')}
|
||||
description={t('settings.notifications')}
|
||||
checked={desktopNotifications}
|
||||
onChange={handleDesktopNotificationsChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Privacy"
|
||||
title={t('settings.privacy')}
|
||||
icon={<span className="text-2xl">🔒</span>}
|
||||
description="Control your privacy settings"
|
||||
description={t('settings.privacy')}
|
||||
>
|
||||
<SettingToggle
|
||||
label="Anonymous Analytics"
|
||||
description="Help improve app with anonymous usage data"
|
||||
label={t('settings.privacy')}
|
||||
description={t('settings.privacy')}
|
||||
checked={anonymousAnalytics}
|
||||
onChange={handleAnonymousAnalyticsChange}
|
||||
/>
|
||||
|
||||
@ -81,9 +81,9 @@ export default function SettingsPage() {
|
||||
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
Configure your application settings
|
||||
{t('settings.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -92,18 +92,18 @@ export default function SettingsPage() {
|
||||
<Link href="/settings/ai">
|
||||
<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" />
|
||||
<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">
|
||||
Configure AI features and provider
|
||||
{t('aiSettings.description')}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/settings/profile">
|
||||
<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" />
|
||||
<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">
|
||||
Manage your account and preferences
|
||||
{t('profile.description')}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
@ -111,17 +111,17 @@ export default function SettingsPage() {
|
||||
|
||||
{/* AI Diagnostics */}
|
||||
<SettingsSection
|
||||
title="AI Diagnostics"
|
||||
title={t('diagnostics.title')}
|
||||
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="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>
|
||||
</div>
|
||||
<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">
|
||||
{status === 'success' && <CheckCircle className="text-green-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' :
|
||||
'text-gray-600'
|
||||
}`}>
|
||||
{status === 'success' ? 'Operational' :
|
||||
status === 'error' ? 'Error' :
|
||||
'Checking...'}
|
||||
{status === 'success' ? t('diagnostics.operational') :
|
||||
status === 'error' ? t('diagnostics.errorStatus') :
|
||||
t('diagnostics.checking')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -139,7 +139,7 @@ export default function SettingsPage() {
|
||||
|
||||
{result && (
|
||||
<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'
|
||||
? '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'
|
||||
@ -149,12 +149,12 @@ export default function SettingsPage() {
|
||||
|
||||
{status === 'error' && (
|
||||
<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">
|
||||
<li>Check that Ollama is running (<code className="bg-red-100 dark:bg-red-900 px-1 rounded">ollama list</code>)</li>
|
||||
<li>Check URL (http://localhost:11434)</li>
|
||||
<li>Verify model (e.g., granite4:latest) is downloaded</li>
|
||||
<li>Check Next.js server terminal for more logs</li>
|
||||
<li>{t('diagnostics.tip1')}</li>
|
||||
<li>{t('diagnostics.tip2')}</li>
|
||||
<li>{t('diagnostics.tip3')}</li>
|
||||
<li>{t('diagnostics.tip4')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
@ -164,45 +164,45 @@ export default function SettingsPage() {
|
||||
<div className="mt-4 flex justify-end">
|
||||
<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" />}
|
||||
Test Connection
|
||||
{t('general.testConnection')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Maintenance */}
|
||||
<SettingsSection
|
||||
title="Maintenance"
|
||||
title={t('settings.maintenance')}
|
||||
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="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-medium flex items-center gap-2">
|
||||
Clean Orphan Tags
|
||||
{t('settings.cleanTags')}
|
||||
</h3>
|
||||
<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>
|
||||
</div>
|
||||
<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" />}
|
||||
Clean
|
||||
{t('general.clean')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-medium flex items-center gap-2">
|
||||
Semantic Indexing
|
||||
{t('settings.semanticIndexing')}
|
||||
</h3>
|
||||
<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>
|
||||
</div>
|
||||
<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" />}
|
||||
Index All
|
||||
{t('general.indexAll')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -21,13 +21,13 @@ export default function ProfileSettingsPage() {
|
||||
const handleNameChange = async (value: string) => {
|
||||
setUser(prev => ({ ...prev, name: value }))
|
||||
// TODO: Implement profile update
|
||||
console.log('Name:', value)
|
||||
|
||||
}
|
||||
|
||||
const handleEmailChange = async (value: string) => {
|
||||
setUser(prev => ({ ...prev, email: value }))
|
||||
// TODO: Implement email update
|
||||
console.log('Email:', value)
|
||||
|
||||
}
|
||||
|
||||
const handleLanguageChange = async (value: string) => {
|
||||
@ -36,7 +36,7 @@ export default function ProfileSettingsPage() {
|
||||
await updateAISettings({ preferredLanguage: value as any })
|
||||
} catch (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 })
|
||||
} catch (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 className="lg:col-span-3 space-y-6">
|
||||
<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">
|
||||
Manage your account and personal information
|
||||
{t('profile.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Information */}
|
||||
<SettingsSection
|
||||
title="Profile Information"
|
||||
title={t('profile.accountSettings')}
|
||||
icon={<span className="text-2xl">👤</span>}
|
||||
description="Update your personal details"
|
||||
description={t('profile.description')}
|
||||
>
|
||||
<SettingInput
|
||||
label="Name"
|
||||
description="Your display name"
|
||||
label={t('profile.displayName')}
|
||||
description={t('profile.displayName')}
|
||||
value={user.name}
|
||||
onChange={handleNameChange}
|
||||
placeholder="Enter your name"
|
||||
placeholder={t('auth.namePlaceholder')}
|
||||
/>
|
||||
|
||||
<SettingInput
|
||||
label="Email"
|
||||
description="Your email address"
|
||||
label={t('profile.email')}
|
||||
description={t('profile.email')}
|
||||
value={user.email}
|
||||
type="email"
|
||||
onChange={handleEmailChange}
|
||||
placeholder="Enter your email"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Preferences */}
|
||||
<SettingsSection
|
||||
title="Preferences"
|
||||
title={t('settings.language')}
|
||||
icon={<span className="text-2xl">⚙️</span>}
|
||||
description="Customize your experience"
|
||||
description={t('profile.languagePreferencesDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label="Language"
|
||||
description="Choose your preferred language"
|
||||
label={t('profile.preferredLanguage')}
|
||||
description={t('profile.languageDescription')}
|
||||
value={language}
|
||||
options={[
|
||||
{ value: 'auto', label: 'Auto-detect' },
|
||||
{ value: 'auto', label: t('profile.autoDetect') },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
@ -123,8 +123,8 @@ export default function ProfileSettingsPage() {
|
||||
/>
|
||||
|
||||
<SettingToggle
|
||||
label="Show Recent Notes"
|
||||
description="Display recently viewed notes in sidebar"
|
||||
label={t('profile.showRecentNotes')}
|
||||
description={t('profile.showRecentNotesDescription')}
|
||||
checked={showRecentNotes}
|
||||
onChange={handleRecentNotesChange}
|
||||
/>
|
||||
@ -135,16 +135,16 @@ export default function ProfileSettingsPage() {
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-4xl">✨</div>
|
||||
<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">
|
||||
Configure AI-powered features, provider selection, and preferences
|
||||
{t('aiSettings.description')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.href = '/settings/ai'}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Configure
|
||||
{t('nav.configureAI')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -7,40 +7,16 @@ import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { updateProfile, changePassword, updateLanguage, updateFontSize, updateShowRecentNotes } from '@/app/actions/profile'
|
||||
|
||||
import { updateProfile, changePassword, updateFontSize, updateShowRecentNotes } from '@/app/actions/profile'
|
||||
import { toast } from 'sonner'
|
||||
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 }) {
|
||||
const router = useRouter()
|
||||
const [selectedLanguage, setSelectedLanguage] = useState(userAISettings?.preferredLanguage || 'auto')
|
||||
const [isUpdatingLanguage, setIsUpdatingLanguage] = useState(false)
|
||||
|
||||
const [fontSize, setFontSize] = useState(userAISettings?.fontSize || 'medium')
|
||||
const [isUpdatingFontSize, setIsUpdatingFontSize] = useState(false)
|
||||
const [showRecentNotes, setShowRecentNotes] = useState(userAISettings?.showRecentNotes ?? false)
|
||||
@ -101,26 +77,7 @@ export function ProfileForm({ user, userAISettings }: { user: any; userAISetting
|
||||
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) => {
|
||||
setIsUpdatingRecentNotes(true)
|
||||
@ -175,39 +132,7 @@ export function ProfileForm({ user, userAISettings }: { user: any; userAISetting
|
||||
</form>
|
||||
</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 { Button } from '@/components/ui/button';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
|
||||
export default function SupportPage() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10 max-w-4xl">
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-4xl font-bold mb-4">
|
||||
Support Memento Development ☕
|
||||
{t('support.title')}
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 mb-10">
|
||||
{/* Ko-fi Card */}
|
||||
<Card className="border-2 border-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">☕</span>
|
||||
Buy me a coffee
|
||||
{t('support.buyMeACoffee')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4">
|
||||
Make a one-time donation or become a monthly supporter.
|
||||
{t('support.donationDescription')}
|
||||
</p>
|
||||
<Button asChild className="w-full">
|
||||
<a href="https://ko-fi.com/yourusername" target="_blank" rel="noopener noreferrer">
|
||||
Donate on Ko-fi
|
||||
{t('support.donateOnKofi')}
|
||||
</a>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
No platform fees • Instant payouts • Secure
|
||||
{t('support.kofiDescription')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* GitHub Sponsors Card */}
|
||||
<Card className="border-2 border-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">💚</span>
|
||||
Sponsor on GitHub
|
||||
{t('support.sponsorOnGithub')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4">
|
||||
Become a monthly sponsor and get recognition.
|
||||
{t('support.sponsorDescription')}
|
||||
</p>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<a href="https://github.com/sponsors/yourusername" target="_blank" rel="noopener noreferrer">
|
||||
Sponsor on GitHub
|
||||
{t('support.sponsorOnGithub')}
|
||||
</a>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Recurring support • Public recognition • Developer-focused
|
||||
{t('support.githubDescription')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* How Donations Are Used */}
|
||||
<Card className="mb-10">
|
||||
<CardHeader>
|
||||
<CardTitle>How Your Support Helps</CardTitle>
|
||||
<CardTitle>{t('support.howSupportHelps')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<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">
|
||||
<li>☕ Keeps me fueled with coffee</li>
|
||||
<li>🐛 Covers hosting and server costs</li>
|
||||
@ -79,7 +81,7 @@ export default function SupportPage() {
|
||||
</ul>
|
||||
</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">
|
||||
<li>🥉 $5/month - Bronze: Name in supporters list</li>
|
||||
<li>🥈 $15/month - Silver: Priority feature requests</li>
|
||||
@ -91,30 +93,29 @@ export default function SupportPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transparency */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>💡 Transparency</CardTitle>
|
||||
<CardTitle>💡 {t('support.transparency')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm mb-4">
|
||||
I believe in complete transparency. Here's how donations are used:
|
||||
{t('support.transparencyDescription')}
|
||||
</p>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Hosting & servers:</span>
|
||||
<span>{t('support.hostingServers')}</span>
|
||||
<span className="font-mono">~$20/month</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Domain & SSL:</span>
|
||||
<span>{t('support.domainSSL')}</span>
|
||||
<span className="font-mono">~$15/year</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>AI API costs:</span>
|
||||
<span>{t('support.aiApiCosts')}</span>
|
||||
<span className="font-mono">~$30/month</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -125,28 +126,27 @@ export default function SupportPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Alternative Ways to Support */}
|
||||
<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">
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
||||
⭐ Star on GitHub
|
||||
⭐ {t('support.starGithub')}
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://github.com/yourusername/memento/issues" target="_blank" rel="noopener noreferrer">
|
||||
🐛 Report a bug
|
||||
🐛 {t('support.reportBug')}
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
||||
📝 Contribute code
|
||||
📝 {t('support.contributeCode')}
|
||||
</a>
|
||||
</Button>
|
||||
<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">
|
||||
🐦 Share on Twitter
|
||||
🐦 {t('support.shareTwitter')}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -1,23 +1,28 @@
|
||||
import { ArchiveHeader } from '@/components/archive-header'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<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>
|
||||
<TrashContent />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ async function checkAdmin() {
|
||||
export async function testSMTP() {
|
||||
const session = await checkAdmin()
|
||||
const email = session.user?.email
|
||||
|
||||
|
||||
if (!email) throw new Error("No admin email found")
|
||||
|
||||
const result = await sendEmail({
|
||||
@ -46,7 +46,7 @@ export async function updateSystemConfig(data: Record<string, string>) {
|
||||
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]) =>
|
||||
prisma.systemConfig.upsert({
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
|
||||
export type UserAISettingsData = {
|
||||
titleSuggestions?: boolean
|
||||
@ -25,9 +25,9 @@ export type UserAISettingsData = {
|
||||
* Update AI settings for the current user
|
||||
*/
|
||||
export async function updateAISettings(settings: UserAISettingsData) {
|
||||
console.log('[updateAISettings] Started with:', JSON.stringify(settings, null, 2))
|
||||
|
||||
const session = await auth()
|
||||
console.log('[updateAISettings] Session User ID:', session?.user?.id)
|
||||
|
||||
|
||||
if (!session?.user?.id) {
|
||||
console.error('[updateAISettings] Unauthorized: No session or user ID')
|
||||
@ -44,10 +44,11 @@ export async function updateAISettings(settings: UserAISettingsData) {
|
||||
},
|
||||
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 }
|
||||
} 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) {
|
||||
let id = userId
|
||||
|
||||
@ -87,66 +158,7 @@ export async function getAISettings(userId?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
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
|
||||
}
|
||||
}
|
||||
return getCachedAISettings(id)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -346,7 +346,7 @@ export async function createNote(data: {
|
||||
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70);
|
||||
|
||||
if (autoLabelingEnabled) {
|
||||
console.log('[AUTO-LABELING] Generating suggestions for new note in notebook:', data.notebookId);
|
||||
|
||||
const suggestions = await contextualAutoTagService.suggestLabels(
|
||||
data.content,
|
||||
data.notebookId,
|
||||
@ -360,12 +360,12 @@ export async function createNote(data: {
|
||||
|
||||
if (appliedLabels.length > 0) {
|
||||
labelsToUse = appliedLabels;
|
||||
console.log(`[AUTO-LABELING] Applied ${appliedLabels.length} labels:`, appliedLabels);
|
||||
|
||||
} else {
|
||||
console.log('[AUTO-LABELING] No suggestions met confidence threshold');
|
||||
|
||||
}
|
||||
} else {
|
||||
console.log('[AUTO-LABELING] Disabled in config');
|
||||
|
||||
}
|
||||
} catch (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 { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
|
||||
export type UserSettingsData = {
|
||||
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
|
||||
@ -12,7 +12,7 @@ export type UserSettingsData = {
|
||||
* Update user settings (theme, etc.)
|
||||
*/
|
||||
export async function updateUserSettings(settings: UserSettingsData) {
|
||||
console.log('[updateUserSettings] Started with:', settings)
|
||||
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
@ -25,9 +25,10 @@ export async function updateUserSettings(settings: UserSettingsData) {
|
||||
where: { id: session.user.id },
|
||||
data: settings
|
||||
})
|
||||
console.log('[updateUserSettings] Success:', result)
|
||||
|
||||
|
||||
revalidatePath('/', 'layout')
|
||||
revalidateTag('user-settings')
|
||||
|
||||
return { success: true }
|
||||
} 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) {
|
||||
let id = userId
|
||||
|
||||
@ -53,19 +79,5 @@ export async function getUserSettings(userId?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
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
|
||||
}
|
||||
}
|
||||
return getCachedUserSettings(id)
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { notebookId } = body
|
||||
const { notebookId, language = 'en' } = body
|
||||
|
||||
if (!notebookId || typeof notebookId !== 'string') {
|
||||
return NextResponse.json(
|
||||
@ -45,7 +45,8 @@ export async function POST(request: NextRequest) {
|
||||
// Get label suggestions
|
||||
const suggestions = await autoLabelCreationService.suggestLabels(
|
||||
notebookId,
|
||||
session.user.id
|
||||
session.user.id,
|
||||
language
|
||||
)
|
||||
|
||||
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
|
||||
const plan = await batchOrganizationService.createOrganizationPlan(
|
||||
session.user.id
|
||||
session.user.id,
|
||||
language
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { notebookId } = body
|
||||
const { notebookId, language = 'en' } = body
|
||||
|
||||
if (!notebookId || typeof notebookId !== 'string') {
|
||||
return NextResponse.json(
|
||||
@ -45,7 +45,8 @@ export async function POST(request: NextRequest) {
|
||||
// Generate summary
|
||||
const summary = await notebookSummaryService.generateSummary(
|
||||
notebookId,
|
||||
session.user.id
|
||||
session.user.id,
|
||||
language
|
||||
)
|
||||
|
||||
if (!summary) {
|
||||
|
||||
@ -10,7 +10,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { noteContent } = body
|
||||
const { noteContent, language = 'en' } = body
|
||||
|
||||
if (!noteContent || typeof noteContent !== 'string') {
|
||||
return NextResponse.json({ error: 'noteContent is required' }, { status: 400 })
|
||||
@ -29,7 +29,8 @@ export async function POST(req: NextRequest) {
|
||||
// Get suggestion from AI service
|
||||
const suggestedNotebook = await notebookSuggestionService.suggestNotebook(
|
||||
noteContent,
|
||||
session.user.id
|
||||
session.user.id,
|
||||
language
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@ -8,6 +8,7 @@ import { z } from 'zod';
|
||||
const requestSchema = z.object({
|
||||
content: z.string().min(1, "Le contenu ne peut pas être vide"),
|
||||
notebookId: z.string().optional(),
|
||||
language: z.string().default('en'),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@ -18,14 +19,15 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
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) {
|
||||
const suggestions = await contextualAutoTagService.suggestLabels(
|
||||
content,
|
||||
notebookId,
|
||||
session.user.id
|
||||
session.user.id,
|
||||
language
|
||||
);
|
||||
|
||||
// Convert label → tag to match TagSuggestion interface
|
||||
@ -37,7 +39,7 @@ export async function POST(req: NextRequest) {
|
||||
...(s.isNewLabel !== undefined && { isNewLabel: s.isNewLabel })
|
||||
}));
|
||||
|
||||
return NextResponse.json({ tags: convertedTags });
|
||||
return NextResponse.json({ tags: convertedTags });
|
||||
}
|
||||
|
||||
// Otherwise, use legacy auto-tagging (generates new tags)
|
||||
|
||||
@ -27,7 +27,7 @@ export const viewport: Viewport = {
|
||||
themeColor: "#f59e0b",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
import { getAISettings } from "@/app/actions/ai-settings";
|
||||
import { getUserSettings } from "@/app/actions/user-settings";
|
||||
@ -59,8 +59,7 @@ export default async function RootLayout({
|
||||
getUserSettings(userId)
|
||||
])
|
||||
|
||||
console.log('[RootLayout] Auth user:', userId)
|
||||
console.log('[RootLayout] Server fetched user settings:', userSettings)
|
||||
|
||||
|
||||
return (
|
||||
<html suppressHydrationWarning>
|
||||
|
||||
@ -127,7 +127,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
|
||||
<FeatureToggle
|
||||
name={t('titleSuggestions.available').replace('💡 ', '')}
|
||||
description="Suggest titles for untitled notes after 50+ words"
|
||||
description={t('aiSettings.titleSuggestionsDesc')}
|
||||
checked={settings.titleSuggestions}
|
||||
onChange={(checked) => handleToggle('titleSuggestions', checked)}
|
||||
/>
|
||||
@ -141,7 +141,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
|
||||
<FeatureToggle
|
||||
name={t('paragraphRefactor.title')}
|
||||
description="AI-powered text improvement options"
|
||||
description={t('aiSettings.paragraphRefactorDesc')}
|
||||
checked={settings.paragraphRefactor}
|
||||
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
|
||||
/>
|
||||
@ -159,7 +159,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
{t('aiSettings.frequency')}
|
||||
</Label>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
How often to analyze note connections
|
||||
{t('aiSettings.frequencyDesc')}
|
||||
</p>
|
||||
<RadioGroup
|
||||
value={settings.memoryEchoFrequency}
|
||||
@ -192,7 +192,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
<Card className="p-4">
|
||||
<Label className="text-base font-medium mb-1">{t('aiSettings.provider')}</Label>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Choose your preferred AI provider
|
||||
{t('aiSettings.providerDesc')}
|
||||
</p>
|
||||
|
||||
<RadioGroup
|
||||
@ -206,7 +206,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
{t('aiSettings.providerAuto')}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-500">
|
||||
Ollama when available, OpenAI fallback
|
||||
{t('aiSettings.providerAutoDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -218,7 +218,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
{t('aiSettings.providerOllama')}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-500">
|
||||
100% private, runs locally on your machine
|
||||
{t('aiSettings.providerOllamaDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -230,7 +230,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
{t('aiSettings.providerOpenAI')}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-500">
|
||||
Most accurate, requires API key
|
||||
{t('aiSettings.providerOpenAIDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -55,7 +55,10 @@ export function AutoLabelSuggestionDialog({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ notebookId }),
|
||||
body: JSON.stringify({
|
||||
notebookId,
|
||||
language: document.documentElement.lang || 'en',
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
@ -68,7 +71,7 @@ export function AutoLabelSuggestionDialog({
|
||||
} else {
|
||||
// No suggestions is not an error - just close the dialog
|
||||
if (data.message) {
|
||||
}
|
||||
}
|
||||
onOpenChange(false)
|
||||
}
|
||||
} catch (error) {
|
||||
@ -113,7 +116,7 @@ export function AutoLabelSuggestionDialog({
|
||||
if (data.success) {
|
||||
toast.success(
|
||||
t('ai.autoLabels.created', { count: data.data.createdCount }) ||
|
||||
`${data.data.createdCount} labels created successfully`
|
||||
`${data.data.createdCount} labels created successfully`
|
||||
)
|
||||
onLabelsCreated()
|
||||
onOpenChange(false)
|
||||
|
||||
@ -38,7 +38,11 @@ export function BatchOrganizationDialog({
|
||||
try {
|
||||
const response = await fetch('/api/ai/batch-organize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
language: document.documentElement.lang || 'en'
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
@ -125,7 +129,7 @@ export function BatchOrganizationDialog({
|
||||
if (data.success) {
|
||||
toast.success(
|
||||
t('ai.batchOrganization.success', { count: data.data.movedCount }) ||
|
||||
`${data.data.movedCount} notes moved successfully`
|
||||
`${data.data.movedCount} notes moved successfully`
|
||||
)
|
||||
onNotesMoved()
|
||||
onOpenChange(false)
|
||||
@ -306,7 +310,7 @@ export function BatchOrganizationDialog({
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
{t('ai.batchOrganization.apply')}
|
||||
{t('ai.batchOrganization.apply', { count: selectedNotes.size })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@ -21,13 +21,13 @@ export function DemoModeToggle({ demoMode, onToggle }: DemoModeToggleProps) {
|
||||
try {
|
||||
await onToggle(checked)
|
||||
if (checked) {
|
||||
toast.success('🧪 Demo Mode activated! Memory Echo will now work instantly.')
|
||||
toast.success(t('demoMode.activated'))
|
||||
} else {
|
||||
toast.success('Demo Mode disabled. Normal parameters restored.')
|
||||
toast.success(t('demoMode.deactivated'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling demo mode:', error)
|
||||
toast.error('Failed to toggle demo mode')
|
||||
toast.error(t('demoMode.toggleFailed'))
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
@ -53,14 +53,11 @@ export function DemoModeToggle({ demoMode, onToggle }: DemoModeToggleProps) {
|
||||
</div>
|
||||
<div>
|
||||
<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" />}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">
|
||||
{demoMode
|
||||
? 'Test Memory Echo instantly with relaxed parameters'
|
||||
: 'Enable instant testing of Memory Echo feature'
|
||||
}
|
||||
{t('demoMode.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@ -77,31 +74,25 @@ export function DemoModeToggle({ demoMode, onToggle }: DemoModeToggleProps) {
|
||||
<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">
|
||||
<p className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
⚡ Demo parameters active:
|
||||
{t('demoMode.parametersActive')}
|
||||
</p>
|
||||
<div className="space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-start gap-2">
|
||||
<Target className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>50% similarity</strong> threshold (normally 75%)
|
||||
</span>
|
||||
<span>{t('demoMode.similarityThreshold')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Zap className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>0-day delay</strong> between notes (normally 7 days)
|
||||
</span>
|
||||
<span>{t('demoMode.delayBetweenNotes')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Lightbulb className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>Unlimited insights</strong> (no frequency limits)
|
||||
</span>
|
||||
<span>{t('demoMode.unlimitedInsights')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
@ -53,7 +53,7 @@ export function FavoritesSection({ pinnedNotes, onEdit, isLoading }: FavoritesSe
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">📌</span>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Pinned Notes
|
||||
{t('notes.pinnedNotes')}
|
||||
<span className="text-sm font-medium text-muted-foreground ml-2">
|
||||
({pinnedNotes.length})
|
||||
</span>
|
||||
|
||||
@ -77,7 +77,7 @@ export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, on
|
||||
onSelectTag(suggestion.tag);
|
||||
}}
|
||||
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 && <Sparkles className="w-3 h-3 mr-1.5 opacity-50" />}
|
||||
|
||||
@ -355,7 +355,7 @@ export function Header({
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push('/admin')} className="cursor-pointer">
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
<span>Admin</span>
|
||||
<span>{t('nav.adminDashboard')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => signOut()} className="cursor-pointer text-red-600 focus:text-red-600">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@ -33,6 +34,7 @@ interface MemoryEchoNotificationProps {
|
||||
}
|
||||
|
||||
export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationProps) {
|
||||
const { t } = useLanguage()
|
||||
const [insight, setInsight] = useState<MemoryEchoInsight | null>(null)
|
||||
const [isLoading, setIsLoading] = 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" />
|
||||
</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">
|
||||
These notes are connected by {similarityPercentage}% similarity
|
||||
{t('connection.similarityInfo', { similarity: similarityPercentage })}
|
||||
</p>
|
||||
</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">
|
||||
{insight.note1.content}
|
||||
</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>
|
||||
|
||||
{/* 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">
|
||||
{insight.note2.content}
|
||||
</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>
|
||||
|
||||
{/* 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">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Is this connection helpful?
|
||||
{t('connection.isHelpful')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleFeedback('thumbs_up')}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${
|
||||
insight.feedback === 'thumbs_up'
|
||||
? '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'
|
||||
}`}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${insight.feedback === 'thumbs_up'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
Helpful
|
||||
{t('memoryEcho.helpful')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleFeedback('thumbs_down')}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${
|
||||
insight.feedback === 'thumbs_down'
|
||||
? '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'
|
||||
}`}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${insight.feedback === 'thumbs_down'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<ThumbsDown className="h-4 w-4" />
|
||||
Not Helpful
|
||||
{t('memoryEcho.notHelpful')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -248,11 +248,11 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
💡 I noticed something...
|
||||
{t('memoryEcho.title')}
|
||||
<Sparkles className="h-4 w-4 text-amber-500" />
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">
|
||||
Proactive connections between your notes
|
||||
{t('memoryEcho.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@ -298,7 +298,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
|
||||
onClick={handleView}
|
||||
>
|
||||
View Connection
|
||||
{t('memoryEcho.viewConnection')}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1 border-l pl-2">
|
||||
@ -307,7 +307,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
size="icon"
|
||||
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')}
|
||||
title="Helpful"
|
||||
title={t('memoryEcho.helpful')}
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
</Button>
|
||||
@ -316,7 +316,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
size="icon"
|
||||
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')}
|
||||
title="Not Helpful"
|
||||
title={t('memoryEcho.notHelpful')}
|
||||
>
|
||||
<ThumbsDown className="h-4 w-4" />
|
||||
</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"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
Dismiss for now
|
||||
{t('memoryEcho.dismiss')}
|
||||
</button>
|
||||
</CardContent>
|
||||
</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()
|
||||
onClose()
|
||||
}}
|
||||
|
||||
@ -57,7 +57,10 @@ export function NotebookSuggestionToast({
|
||||
const response = await fetch('/api/ai/suggest-notebook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ noteContent })
|
||||
body: JSON.stringify({
|
||||
noteContent,
|
||||
language: document.documentElement.lang || 'en',
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
@ -52,7 +52,10 @@ export function NotebookSummaryDialog({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ notebookId }),
|
||||
body: JSON.stringify({
|
||||
notebookId,
|
||||
language: document.documentElement.lang || 'en',
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
@ -82,6 +85,10 @@ export function NotebookSummaryDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<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">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Note } from '@/lib/types'
|
||||
import { Clock, FileText, Tag } from 'lucide-react'
|
||||
import { Clock } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
@ -11,27 +11,24 @@ interface 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)
|
||||
|
||||
if (topThree.length === 0) return null
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{language === 'fr' ? 'Récent' : 'Recent'}
|
||||
{t('notes.recent')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
· {topThree.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Compact 3-card row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{topThree.map((note, index) => (
|
||||
<CompactCard
|
||||
@ -46,7 +43,6 @@ export function RecentNotesSection({ recentNotes, onEdit }: RecentNotesSectionPr
|
||||
)
|
||||
}
|
||||
|
||||
// Compact card - matching your app's clean design
|
||||
function CompactCard({
|
||||
note,
|
||||
index,
|
||||
@ -56,9 +52,8 @@ function CompactCard({
|
||||
index: number
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
}) {
|
||||
const { language } = useLanguage()
|
||||
// Use contentUpdatedAt - only reflects actual content changes, not property changes (size, color, etc.)
|
||||
const timeAgo = getCompactTime(note.contentUpdatedAt || note.updatedAt, language)
|
||||
const { t } = useLanguage()
|
||||
const timeAgo = getCompactTime(note.contentUpdatedAt || note.updatedAt, t)
|
||||
const isFirstNote = index === 0
|
||||
|
||||
return (
|
||||
@ -69,7 +64,6 @@ function CompactCard({
|
||||
isFirstNote && "ring-2 ring-primary/20"
|
||||
)}
|
||||
>
|
||||
{/* Subtle left accent - colored based on recency */}
|
||||
<div className={cn(
|
||||
"absolute left-0 top-0 bottom-0 w-1 rounded-l-xl",
|
||||
isFirstNote
|
||||
@ -79,74 +73,54 @@ function CompactCard({
|
||||
: "bg-muted dark:bg-muted/60"
|
||||
)} />
|
||||
|
||||
{/* Content with left padding for accent line */}
|
||||
<div className="pl-2">
|
||||
{/* Title */}
|
||||
<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>
|
||||
|
||||
{/* Preview - 2 lines max */}
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-3 min-h-[2.5rem]">
|
||||
{note.content?.substring(0, 80) || ''}
|
||||
{note.content && note.content.length > 80 && '...'}
|
||||
</p>
|
||||
|
||||
{/* Footer with time and indicators */}
|
||||
<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">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span className="font-medium">{timeAgo}</span>
|
||||
</span>
|
||||
|
||||
{/* Indicators - right */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Notebook indicator */}
|
||||
{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 && (
|
||||
<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>
|
||||
|
||||
{/* 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" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Compact time display - matching your app's style
|
||||
// NOTE: Ensure dates are properly parsed from database (may come as strings)
|
||||
function getCompactTime(date: Date | string, language: string): string {
|
||||
function getCompactTime(date: Date | string, t: (key: string, params?: Record<string, any>) => string): string {
|
||||
const now = new Date()
|
||||
const then = date instanceof Date ? date : new Date(date)
|
||||
|
||||
// Validate date
|
||||
if (isNaN(then.getTime())) {
|
||||
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 minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (language === 'fr') {
|
||||
if (seconds < 60) return 'à l\'instant'
|
||||
if (minutes < 60) return `il y a ${minutes}m`
|
||||
if (hours < 24) return `il y a ${hours}h`
|
||||
const days = Math.floor(hours / 24)
|
||||
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`
|
||||
}
|
||||
if (seconds < 60) return t('time.justNow')
|
||||
if (minutes < 60) return t('time.minutesAgo', { count: minutes })
|
||||
if (hours < 24) return t('time.hoursAgo', { count: hours })
|
||||
const days = Math.floor(hours / 24)
|
||||
return t('time.daysAgo', { count: days })
|
||||
}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ReminderDialogProps {
|
||||
open: boolean
|
||||
@ -18,6 +21,7 @@ export function ReminderDialog({
|
||||
onSave,
|
||||
onRemove
|
||||
}: ReminderDialogProps) {
|
||||
const { t } = useLanguage()
|
||||
const [reminderDate, setReminderDate] = useState('')
|
||||
const [reminderTime, setReminderTime] = useState('')
|
||||
|
||||
@ -51,7 +55,6 @@ export function ReminderDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
onInteractOutside={(event) => {
|
||||
// Prevent dialog from closing when interacting with Sonner toasts
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
const isSonnerElement =
|
||||
@ -75,12 +78,12 @@ export function ReminderDialog({
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set Reminder</DialogTitle>
|
||||
<DialogTitle>{t('reminder.setReminder')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reminder-date" className="text-sm font-medium">
|
||||
Date
|
||||
{t('reminder.reminderDate')}
|
||||
</label>
|
||||
<Input
|
||||
id="reminder-date"
|
||||
@ -92,7 +95,7 @@ export function ReminderDialog({
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reminder-time" className="text-sm font-medium">
|
||||
Time
|
||||
{t('reminder.reminderTime')}
|
||||
</label>
|
||||
<Input
|
||||
id="reminder-time"
|
||||
@ -107,16 +110,16 @@ export function ReminderDialog({
|
||||
<div>
|
||||
{currentReminder && (
|
||||
<Button variant="outline" onClick={() => { onRemove(); onOpenChange(false); }}>
|
||||
Remove Reminder
|
||||
{t('reminder.removeReminder')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t('reminder.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
Set Reminder
|
||||
{t('reminder.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Loader2, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface SettingInputProps {
|
||||
label: string
|
||||
@ -25,6 +26,7 @@ export function SettingInput({
|
||||
placeholder,
|
||||
disabled
|
||||
}: SettingInputProps) {
|
||||
const { t } = useLanguage()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSaved, setIsSaved] = useState(false)
|
||||
|
||||
@ -35,15 +37,12 @@ export function SettingInput({
|
||||
try {
|
||||
await onChange(newValue)
|
||||
setIsSaved(true)
|
||||
toast.success('Setting saved')
|
||||
toast.success(t('toast.saved'))
|
||||
|
||||
// Clear saved indicator after 2 seconds
|
||||
setTimeout(() => setIsSaved(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Error updating setting:', err)
|
||||
toast.error('Failed to save setting', {
|
||||
description: 'Please try again'
|
||||
})
|
||||
toast.error(t('toast.saveFailed'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface SelectOption {
|
||||
value: string
|
||||
@ -29,6 +30,7 @@ export function SettingSelect({
|
||||
onChange,
|
||||
disabled
|
||||
}: SettingSelectProps) {
|
||||
const { t } = useLanguage()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleChange = async (newValue: string) => {
|
||||
@ -36,14 +38,10 @@ export function SettingSelect({
|
||||
|
||||
try {
|
||||
await onChange(newValue)
|
||||
toast.success('Setting saved', {
|
||||
description: `${label} has been updated`
|
||||
})
|
||||
toast.success(t('toast.saved'))
|
||||
} catch (err) {
|
||||
console.error('Error updating setting:', err)
|
||||
toast.error('Failed to save setting', {
|
||||
description: 'Please try again'
|
||||
})
|
||||
toast.error(t('toast.saveFailed'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Loader2, Check, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface SettingToggleProps {
|
||||
label: string
|
||||
@ -22,6 +23,7 @@ export function SettingToggle({
|
||||
onChange,
|
||||
disabled
|
||||
}: SettingToggleProps) {
|
||||
const { t } = useLanguage()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
@ -31,15 +33,11 @@ export function SettingToggle({
|
||||
|
||||
try {
|
||||
await onChange(newChecked)
|
||||
toast.success('Setting saved', {
|
||||
description: `${label} has been ${newChecked ? 'enabled' : 'disabled'}`
|
||||
})
|
||||
toast.success(t('toast.saved'))
|
||||
} catch (err) {
|
||||
console.error('Error updating setting:', err)
|
||||
setError(true)
|
||||
toast.error('Failed to save setting', {
|
||||
description: 'Please try again'
|
||||
})
|
||||
toast.error(t('toast.saveFailed'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Settings, Sparkles, Palette, User, Database, Info, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface SettingsSection {
|
||||
id: string
|
||||
@ -18,41 +19,42 @@ interface SettingsNavProps {
|
||||
|
||||
export function SettingsNav({ className }: SettingsNavProps) {
|
||||
const pathname = usePathname()
|
||||
const { t } = useLanguage()
|
||||
|
||||
const sections: SettingsSection[] = [
|
||||
{
|
||||
id: 'general',
|
||||
label: 'General',
|
||||
label: t('generalSettings.title'),
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
href: '/settings/general'
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
label: 'AI',
|
||||
label: t('aiSettings.title'),
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
href: '/settings/ai'
|
||||
},
|
||||
{
|
||||
id: 'appearance',
|
||||
label: 'Appearance',
|
||||
label: t('appearance.title'),
|
||||
icon: <Palette className="h-5 w-5" />,
|
||||
href: '/settings/appearance'
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
label: 'Profile',
|
||||
label: t('profile.title'),
|
||||
icon: <User className="h-5 w-5" />,
|
||||
href: '/settings/profile'
|
||||
},
|
||||
{
|
||||
id: 'data',
|
||||
label: 'Data',
|
||||
label: t('dataManagement.title'),
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
href: '/settings/data'
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
label: 'About',
|
||||
label: t('about.title'),
|
||||
icon: <Info className="h-5 w-5" />,
|
||||
href: '/settings/about'
|
||||
}
|
||||
|
||||
@ -109,11 +109,11 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
|
||||
{/* Footer / Copyright / Terms */}
|
||||
<div className="mt-auto px-6 py-4 text-[10px] text-gray-400">
|
||||
<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>
|
||||
<Link href="#" className="hover:underline">Conditions</Link>
|
||||
<Link href="#" className="hover:underline">{t('footer.terms')}</Link>
|
||||
</div>
|
||||
<p>Open Source Clone</p>
|
||||
<p>{t('footer.openSource')}</p>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@ -14,10 +14,12 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { useSession, signOut } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { LogOut, Settings, User, Shield } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function UserNav({ user }: { user?: any }) {
|
||||
const { data: session } = useSession()
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
|
||||
const currentUser = user || session?.user
|
||||
|
||||
@ -51,23 +53,23 @@ export function UserNav({ user }: { user?: any }) {
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => router.push('/settings/profile')}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
<span>{t('nav.profile')}</span>
|
||||
</DropdownMenuItem>
|
||||
{userRole === 'ADMIN' && (
|
||||
<DropdownMenuItem onClick={() => router.push('/admin')}>
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
<span>Admin Dashboard</span>
|
||||
<span>{t('nav.adminDashboard')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => router.push('/settings')}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Diagnostics</span>
|
||||
<span>{t('nav.diagnostics')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => signOut({ callbackUrl: '/login' })}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
<span>{t('nav.logout')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@ -41,7 +41,8 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
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)
|
||||
* @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
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: {
|
||||
@ -84,7 +84,7 @@ export class AutoLabelCreationService {
|
||||
}
|
||||
|
||||
// 2. Use AI to detect recurring themes
|
||||
const suggestions = await this.detectRecurringThemes(notes, notebook)
|
||||
const suggestions = await this.detectRecurringThemes(notes, notebook, language)
|
||||
|
||||
return suggestions
|
||||
}
|
||||
@ -94,13 +94,14 @@ export class AutoLabelCreationService {
|
||||
*/
|
||||
private async detectRecurringThemes(
|
||||
notes: any[],
|
||||
notebook: any
|
||||
notebook: any,
|
||||
language: string
|
||||
): Promise<AutoLabelSuggestion | null> {
|
||||
const existingLabelNames = new Set<string>(
|
||||
notebook.labels.map((l: any) => l.name.toLowerCase())
|
||||
)
|
||||
|
||||
const prompt = this.buildPrompt(notes, existingLabelNames)
|
||||
const prompt = this.buildPrompt(notes, existingLabelNames, language)
|
||||
|
||||
try {
|
||||
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
|
||||
.map((note, index) => {
|
||||
const title = note.title || 'Sans titre'
|
||||
@ -141,7 +142,8 @@ export class AutoLabelCreationService {
|
||||
|
||||
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.
|
||||
|
||||
CARNET ANALYSÉ :
|
||||
@ -182,7 +184,178 @@ Exemples de bonnes étiquettes :
|
||||
- "marie", "jean", "équipe" (personnes)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
* @param userId - User ID
|
||||
* @param language - User's preferred language (default: 'en')
|
||||
* @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)
|
||||
const notesWithoutNotebook = await prisma.note.findMany({
|
||||
where: {
|
||||
@ -86,7 +87,7 @@ export class BatchOrganizationService {
|
||||
}
|
||||
|
||||
// 3. Call AI to create organization plan
|
||||
const plan = await this.aiOrganizeNotes(notesWithoutNotebook, notebooks)
|
||||
const plan = await this.aiOrganizeNotes(notesWithoutNotebook, notebooks, language)
|
||||
|
||||
return plan
|
||||
}
|
||||
@ -96,9 +97,10 @@ export class BatchOrganizationService {
|
||||
*/
|
||||
private async aiOrganizeNotes(
|
||||
notes: NoteForOrganization[],
|
||||
notebooks: any[]
|
||||
notebooks: any[],
|
||||
language: string
|
||||
): Promise<OrganizationPlan> {
|
||||
const prompt = this.buildPrompt(notes, notebooks)
|
||||
const prompt = this.buildPrompt(notes, notebooks, language)
|
||||
|
||||
try {
|
||||
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
|
||||
.map(nb => {
|
||||
const labels = nb.labels.map((l: any) => l.name).join(', ')
|
||||
@ -140,7 +142,9 @@ export class BatchOrganizationService {
|
||||
})
|
||||
.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.
|
||||
|
||||
CARNETS DISPONIBLES :
|
||||
@ -175,7 +179,7 @@ Pour chaque carnet, liste les notes qui lui appartiennent :
|
||||
{
|
||||
"index": 0,
|
||||
"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
|
||||
- Si une note est trop générique, ne l'assigne pas
|
||||
- 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()
|
||||
}
|
||||
|
||||
// Return instruction for requested language, fallback to English
|
||||
return instructions[language] || instructions['en'] || instructions['fr']
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -26,7 +26,8 @@ export class ContextualAutoTagService {
|
||||
async suggestLabels(
|
||||
noteContent: string,
|
||||
notebookId: string | null,
|
||||
userId: string
|
||||
userId: string,
|
||||
language: string = 'en'
|
||||
): Promise<LabelSuggestion[]> {
|
||||
// If no notebook, return empty (no context)
|
||||
if (!notebookId) {
|
||||
@ -54,11 +55,11 @@ export class ContextualAutoTagService {
|
||||
|
||||
// CASE 1: Notebook has existing labels → suggest from them (IA2)
|
||||
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
|
||||
return await this.suggestNewLabels(noteContent, notebook)
|
||||
return await this.suggestNewLabels(noteContent, notebook, language)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -66,12 +67,13 @@ export class ContextualAutoTagService {
|
||||
*/
|
||||
private async suggestFromExistingLabels(
|
||||
noteContent: string,
|
||||
notebook: any
|
||||
notebook: any,
|
||||
language: string
|
||||
): Promise<LabelSuggestion[]> {
|
||||
const availableLabels = notebook.labels.map((l: any) => l.name)
|
||||
|
||||
// Build prompt with available labels
|
||||
const prompt = this.buildPrompt(noteContent, notebook.name, availableLabels)
|
||||
const prompt = this.buildPrompt(noteContent, notebook.name, availableLabels, language)
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
@ -151,10 +153,11 @@ export class ContextualAutoTagService {
|
||||
*/
|
||||
private async suggestNewLabels(
|
||||
noteContent: string,
|
||||
notebook: any
|
||||
notebook: any,
|
||||
language: string
|
||||
): Promise<LabelSuggestion[]> {
|
||||
// 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 {
|
||||
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')
|
||||
|
||||
return `
|
||||
const instructions: Record<string, string> = {
|
||||
fr: `
|
||||
Tu es un assistant qui suggère les labels les plus appropriés pour une note.
|
||||
|
||||
CONTENU DE LA NOTE :
|
||||
@ -267,14 +271,154 @@ FORMAT DE RÉPONSE (JSON uniquement) :
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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 {
|
||||
return `
|
||||
private buildNewLabelsPrompt(noteContent: string, notebookName: string, language: string = 'en'): string {
|
||||
const instructions: Record<string, string> = {
|
||||
fr: `
|
||||
Tu es un assistant qui suggère de nouveaux labels pour organiser une 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"}]}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
return instructions[language] || instructions['en'] || instructions['fr']
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ export class NotebookSuggestionService {
|
||||
* @param userId - User ID (for fetching user's notebooks)
|
||||
* @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
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
@ -28,7 +28,7 @@ export class NotebookSuggestionService {
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
.map(nb => {
|
||||
const labels = nb.labels.map((l: any) => l.name).join(', ')
|
||||
@ -68,7 +68,8 @@ export class NotebookSuggestionService {
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `
|
||||
const instructions: Record<string, string> = {
|
||||
fr: `
|
||||
Tu es un assistant qui suggère à quel carnet une note devrait appartenir.
|
||||
|
||||
CONTENU DE LA NOTE :
|
||||
@ -107,7 +108,148 @@ Exemples :
|
||||
- "Achat d'une chemise et d'un jean" → carnet "Personnel"
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
return instructions[language] || instructions['en'] || instructions['fr']
|
||||
}
|
||||
|
||||
/**
|
||||
@ -118,14 +260,15 @@ Ta suggestion :
|
||||
*/
|
||||
async suggestNotebooksBatch(
|
||||
noteContents: string[],
|
||||
userId: string
|
||||
userId: string,
|
||||
language: string = 'en'
|
||||
): Promise<Map<number, Notebook | null>> {
|
||||
const results = new Map<number, Notebook | null>()
|
||||
|
||||
// For efficiency, we could batch this into a single AI call
|
||||
// For now, process sequentially (could be parallelized)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ export class NotebookSummaryService {
|
||||
* @param userId - User ID (for authorization)
|
||||
* @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
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: {
|
||||
@ -79,7 +79,7 @@ export class NotebookSummaryService {
|
||||
}
|
||||
|
||||
// 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
|
||||
const labelsUsed = Array.from(
|
||||
@ -107,7 +107,7 @@ export class NotebookSummaryService {
|
||||
/**
|
||||
* 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
|
||||
const notesSummary = notes
|
||||
.map((note, index) => {
|
||||
@ -122,7 +122,7 @@ ${content}...`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
const prompt = this.buildPrompt(notesSummary, notebook.name)
|
||||
const prompt = this.buildPrompt(notesSummary, notebook.name, language)
|
||||
|
||||
try {
|
||||
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 {
|
||||
return `
|
||||
private buildPrompt(notesSummary: string, notebookName: string, language: string = 'en'): string {
|
||||
const instructions: Record<string, string> = {
|
||||
fr: `
|
||||
Tu es un assistant qui génère des synthèses structurées de carnets de notes.
|
||||
|
||||
CARNET: ${notebookName}
|
||||
@ -181,9 +182,197 @@ RÈGLES:
|
||||
- Identifie les vraies tendances, ne pas inventer d'informations
|
||||
- Si une section n'est pas pertinente, utilise "N/A" ou omets-la
|
||||
- Ton: professionnel mais accessible
|
||||
- TA RÉPONSE DOIT ÊTRE EN FRANÇAIS
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
return instructions[language] || instructions['en'] || instructions['fr']
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -24,6 +24,15 @@ export interface Translations {
|
||||
createAccount: string
|
||||
rememberMe: string
|
||||
orContinueWith: string
|
||||
checkYourEmail: string
|
||||
resetEmailSent: string
|
||||
returnToLogin: string
|
||||
forgotPasswordTitle: string
|
||||
forgotPasswordDescription: string
|
||||
sending: string
|
||||
sendResetLink: string
|
||||
backToLogin: string
|
||||
signOut: string
|
||||
}
|
||||
sidebar: {
|
||||
notes: string
|
||||
@ -65,6 +74,8 @@ export interface Translations {
|
||||
invalidDateTime: string
|
||||
reminderMustBeFuture: string
|
||||
reminderSet: string
|
||||
reminderPastError: string
|
||||
reminderRemoved: string
|
||||
addImage: string
|
||||
addLink: string
|
||||
linkAdded: string
|
||||
@ -92,6 +103,34 @@ export interface Translations {
|
||||
noNotes: string
|
||||
noNotesFound: 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: {
|
||||
title: string
|
||||
@ -110,9 +149,19 @@ export interface Translations {
|
||||
labelName: string
|
||||
labelColor: string
|
||||
manageLabels: string
|
||||
manageLabelsDescription: string
|
||||
selectedLabels: string
|
||||
allLabels: string
|
||||
clearAll: string
|
||||
filterByLabel: string
|
||||
tagAdded: string
|
||||
showLess: string
|
||||
showMore: string
|
||||
editLabels: string
|
||||
editLabelsDescription: string
|
||||
noLabelsFound: string
|
||||
loading: string
|
||||
notebookRequired: string
|
||||
}
|
||||
search: {
|
||||
placeholder: string
|
||||
@ -133,6 +182,27 @@ export interface Translations {
|
||||
canEdit: string
|
||||
canView: 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: {
|
||||
analyzing: string
|
||||
@ -143,6 +213,64 @@ export interface Translations {
|
||||
poweredByAI: string
|
||||
languageDetected: 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: {
|
||||
available: string
|
||||
@ -169,6 +297,71 @@ export interface Translations {
|
||||
description: string
|
||||
dailyInsight: 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: {
|
||||
home: string
|
||||
@ -181,6 +374,29 @@ export interface Translations {
|
||||
aiSettings: string
|
||||
logout: 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: {
|
||||
title: string
|
||||
@ -230,6 +446,21 @@ export interface Translations {
|
||||
profileError: string
|
||||
accountSettings: 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: {
|
||||
title: string
|
||||
@ -287,6 +518,25 @@ export interface Translations {
|
||||
save: 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: {
|
||||
title: string
|
||||
description: string
|
||||
@ -296,6 +546,354 @@ export interface Translations {
|
||||
moveToNotebook: 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> {
|
||||
try {
|
||||
const translations = await import(`@/locales/${language}.json`)
|
||||
return translations.default as Translations
|
||||
return translations.default as unknown as Translations
|
||||
} catch (error) {
|
||||
console.error(`Failed to load translations for ${language}:`, error)
|
||||
// Fallback to English
|
||||
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.",
|
||||
"sending": "Sending...",
|
||||
"sendResetLink": "Send Reset Link",
|
||||
"backToLogin": "Back to login"
|
||||
"backToLogin": "Back to login",
|
||||
"signOut": "Sign out"
|
||||
},
|
||||
"sidebar": {
|
||||
"notes": "Notes",
|
||||
@ -118,7 +119,24 @@
|
||||
"markdownOn": "Markdown ON",
|
||||
"markdownOff": "Markdown OFF",
|
||||
"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": {
|
||||
"previous": "←",
|
||||
@ -154,7 +172,9 @@
|
||||
"editLabelsDescription": "Create, edit colors, or delete labels.",
|
||||
"noLabelsFound": "No labels found.",
|
||||
"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": {
|
||||
"placeholder": "Search",
|
||||
@ -235,7 +255,41 @@
|
||||
"improveStyle": "Improve style",
|
||||
"reformulationComparison": "Reformulation Comparison",
|
||||
"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": {
|
||||
"error": "Failed to create organization plan",
|
||||
@ -300,6 +354,7 @@
|
||||
"connection": "connection",
|
||||
"connectionsBadge": "{count} connection{plural}",
|
||||
"fused": "Fused",
|
||||
"clickToView": "Click to view note →",
|
||||
"overlay": {
|
||||
"title": "Connected Notes",
|
||||
"searchPlaceholder": "Search connections...",
|
||||
@ -309,7 +364,8 @@
|
||||
"sortOldest": "Oldest",
|
||||
"viewAll": "View all side by side",
|
||||
"loading": "Loading...",
|
||||
"noConnections": "No connections found"
|
||||
"noConnections": "No connections found",
|
||||
"error": "Failed to load connections"
|
||||
},
|
||||
"comparison": {
|
||||
"title": "💡 Note Comparison",
|
||||
@ -328,7 +384,8 @@
|
||||
"compare": "Compare",
|
||||
"merge": "Merge",
|
||||
"compareAll": "Compare all",
|
||||
"mergeAll": "Merge all"
|
||||
"mergeAll": "Merge all",
|
||||
"close": "Close"
|
||||
},
|
||||
"fusion": {
|
||||
"title": "🔗 Intelligent Fusion",
|
||||
@ -408,7 +465,16 @@
|
||||
"about": "About",
|
||||
"version": "Version",
|
||||
"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": {
|
||||
"title": "Profile",
|
||||
@ -468,7 +534,14 @@
|
||||
"frequencyWeekly": "Weekly",
|
||||
"saving": "Saving...",
|
||||
"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": {
|
||||
"loading": "Loading...",
|
||||
@ -489,7 +562,11 @@
|
||||
"tryAgain": "Please try again",
|
||||
"error": "An error occurred",
|
||||
"operationSuccess": "Operation successful",
|
||||
"operationFailed": "Operation failed"
|
||||
"operationFailed": "Operation failed",
|
||||
"testConnection": "Test Connection",
|
||||
"clean": "Clean",
|
||||
"indexAll": "Index All",
|
||||
"preview": "Preview"
|
||||
},
|
||||
"colors": {
|
||||
"default": "Default",
|
||||
@ -528,7 +605,9 @@
|
||||
"summary": "Notebook Summary",
|
||||
"summaryDescription": "Generate an AI-powered summary of all notes in this notebook.",
|
||||
"generating": "Generating summary...",
|
||||
"summaryError": "Error generating summary"
|
||||
"summaryError": "Error generating summary",
|
||||
"labels": "Labels:",
|
||||
"noLabels": "No labels"
|
||||
},
|
||||
"notebookSuggestion": {
|
||||
"title": "Move to {icon} {name}?",
|
||||
@ -538,5 +617,378 @@
|
||||
"dismissIn": "Dismiss (closes in {timeLeft}s)",
|
||||
"moveToNotebook": "Move to notebook",
|
||||
"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