feat: 8 AI providers, rich text editor, agent notifications, UI contrast & font settings
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m25s

- Add DeepSeek, OpenRouter, Mistral, Z.AI, LM Studio as AI providers
  with editable model names via Combobox in admin settings
- Fix OpenRouter broken by normalizeProvider bug in config.ts
- Convert agent-created notes from Markdown to HTML (TipTap rich text)
- Add Notification model + in-app notifications for agent results
- Agent notification click opens the created note directly
- Add note count display on notebook and inbox headers
- Fix checklist toggle in card view (persist state via localCheckItems)
- Add checklist creation option in tabs/list view (dropdown on + button)
- Fix image description ENOENT error with HTTP fallback
- Improve UI contrast across all themes (input, border, checkbox visibility)
- Add font family setting (Inter vs System Default) in Appearance settings
- Fix CSS font-sans variable conflict (removed dead Geist references)
- Update README with new features and 8 providers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-05-01 16:14:07 +02:00
parent 1345403a31
commit dbd49d6fcb
64 changed files with 4124 additions and 1392 deletions

View File

@@ -11,11 +11,14 @@ NEXTAUTH_SECRET="generate-with-openssl-rand-base64-32"
NEXTAUTH_URL="http://localhost:3000"
# -----------------------------------------------------------------------------
# Registration
# Registration & Admin
# -----------------------------------------------------------------------------
# Set to "false" to disable public registration (default: true)
# ALLOW_REGISTRATION="true"
# Admin email - The first user registering with this email gets ADMIN role (REQUIRED)
# ADMIN_EMAIL="admin@yourdomain.com"
# -----------------------------------------------------------------------------
# AI Providers
# -----------------------------------------------------------------------------

View File

@@ -51,7 +51,7 @@ RUN useradd --system --uid 1001 --gid nodejs nextjs
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# Upload directory (outside public/ — served via API route)
RUN mkdir -p ./data/uploads/notes && chown -R nextjs:nodejs ./data
RUN mkdir -p ./data/uploads/notes ./data/backups && chown -R nextjs:nodejs ./data
# Next.js standalone output
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./

View File

