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

@@ -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);