fix(ui): thème, textes et lisibilité restants de la revue
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:
@@ -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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
13
memento-note/app/api/public/byok-catalog/route.ts
Normal file
13
memento-note/app/api/public/byok-catalog/route.ts
Normal 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' } },
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user