@@ -1,5 +1,6 @@
import { AdminProvidersWrapper } from '@/components/admin-providers-wrapper'
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
import { headers } from 'next/headers'
import { detectUserLanguage, parseAcceptLanguage } from '@/lib/i18n/detect-user-language'
import { loadTranslations } from '@/lib/i18n/load-translations'
// No <Suspense> here intentionally: combining a Suspense boundary with <Link>
@@ -10,7 +11,9 @@ export default async function AdminGroupLayout({
}: {
children: React.ReactNode
}) {
const initialLanguage = await detectUserLanguage()
const headersList = await headers()
const browserLang = parseAcceptLanguage(headersList.get('accept-language'))
const initialLanguage = await detectUserLanguage(browserLang)
const initialTranslations = await loadTranslations(initialLanguage)
return (

View File

@@ -3,7 +3,8 @@ import { HeaderWrapper } from "@/components/header-wrapper";
import { Sidebar } from "@/components/sidebar";
import { ProvidersWrapper } from "@/components/providers-wrapper";
import { auth } from "@/auth";
import { detectUserLanguage } from "@/lib/i18n/detect-user-language";
import { headers } from "next/headers";
import { detectUserLanguage, parseAcceptLanguage } from "@/lib/i18n/detect-user-language";
import { loadTranslations } from "@/lib/i18n/load-translations";
import { getAISettings } from "@/app/actions/ai-settings";
@@ -14,10 +15,14 @@ export default async function MainLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
// Read browser language hint from Accept-Language header
const headersList = await headers();
const browserLang = parseAcceptLanguage(headersList.get("accept-language"));
// Run auth + language detection + translation loading in parallel
const [session, initialLanguage] = await Promise.all([
auth(),
detectUserLanguage(),
detectUserLanguage(browserLang),
]);
// Load initial translations server-side to prevent hydration mismatch

View File

@@ -12,14 +12,16 @@ interface AppearanceSettingsClientProps {
initialTheme: string
initialNotesViewMode: 'masonry' | 'tabs'
initialCardSizeMode?: 'variable' | 'uniform'
initialFontFamily?: string
}
export function AppearanceSettingsClient({ initialFontSize, initialTheme, initialNotesViewMode, initialCardSizeMode = 'variable' }: AppearanceSettingsClientProps) {
export function AppearanceSettingsClient({ initialFontSize, initialTheme, initialNotesViewMode, initialCardSizeMode = 'variable', initialFontFamily = 'inter' }: AppearanceSettingsClientProps) {
const { t } = useLanguage()
const [theme, setTheme] = useState(initialTheme || 'light')
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
const [notesViewMode, setNotesViewMode] = useState<'masonry' | 'tabs'>(initialNotesViewMode)
const [cardSizeMode, setCardSizeMode] = useState<'variable' | 'uniform'>(initialCardSizeMode)
const [fontFamily, setFontFamily] = useState(initialFontFamily)
const handleThemeChange = async (value: string) => {
setTheme(value)
@@ -69,6 +71,20 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
toast.success(t('settings.settingsSaved') || 'Saved')
}
const handleFontFamilyChange = async (value: string) => {
const font = value === 'system' ? 'system' : 'inter'
setFontFamily(font)
localStorage.setItem('font-family', font)
const root = document.documentElement
if (font === 'system') {
root.classList.add('font-system')
} else {
root.classList.remove('font-system')
}
await updateAISettings({ fontFamily: font })
toast.success(t('settings.settingsSaved') || 'Saved')
}
return (
<div className="space-y-6">
<div>
@@ -116,6 +132,23 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
/>
</SettingsSection>
<SettingsSection
title={t('appearance.fontFamilyLabel') || 'Font Family'}
icon={<span className="text-2xl">🔤</span>}
description={t('appearance.fontFamilyDescription') || 'Choose the font used throughout the app'}
>
<SettingSelect
label={t('appearance.fontFamilyLabel') || 'Font Family'}
description={t('appearance.selectFontFamily') || 'Inter is optimized for readability, System uses your OS native font'}
value={fontFamily}
options={[
{ value: 'inter', label: 'Inter' },
{ value: 'system', label: t('appearance.fontSystem') || 'System Default' },
]}
onChange={handleFontFamilyChange}
/>
</SettingsSection>
<SettingsSection
title={t('appearance.notesViewLabel')}
icon={<span className="text-2xl">📋</span>}

View File

@@ -21,6 +21,7 @@ export default async function AppearanceSettingsPage() {
initialTheme={userSettings.theme}
initialNotesViewMode={aiSettings.notesViewMode === 'masonry' ? 'masonry' : 'tabs'}
initialCardSizeMode={userSettings.cardSizeMode}
initialFontFamily={aiSettings.fontFamily || 'inter'}
/>
)
}

View File

@@ -3,7 +3,6 @@
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { sendEmail } from '@/lib/mail'
import { updateTag } from 'next/cache'
async function checkAdmin() {
const session = await auth()
@@ -62,9 +61,6 @@ export async function updateSystemConfig(data: Record<string, string>) {
await prisma.$transaction(operations)
// Invalidate cache after update
updateTag('system-config')
return { success: true }
} catch (error) {
console.error('Failed to update settings:', error)

View File

@@ -23,6 +23,7 @@ export type UserAISettingsData = {
autoLabeling?: boolean
noteHistory?: boolean
noteHistoryMode?: 'manual' | 'auto'
fontFamily?: 'inter' | 'system'
}
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
@@ -45,6 +46,7 @@ const USER_AI_SETTINGS_PRISMA_KEYS = [
'autoLabeling',
'noteHistory',
'noteHistoryMode',
'fontFamily',
] as const
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
@@ -157,6 +159,7 @@ const getCachedAISettings = unstable_cache(
autoLabeling: true,
noteHistory: false,
noteHistoryMode: 'manual' as const,
fontFamily: 'inter' as const,
}
}
@@ -188,6 +191,7 @@ const getCachedAISettings = unstable_cache(
autoLabeling: settings.autoLabeling ?? true,
noteHistory: settings.noteHistory ?? false,
noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'system',
}
} catch (error) {
console.error('Error getting AI settings:', error)
@@ -212,6 +216,7 @@ const getCachedAISettings = unstable_cache(
autoLabeling: true,
noteHistory: false,
noteHistoryMode: 'manual' as const,
fontFamily: 'inter' as const,
}
}
},
@@ -249,6 +254,7 @@ export async function getAISettings(userId?: string) {
autoLabeling: true,
noteHistory: false,
noteHistoryMode: 'manual' as const,
fontFamily: 'inter' as const,
}
}

View File

@@ -1,6 +1,7 @@
'use server'
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
import { headers } from 'next/headers'
import { detectUserLanguage, parseAcceptLanguage } from '@/lib/i18n/detect-user-language'
import { SupportedLanguage } from '@/lib/i18n/load-translations'
/**
@@ -8,5 +9,11 @@ import { SupportedLanguage } from '@/lib/i18n/load-translations'
* Called on app load to set initial language
*/
export async function getInitialLanguage(): Promise<SupportedLanguage> {
return await detectUserLanguage()
try {
const headersList = await headers()
const browserLang = parseAcceptLanguage(headersList.get('accept-language'))
return await detectUserLanguage(browserLang)
} catch {
return await detectUserLanguage()
}
}

View File

@@ -0,0 +1,76 @@
'use server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
export interface AppNotification {
id: string
type: string
title: string
message: string | null
read: boolean
actionUrl: string | null
relatedId: string | null
createdAt: Date
}
export async function getUnreadNotifications(): Promise<AppNotification[]> {
const session = await auth()
if (!session?.user?.id) return []
try {
const notifications = await prisma.notification.findMany({
where: { userId: session.user.id, read: false },
orderBy: { createdAt: 'desc' },
take: 20,
})
return notifications
} catch {
return []
}
}
export async function markNotificationRead(id: string): Promise<void> {
const session = await auth()
if (!session?.user?.id) return
await prisma.notification.updateMany({
where: { id, userId: session.user.id },
data: { read: true },
})
}
export async function markAllNotificationsRead(): Promise<void> {
const session = await auth()
if (!session?.user?.id) return
await prisma.notification.updateMany({
where: { userId: session.user.id, read: false },
data: { read: true },
})
}
/** Create a notification (called from server-side code, not exposed to client) */
export async function createNotification(data: {
userId: string
type: string
title: string
message?: string
actionUrl?: string
relatedId?: string
}): Promise<void> {
try {
await prisma.notification.create({
data: {
userId: data.userId,
type: data.type,
title: data.title,
message: data.message || null,
actionUrl: data.actionUrl || null,
relatedId: data.relatedId || null,
},
})
} catch (e) {
console.error('[Notification] Failed to create:', e)
}
}

View File

@@ -10,13 +10,13 @@ async function requireAdmin() {
/**
* GET /api/admin/models?type=ollama&url=<base_url>
* GET /api/admin/models?type=custom&url=<base_url>&key=<api_key>&kind=tags|embeddings
* GET /api/admin/models?type=deepseek&key=<api_key>&kind=tags|embeddings
* GET /api/admin/models?type=openrouter&key=<api_key>&kind=tags|embeddings
* GET /api/admin/models?type=mistral&key=<api_key>&kind=tags|embeddings
* GET /api/admin/models?type=zai&key=<api_key>&kind=tags|embeddings
* GET /api/admin/models?type=lmstudio&url=<base_url>
*
* Route API (not a Server Action) for fetching AI model lists from Ollama or
* OpenAI-compatible providers. Using a Route Handler instead of a Server Action
* is the correct architecture for client-side GET requests: Server Actions are
* for data mutations, and calling them from useEffect pushes items into the
* App Router's internal action queue, which is drained during render (inside
* AppRouter's useMemo), triggering React Error #310 when multiple calls stack up.
* Route API for fetching AI model lists from providers.
*/
export async function GET(request: NextRequest) {
if (!(await requireAdmin())) {
@@ -29,14 +29,22 @@ export async function GET(request: NextRequest) {
const apiKey = searchParams.get('key') ?? undefined
const kind = searchParams.get('kind') ?? 'tags'
if (!rawUrl) {
return NextResponse.json({ success: false, models: [], error: 'url parameter is required' })
// Provider-specific base URLs (used when url param is empty)
const PROVIDER_URLS: Record<string, string> = {
deepseek: 'https://api.deepseek.com/v1',
openrouter: 'https://openrouter.ai/api/v1',
mistral: 'https://api.mistral.ai/v1',
zai: 'https://api.zukijourney.com/v1',
lmstudio: 'http://localhost:1234/v1',
}
const baseUrl = rawUrl.replace(/\/$/, '').replace(/\/v1$/, '')
try {
// Ollama: uses native /api/tags endpoint
if (type === 'ollama') {
if (!rawUrl) {
return NextResponse.json({ success: false, models: [], error: 'url parameter is required for Ollama' })
}
const baseUrl = rawUrl.replace(/\/$/, '').replace(/\/api$/, '')
const res = await fetch(`${baseUrl}/api/tags`, {
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(5000),
@@ -49,58 +57,69 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ success: true, models })
}
if (type === 'custom') {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
// All other providers: use OpenAI-compatible /v1/models endpoint
const baseUrl = rawUrl
? rawUrl.replace(/\/$/, '')
: (PROVIDER_URLS[type || ''] || '')
if (kind === 'embeddings') {
// Try provider-specific embeddings endpoint first (e.g. OpenRouter)
try {
const embRes = await fetch(`${baseUrl}/v1/embeddings/models`, {
headers,
signal: AbortSignal.timeout(8000),
})
if (embRes.ok) {
const embData = await embRes.json()
const embModels: string[] = (embData.data ?? [])
.map((m: { id: string }) => m.id)
.filter(Boolean)
.sort()
if (embModels.length > 0) {
return NextResponse.json({ success: true, models: embModels })
}
}
} catch {
// Fall through to /v1/models with keyword filter
}
}
const res = await fetch(`${baseUrl}/v1/models`, {
headers,
signal: AbortSignal.timeout(8000),
})
if (!res.ok) {
return NextResponse.json({ success: false, models: [], error: `Provider ${res.status}` })
}
const data = await res.json()
let models: string[] = (data.data ?? [])
.map((m: { id: string }) => m.id)
.filter(Boolean)
.sort()
if (kind === 'embeddings') {
const keywords = ['embed', 'embedding', 'ada', 'e5', 'bge', 'gte', 'minilm']
const filtered = models.filter((id) =>
keywords.some((kw) => id.toLowerCase().includes(kw))
)
if (filtered.length > 0) models = filtered
}
return NextResponse.json({ success: true, models })
if (!baseUrl) {
return NextResponse.json({ success: false, models: [], error: 'url parameter is required' })
}
return NextResponse.json({ success: false, models: [], error: `Unknown type: ${type}` })
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
// For OpenRouter, add required headers
if (type === 'openrouter') {
headers['HTTP-Referer'] = 'https://localhost:3000'
headers['X-Title'] = 'Memento AI'
}
// Try provider-specific embeddings endpoint first for embeddings kind
if (kind === 'embeddings') {
try {
const embRes = await fetch(`${baseUrl}/embeddings/models`, {
headers,
signal: AbortSignal.timeout(8000),
})
if (embRes.ok) {
const embData = await embRes.json()
const embModels: string[] = (embData.data ?? [])
.map((m: { id: string }) => m.id)
.filter(Boolean)
.sort()
if (embModels.length > 0) {
return NextResponse.json({ success: true, models: embModels })
}
}
} catch {
// Fall through to /v1/models with keyword filter
}
}
const res = await fetch(`${baseUrl}/models`, {
headers,
signal: AbortSignal.timeout(8000),
})
if (!res.ok) {
return NextResponse.json({ success: false, models: [], error: `Provider ${res.status}` })
}
const data = await res.json()
let models: string[] = (data.data ?? [])
.map((m: { id: string }) => m.id)
.filter(Boolean)
.sort()
if (kind === 'embeddings') {
const keywords = ['embed', 'embedding', 'ada', 'e5', 'bge', 'gte', 'minilm']
const filtered = models.filter((id) =>
keywords.some((kw) => id.toLowerCase().includes(kw))
)
if (filtered.length > 0) models = filtered
}
return NextResponse.json({ success: true, models })
} catch (err: any) {
return NextResponse.json({ success: false, models: [], error: err.message ?? 'Request failed' })
}

View File

@@ -63,8 +63,8 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: var(--font-inter);
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, Consolas, monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -117,11 +117,11 @@
--secondary-foreground: #1e293b;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--accent: #f8fafc;
--accent: #f1f5f9;
--accent-foreground: #0284c7;
--destructive: oklch(0.6 0.18 25); /* Rouge */
--border: #e2e8f0; /* Gris-bleu très clair */
--input: #ffffff;
--border: #cbd5e1; /* Gris-bleu visible */
--input: #cbd5e1; /* Bordure visible pour inputs/checkbox */
--ring: #0284c7;
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
@@ -151,11 +151,11 @@
--secondary-foreground: oklch(0.2 0.02 230);
--muted: oklch(0.92 0.005 230);
--muted-foreground: oklch(0.6 0.01 230);
--accent: oklch(0.94 0.005 230);
--accent: oklch(0.92 0.005 230);
--accent-foreground: oklch(0.2 0.02 230);
--destructive: oklch(0.6 0.18 25); /* Rouge */
--border: oklch(0.9 0.008 230); /* Gris-bleu très clair */
--input: oklch(0.98 0.003 230);
--border: oklch(0.85 0.008 230); /* Gris-bleu visible */
--input: oklch(0.85 0.008 230); /* Bordure visible */
--ring: oklch(0.7 0.005 230);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.02 230);
@@ -165,7 +165,7 @@
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.94 0.005 230);
--sidebar-accent-foreground: oklch(0.2 0.02 230);
--sidebar-border: oklch(0.9 0.008 230);
--sidebar-border: oklch(0.85 0.008 230);
--sidebar-ring: oklch(0.7 0.005 230);
}
@@ -182,11 +182,11 @@
--secondary-foreground: oklch(0.97 0.003 230);
--muted: oklch(0.22 0.006 230);
--muted-foreground: oklch(0.55 0.01 230);
--accent: oklch(0.24 0.006 230);
--accent: oklch(0.26 0.008 230);
--accent-foreground: oklch(0.97 0.003 230);
--destructive: oklch(0.65 0.18 25);
--border: oklch(0.28 0.01 230);
--input: oklch(0.2 0.006 230);
--border: oklch(0.33 0.01 230);
--input: oklch(0.33 0.01 230);
--ring: oklch(0.6 0.01 230);
--popover: oklch(0.18 0.006 230);
--popover-foreground: oklch(0.97 0.003 230);
@@ -213,11 +213,11 @@
--secondary-foreground: oklch(0.97 0.003 230);
--muted: oklch(0.22 0.006 230);
--muted-foreground: oklch(0.55 0.01 230);
--accent: oklch(0.24 0.006 230);
--accent: oklch(0.26 0.008 230);
--accent-foreground: oklch(0.97 0.003 230);
--destructive: oklch(0.65 0.18 25);
--border: oklch(0.28 0.01 230);
--input: oklch(0.2 0.006 230);
--border: oklch(0.33 0.01 230);
--input: oklch(0.33 0.01 230);
--ring: oklch(0.6 0.01 230);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
@@ -248,8 +248,8 @@
--accent: oklch(0.25 0.015 250);
--accent-foreground: oklch(0.18 0.03 250);
--destructive: oklch(0.6 0.22 25);
--border: oklch(0.85 0.015 250);
--input: oklch(0.25 0.01 250);
--border: oklch(0.82 0.015 250);
--input: oklch(0.82 0.015 250);
--ring: oklch(0.65 0.015 250);
--popover: oklch(0.97 0.006 250);
--popover-foreground: oklch(0.18 0.03 250);
@@ -274,11 +274,11 @@
--secondary-foreground: oklch(0.96 0.005 250);
--muted: oklch(0.2 0.015 250);
--muted-foreground: oklch(0.5 0.02 250);
--accent: oklch(0.22 0.02 250);
--accent: oklch(0.26 0.02 250);
--accent-foreground: oklch(0.96 0.005 250);
--destructive: oklch(0.65 0.2 25);
--border: oklch(0.3 0.02 250);
--input: oklch(0.22 0.02 250);
--border: oklch(0.33 0.02 250);
--input: oklch(0.33 0.02 250);
--ring: oklch(0.55 0.02 250);
--popover: oklch(0.15 0.015 250);
--popover-foreground: oklch(0.96 0.005 250);
@@ -306,8 +306,8 @@
--accent: oklch(0.93 0.01 225);
--accent-foreground: oklch(0.18 0.035 225);
--destructive: oklch(0.6 0.2 25);
--border: oklch(0.87 0.012 225);
--input: oklch(0.95 0.01 225);
--border: oklch(0.83 0.012 225);
--input: oklch(0.83 0.012 225);
--ring: oklch(0.65 0.015 225);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.18 0.035 225);
@@ -332,11 +332,11 @@
--secondary-foreground: oklch(0.97 0.006 225);
--muted: oklch(0.25 0.02 225);
--muted-foreground: oklch(0.52 0.018 225);
--accent: oklch(0.25 0.025 225);
--accent: oklch(0.28 0.025 225);
--accent-foreground: oklch(0.97 0.006 225);
--destructive: oklch(0.65 0.22 25);
--border: oklch(0.32 0.018 225);
--input: oklch(0.25 0.02 225);
--border: oklch(0.35 0.018 225);
--input: oklch(0.35 0.018 225);
--ring: oklch(0.55 0.02 225);
--popover: oklch(0.17 0.01 225);
--popover-foreground: oklch(0.97 0.006 225);
@@ -364,8 +364,8 @@
--accent: oklch(0.93 0.01 45);
--accent-foreground: oklch(0.2 0.015 45);
--destructive: oklch(0.6 0.2 25);
--border: oklch(0.88 0.012 45);
--input: oklch(0.97 0.008 45);
--border: oklch(0.83 0.012 45);
--input: oklch(0.83 0.012 45);
--ring: oklch(0.68 0.01 45);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.015 45);
@@ -390,11 +390,11 @@
--secondary-foreground: oklch(0.97 0.005 45);
--muted: oklch(0.23 0.02 45);
--muted-foreground: oklch(0.55 0.012 45);
--accent: oklch(0.27 0.018 45);
--accent: oklch(0.29 0.018 45);
--accent-foreground: oklch(0.97 0.005 45);
--destructive: oklch(0.65 0.2 25);
--border: oklch(0.3 0.018 45);
--input: oklch(0.27 0.02 45);
--border: oklch(0.33 0.018 45);
--input: oklch(0.33 0.018 45);
--ring: oklch(0.58 0.02 45);
--popover: oklch(0.19 0.01 45);
--popover-foreground: oklch(0.97 0.005 45);
@@ -408,6 +408,16 @@
--sidebar-ring: oklch(0.58 0.02 45);
}
/* System font mode — override Inter with native OS fonts.
Must be outside @layer base to win over next/font's generated class. */
html.font-system {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important;
}
html.font-system body,
html.font-system * {
font-family: inherit !important;
}
@layer base {
* {
@apply border-border outline-ring/50;

View File

@@ -89,7 +89,7 @@ export default async function RootLayout({
<SessionProviderWrapper>
<ErrorReporter />
<DirectionInitializer />
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} />
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} fontFamily={aiSettings.fontFamily} />
{children}
<Toaster />
</SessionProviderWrapper>

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { Note } from '@/lib/types'
import { getAllNotes, searchNotes, enableNoteHistory } from '@/app/actions/notes'
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById } from '@/app/actions/notes'
import { NoteInput } from '@/components/note-input'
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
import { NotesViewToggle } from '@/components/notes-view-toggle'
@@ -165,6 +165,33 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
useReminderCheck(notes)
// Handle ?openNote=ID — open a note from a notification click
useEffect(() => {
const openNoteId = searchParams.get('openNote')
if (!openNoteId) return
const openNote = async () => {
// Try to find the note in current state first
const existing = notes.find(n => n.id === openNoteId)
if (existing) {
setEditingNote({ note: existing, readOnly: false })
} else {
// Fetch from server
const fetched = await getNoteById(openNoteId)
if (fetched) {
setEditingNote({ note: fetched, readOnly: false })
}
}
// Clean URL — remove openNote param
const params = new URLSearchParams(searchParams.toString())
params.delete('openNote')
router.replace(params.toString() ? `/?${params.toString()}` : '/', { scroll: false })
}
openNote()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams])
// Listen for global label deletion and immediately update local state
useEffect(() => {
const handler = (e: Event) => {
@@ -321,6 +348,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
})()}
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{currentNotebook.name}</h1>
<span className="text-sm font-medium text-muted-foreground mt-2">({notes.length})</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
@@ -351,6 +379,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
<FileText className="w-8 h-8 text-primary" />
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
<span className="text-sm font-medium text-muted-foreground mt-2">{notes.length} {notes.length === 1 ? (t('notes.note') || 'note') : (t('notes.notes') || 'notes')}</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />

View File

@@ -218,6 +218,13 @@ export const NoteCard = memo(function NoteCard({
// Local color state so color persists after transition ends
const [localColor, setLocalColor] = useState(note.color)
// Local checkItems state so checklist toggles persist after transition ends
const [localCheckItems, setLocalCheckItems] = useState(note.checkItems)
// Sync local state when parent data refreshes
useEffect(() => {
setLocalCheckItems(note.checkItems)
}, [note.checkItems])
const colorClasses = NOTE_COLORS[(localColor || optimisticNote.color) as NoteColor] || NOTE_COLORS.default
@@ -373,14 +380,15 @@ export const NoteCard = memo(function NoteCard({
}
const handleCheckItem = async (checkItemId: string) => {
if (note.type === 'checklist' && Array.isArray(note.checkItems)) {
const updatedItems = note.checkItems.map(item =>
if (note.type === 'checklist') {
const currentItems = localCheckItems || note.checkItems || []
const updatedItems = currentItems.map(item =>
item.id === checkItemId ? { ...item, checked: !item.checked } : item
)
setLocalCheckItems(updatedItems) // instant visual update, survives transition
startTransition(async () => {
addOptimisticNote({ checkItems: updatedItems })
await updateNote(note.id, { checkItems: updatedItems })
// No router.refresh() — optimistic update is sufficient and avoids grid rebuild
})
}
}
@@ -621,7 +629,7 @@ export const NoteCard = memo(function NoteCard({
{/* Content */}
{optimisticNote.type === 'checklist' ? (
<NoteChecklist
items={optimisticNote.checkItems || []}
items={localCheckItems || optimisticNote.checkItems || []}
onToggleItem={handleCheckItem}
/>
) : optimisticNote.type === 'richtext' ? (

View File

@@ -497,8 +497,14 @@ export function NoteInlineEditor({
value={noteType}
onChange={(newType) => {
setNoteType(newType)
if (newType !== 'markdown') setShowMarkdownPreview(false)
onChange?.(note.id, { isMarkdown: newType === 'markdown' })
if (newType === 'markdown') setShowMarkdownPreview(true)
else setShowMarkdownPreview(false)
// Persist both type and isMarkdown immediately
saveInline(note.id, {
type: newType,
isMarkdown: newType === 'markdown',
}).catch(() => {})
onChange?.(note.id, { type: newType, isMarkdown: newType === 'markdown' })
}}
compact
/>

View File

@@ -19,7 +19,7 @@ import {
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
import { Note, NOTE_COLORS, NoteColor, NoteType } from '@/lib/types'
import { cn } from '@/lib/utils'
import { NoteInlineEditor } from '@/components/note-inline-editor'
import { useLanguage } from '@/lib/i18n'
@@ -65,6 +65,7 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
@@ -737,11 +738,13 @@ export function NotesTabsView({
const selected = items.find((n) => n.id === selectedId) ?? null
const colorKey = selected ? getColorKey(selected) : 'default'
const handleCreateNote = () => {
const handleCreateNote = (noteType: NoteType = 'richtext') => {
startCreating(async () => {
try {
const newNote = await createNote({
content: '',
content: noteType === 'checklist' ? '' : '',
type: noteType,
checkItems: noteType === 'checklist' ? [{ id: Date.now().toString(), text: '', checked: false }] : undefined,
title: undefined,
notebookId: currentNotebookId || undefined,
skipRevalidation: true
@@ -843,19 +846,32 @@ export function NotesTabsView({
</DropdownMenuContent>
</DropdownMenu>
{/* New note button */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"
onClick={handleCreateNote}
disabled={isCreating}
title={t('notes.newNote')}
>
{isCreating
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-4 w-4" />}
</Button>
{/* New note button — dropdown to choose type */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"
disabled={isCreating}
title={t('notes.newNote')}
>
{isCreating
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-4 w-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem onClick={() => handleCreateNote('richtext')}>
<FileText className="h-4 w-4 mr-2" />
{t('notes.newNote') || 'Note'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCreateNote('checklist')}>
<ListChecks className="h-4 w-4 mr-2" />
{t('notes.newChecklist') || 'Checklist'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>

View File

@@ -1,15 +1,17 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2 } from 'lucide-react'
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2 } from 'lucide-react'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { getPendingShareRequests, respondToShareRequest, getNotesWithReminders, toggleReminderDone } from '@/app/actions/notes'
import { getUnreadNotifications, markNotificationRead, markAllNotificationsRead, type AppNotification } from '@/app/actions/notifications'
import { toast } from 'sonner'
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
import { cn } from '@/lib/utils'
@@ -47,19 +49,23 @@ interface ReminderNote {
export function NotificationPanel() {
const { triggerRefresh } = useNoteRefreshOptional()
const { t } = useLanguage()
const router = useRouter()
const [requests, setRequests] = useState<ShareRequest[]>([])
const [reminders, setReminders] = useState<ReminderNote[]>([])
const [appNotifications, setAppNotifications] = useState<AppNotification[]>([])
const [isLoading, setIsLoading] = useState(false)
const [open, setOpen] = useState(false)
const loadData = useCallback(async () => {
try {
const [shareData, reminderData] = await Promise.all([
const [shareData, reminderData, notifData] = await Promise.all([
getPendingShareRequests(),
getNotesWithReminders(),
getUnreadNotifications(),
])
setRequests(shareData as any)
setReminders((reminderData as any) || [])
setAppNotifications(notifData || [])
} catch (error: any) {
console.error('Failed to load notifications:', error)
}
@@ -81,7 +87,7 @@ export function NotificationPanel() {
const overdueReminders = activeReminders.filter(r => new Date(r.reminder!) < now)
const upcomingReminders = activeReminders.filter(r => new Date(r.reminder!) >= now)
const pendingCount = requests.length + overdueReminders.length
const pendingCount = requests.length + overdueReminders.length + appNotifications.length
const handleAccept = async (shareId: string) => {
try {
@@ -121,7 +127,17 @@ export function NotificationPanel() {
}
}
const hasContent = requests.length > 0 || activeReminders.length > 0
const handleMarkNotifRead = async (notifId: string) => {
await markNotificationRead(notifId)
setAppNotifications(prev => prev.filter(n => n.id !== notifId))
}
const handleMarkAllRead = async () => {
await markAllNotificationsRead()
setAppNotifications([])
}
const hasContent = requests.length > 0 || activeReminders.length > 0 || appNotifications.length > 0
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -149,11 +165,22 @@ export function NotificationPanel() {
<Bell className="h-4 w-4 text-primary dark:text-primary-foreground" />
<span className="font-semibold text-sm">{t('notification.notifications')}</span>
</div>
{pendingCount > 0 && (
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md">
{pendingCount}
</Badge>
)}
<div className="flex items-center gap-2">
{appNotifications.length > 0 && (
<button
onClick={handleMarkAllRead}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
title={t('notification.markAllRead') || 'Mark all read'}
>
<Check className="h-3.5 w-3.5" />
</button>
)}
{pendingCount > 0 && (
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md">
{pendingCount}
</Badge>
)}
</div>
</div>
</div>
@@ -168,6 +195,65 @@ export function NotificationPanel() {
</div>
) : (
<div className="max-h-96 overflow-y-auto">
{/* App notifications (agents, system) */}
{appNotifications.map((notif) => (
<div
key={notif.id}
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150 cursor-pointer"
onClick={() => {
if (notif.actionUrl) {
handleMarkNotifRead(notif.id)
setOpen(false)
router.push(notif.actionUrl)
}
}}
>
<div className="flex items-start gap-3">
<div className={cn(
"mt-0.5 flex-none rounded-full p-1",
notif.type === 'agent_success' && 'bg-green-100 dark:bg-green-900/30 text-green-600',
notif.type === 'agent_failure' && 'bg-red-100 dark:bg-red-900/30 text-red-600',
notif.type === 'system' && 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
)}>
{notif.type.startsWith('agent') ? (
<Bot className="w-3.5 h-3.5" />
) : (
<AlertCircle className="w-3.5 h-3.5" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<span className={cn(
"text-[10px] font-semibold uppercase tracking-wider",
notif.type === 'agent_success' && 'text-green-600 dark:text-green-400',
notif.type === 'agent_failure' && 'text-red-600 dark:text-red-400',
notif.type === 'system' && 'text-blue-600 dark:text-blue-400',
)}>
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent completed')}
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent failed')}
{notif.type === 'system' && 'System'}
</span>
</div>
<p className="text-sm font-medium truncate">{notif.title}</p>
{notif.message && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{notif.message}</p>
)}
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); handleMarkNotifRead(notif.id) }}
className="mt-0.5 text-muted-foreground/40 hover:text-foreground transition-colors"
title={t('notification.dismiss') || 'Dismiss'}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
{/* Overdue reminders */}
{overdueReminders.map((note) => (
<div

View File

@@ -5,9 +5,10 @@ import { useEffect } from 'react'
interface ThemeInitializerProps {
theme?: string
fontSize?: string
fontFamily?: string
}
export function ThemeInitializer({ theme, fontSize }: ThemeInitializerProps) {
export function ThemeInitializer({ theme, fontSize, fontFamily }: ThemeInitializerProps) {
useEffect(() => {
// Helper to apply theme
@@ -77,7 +78,20 @@ export function ThemeInitializer({ theme, fontSize }: ThemeInitializerProps) {
}
applyFontSize(fontSize)
}, [theme, fontSize])
// Apply font family
const localFontFamily = localStorage.getItem('font-family')
const effectiveFontFamily = localFontFamily || fontFamily || 'inter'
const root = document.documentElement
if (effectiveFontFamily === 'system') {
root.classList.add('font-system')
} else {
root.classList.remove('font-system')
}
if (!localFontFamily && fontFamily) {
localStorage.setItem('font-family', fontFamily)
}
}, [theme, fontSize, fontFamily])
return null
}

View File

@@ -14,7 +14,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer border-input dark:bg-input/50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}

View File

@@ -23,6 +23,8 @@ interface ComboboxProps {
emptyMessage?: string
disabled?: boolean
className?: string
/** When true, the search text can be submitted as a custom value not in the options list */
allowCustomValue?: boolean
}
export function Combobox({
@@ -34,6 +36,7 @@ export function Combobox({
emptyMessage = 'No results found.',
disabled = false,
className,
allowCustomValue = false,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false)
const [search, setSearch] = React.useState('')
@@ -56,6 +59,14 @@ export function Combobox({
setSearch('')
}
const handleConfirmCustom = () => {
if (allowCustomValue && search.trim()) {
onChange(search.trim())
setOpen(false)
setSearch('')
}
}
return (
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (!v) setSearch('') }}>
<PopoverTrigger asChild>
@@ -85,14 +96,26 @@ export function Combobox({
placeholder={searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleConfirmCustom() } }}
autoFocus
/>
</div>
<div className="max-h-60 overflow-y-auto p-1">
{filtered.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
allowCustomValue && search.trim() ? (
<button
type="button"
onClick={handleConfirmCustom}
className="flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
>
<span className="text-muted-foreground mr-2">+</span>
<span>Use "<strong>{search.trim()}</strong>"</span>
</button>
) : (
<div className="py-6 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
)
) : (
filtered.map((option) => {
const isSelected = option.value === value

View File

@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/50 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className

View File

@@ -38,6 +38,7 @@ services:
# Application (IMPORTANT: Change these!)
- NEXTAUTH_URL=http://your-domain.com:3000
- NEXTAUTH_SECRET=change-this-to-a-random-secret-string
- ADMIN_EMAIL=admin@yourdomain.com # REQUIRED: first user with this email gets ADMIN role
# Disable Next.js telemetry
- NEXT_TELEMETRY_DISABLED=1

View File

@@ -1,8 +1,11 @@
#!/bin/sh
# ============================================================
# Memento Note — Docker Entrypoint
# Automatic DB migration with backup, retry, and cleanup.
# Safe for production: no data loss on image updates.
# Reliable DB migration for fresh installs and upgrades.
# ============================================================
# Strategy:
# 1. prisma migrate deploy → fresh DB, normal upgrades
# 2. prisma db push → fallback for DBs without migration history
# ============================================================
set -e
@@ -26,13 +29,12 @@ case "$DATABASE_URL" in
esac
log "Database type: $DB_TYPE"
# --- Wait for database to be reachable ---
wait_for_db() {
if [ "$DB_TYPE" = "sqlite" ]; then
log "SQLite — no connection check needed."
return 0
fi
# --- Ensure data directories exist ---
mkdir -p "$BACKUP_DIR" 2>/dev/null || true
mkdir -p /app/data/uploads/notes 2>/dev/null || true
# --- Wait for database to be reachable ---
if [ "$DB_TYPE" != "sqlite" ]; then
log "Waiting for database connection..."
i=0
while [ "$i" -lt "$DB_WAIT_RETRIES" ]; do
@@ -45,107 +47,74 @@ wait_for_db() {
s.on('error', () => process.exit(1));
" 2>/dev/null; then
log "Database is reachable."
return 0
break
fi
i=$((i + 1))
log " Attempt $i/$DB_WAIT_RETRIES — retrying in ${DB_WAIT_INTERVAL}s..."
sleep "$DB_WAIT_INTERVAL"
done
err "Database unreachable after $((DB_WAIT_RETRIES * DB_WAIT_INTERVAL)) seconds. Aborting."
exit 1
}
# --- Create backup before migration ---
create_backup() {
mkdir -p "$BACKUP_DIR"
ts=$(date '+%Y%m%d_%H%M%S')
case "$DB_TYPE" in
postgres)
backup_file="$BACKUP_DIR/pre_migrate_${ts}.sql.gz"
log "Creating PostgreSQL backup: $backup_file"
if command -v pg_dump >/dev/null 2>&1; then
if pg_dump --no-owner --no-privileges --format=plain "$DATABASE_URL" 2>/dev/null | gzip > "$backup_file" 2>/dev/null; then
size=$(du -h "$backup_file" | cut -f1)
log "Backup created successfully ($size)"
else
warn "pg_dump failed. Continuing without backup."
rm -f "$backup_file"
fi
else
warn "pg_dump not available. Skipping PostgreSQL backup."
warn "Install postgresql-client in the Docker image for automatic backups."
fi
;;
sqlite)
db_path="${DATABASE_URL#file:}"
# Handle relative paths
case "$db_path" in
/*) ;;
*) db_path="/app/$db_path" ;;
esac
backup_file="$BACKUP_DIR/pre_migrate_${ts}.sqlite"
if [ -f "$db_path" ]; then
log "Creating SQLite backup: $backup_file"
cp "$db_path" "$backup_file"
size=$(du -h "$backup_file" | cut -f1)
log "Backup created successfully ($size)"
else
warn "SQLite file not found at $db_path — skipping backup (first run)."
fi
;;
*)
warn "Unknown database type '$DB_TYPE'. Skipping backup."
;;
esac
}
# --- Clean up old backups (keep last N) ---
cleanup_old_backups() {
count=$(ls -1 "$BACKUP_DIR"/pre_migrate_* 2>/dev/null | wc -l)
if [ "$count" -gt "$MAX_BACKUPS" ]; then
to_remove=$((count - MAX_BACKUPS))
log "Cleaning up $to_remove old backup(s), keeping last $MAX_BACKUPS..."
ls -1t "$BACKUP_DIR"/pre_migrate_* 2>/dev/null | tail -n "$to_remove" | xargs rm -f
if [ "$i" -ge "$DB_WAIT_RETRIES" ]; then
err "Database unreachable after $((DB_WAIT_RETRIES * DB_WAIT_INTERVAL)) seconds."
exit 1
fi
}
# --- Run Prisma migrations ---
run_migrations() {
log "Running Prisma migrations..."
if timeout "$MIGRATE_TIMEOUT" node ./node_modules/prisma/build/index.js migrate deploy 2>&1; then
log "Migrations applied successfully."
return 0
else
exit_code=$?
err "Migration failed (exit code $exit_code)."
return "$exit_code"
fi
}
# ============================================================
# Main flow
# ============================================================
# Step 1: Wait for database
wait_for_db
# Step 2: Backup
create_backup
# Step 3: Cleanup old backups
cleanup_old_backups
# Step 4: Migrate
if ! run_migrations; then
err "Migration failed — server will NOT start to prevent data corruption."
err "A backup was saved in $BACKUP_DIR (if backup was successful)."
err "To restore: gunzip the .sql.gz file, then: psql DATABASE_URL < backup.sql"
exit 1
fi
# Background scheduler: call /api/cron/agents every 5 minutes
# --- Create backup before migration (best-effort) ---
if [ "$DB_TYPE" = "postgres" ] && command -v pg_dump >/dev/null 2>&1; then
ts=$(date '+%Y%m%d_%H%M%S')
backup_file="$BACKUP_DIR/pre_migrate_${ts}.sql.gz"
log "Creating backup: $backup_file"
if pg_dump --no-owner --no-privileges --format=plain "$DATABASE_URL" 2>/dev/null | gzip > "$backup_file" 2>/dev/null; then
size=$(du -h "$backup_file" | cut -f1)
log "Backup OK ($size)"
else
warn "pg_dump failed (DB may be empty). Continuing without backup."
rm -f "$backup_file"
fi
fi
# --- Cleanup old backups ---
count=$(ls -1 "$BACKUP_DIR"/pre_migrate_* 2>/dev/null | wc -l)
if [ "$count" -gt "$MAX_BACKUPS" ]; then
to_remove=$((count - MAX_BACKUPS))
log "Cleaning up $to_remove old backup(s)..."
ls -1t "$BACKUP_DIR"/pre_migrate_* 2>/dev/null | tail -n "$to_remove" | xargs rm -f
fi
# ============================================================
# Migration — two strategies
# ============================================================
PRISMA="node ./node_modules/prisma/build/index.js"
log "Running database migrations..."
# Strategy 1: prisma migrate deploy
# Works for: fresh DB (empty), existing DB with migration history
if timeout "$MIGRATE_TIMEOUT" $PRISMA migrate deploy 2>&1; then
log "Migrations applied successfully."
else
migrate_exit=$?
log "prisma migrate deploy failed (exit $migrate_exit)."
# Strategy 2: prisma db push
# Works for: existing DB without migration history (created with db push),
# schema drift, or any other case where migrate deploy fails.
# This syncs schema.prisma → database, preserving existing data.
log "Falling back to prisma db push..."
if timeout "$MIGRATE_TIMEOUT" $PRISMA db push --skip-generate --accept-data-loss 2>&1; then
log "db push completed successfully."
else
err "All migration strategies failed."
err "To fix manually:"
err " docker compose exec memento-note npx prisma migrate deploy"
err " docker compose exec memento-note npx prisma db push --skip-generate"
exit 1
fi
fi
# --- Background cron scheduler ---
(
sleep 60
while true; do
@@ -163,6 +132,6 @@ fi
done
) &
# Step 5: Start server
# --- Start server ---
log "Starting server..."
exec node server.js

View File

@@ -3,7 +3,36 @@ import { OllamaProvider } from './providers/ollama';
import { CustomOpenAIProvider } from './providers/custom-openai';
import { AIProvider } from './types';
type ProviderType = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter';
type ProviderType = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter' | 'mistral' | 'zai' | 'lmstudio';
// --- Provider defaults ---
const PROVIDER_DEFAULTS: Record<string, { baseUrl: string; model: string; embeddingModel: string }> = {
deepseek: {
baseUrl: 'https://api.deepseek.com/v1',
model: 'deepseek-chat',
embeddingModel: '',
},
openrouter: {
baseUrl: 'https://openrouter.ai/api/v1',
model: 'openai/gpt-4o-mini',
embeddingModel: 'openai/text-embedding-3-small',
},
mistral: {
baseUrl: 'https://api.mistral.ai/v1',
model: 'mistral-small-latest',
embeddingModel: 'mistral-embed',
},
zai: {
baseUrl: 'https://api.zukijourney.com/v1',
model: 'gpt-4o-mini',
embeddingModel: 'text-embedding-3-small',
},
lmstudio: {
baseUrl: 'http://localhost:1234/v1',
model: '',
embeddingModel: '',
},
};
function createOllamaProvider(config: Record<string, string>, modelName: string, embeddingModelName: string, baseUrlOverride?: string): OllamaProvider {
let baseUrl = baseUrlOverride || config?.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL
@@ -19,7 +48,7 @@ function createOllamaProvider(config: Record<string, string>, modelName: string,
// Ensure baseUrl doesn't end with /api, we'll add it in OllamaProvider
if (baseUrl.endsWith('/api')) {
baseUrl = baseUrl.slice(0, -4); // Remove /api
baseUrl = baseUrl.slice(0, -4);
}
return new OllamaProvider(baseUrl, modelName, embeddingModelName);
@@ -51,15 +80,39 @@ function createCustomOpenAIProvider(config: Record<string, string>, modelName: s
}
function createDeepSeekProvider(config: Record<string, string>, modelName: string, embeddingModelName: string): CustomOpenAIProvider {
const apiKey = config?.DEEPSEEK_API_KEY || config?.CUSTOM_OPENAI_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.CUSTOM_OPENAI_API_KEY || '';
const apiKey = config?.DEEPSEEK_API_KEY || process.env.DEEPSEEK_API_KEY || '';
if (!apiKey) throw new Error('DEEPSEEK_API_KEY is required when using DeepSeek provider');
return new CustomOpenAIProvider(apiKey, 'https://api.deepseek.com/v1', modelName, embeddingModelName);
const defaults = PROVIDER_DEFAULTS.deepseek;
return new CustomOpenAIProvider(apiKey, defaults.baseUrl, modelName || defaults.model, embeddingModelName || defaults.embeddingModel);
}
function createOpenRouterProvider(config: Record<string, string>, modelName: string, embeddingModelName: string): CustomOpenAIProvider {
const apiKey = config?.OPENROUTER_API_KEY || config?.CUSTOM_OPENAI_API_KEY || process.env.OPENROUTER_API_KEY || process.env.CUSTOM_OPENAI_API_KEY || '';
const apiKey = config?.OPENROUTER_API_KEY || process.env.OPENROUTER_API_KEY || '';
if (!apiKey) throw new Error('OPENROUTER_API_KEY is required when using OpenRouter provider');
return new CustomOpenAIProvider(apiKey, 'https://openrouter.ai/api/v1', modelName, embeddingModelName);
const defaults = PROVIDER_DEFAULTS.openrouter;
return new CustomOpenAIProvider(apiKey, defaults.baseUrl, modelName || defaults.model, embeddingModelName || defaults.embeddingModel);
}
function createMistralProvider(config: Record<string, string>, modelName: string, embeddingModelName: string): CustomOpenAIProvider {
const apiKey = config?.MISTRAL_API_KEY || process.env.MISTRAL_API_KEY || '';
if (!apiKey) throw new Error('MISTRAL_API_KEY is required when using Mistral provider');
const defaults = PROVIDER_DEFAULTS.mistral;
return new CustomOpenAIProvider(apiKey, defaults.baseUrl, modelName || defaults.model, embeddingModelName || defaults.embeddingModel);
}
function createZAIProvider(config: Record<string, string>, modelName: string, embeddingModelName: string): CustomOpenAIProvider {
const apiKey = config?.ZAI_API_KEY || process.env.ZAI_API_KEY || '';
if (!apiKey) throw new Error('ZAI_API_KEY is required when using Z.AI provider');
const defaults = PROVIDER_DEFAULTS.zai;
return new CustomOpenAIProvider(apiKey, defaults.baseUrl, modelName || defaults.model, embeddingModelName || defaults.embeddingModel);
}
function createLMStudioProvider(config: Record<string, string>, modelName: string, embeddingModelName: string): CustomOpenAIProvider {
const baseUrl = config?.LMSTUDIO_BASE_URL || process.env.LMSTUDIO_BASE_URL || PROVIDER_DEFAULTS.lmstudio.baseUrl;
// LM Studio doesn't require an API key, but the CustomOpenAI provider needs one
// Use a dummy key if not provided
const apiKey = config?.LMSTUDIO_API_KEY || process.env.LMSTUDIO_API_KEY || 'lm-studio';
return new CustomOpenAIProvider(apiKey, baseUrl, modelName, embeddingModelName);
}
function getProviderInstance(providerType: ProviderType, config: Record<string, string>, modelName: string, embeddingModelName: string, ollamaBaseUrl?: string): AIProvider {
@@ -74,28 +127,47 @@ function getProviderInstance(providerType: ProviderType, config: Record<string,
return createDeepSeekProvider(config, modelName, embeddingModelName);
case 'openrouter':
return createOpenRouterProvider(config, modelName, embeddingModelName);
case 'mistral':
return createMistralProvider(config, modelName, embeddingModelName);
case 'zai':
return createZAIProvider(config, modelName, embeddingModelName);
case 'lmstudio':
return createLMStudioProvider(config, modelName, embeddingModelName);
default:
return createOllamaProvider(config, modelName, embeddingModelName, ollamaBaseUrl);
}
}
// Resolve the effective provider type and config keys for a given provider
// Returns { providerType, apiKeyConfigKey, baseUrlConfigKey }
function getProviderConfigKeys(providerType: string): { apiKeyConfigKey: string; baseUrlConfigKey: string } {
switch (providerType) {
case 'deepseek': return { apiKeyConfigKey: 'DEEPSEEK_API_KEY', baseUrlConfigKey: '' };
case 'openrouter': return { apiKeyConfigKey: 'OPENROUTER_API_KEY', baseUrlConfigKey: '' };
case 'mistral': return { apiKeyConfigKey: 'MISTRAL_API_KEY', baseUrlConfigKey: '' };
case 'zai': return { apiKeyConfigKey: 'ZAI_API_KEY', baseUrlConfigKey: '' };
case 'lmstudio': return { apiKeyConfigKey: 'LMSTUDIO_API_KEY', baseUrlConfigKey: 'LMSTUDIO_BASE_URL' };
case 'openai': return { apiKeyConfigKey: 'OPENAI_API_KEY', baseUrlConfigKey: '' };
case 'custom': return { apiKeyConfigKey: 'CUSTOM_OPENAI_API_KEY', baseUrlConfigKey: 'CUSTOM_OPENAI_BASE_URL' };
default: return { apiKeyConfigKey: '', baseUrlConfigKey: 'OLLAMA_BASE_URL' };
}
}
export function getTagsProvider(config?: Record<string, string>): AIProvider {
// Check database config first, then environment variables
const providerType = (
config?.AI_PROVIDER_TAGS ||
config?.AI_PROVIDER_EMBEDDING ||
config?.AI_PROVIDER_TAGS ||
config?.AI_PROVIDER_EMBEDDING ||
config?.AI_PROVIDER ||
process.env.AI_PROVIDER_TAGS ||
process.env.AI_PROVIDER_EMBEDDING ||
process.env.AI_PROVIDER
);
// If no provider is configured, throw a clear error
if (!providerType) {
console.error('[getTagsProvider] FATAL: No provider configured. Config received:', config);
throw new Error(
'AI_PROVIDER_TAGS is not configured. Please set it in the admin settings or environment variables. ' +
'Options: ollama, openai, custom'
'Options: ollama, openai, deepseek, openrouter, mistral, zai, lmstudio, custom'
);
}
@@ -108,22 +180,20 @@ export function getTagsProvider(config?: Record<string, string>): AIProvider {
}
export function getEmbeddingsProvider(config?: Record<string, string>): AIProvider {
// Check database config first, then environment variables
const providerType = (
config?.AI_PROVIDER_EMBEDDING ||
config?.AI_PROVIDER_TAGS ||
config?.AI_PROVIDER_EMBEDDING ||
config?.AI_PROVIDER_TAGS ||
config?.AI_PROVIDER ||
process.env.AI_PROVIDER_EMBEDDING ||
process.env.AI_PROVIDER_TAGS ||
process.env.AI_PROVIDER
);
// If no provider is configured, throw a clear error
if (!providerType) {
console.error('[getEmbeddingsProvider] FATAL: No provider configured. Config received:', config);
throw new Error(
'AI_PROVIDER_EMBEDDING is not configured. Please set it in the admin settings or environment variables. ' +
'Options: ollama, openai, custom'
'Options: ollama, openai, deepseek, openrouter, mistral, zai, lmstudio, custom'
);
}
@@ -140,8 +210,6 @@ export function getAIProvider(config?: Record<string, string>): AIProvider {
}
export function getChatProvider(config?: Record<string, string>): AIProvider {
// Check database config first, then environment variables
// Fallback cascade: chat -> tags -> embeddings
const providerType = (
config?.AI_PROVIDER_CHAT ||
config?.AI_PROVIDER_TAGS ||
@@ -153,12 +221,11 @@ export function getChatProvider(config?: Record<string, string>): AIProvider {
process.env.AI_PROVIDER
);
// If no provider is configured, throw a clear error
if (!providerType) {
console.error('[getChatProvider] FATAL: No provider configured. Config received:', config);
throw new Error(
'AI_PROVIDER_CHAT is not configured. Please set it in the admin settings or environment variables. ' +
'Options: ollama, openai, custom'
'Options: ollama, openai, deepseek, openrouter, mistral, zai, lmstudio, custom'
);
}
@@ -173,3 +240,6 @@ export function getChatProvider(config?: Record<string, string>): AIProvider {
return getProviderInstance(provider, config || {}, modelName, embeddingModelName, ollamaBaseUrl);
}
// Export for use by admin settings form and deploy scripts
export { PROVIDER_DEFAULTS, getProviderConfigKeys };

View File

@@ -15,6 +15,8 @@ import { sendEmail } from '@/lib/mail'
import { getAgentEmailTemplate } from '@/lib/agent-email-template'
import { extractAndDownloadImages, extractImageUrlsFromHtml, downloadImage } from '../tools/extract-images'
import { calculateNextRun } from '@/lib/agents/schedule'
import { markdownToHtml } from '@/lib/markdown-to-html'
import { createNotification } from '@/app/actions/notifications'
// Import tools for side-effect registration
import '../tools'
@@ -30,6 +32,32 @@ export interface AgentExecutionResult {
error?: string
}
// --- Note creation helper ---
/** Create an agent note as rich text (TipTap-compatible HTML).
* Converts the markdown content to HTML and sets type='richtext'. */
async function createAgentNote(data: {
title: string
content: string
userId: string
notebookId: string | null
autoGenerated?: boolean
}) {
const htmlContent = markdownToHtml(data.content)
return prisma.note.create({
data: {
title: data.title,
content: htmlContent,
type: 'richtext',
isMarkdown: false,
autoGenerated: data.autoGenerated ?? true,
userId: data.userId,
notebookId: data.notebookId,
},
select: { id: true },
})
}
// --- Language Helper ---
type Lang = 'fr' | 'en'
@@ -324,15 +352,11 @@ async function executeScraperAgent(
const title = await generateTitle(fullContent, agent.name, lang)
const note = await prisma.note.create({
data: {
title,
content: fullContent,
isMarkdown: true,
autoGenerated: true,
userId: agent.userId,
notebookId: agent.targetNotebookId,
}
const note = await createAgentNote({
title,
content: fullContent,
userId: agent.userId,
notebookId: agent.targetNotebookId,
})
const logMsg = lang === 'fr'
@@ -424,15 +448,11 @@ async function executeResearcherAgent(
const title = await generateTitle(fullContent, agent.name, lang)
const note = await prisma.note.create({
data: {
title,
content: fullContent,
isMarkdown: true,
autoGenerated: true,
userId: agent.userId,
notebookId: agent.targetNotebookId,
}
const note = await createAgentNote({
title,
content: fullContent,
userId: agent.userId,
notebookId: agent.targetNotebookId,
})
const logMsg = lang === 'fr'
@@ -526,15 +546,11 @@ async function executeMonitorAgent(
const title = await generateTitle(fullContent, agent.name, lang)
const note = await prisma.note.create({
data: {
title,
content: fullContent,
isMarkdown: true,
autoGenerated: true,
userId: agent.userId,
notebookId: agent.targetNotebookId,
}
const note = await createAgentNote({
title,
content: fullContent,
userId: agent.userId,
notebookId: agent.targetNotebookId,
})
const logMsg = lang === 'fr' ? `Analyse de ${notes.length} notes. Note créée: ${note.id}` : `Analyzed ${notes.length} notes. Note created: ${note.id}`
@@ -597,15 +613,11 @@ async function executeCustomAgent(
const title = await generateTitle(fullContent, agent.name, lang)
const note = await prisma.note.create({
data: {
title,
content: fullContent,
isMarkdown: true,
autoGenerated: true,
userId: agent.userId,
notebookId: agent.targetNotebookId,
}
const note = await createAgentNote({
title,
content: fullContent,
userId: agent.userId,
notebookId: agent.targetNotebookId,
})
const toolLogData = JSON.stringify([{
@@ -966,15 +978,11 @@ async function executeToolUseAgent(
const fullContent = `# ${agent.name}\n\n${text}\n\n---\n\n_Agent execution: ${totalToolCalls} tool calls in ${Math.round(duration / 1000)}s_`
const title = await generateTitle(fullContent, agent.name, lang)
const note = await prisma.note.create({
data: {
title,
content: fullContent,
isMarkdown: true,
autoGenerated: true,
userId: agent.userId,
notebookId: agent.targetNotebookId || null,
}
const note = await createAgentNote({
title,
content: fullContent,
userId: agent.userId,
notebookId: agent.targetNotebookId || null,
})
noteId = note.id
}
@@ -1119,6 +1127,20 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
}
}
// Create in-app notification for agent result
if (result.success) {
await createNotification({
userId,
type: 'agent_success',
title: agent.name,
message: result.noteId
? (lang === 'fr' ? `L'agent a terminé avec succès — note créée.` : `Agent completed successfully — note created.`)
: (lang === 'fr' ? `L'agent a terminé avec succès.` : `Agent completed successfully.`),
actionUrl: result.noteId ? `/?openNote=${result.noteId}` : '/agents',
relatedId: result.noteId || agentId,
})
}
return result
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
@@ -1129,6 +1151,16 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
data: { status: 'failure', log: message }
})
// Notify user of agent failure
await createNotification({
userId,
type: 'agent_failure',
title: agent?.name || 'Agent',
message: message.length > 200 ? message.substring(0, 200) + '...' : message,
actionUrl: '/agents',
relatedId: agentId,
})
return { success: false, actionId: action.id, error: message }
}
}

View File

@@ -19,23 +19,43 @@ export interface ImageDescriptionResult {
const UPLOAD_DIR = path.join(process.cwd(), 'data', 'uploads')
async function resolveImageAsBase64(imageUrl: string): Promise<string> {
async function resolveImageAsBase64(imageUrl: string): Promise<string | null> {
const localMatch = imageUrl.match(/\/uploads\/(.+)/)
if (localMatch) {
const filePath = path.join(UPLOAD_DIR, localMatch[1])
const buffer = await readFile(filePath)
const ext = path.extname(imageUrl).toLowerCase()
const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'
return `data:${mime};base64,${buffer.toString('base64')}`
// Try reading from filesystem first
try {
const filePath = path.join(UPLOAD_DIR, localMatch[1])
const buffer = await readFile(filePath)
const ext = path.extname(imageUrl).toLowerCase()
const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'
return `data:${mime};base64,${buffer.toString('base64')}`
} catch {
// File not on disk — fallback to internal HTTP API (same path the browser uses)
try {
const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const res = await fetch(`${baseUrl}${imageUrl}`)
if (!res.ok) return null
const contentType = res.headers.get('content-type') || 'image/jpeg'
const arrayBuffer = await res.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
return `data:${contentType};base64,${base64}`
} catch {
return null
}
}
}
// Remote URL — fetch and convert
const res = await fetch(imageUrl)
if (!res.ok) throw new Error(`Failed to fetch image: ${imageUrl}`)
const contentType = res.headers.get('content-type') || 'image/jpeg'
const arrayBuffer = await res.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
return `data:${contentType};base64,${base64}`
try {
const res = await fetch(imageUrl)
if (!res.ok) return null
const contentType = res.headers.get('content-type') || 'image/jpeg'
const arrayBuffer = await res.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
return `data:${contentType};base64,${base64}`
} catch {
return null
}
}
export async function describeImages(
@@ -55,8 +75,9 @@ export async function describeImages(
}
const langName = langMap[language] || 'English'
// Resolve all images as base64 data URLs (same approach as the chat route)
const imageDataUrls = await Promise.all(imageUrls.map(url => resolveImageAsBase64(url)))
// Resolve all images as base64 data URLs — skip any that can't be found
const resolved = await Promise.all(imageUrls.map(url => resolveImageAsBase64(url)))
const imageDataUrls = resolved.filter((d): d is string => d !== null)
if (isTitleMode) {
const prompt = imageUrls.length === 1

View File

@@ -7,6 +7,7 @@ import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma'
import { markdownToHtml } from '@/lib/markdown-to-html'
// --- note_read ---
toolRegistry.register({
@@ -50,11 +51,13 @@ toolRegistry.register({
}),
execute: async ({ title, content, notebookId, images }) => {
try {
const htmlContent = markdownToHtml(content)
const note = await prisma.note.create({
data: {
title,
content,
isMarkdown: true,
content: htmlContent,
type: 'richtext',
isMarkdown: false,
autoGenerated: true,
userId: ctx.userId,
notebookId: notebookId || null,

View File

@@ -1,24 +1,32 @@
import prisma from './prisma'
// "openrouter" était une ancienne valeur de provider — on la normalise en "custom"
function normalizeProvider(val: string | undefined): string {
if (!val) return ''
return val === 'openrouter' ? 'custom' : val
}
// Environment variable fallbacks for system config keys
const ENV_FALLBACKS: Record<string, string> = {
// AI providers (openrouter → custom)
AI_PROVIDER_TAGS: normalizeProvider(process.env.AI_PROVIDER_TAGS),
// AI providers
AI_PROVIDER_TAGS: process.env.AI_PROVIDER_TAGS || '',
AI_MODEL_TAGS: process.env.AI_MODEL_TAGS || '',
AI_PROVIDER_EMBEDDING: normalizeProvider(process.env.AI_PROVIDER_EMBEDDING),
AI_PROVIDER_EMBEDDING: process.env.AI_PROVIDER_EMBEDDING || '',
AI_MODEL_EMBEDDING: process.env.AI_MODEL_EMBEDDING || '',
AI_PROVIDER_CHAT: normalizeProvider(process.env.AI_PROVIDER_CHAT),
AI_PROVIDER_CHAT: process.env.AI_PROVIDER_CHAT || '',
AI_MODEL_CHAT: process.env.AI_MODEL_CHAT || '',
// Ollama
OLLAMA_BASE_URL: process.env.OLLAMA_BASE_URL || '',
// OpenAI
OPENAI_API_KEY: process.env.OPENAI_API_KEY || '',
// Custom OpenAI
CUSTOM_OPENAI_API_KEY: process.env.CUSTOM_OPENAI_API_KEY || '',
CUSTOM_OPENAI_BASE_URL: process.env.CUSTOM_OPENAI_BASE_URL || '',
// DeepSeek
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY || '',
// OpenRouter
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY || '',
// Mistral
MISTRAL_API_KEY: process.env.MISTRAL_API_KEY || '',
// Z.AI
ZAI_API_KEY: process.env.ZAI_API_KEY || '',
// LM Studio
LMSTUDIO_BASE_URL: process.env.LMSTUDIO_BASE_URL || '',
LMSTUDIO_API_KEY: process.env.LMSTUDIO_API_KEY || '',
// Email
EMAIL_PROVIDER: process.env.EMAIL_PROVIDER || (process.env.RESEND_API_KEY ? 'resend' : 'smtp'),
RESEND_API_KEY: process.env.RESEND_API_KEY || '',
@@ -51,11 +59,6 @@ export async function getSystemConfig() {
console.error('Failed to load system config from DB:', e)
}
// Normalise les valeurs openrouter → custom dans la DB aussi
for (const key of ['AI_PROVIDER_TAGS', 'AI_PROVIDER_EMBEDDING', 'AI_PROVIDER_CHAT']) {
if (dbConfig[key] === 'openrouter') dbConfig[key] = 'custom'
}
// Merge: DB values take precedence, env vars as fallback
const merged = { ...ENV_FALLBACKS, ...dbConfig }
return merged

View File

@@ -1,6 +1,9 @@
/**
* Detect user's preferred language from their existing notes
* Uses a single DB-level GROUP BY query — no note content is loaded
* Detect user's preferred language.
* Priority:
* 1. Most common language among user's notes (DB GROUP BY)
* 2. Browser language hint (passed from server component via Accept-Language)
* 3. Default: 'en'
*/
import { auth } from '@/auth'
@@ -10,10 +13,37 @@ import { SupportedLanguage } from './load-translations'
const SUPPORTED_LANGUAGES = new Set(['en', 'fr', 'es', 'de', 'fa', 'it', 'pt', 'ru', 'zh', 'ja', 'ko', 'ar', 'hi', 'nl', 'pl'])
/**
* Parse an Accept-Language header string and find the best matching supported language
*/
export function parseAcceptLanguage(acceptLanguage: string | null): SupportedLanguage | null {
if (!acceptLanguage) return null
// Parse Accept-Language: "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7"
const languages = acceptLanguage
.split(',')
.map(lang => {
const [code, q] = lang.trim().split(';q=')
return { code: code.trim().toLowerCase(), quality: q ? parseFloat(q) : 1.0 }
})
.sort((a, b) => b.quality - a.quality)
for (const { code } of languages) {
if (SUPPORTED_LANGUAGES.has(code)) {
return code as SupportedLanguage
}
const base = code.split('-')[0]
if (SUPPORTED_LANGUAGES.has(base)) {
return base as SupportedLanguage
}
}
return null
}
const getCachedUserLanguage = unstable_cache(
async (userId: string): Promise<SupportedLanguage> => {
async (userId: string): Promise<SupportedLanguage | null> => {
try {
// Single aggregated query — no notes are fetched, only language counts
const result = await prisma.note.groupBy({
by: ['language'],
where: {
@@ -33,22 +63,39 @@ const getCachedUserLanguage = unstable_cache(
}
}
return 'en'
return null
} catch (error) {
console.error('Error detecting user language:', error)
return 'en'
console.error('Error detecting user language from notes:', error)
return null
}
},
['user-language'],
{ tags: ['user-language'] }
)
export async function detectUserLanguage(): Promise<SupportedLanguage> {
/**
* Detect user language.
* @param browserLanguageHint - Optional browser language parsed from Accept-Language header.
* Should be passed from server components that have access to headers().
*/
export async function detectUserLanguage(browserLanguageHint?: SupportedLanguage | null): Promise<SupportedLanguage> {
const session = await auth()
if (!session?.user?.id) {
return 'en'
return browserLanguageHint || 'en'
}
return getCachedUserLanguage(session.user.id)
// 1. Try to detect from user's notes
const noteLanguage = await getCachedUserLanguage(session.user.id)
if (noteLanguage) {
return noteLanguage
}
// 2. Fall back to browser language hint
if (browserLanguageHint) {
return browserLanguageHint
}
// 3. Default
return 'en'
}

View File

@@ -0,0 +1,225 @@
/**
* Server-side Markdown → HTML converter.
* Converts AI-generated markdown notes into TipTap-compatible rich text HTML.
* Uses a lightweight regex-based approach to avoid heavy remark/rehype dependencies.
*
* Handles: headings, bold, italic, strikethrough, code blocks, inline code,
* links, images, lists (ul/ol), blockquotes, horizontal rules, tables, paragraphs.
*/
export function markdownToHtml(markdown: string): string {
if (!markdown || !markdown.trim()) return ''
let html = markdown
// Escape HTML entities (but preserve markdown)
html = html.replace(/&/g, '&amp;')
html = html.replace(/</g, '&lt;')
html = html.replace(/>/g, '&gt;')
// Code blocks (``` ... ```) — protect from further processing
const codeBlocks: string[] = []
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
const idx = codeBlocks.length
codeBlocks.push(`<pre><code class="language-${lang || 'plaintext'}">${code.trim()}</code></pre>`)
return `%%CODEBLOCK_${idx}%%`
})
// Inline code (`...`)
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
// Images (![alt](url))
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
// Links ([text](url))
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
// Headings (h1-h6)
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>')
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>')
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>')
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>')
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>')
// Bold (**text** or __text__)
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>')
// Italic (*text* or _text_)
html = html.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
html = html.replace(/(?<!_)_([^_]+)_(?!_)/g, '<em>$1</em>')
// Strikethrough (~~text~~)
html = html.replace(/~~([^~]+)~~/g, '<s>$1</s>')
// Horizontal rules (---, ***, ___)
html = html.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '<hr />')
// Tables
html = convertTables(html)
// Blockquotes (> text)
html = html.replace(/^&gt;\s+(.+)$/gm, '<blockquote><p>$1</p></blockquote>')
// Merge consecutive blockquotes
html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n')
// Unordered lists (- item or * item)
html = convertUnorderedLists(html)
// Ordered lists (1. item)
html = convertOrderedLists(html)
// Restore code blocks
codeBlocks.forEach((block, idx) => {
html = html.replace(`%%CODEBLOCK_${idx}%%`, block)
})
// Paragraphs — wrap remaining loose text in <p> tags
html = wrapParagraphs(html)
// Clean up empty paragraphs
html = html.replace(/<p>\s*<\/p>/g, '')
return html.trim()
}
function convertTables(html: string): string {
// Simple table conversion: | header | header |\n| --- | --- |\n| cell | cell |
const tableRegex = /(?:^|\n)((?:\|[^\n]+\|\n)+)/g
return html.replace(tableRegex, (match) => {
const rows = match.trim().split('\n').filter(r => r.trim())
if (rows.length < 2) return match
// Check if second row is separator
const separator = rows[1].trim()
if (!/^[\s|:-]+$/.test(separator)) return match
let table = '<table>'
// Header row
const headers = parseTableRow(rows[0])
if (headers.length > 0) {
table += '<thead><tr>'
headers.forEach(h => { table += `<th>${h}</th>` })
table += '</tr></thead>'
}
// Body rows (skip separator)
const bodyRows = rows.slice(2)
if (bodyRows.length > 0) {
table += '<tbody>'
bodyRows.forEach(row => {
const cells = parseTableRow(row)
table += '<tr>'
cells.forEach(c => { table += `<td>${c}</td>` })
table += '</tr>'
})
table += '</tbody>'
}
table += '</table>'
return '\n' + table + '\n'
})
}
function parseTableRow(row: string): string[] {
return row.split('|')
.map(cell => cell.trim())
.filter((_, i, arr) => i > 0 && i < arr.length) // Skip first and last empty from leading/trailing |
}
function convertUnorderedLists(html: string): string {
const lines = html.split('\n')
const result: string[] = []
let inList = false
for (const line of lines) {
const listMatch = line.match(/^(\s*)[-*]\s+(.+)$/)
if (listMatch) {
if (!inList) {
result.push('<ul>')
inList = true
}
result.push(`<li>${listMatch[2]}</li>`)
} else {
if (inList) {
result.push('</ul>')
inList = false
}
result.push(line)
}
}
if (inList) result.push('</ul>')
return result.join('\n')
}
function convertOrderedLists(html: string): string {
const lines = html.split('\n')
const result: string[] = []
let inList = false
for (const line of lines) {
const listMatch = line.match(/^(\s*)\d+\.\s+(.+)$/)
if (listMatch) {
if (!inList) {
result.push('<ol>')
inList = true
}
result.push(`<li>${listMatch[2]}</li>`)
} else {
if (inList) {
result.push('</ol>')
inList = false
}
result.push(line)
}
}
if (inList) result.push('</ol>')
return result.join('\n')
}
function wrapParagraphs(html: string): string {
const blockTags = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'p', 'div', 'img'])
const lines = html.split('\n')
const result: string[] = []
let buffer: string[] = []
const flushBuffer = () => {
const text = buffer.join('\n').trim()
if (text) {
// Don't double-wrap if already starts with a block tag
const firstTag = text.match(/^<(\w+)/)
if (firstTag && blockTags.has(firstTag[1].toLowerCase())) {
result.push(text)
} else {
result.push(`<p>${text}</p>`)
}
}
buffer = []
}
for (const line of lines) {
const trimmed = line.trim()
// Check if this line is a block-level element
const isBlockLine = trimmed.startsWith('<') && (() => {
const tag = trimmed.match(/^<(\w+)/)
return tag ? blockTags.has(tag[1].toLowerCase()) : false
})()
if (isBlockLine || trimmed === '') {
flushBuffer()
if (isBlockLine) result.push(trimmed)
} else {
buffer.push(trimmed)
}
}
flushBuffer()
return result.join('\n')
}

View File

@@ -837,6 +837,11 @@
"providerOllamaOption": "🦙 Ollama (Local & Free)",
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
"providerDeepSeekOption": "🔍 DeepSeek",
"providerOpenRouterOption": "🌐 OpenRouter",
"providerMistralOption": "🌀 Mistral AI",
"providerZAIOption": "✨ Z.AI",
"providerLMStudioOption": "🖥️ LM Studio (محلي)",
"bestValue": "أفضل قيمة",
"bestQuality": "أفضل جودة",
"saved": "(تم الحفظ)",
@@ -1175,7 +1180,11 @@
"notesViewLabel": "عرض الملاحظات",
"notesViewTabs": "علامات تبويب (نمط OneNote)",
"notesViewMasonry": "بطاقات (شبكة)",
"selectTheme": "Select theme"
"selectTheme": "Select theme",
"fontFamilyLabel": "عائلة الخطوط",
"fontFamilyDescription": "اختر الخط المستخدم في جميع أنحاء التطبيق",
"selectFontFamily": "Inter مُحسّن لسهولة القراءة، النظام يستخدم الخط الأصلي لنظام التشغيل",
"fontSystem": "الخط الافتراضي للنظام"
},
"generalSettings": {
"title": "الإعدادات العامة",

View File

@@ -837,6 +837,11 @@
"providerOllamaOption": "🦙 Ollama (Local & Free)",
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
"providerDeepSeekOption": "🔍 DeepSeek",
"providerOpenRouterOption": "🌐 OpenRouter",
"providerMistralOption": "🌀 Mistral AI",
"providerZAIOption": "✨ Z.AI",
"providerLMStudioOption": "🖥️ LM Studio (Lokal)",
"bestValue": "Bestes Preis-Leistungs-Verhältnis",
"bestQuality": "Beste Qualität",
"saved": "(Gespeichert)",
@@ -1175,7 +1180,11 @@
"notesViewLabel": "Notizen-Ansicht",
"notesViewTabs": "Tabs (OneNote-Stil)",
"notesViewMasonry": "Karten (Raster)",
"selectTheme": "Select theme"
"selectTheme": "Select theme",
"fontFamilyLabel": "Schriftfamilie",
"fontFamilyDescription": "Wählen Sie die im gesamten Programm verwendete Schriftart",
"selectFontFamily": "Inter ist für Lesbarkeit optimiert, System verwendet die native Schriftart Ihres Betriebssystems",
"fontSystem": "Standardsystemschriftart"
},
"generalSettings": {
"title": "Allgemeine Einstellungen",

View File

@@ -841,6 +841,11 @@
"providerOllamaOption": "🦙 Ollama (Local & Free)",
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
"providerDeepSeekOption": "🔍 DeepSeek",
"providerOpenRouterOption": "🌐 OpenRouter",
"providerMistralOption": "🌀 Mistral AI",
"providerZAIOption": "✨ Z.AI",
"providerLMStudioOption": "🖥️ LM Studio (Local)",
"bestValue": "Best value",
"bestQuality": "Best quality",
"saved": "(Saved)",
@@ -1179,7 +1184,11 @@
"notesViewLabel": "Notes layout",
"notesViewTabs": "Tabs (OneNote-style)",
"notesViewMasonry": "Cards (grid)",
"selectTheme": "Select theme"
"selectTheme": "Select theme",
"fontFamilyLabel": "Font Family",
"fontFamilyDescription": "Choose the font used throughout the app",
"selectFontFamily": "Inter is optimized for readability, System uses your OS native font",
"fontSystem": "System Default"
},
"generalSettings": {
"title": "General Settings",

View File

@@ -837,6 +837,11 @@
"providerOllamaOption": "🦙 Ollama (Local & Free)",
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
"providerDeepSeekOption": "🔍 DeepSeek",
"providerOpenRouterOption": "🌐 OpenRouter",
"providerMistralOption": "🌀 Mistral AI",
"providerZAIOption": "✨ Z.AI",
"providerLMStudioOption": "🖥️ LM Studio (Local)",
"bestValue": "Mejor relación calidad/precio",
"bestQuality": "Mejor calidad",
"saved": "(Guardado)",
@@ -1175,7 +1180,11 @@
"notesViewLabel": "Vista de notas",
"notesViewTabs": "Pestañas (estilo OneNote)",
"notesViewMasonry": "Tarjetas (cuadrícula)",
"selectTheme": "Select theme"
"selectTheme": "Select theme",
"fontFamilyLabel": "Familia de fuentes",
"fontFamilyDescription": "Elige la fuente utilizada en toda la aplicación",
"selectFontFamily": "Inter está optimizado para la legibilidad, Sistema usa la fuente nativa de tu sistema operativo",
"fontSystem": "Fuente del sistema predeterminada"
},
"generalSettings": {
"title": "Configuración general",

View File

@@ -837,6 +837,11 @@
"providerOllamaOption": "🦙 Ollama (محلی و رایگان)",
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
"providerCustomOption": "🔧 سفارشی سازگار با OpenAI",
"providerDeepSeekOption": "🔍 DeepSeek",
"providerOpenRouterOption": "🌐 OpenRouter",
"providerMistralOption": "🌀 Mistral AI",
"providerZAIOption": "✨ Z.AI",
"providerLMStudioOption": "🖥️ LM Studio (محلی)",
"bestValue": "بهترین ارزش",
"bestQuality": "بهترین کیفیت",
"saved": "(ذخیره شد)",
@@ -1175,7 +1180,11 @@
"notesViewLabel": "چیدمان یادداشت‌ها",
"notesViewTabs": "زبانه‌ها (سبک OneNote)",
"notesViewMasonry": "کارت‌ها (شبکه‌ای)",
"selectTheme": "Select theme"
"selectTheme": "Select theme",
"fontFamilyLabel": "خانواده فونت",
"fontFamilyDescription": "فونت استفاده شده در سراسر برنامه را انتخاب کنید",
"selectFontFamily": "Inter برای خوانایی بهینه شده است، سیستم از فونت بومی سیستم‌عامل شما استفاده می‌کند",
"fontSystem": "فونت پیش‌فرض سیستم"
},
"generalSettings": {
"title": "تنظیمات عمومی",

View File

@@ -841,6 +841,11 @@
"providerOllamaOption": "🦙 Ollama (Local & Gratuit)",
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
"providerCustomOption": "🔧 Custom Compatible OpenAI",
"providerDeepSeekOption": "🔍 DeepSeek",
"providerOpenRouterOption": "🌐 OpenRouter",
"providerMistralOption": "🌀 Mistral AI",
"providerZAIOption": "✨ Z.AI",
"providerLMStudioOption": "🖥️ LM Studio (Local)",
"bestValue": "Meilleur rapport qualité/prix",
"bestQuality": "Meilleure qualité",
"saved": "(Enregistré)",
@@ -1179,7 +1184,11 @@
"notesViewLabel": "Affichage des notes",
"notesViewTabs": "Onglets (type OneNote)",
"notesViewMasonry": "Cartes (grille)",
"selectTheme": "Sélectionner le thème"
"selectTheme": "Sélectionner le thème",
"fontFamilyLabel": "Famille de polices",
"fontFamilyDescription": "Choisissez la police utilisée dans toute l'application",
"selectFontFamily": "Inter est optimisé pour la lisibilité, Système utilise la police native de votre système d'exploitation",
"fontSystem": "Police système par défaut"
},
"generalSettings": {
"title": "Paramètres généraux",

View File

@@ -837,6 +837,11 @@
"providerOllamaOption": "🦙 Ollama (Local & Free)",
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
"providerDeepSeekOption": "🔍 DeepSeek",
"providerOpenRouterOption": "🌐 OpenRouter",
"providerMistralOption": "🌀 Mistral AI",
"providerZAIOption": "✨ Z.AI",
"providerLMStudioOption": "🖥️ LM Studio (स्थानीय)",
"bestValue": "सर्वोत्तम मूल्य",
"bestQuality": "सर्वोत्तम गुणवत्ता",
"saved": "(सहेजा गया)",
@@ -1175,7 +1180,11 @@
"notesViewLabel": "नोट्स दृश्य",
"notesViewTabs": "टैब (OneNote-शैली)",
"notesViewMasonry": "कार्ड (ग्रिड)",
"selectTheme": "Select theme"
"selectTheme": "Select theme",
"fontFamilyLabel": "फ़ॉन्ट परिवार",
"fontFamilyDescription": "पूरे ऐप में उपयोग किए जाने वाले फ़ॉन्ट का चयन करें",
"selectFontFamily": "Inter पठनीयता के लिए अनुकूलित है, सिस्टम आपके OS के मूल फ़ॉन्ट का उपयोग करता है",
"fontSystem": "सिस्टम डिफ़ॉल्ट"
},
"generalSettings": {
"title": "सामान्य सेटिंग्स",

View File

@@ -837,6 +837,11 @@
"providerOllamaOption": "🦙 Ollama (Local & Free)",
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
"providerDeepSeekOption": "🔍 DeepSeek",
"providerOpenRouterOption": "🌐 OpenRouter",
"providerMistralOption": "🌀 Mistral AI",
"providerZAIOption": "✨ Z.AI",
"providerLMStudioOption": "🖥️ LM Studio (Locale)",
"bestValue": "Miglior rapporto qualità/prezzo",
"bestQuality": "Miglior qualità",
"saved": "(Salvato)",
@@ -1175,7 +1180,11 @@
"notesViewLabel": "Vista note",
"notesViewTabs": "Schede (stile OneNote)",
"notesViewMasonry": "Schede (griglia)",
"selectTheme": "Select theme"
"selectTheme": "Select theme",
"fontFamilyLabel": "Famiglia di caratteri",
"fontFamilyDescription": "Scegli il carattere utilizzato in tutta l'app",
"selectFontFamily": "Inter è ottimizzato per la leggibilità, Sistema usa il carattere nativo del tuo sistema operativo",
"fontSystem": "Carattere predefinito di sistema"
},
"generalSettings": {
"title": "Impostazioni generali",

View File

@@ -837,6 +837,11 @@
"providerOllamaOption": "🦙 Ollama (Local & Free)",
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
"providerDeepSeekOption": "🔍 DeepSeek",
"providerOpenRouterOption": "🌐 OpenRouter",
"providerMistralOption": "🌀 Mistral AI",
"providerZAIOption": "✨ Z.AI",
"providerLMStudioOption": "🖥️ LM Studio (ローカル)",
"bestValue": "最もコスパが良い",
"bestQuality": "最高品質",
"saved": "(保存済み)",
@@ -1175,7 +1180,11 @@
"notesViewLabel": "ノートのレイアウト",
"notesViewTabs": "タブOneNote風",
"notesViewMasonry": "カード(グリッド)",
"selectTheme": "Select theme"
"selectTheme": "Select theme",
"fontFamilyLabel": "フォントファミリー",
"fontFamilyDescription": "アプリ全体で使用するフォントを選択してください",
"selectFontFamily": "Inter は読みやすさに最適化されています。システムはOSのネイティブフォントを使用します",
"fontSystem": "システムデフォルト"
},
"generalSettings": {
"title": "一般設定",

View File

@@ -837,6 +837,11 @@
"providerOllamaOption": "🦙 Ollama (로컬 및 무료)",
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
"providerCustomOption": "🔧 사용자 정의 OpenAI 호환",
"providerDeepSeekOption": "🔍 DeepSeek",
"providerOpenRouterOption": "🌐 OpenRouter",
"providerMistralOption": "🌀 Mistral AI",
"providerZAIOption": "✨ Z.AI",
"providerLMStudioOption": "🖥️ LM Studio (로컬)",
"bestValue": "최고 가성비",
"bestQuality": "최고 품질",
"saved": "(저장됨)",
@@ -1175,7 +1180,11 @@
"notesViewLabel": "메모 레이아웃",
"notesViewTabs": "탭 (OneNote 스타일)",
"notesViewMasonry": "카드 (그리드)",
"selectTheme": "Select theme"
"selectTheme": "Select theme",
"fontFamilyLabel": "글꼴 패밀리",
"fontFamilyDescription": "앱 전체에서 사용할 글꼴을 선택하세요",
"selectFontFamily": "Inter는 가독성에 최적화되어 있으며, 시스템은 OS 기본 글꼴을 사용합니다",
"fontSystem": "시스템 기본 글꼴"
},
"generalSettings": {
"title": "일반 설정",

View File

@@ -837,6 +837,11 @@
"providerOllamaOption": "🦙 Ollama (Local & Free)",
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
"providerDeepSeekOption": "🔍 DeepSeek",
"providerOpenRouterOption": "🌐 OpenRouter",
"providerMistralOption": "🌀 Mistral AI",
"providerZAIOption": "✨ Z.AI",
"providerLMStudioOption": "🖥️ LM Studio (Lokaal)",
"bestValue": "Beste prijs-kwaliteit",
"bestQuality": "Beste kwaliteit",
"saved": "(Opgeslagen)",
@@ -1175,7 +1180,11 @@
"notesViewLabel": "Notities weergave",
"notesViewTabs": "Tabbladen (OneNote-stijl)",
"notesViewMasonry": "Kaarten (raster)",
"selectTheme": "Select theme"
"selectTheme": "Select theme",
"fontFamilyLabel": "Lettertypefamilie",
"fontFamilyDescription": "Kies het lettertype dat in de hele app wordt gebruikt",
"selectFontFamily": "Inter is geoptimaliseerd voor leesbaarheid, Systeem gebruikt het native lettertype van uw besturingssysteem",
"fontSystem": "Standaard systeemlettertype"
},
"generalSettings": {
"title": "Algemene instellingen",

View File

@@ -837,6 +837,11 @@
"providerOllamaOption": "🦙 Ollama (lokalny i darmowy)",
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
"providerCustomOption": "🔧 Niestandardowy (kompatybilny z OpenAI)",
"providerDeepSeekOption": "🔍 DeepSeek",
"providerOpenRouterOption": "🌐 OpenRouter",
"providerMistralOption": "🌀 Mistral AI",
"providerZAIOption": "✨ Z.AI",
"providerLMStudioOption": "🖥️ LM Studio (Lokalny)",
"bestValue": "Najlepszy stosunek jakości do ceny",
"bestQuality": "Najwyższa jakość",
"saved": "(Zapisano)",
@@ -1175,7 +1180,11 @@
"notesViewLabel": "Układ notatek",
"notesViewTabs": "Karty (styl OneNote)",
"notesViewMasonry": "Karty (siatka)",
"selectTheme": "Select theme"
"selectTheme": "Select theme",
"fontFamilyLabel": "Rodzina czcionek",
"fontFamilyDescription": "Wybierz czcionkę używaną w całej aplikacji",
"selectFontFamily": "Inter jest zoptymalizowany pod kątem czytelności, Systemowa używa natywnej czcionki systemu operacyjnego",
"fontSystem": "Domyślna czcionka systemowa"
},
"generalSettings": {
"title": "Ustawienia ogólne",

View File

@@ -837,6 +837,11 @@
"providerOllamaOption": "🦙 Ollama (Local e Gratuito)",
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
"providerCustomOption": "🔧 Compatível com OpenAI (Personalizado)",
"providerDeepSeekOption": "🔍 DeepSeek",
"providerOpenRouterOption": "🌐 OpenRouter",
"providerMistralOption": "🌀 Mistral AI",
"providerZAIOption": "✨ Z.AI",
"providerLMStudioOption": "🖥️ LM Studio (Local)",
"bestValue": "Melhor custo-benefício",
"bestQuality": "Melhor qualidade",
"saved": "(Salvo)",
@@ -1175,7 +1180,11 @@
"notesViewLabel": "Layout das notas",
"notesViewTabs": "Abas (estilo OneNote)",
"notesViewMasonry": "Cartões (grade)",
"selectTheme": "Select theme"
"selectTheme": "Select theme",
"fontFamilyLabel": "Família de fontes",
"fontFamilyDescription": "Escolha a fonte usada em todo o aplicativo",
"selectFontFamily": "Inter é otimizado para legibilidade, Sistema usa a fonte nativa do seu sistema operacional",
"fontSystem": "Fonte padrão do sistema"
},
"generalSettings": {
"title": "Configurações gerais",

View File

@@ -837,6 +837,11 @@
"providerOllamaOption": "🦙 Ollama (Локальный и бесплатный)",
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
"providerCustomOption": "🔧 Пользовательский (совместимый с OpenAI)",
"providerDeepSeekOption": "🔍 DeepSeek",
"providerOpenRouterOption": "🌐 OpenRouter",
"providerMistralOption": "🌀 Mistral AI",
"providerZAIOption": "✨ Z.AI",
"providerLMStudioOption": "🖥️ LM Studio (Локальный)",
"bestValue": "Лучшее соотношение цена/качество",
"bestQuality": "Лучшее качество",
"saved": "(Сохранено)",
@@ -1175,7 +1180,11 @@
"notesViewLabel": "Макет заметок",
"notesViewTabs": "Вкладки (в стиле OneNote)",
"notesViewMasonry": "Карточки (сетка)",
"selectTheme": "Select theme"
"selectTheme": "Select theme",
"fontFamilyLabel": "Семейство шрифтов",
"fontFamilyDescription": "Выберите шрифт, используемый во всём приложении",
"selectFontFamily": "Inter оптимизирован для читаемости, Системный использует нативный шрифт вашей ОС",
"fontSystem": "Системный шрифт по умолчанию"
},
"generalSettings": {
"title": "Общие настройки",

View File

@@ -837,6 +837,11 @@
"providerOllamaOption": "🦙 Ollama (Local & Free)",
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
"providerDeepSeekOption": "🔍 DeepSeek",
"providerOpenRouterOption": "🌐 OpenRouter",
"providerMistralOption": "🌀 Mistral AI",
"providerZAIOption": "✨ Z.AI",
"providerLMStudioOption": "🖥️ LM Studio (本地)",
"bestValue": "最佳性价比",
"bestQuality": "最佳质量",
"saved": "(已保存)",
@@ -1175,7 +1180,11 @@
"notesViewLabel": "笔记布局",
"notesViewTabs": "标签页OneNote 风格)",
"notesViewMasonry": "卡片(网格)",
"selectTheme": "Select theme"
"selectTheme": "Select theme",
"fontFamilyLabel": "字体系列",
"fontFamilyDescription": "选择应用程序中使用的字体",
"selectFontFamily": "Inter 针对可读性进行了优化,系统使用您操作系统的原生字体",
"fontSystem": "系统默认字体"
},
"generalSettings": {
"title": "常规设置",

View File

@@ -501,7 +501,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -548,7 +547,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -576,7 +574,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -640,6 +637,28 @@
"integrity": "sha512-OTFTDQcWS+1ZREOdCWuk5hCBgYO4OsD30lXcOCyVOAjXMhgL5rBRDnt/otb6Nz8CzU0L/igdcaQBDLWc4t9gvg==",
"license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -1601,7 +1620,6 @@
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
@@ -2357,337 +2375,12 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.6",
"@parcel/watcher-darwin-arm64": "2.5.6",
"@parcel/watcher-darwin-x64": "2.5.6",
"@parcel/watcher-freebsd-x64": "2.5.6",
"@parcel/watcher-linux-arm-glibc": "2.5.6",
"@parcel/watcher-linux-arm-musl": "2.5.6",
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
"@parcel/watcher-linux-arm64-musl": "2.5.6",
"@parcel/watcher-linux-x64-glibc": "2.5.6",
"@parcel/watcher-linux-x64-musl": "2.5.6",
"@parcel/watcher-win32-arm64": "2.5.6",
"@parcel/watcher-win32-ia32": "2.5.6",
"@parcel/watcher-win32-x64": "2.5.6"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright": "1.59.1"
},
@@ -2714,7 +2407,6 @@
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=16.13"
},
@@ -6215,7 +5907,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz",
"integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -6477,7 +6168,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz",
"integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -6650,7 +6340,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.22.5.tgz",
"integrity": "sha512-jt63jy8YbhZJUGMxTUzeivLhowGtFp6YbCFrrmZJ7G6IHu8X8LJzO81ksz5nT5l8DKpldGwnINUfA6iE91JIAg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -6690,7 +6379,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz",
"integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -6705,7 +6393,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz",
"integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-commands": "^1.6.2",
@@ -7215,7 +6902,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -7225,7 +6911,6 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -7286,7 +6971,6 @@
"integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.4",
@@ -7632,7 +7316,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -7804,7 +7487,6 @@
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
@@ -8065,7 +7747,6 @@
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.2.tgz",
"integrity": "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10"
}
@@ -8475,7 +8156,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -9570,7 +9250,6 @@
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz",
"integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.20.0"
},
@@ -10546,7 +10225,6 @@
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@chevrotain/cst-dts-gen": "12.0.0",
"@chevrotain/gast": "12.0.0",
@@ -11348,14 +11026,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/node-releases": {
"version": "2.0.37",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
@@ -11367,7 +11037,6 @@
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
"license": "MIT-0",
"peer": true,
"engines": {
"node": ">=6.0.0"
}
@@ -11426,7 +11095,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -11523,7 +11191,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz",
"integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -11843,7 +11510,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
"integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -11883,7 +11549,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@@ -12357,7 +12022,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -12392,7 +12056,6 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -12414,7 +12077,6 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/engines": "5.22.0"
},
@@ -12432,7 +12094,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -12565,7 +12226,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@@ -12595,7 +12255,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@@ -12656,7 +12315,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@@ -12857,7 +12515,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -12877,7 +12534,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -13595,8 +13251,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.3.tgz",
"integrity": "sha512-fA/NX5gMf0ooCLISgB0wScaWgaj6rjTN2SVAwleURjiya7ITNkV+VMmoHtKkldP6CIZoYCZyxb8zP/e2TWoEtQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.2",
@@ -13681,7 +13336,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14185,7 +13839,6 @@
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.1.4",
"@vitest/mocker": "4.1.4",
@@ -14297,23 +13950,6 @@
}
}
},
"node_modules/vitest/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/vitest/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -14329,14 +13965,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/vitest/node_modules/immutable": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
@@ -14350,28 +13978,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitest/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/vitest/node_modules/vite": {
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -14646,7 +14258,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "Notification" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"message" TEXT,
"read" BOOLEAN NOT NULL DEFAULT false,
"actionUrl" TEXT,
"relatedId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Notification_userId_read_idx" ON "Notification"("userId", "read");
-- CreateIndex
CREATE INDEX "Notification_userId_createdAt_idx" ON "Notification"("userId", "createdAt");
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "UserAISettings" ADD COLUMN "fontFamily" TEXT NOT NULL DEFAULT 'inter';

View File

@@ -37,6 +37,7 @@ model User {
sessions Session[]
aiSettings UserAISettings?
workflows Workflow[]
notifications Notification[]
}
model Account {
@@ -278,6 +279,7 @@ model UserAISettings {
noteHistory Boolean @default(false)
noteHistoryMode String @default("manual")
languageDetection Boolean @default(true)
fontFamily String @default("inter")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([memoryEcho])
@@ -409,3 +411,19 @@ model WorkflowRun {
@@index([workflowId])
@@index([status])
}
model Notification {
id String @id @default(cuid())
userId String
type String // "agent_success", "agent_failure", "share", "reminder", "system"
title String
message String?
read Boolean @default(false)
actionUrl String? // e.g. "/agents" or "/notes/xxx"
relatedId String? // e.g. agentId or noteId
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, read])
@@index([userId, createdAt])
}