fix(ui): thème, textes et lisibilité restants de la revue
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m57s
CI / Deploy production (on server) (push) Successful in 24s

Les boutons suivent la couleur d’apparence, les libellés trop petits ou trop techniques sont clarifiés, et le catalogue des fournisseurs se met à jour tout seul.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-08-30 21:13:07 +00:00
parent ebf6f16fde
commit afbb0dfc2d
77 changed files with 2842 additions and 1546 deletions

View File

@@ -103,7 +103,7 @@ export default function InsightsPage() {
const [isStale, setIsStale] = useState(false)
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard')
const [graphMode, setGraphMode] = useState<'visual' | 'list'>('visual')
const [graphMode, setGraphMode] = useState<'visual' | 'list'>('list')
const [listFilter, setListFilter] = useState('')
const [listSort, setListSort] = useState<'size' | 'alpha' | 'bridges'>('size')
/** Un seul cluster déplié à la fois (null = tous repliés) */
@@ -445,7 +445,7 @@ export default function InsightsPage() {
<button
className="p-2 -ms-1 text-foreground hover:bg-foreground/5 rounded-lg transition-colors shrink-0 cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
onClick={() => window.dispatchEvent(new CustomEvent('toggle-insights-sidebar'))}
aria-label="Toggle sidebar"
aria-label={t('insightsView.toggleMenu')}
>
<Menu size={22} />
</button>
@@ -717,7 +717,7 @@ export default function InsightsPage() {
style={{ backgroundColor: cluster.color }}
aria-hidden
/>
<h3 className="text-xs font-bold uppercase tracking-wider text-ink dark:text-dark-ink truncate flex-1 min-w-0">
<h3 className="min-w-0 flex-1 text-[13px] font-semibold leading-snug text-ink dark:text-dark-ink">
{name}
</h3>
<span className="text-[9px] text-concrete shrink-0 tabular-nums">

View File

@@ -5,7 +5,7 @@ import { BillingPlans } from '@/components/settings/billing-plans';
export const dynamic = 'force-dynamic';
export const metadata = {
title: 'Billing',
title: 'Facturation — Memento',
};
function Fallback() {

View File

@@ -2,6 +2,7 @@
import { Menu } from 'lucide-react'
import { SettingsNav } from '@/components/settings'
import { SettingsDocumentTitle } from '@/components/settings/settings-document-title'
import { useLanguage } from '@/lib/i18n'
export default function SettingsLayout({
@@ -11,9 +12,10 @@ export default function SettingsLayout({
}) {
const { t } = useLanguage()
return (
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-zinc-950">
<header className="px-4 sm:px-8 md:px-12 pt-8 sm:pt-14 md:pt-20 pb-6 sm:pb-10 md:pb-16 space-y-6 sm:space-y-10 md:space-y-12 shrink-0">
<div className="flex items-start gap-3">
<div className="flex flex-col h-full bg-background">
<SettingsDocumentTitle />
<header className="px-4 sm:px-8 md:px-12 pt-6 sm:pt-8 md:pt-10 pb-4 sm:pb-6 shrink-0">
<div className="flex items-start gap-3 mb-6">
<button
className="md:hidden p-2 -ms-1 text-ink dark:text-zinc-200 hover:bg-ink/5 dark:hover:bg-white/10 rounded-lg transition-colors shrink-0 mt-1"
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
@@ -22,10 +24,10 @@ export default function SettingsLayout({
<Menu size={22} />
</button>
<div>
<h1 className="text-3xl sm:text-5xl md:text-[64px] font-serif text-ink dark:text-zinc-50 tracking-tight leading-none italic font-medium">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-serif text-ink dark:text-zinc-50 tracking-tight leading-tight font-medium">
{t('settings.title')}
</h1>
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete dark:text-zinc-500 opacity-60 mt-4">
<p className="text-sm text-concrete dark:text-zinc-400 mt-1.5">
{t('settings.description')}
</p>
</div>

View File

@@ -15,7 +15,7 @@ export function McpSettingsHeader() {
{ text: t('mcpSettings.helpBox.step2') },
{ text: t('mcpSettings.helpBox.step3') },
{ text: t('mcpSettings.helpBox.step4'), link: { label: t('mcpSettings.helpBox.step4Link'), href: 'https://modelcontextprotocol.io/docs' } },
{ icon: '⚡', text: t('mcpSettings.helpBox.step5') },
{ text: t('mcpSettings.helpBox.step5') },
]}
/>
)

View File

@@ -1,19 +1,28 @@
'use client'
import { motion } from 'motion/react'
import { Shield } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Check } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useLanguage } from '@/lib/i18n'
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
import { useState } from 'react'
export default function PricingPage() {
const { t } = useLanguage()
const router = useRouter()
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
const trialDays = SUBSCRIPTION_TRIAL_DAYS
const { data: byokCatalog } = useQuery({
queryKey: ['public', 'byok-catalog'],
queryFn: async () => {
const res = await fetch('/api/public/byok-catalog')
if (!res.ok) throw new Error('catalog')
return res.json() as Promise<{ providers: { id: string }[] }>
},
staleTime: 60_000,
})
const providerCount = byokCatalog?.providers.length || '…'
const PLANS = [
const plans = [
{ key: 'basic', popular: false, hasTrial: false, price: t('landing.pricing.basicPrice'), period: '' },
{
key: 'pro',
@@ -39,88 +48,113 @@ export default function PricingPage() {
]
return (
<main className="min-h-screen bg-paper">
<section className="py-32 px-8">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-12">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.pricing.label')}</span>
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-6">{t('landing.pricing.title')}</h2>
<p className="text-concrete font-light max-w-xl mx-auto mb-12">{t('landing.pricing.desc')}</p>
<main className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white">
<nav className="sticky top-0 z-[100] px-5 sm:px-8 py-4 flex items-center justify-between bg-[#0B0A09]/70 backdrop-blur-xl border-b border-white/[0.06]">
<Link href="/" className="flex items-center gap-2.5 group">
<div className="w-9 h-9 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-lg">
<span className="font-serif text-xl font-bold leading-none">M</span>
</div>
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
</Link>
<div className="flex items-center gap-2 sm:gap-3">
<Link href="/login" className="text-[13px] text-white/75 hover:text-white transition-colors px-2">
{t('landing.nav.login')}
</Link>
<Link
href="/register"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[13px] font-semibold hover:bg-white transition-colors"
>
{t('landing.nav.cta')}
</Link>
</div>
</nav>
<div className="flex items-center justify-center gap-10 mb-8">
<button onClick={() => setBillingInterval('monthly')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'monthly' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.monthly')}</span>
{billingInterval === 'monthly' && (
<motion.div layoutId="interval-active-pricing" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
)}
<section className="px-5 sm:px-8 py-28">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-[#D4A373] mb-4 block">
{t('landing.pricing.label')}
</span>
<h1 className="font-serif text-3xl sm:text-5xl tracking-tight mb-4">{t('landing.pricing.title')}</h1>
<p className="text-white/70 mb-8">{t('landing.pricing.desc')}</p>
<div className="inline-flex p-1 rounded-full border border-white/10 bg-white/[0.03]">
<button
type="button"
onClick={() => setBillingInterval('monthly')}
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all ${billingInterval === 'monthly' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/70'}`}
>
{t('landing.pricing.monthly')}
</button>
<button
type="button"
onClick={() => setBillingInterval('annual')}
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all relative ${billingInterval === 'annual' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/70'}`}
>
{t('landing.pricing.annual')}
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373] whitespace-nowrap">
{t('landing.pricing.savePercent')}
</span>
</button>
<div className="relative">
<button onClick={() => setBillingInterval('annual')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'annual' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.annual')}</span>
{billingInterval === 'annual' && (
<motion.div layoutId="interval-active-pricing" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
)}
</button>
<div className="absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap">
<span className="text-[9px] font-bold text-ochre uppercase tracking-widest italic animate-pulse">
{t('landing.pricing.savePercent')}
</span>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
{PLANS.map((plan) => (
<div key={plan.key} className={`relative p-8 rounded-[32px] border flex flex-col transition-all duration-300 hover:shadow-2xl hover:shadow-ink/5 ${plan.popular ? 'bg-ink text-paper border-ink ring-4 ring-ochre/20' : 'bg-white border-border text-ink'}`}>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{plans.map((plan) => (
<div
key={plan.key}
className={`rounded-2xl border p-6 flex flex-col ${
plan.popular
? 'border-[#D4A373]/50 bg-[#D4A373]/10'
: 'border-white/[0.08] bg-white/[0.02]'
}`}
>
{plan.popular && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1 bg-ochre text-ink text-[10px] font-bold uppercase tracking-widest rounded-full">
<span className="text-[10px] font-bold uppercase tracking-widest text-[#D4A373] mb-3">
{t('landing.pricing.popular')}
</div>
</span>
)}
<div className="mb-8">
<h4 className="text-[11px] font-bold uppercase tracking-widest mb-2 opacity-60">{t(`landing.pricing.${plan.key}.name`)}</h4>
<div className="flex items-baseline gap-1 mb-4">
<span className="text-4xl font-serif font-medium">{plan.price}</span>
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>}
</div>
{plan.hasTrial && (
<p className={`text-[11px] font-semibold mb-3 ${plan.popular ? 'text-ochre' : 'text-brand-accent'}`}>
{t('landing.pricing.trialBadge', { days: trialDays })}
</p>
)}
<p className="text-sm font-light leading-relaxed opacity-80">{t(`landing.pricing.${plan.key}.desc`)}</p>
<h2 className="text-[13px] font-medium tracking-wide text-white/80 mb-2">
{t(`landing.pricing.${plan.key}.name`)}
</h2>
<div className="flex items-baseline gap-1 mb-2">
<span className="text-3xl font-serif">{plan.price}</span>
{plan.period && <span className="text-sm text-white/70">{plan.period}</span>}
</div>
<div className="flex-1 space-y-4 mb-10">
{plan.hasTrial && (
<p className="text-[11px] font-semibold text-[#D4A373] mb-3">
{t('landing.pricing.trialBadge', { days: trialDays })}
</p>
)}
<p className="text-sm text-white/70 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
<ul className="space-y-2.5 mb-8 flex-1">
{plan.hasTrial && (
<div className="flex items-start gap-3">
<div className={`mt-1 rounded-full p-0.5 ${plan.popular ? 'bg-ochre text-ink' : 'bg-brand-accent/10 text-brand-accent'}`}>
<Shield size={10} fill="currentColor" />
</div>
<span className="text-xs font-medium">{t('landing.pricing.trialFeature', { days: trialDays })}</span>
</div>
<li className="flex gap-2 text-xs text-[#D4A373]/90">
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
{t('landing.pricing.trialFeature', { days: trialDays })}
</li>
)}
{[0, 1, 2, 3, 4, 5].map(j => {
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null
{[0, 1, 2, 3, 4, 5].map((j) => {
const feat = t(`landing.pricing.${plan.key}.feature${j}`, { count: providerCount })
if (!feat || feat.startsWith('landing.')) return null
return (
<div key={j} className="flex items-start gap-3">
<div className={`mt-1 rounded-full p-0.5 ${plan.popular ? 'bg-ochre text-ink' : 'bg-brand-accent/10 text-brand-accent'}`}>
<Shield size={10} fill="currentColor" />
</div>
<span className="text-xs font-light">{feat}</span>
</div>
<li key={j} className="flex gap-2 text-sm text-white/80">
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
{feat}
</li>
)
})}
</div>
<button
onClick={() => router.push('/register')}
className={`w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-widest transition-all ${plan.popular ? 'bg-ochre text-ink hover:opacity-90' : 'bg-ink text-paper hover:bg-ink/90'}`}
</ul>
<Link
href="/register"
className={`py-3 rounded-xl text-center text-[13px] font-semibold transition-colors ${
plan.popular
? 'bg-[#F4F1EA] text-[#0B0A09] hover:bg-white'
: 'bg-white/10 text-white hover:bg-white/15'
}`}
>
{plan.hasTrial
? t('landing.pricing.trialCta', { days: trialDays })
: t(`landing.pricing.${plan.key}.cta`)}
</button>
</Link>
</div>
))}
</div>

View File

@@ -368,11 +368,11 @@ export default async function PrivacyPage({
{locale === 'fr' ? 'Confidentialité' : 'Privacy'}
</p>
<h1 className="font-serif text-4xl sm:text-5xl tracking-tight mb-4">{doc.title}</h1>
<p className="text-sm text-white/40 mb-3">{doc.lastUpdated}</p>
<p className="text-sm text-white/70 mb-3">{doc.lastUpdated}</p>
<p className="text-white/65 leading-relaxed text-lg mb-12">{doc.intro}</p>
<nav aria-label="Sommaire" className="mb-12 p-5 rounded-xl bg-white/[0.03] border border-white/[0.06]">
<p className="text-[11px] uppercase tracking-[0.2em] text-white/40 mb-3">
<p className="text-[13px] font-medium tracking-wide text-white/70 mb-3">
{locale === 'fr' ? 'Sommaire' : 'Contents'}
</p>
<ul className="space-y-1.5 text-sm">

View File

@@ -6,6 +6,7 @@ import { auth } from '@/auth'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { SubscriptionTier, SubscriptionStatus } from '@prisma/client'
import { addUtcMonths } from '@/lib/billing/period'
// Schema pour la création d'utilisateur
const CreateUserSchema = z.object({
@@ -152,8 +153,7 @@ export async function updateUserSubscription(userId: string, tier: string) {
const oldTier = existing?.tier ?? 'BASIC'
const now = new Date()
const periodEnd = new Date(now)
periodEnd.setFullYear(periodEnd.getFullYear() + 1)
const periodEnd = addUtcMonths(now, 1)
await prisma.subscription.upsert({
where: { userId },

View File

@@ -8,25 +8,12 @@ import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { z } from 'zod'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { countPlainWords, MIN_WORDS_FOR_TITLE_SUGGESTION, stripHtmlToPlainText } from '@/lib/text/plain-text'
const requestSchema = z.object({
content: z.string().min(1, "Le contenu ne peut pas être vide"),
})
/** Supprime les balises HTML pour extraire le texte brut */
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
export async function POST(req: NextRequest) {
try {
// Check authentication and user setting
@@ -48,17 +35,10 @@ export async function POST(req: NextRequest) {
const body = await req.json()
const { content: rawContent } = requestSchema.parse(body)
// Nettoyer le HTML (l'éditeur TipTap envoie du HTML)
const content = stripHtml(rawContent)
const content = stripHtmlToPlainText(rawContent)
// Vérifier qu'il y a au moins 10 mots
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (wordCount < 10) {
return NextResponse.json(
{ error: 'Le contenu doit avoir au moins 10 mots' },
{ status: 400 }
)
if (countPlainWords(content) < MIN_WORDS_FOR_TITLE_SUGGESTION) {
return NextResponse.json({ suggestions: [] })
}
const config = await getSystemConfig()

View File

@@ -5,6 +5,7 @@ import { stripe } from '@/lib/stripe';
import { getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
import { syncSubscriptionFromStripe } from '@/lib/billing/sync-subscription-from-stripe';
import { shouldOfferSubscriptionTrial, SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial';
import { periodsDiffer, rollBillingPeriod } from '@/lib/billing/period';
export const dynamic = 'force-dynamic';
@@ -41,9 +42,37 @@ export async function GET(req: NextRequest) {
}
}
try {
let subscription = await prisma.subscription.findUnique({ where: { userId } });
const stripeSecret = process.env.STRIPE_SECRET_KEY;
const canTalkToStripe = !!stripeSecret && stripeSecret !== 'sk_test_placeholder';
if (
subscription?.stripeSubscriptionId
&& !subscription.stripeSubscriptionId.startsWith('sub_mock')
&& canTalkToStripe
) {
try {
const live = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId);
await syncSubscriptionFromStripe(live, userId);
subscription = await prisma.subscription.findUnique({ where: { userId } });
} catch (syncErr) {
console.error('[billing/status] live Stripe period sync failed:', syncErr);
}
} else if (subscription && !subscription.cancelAtPeriodEnd) {
const rolled = rollBillingPeriod(subscription.currentPeriodStart);
if (periodsDiffer(rolled, subscription)) {
subscription = await prisma.subscription.update({
where: { userId },
data: {
currentPeriodStart: rolled.currentPeriodStart,
currentPeriodEnd: rolled.currentPeriodEnd,
},
});
}
}
const { tier, status, currentPeriodEnd } = await getUserInfo(userId);
const effectiveTier = await getEffectiveTier(userId);
const subscription = await prisma.subscription.findUnique({ where: { userId } });
const prices = await getDynamicPrices();
const billingEnabled = await isBillingEnabled();
const { getPackPublicPrices } = await import('@/lib/billing/credit-packs');
@@ -89,8 +118,8 @@ export async function GET(req: NextRequest) {
tier,
effectiveTier,
status,
currentPeriodEnd: currentPeriodEnd ?? null,
currentPeriodStart: subscription?.currentPeriodStart ?? null,
currentPeriodEnd: subscription?.currentPeriodEnd?.toISOString() ?? currentPeriodEnd?.toISOString() ?? null,
currentPeriodStart: subscription?.currentPeriodStart?.toISOString() ?? null,
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
trialEndsAt: subscription?.trialEndsAt?.toISOString() ?? null,

View File

@@ -77,7 +77,7 @@ export async function GET() {
notebookId: true, updatedAt: true, createdAt: true,
},
orderBy: { updatedAt: 'desc' },
take: 8,
take: 12,
}),
prisma.note.count({
@@ -86,7 +86,7 @@ export async function GET() {
prisma.note.findMany({
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
select: { id: true, title: true, notebookId: true, updatedAt: true },
select: { id: true, title: true, content: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 3,
}),
@@ -132,7 +132,7 @@ export async function GET() {
prisma.note.findMany({
where: { userId, isPinned: true, trashedAt: null, isArchived: false },
select: { id: true, title: true, notebookId: true, updatedAt: true },
select: { id: true, title: true, content: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 5,
}),
@@ -170,7 +170,10 @@ export async function GET() {
insightRows = [...insightRows, ...viewedRecent]
}
const notebookIds = [...new Set(recentNotes.map(n => n.notebookId).filter(Boolean))] as string[]
const notebookIds = [...new Set([
...recentNotes.map(n => n.notebookId),
...pinnedNotes.map(n => n.notebookId),
].filter(Boolean))] as string[]
const notebooks = notebookIds.length > 0
? await prisma.notebook.findMany({
where: { id: { in: notebookIds } },
@@ -188,6 +191,7 @@ export async function GET() {
inboxPreview: inboxPreview.map(n => ({
id: n.id,
title: n.title,
excerpt: excerptNoteContent(n.content, 80),
notebookId: n.notebookId,
updatedAt: n.updatedAt.toISOString(),
})),
@@ -235,8 +239,10 @@ export async function GET() {
pinnedNotes: pinnedNotes.map(n => ({
id: n.id,
title: n.title,
excerpt: excerptNoteContent(n.content, 80),
notebookId: n.notebookId,
updatedAt: n.updatedAt.toISOString(),
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
})),
writingActivity,
agentSuggestions: agentSuggestions.map(s => ({

View File

@@ -22,7 +22,7 @@ export async function GET() {
const userId = session.user.id
const locale = await detectUserLanguage()
const cacheKey = `briefing:sentiment:${userId}:${locale}`
const cacheKey = `briefing:sentiment:${userId}:${locale}:v2`
try {
const cached = await redis.get(cacheKey)
@@ -39,7 +39,7 @@ export async function GET() {
isArchived: false,
updatedAt: { gte: weekAgo },
},
select: { title: true, content: true },
select: { id: true, title: true, content: true, notebookId: true },
take: 20,
orderBy: { updatedAt: 'desc' },
})
@@ -64,10 +64,14 @@ export async function GET() {
}
const localeLabel = locale === 'fr' ? 'French' : locale === 'en' ? 'English' : locale
const voice = locale === 'fr'
? 'Write summary in French, second person singular (tu). Example: « Cette semaine, tu as surtout… ». Never write « the person », « the user », or third person.'
: `Write summary in ${localeLabel}, second person ("you"). Never write "the person", "the user", or third person.`
const prompt = `Analyze the emotional patterns in these notes from the past week. Return ONLY valid JSON (no markdown, no code fences).
Write "summary" and "topTopic" in ${localeLabel} (locale code: ${locale}). Keep dominantEmotion keys in English as listed.
${voice}
Keep dominantEmotion keys in English as listed. One short sentence for summary. Do not quote secrets, passwords, or URLs.
{"dominantEmotion":"focused|curious|enthusiastic|frustrated|calm|anxious|creative|reflective","sentimentScore":number from -1 to 1,"emotions":{"focused":number,"curious":number,"enthusiastic":number,"frustrated":number,"calm":number,"anxious":number,"creative":number,"reflective":number},"summary":"one sentence describing the emotional pattern","topTopic":"most discussed topic"}
{"dominantEmotion":"focused|curious|enthusiastic|frustrated|calm|anxious|creative|reflective","sentimentScore":number from -1 to 1,"emotions":{"focused":number,"curious":number,"enthusiastic":number,"frustrated":number,"calm":number,"anxious":number,"creative":number,"reflective":number},"summary":"one sentence","topTopic":"most discussed topic"}
Notes:
${snippets.slice(0, 3000)}`
@@ -84,7 +88,16 @@ ${snippets.slice(0, 3000)}`
}
const parsed = JSON.parse(jsonMatch[0])
const payload = { available: true, ...parsed }
const relatedNotes = recentNotes.slice(0, 2).map(n => {
const title = n.title?.trim()
const plain = n.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
return {
id: n.id,
title: title || (plain ? (plain.length > 80 ? `${plain.slice(0, 80).trim()}` : plain) : null),
notebookId: n.notebookId,
}
})
const payload = { available: true, ...parsed, relatedNotes }
try { await redis.setex(cacheKey, CACHE_TTL_SEC, JSON.stringify(payload)) } catch {}
return NextResponse.json(payload)
} catch (error) {

View File

@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server'
import { getPublicByokCatalog } from '@/lib/ai/byok-catalog'
export const dynamic = 'force-dynamic'
/** Liste publique des fournisseurs / modèles BYOK — aucun secret. */
export async function GET() {
const providers = await getPublicByokCatalog()
return NextResponse.json(
{ providers },
{ headers: { 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400' } },
)
}

View File

@@ -1,18 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getEffectiveTier } from '@/lib/entitlements';
import { isByokProviderAllowed } from '@/lib/byok';
import { fetchLiveModelsForProvider, PROVIDER_MODEL_SUGGESTIONS, type FetchModelsResult } from '@/lib/ai/models-list';
import { getActiveByokKey, isByokProviderAllowed } from '@/lib/byok';
import { decryptApiKey } from '@/lib/crypto';
import { fetchLiveModelsForProvider, type FetchModelsResult } from '@/lib/ai/models-list';
import { VALID_PROVIDERS, type AiGatewayProvider } from '@/lib/ai/router';
// Providers that return static suggestions regardless of key
const STATIC_PROVIDERS = new Set(['anthropic', 'anthropic_custom', 'custom_anthropic', 'google', 'minimax']);
/**
* GET /api/user/api-keys/live-models?provider=<provider>[&key=<api_key>][&baseUrl=<url>]
*
* - Static providers (minimax, anthropic, google): returns suggestions immediately, no key needed.
* - Live providers (openai, deepseek…): requires key to fetch live from provider.
* Liste les modèles chez le fournisseur. Sans clé (saisie ou déjà enregistrée) : liste vide.
* Pas de liste de secours figée.
*/
export async function GET(request: NextRequest) {
const session = await auth();
@@ -27,8 +25,8 @@ export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const provider = searchParams.get('provider') as AiGatewayProvider;
const apiKey = searchParams.get('key') ?? '';
const baseUrl = searchParams.get('baseUrl') ?? undefined;
let apiKey = searchParams.get('key') ?? '';
let baseUrl = searchParams.get('baseUrl') ?? undefined;
if (!provider) {
return NextResponse.json({ error: 'Missing provider' }, { status: 400 });
@@ -42,16 +40,20 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Tier restricted' }, { status: 403 });
}
// Static suggestion providers: return immediately without a key
if (STATIC_PROVIDERS.has(provider)) {
const base = provider === 'anthropic_custom' || provider === 'custom_anthropic' ? 'anthropic' : provider;
const models = PROVIDER_MODEL_SUGGESTIONS[base] ?? [];
return NextResponse.json({ success: true, models, fromApi: false });
if (!apiKey || apiKey.length < 4) {
const saved = await getActiveByokKey(session.user.id, provider, tier);
if (saved) {
try {
apiKey = await decryptApiKey(saved.encryptedKey);
if (!baseUrl && saved.baseUrl) baseUrl = saved.baseUrl;
} catch {
apiKey = '';
}
}
}
// Live providers need a key
if (!apiKey || apiKey.length < 4) {
return NextResponse.json({ success: true, models: PROVIDER_MODEL_SUGGESTIONS[provider] ?? [], fromApi: false });
return NextResponse.json({ success: true, models: [], fromApi: false });
}
const result: FetchModelsResult = await fetchLiveModelsForProvider(provider, apiKey, baseUrl);