diff --git a/memento-note/app/(public)/layout.tsx b/memento-note/app/(public)/layout.tsx index 6aca00e..bbce362 100644 --- a/memento-note/app/(public)/layout.tsx +++ b/memento-note/app/(public)/layout.tsx @@ -19,8 +19,8 @@ export default async function PublicLayout({ children }: { children: React.React
{children}
diff --git a/memento-note/app/api/agents/run-for-note/route.ts b/memento-note/app/api/agents/run-for-note/route.ts index 664b8a5..e35226f 100644 --- a/memento-note/app/api/agents/run-for-note/route.ts +++ b/memento-note/app/api/agents/run-for-note/route.ts @@ -3,7 +3,7 @@ import { auth } from '@/auth' import { prisma } from '@/lib/prisma' import { QuotaExceededError } from '@/lib/entitlements' import { reserveAiUsageOrThrow } from '@/lib/ai-quota' -import { slideGenerateCreditCost, resolveCreditCost } from '@/lib/credits' +import { slideGenerateCreditCost, notebookSlideCreditCost, resolveCreditCost } from '@/lib/credits' import type { FeatureName } from '@/lib/quota-utils' import { logAuditEvent, getClientIp } from '@/lib/audit-log' import { @@ -42,8 +42,10 @@ export async function POST(req: NextRequest) { const userId = session.user.id const body = await req.json() - const { noteId, type, theme, style, language, template, purpose, audience, slideCount } = body as { - noteId: string + const { noteId, notebookId, type, theme, style, language, template, purpose, audience, slideCount } = body as { + noteId?: string + /** Si fourni (slides), génère un deck multi-notes du carnet */ + notebookId?: string type: GenerateType theme?: string style?: string @@ -54,21 +56,47 @@ export async function POST(req: NextRequest) { slideCount?: number } - if (!noteId || !type || !TYPE_DEFAULTS[type]) { + if ((!noteId && !notebookId) || !type || !TYPE_DEFAULTS[type]) { return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 }) } + if (notebookId && type !== 'slide-generator') { + return NextResponse.json({ error: 'notebookId réservé aux présentations' }, { status: 400 }) + } - // Crédits globaux (respecte le BYOK) — slides = 1 + N + let noteCountForPack = 1 + if (notebookId) { + noteCountForPack = await prisma.note.count({ + where: { + notebookId, + userId, + isArchived: false, + trashedAt: null, + }, + }) + if (noteCountForPack < 1) { + return NextResponse.json({ error: 'Carnet vide ou introuvable' }, { status: 404 }) + } + } + + // Crédits globaux (respecte le BYOK) — slides note = 1+N ; carnet = coût notebook const featureKey = (type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate') as FeatureName const creditCost = type === 'slide-generator' - ? slideGenerateCreditCost(slideCount) + ? notebookId + ? notebookSlideCreditCost({ slideCount, noteCount: noteCountForPack }) + : slideGenerateCreditCost(slideCount) : resolveCreditCost(featureKey) try { await reserveAiUsageOrThrow(userId, featureKey, { amount: creditCost, slideCount: type === 'slide-generator' ? slideCount : undefined, - metadata: { noteId, type, slideCount: slideCount ?? null }, + metadata: { + noteId: noteId ?? null, + notebookId: notebookId ?? null, + type, + slideCount: slideCount ?? null, + noteCount: noteCountForPack, + }, lane: 'chat', }) } catch (e) { @@ -86,12 +114,28 @@ export async function POST(req: NextRequest) { throw e } - const note = await prisma.note.findFirst({ - where: { id: noteId, userId }, - select: { id: true, title: true, notebookId: true }, - }) - if (!note) { - return NextResponse.json({ error: 'Note introuvable' }, { status: 404 }) + let note: { id: string; title: string | null; notebookId: string | null } | null = null + if (noteId) { + note = await prisma.note.findFirst({ + where: { id: noteId, userId }, + select: { id: true, title: true, notebookId: true }, + }) + if (!note) { + return NextResponse.json({ error: 'Note introuvable' }, { status: 404 }) + } + } else if (notebookId) { + const notebook = await prisma.notebook.findFirst({ + where: { id: notebookId, userId }, + select: { id: true, name: true }, + }) + if (!notebook) { + return NextResponse.json({ error: 'Carnet introuvable' }, { status: 404 }) + } + note = { + id: notebookId, + title: notebook.name, + notebookId, + } } const defaults = TYPE_DEFAULTS[type] @@ -110,6 +154,10 @@ export async function POST(req: NextRequest) { role = `Génère un deck exécutif de haute qualité (titres d'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).${recipeHint}` } + if (!note) { + return NextResponse.json({ error: 'Source introuvable' }, { status: 404 }) + } + const agentName = type === 'slide-generator' ? `${isEn ? 'Slides' : 'Présentation'} — ${(note.title || 'Note').substring(0, 40)}` : `${isEn ? 'Diagram' : 'Diagramme'} — ${(note.title || 'Note').substring(0, 40)}` @@ -136,6 +184,7 @@ export async function POST(req: NextRequest) { } } + const sourceNotebookId = notebookId || note.notebookId || undefined const agent = await prisma.agent.create({ data: { name: agentName, @@ -149,8 +198,9 @@ export async function POST(req: NextRequest) { maxSteps: defaults.maxSteps, frequency: 'one-shot', isEnabled: true, - sourceNoteIds: JSON.stringify([noteId]), - targetNotebookId: note.notebookId ?? undefined, + sourceNoteIds: noteId ? JSON.stringify([noteId]) : JSON.stringify([]), + sourceNotebookId: notebookId ? notebookId : undefined, + targetNotebookId: sourceNotebookId, slideTheme: theme ?? 'keynote', slideStyle: style ?? 'soft', userId, @@ -168,11 +218,22 @@ export async function POST(req: NextRequest) { userId, action: 'AI_REQUEST', resource: featureKey, - metadata: { agentId: agent.id, noteId, featureKey }, + metadata: { + agentId: agent.id, + noteId: noteId ?? null, + notebookId: notebookId ?? null, + featureKey, + creditCost, + }, ip: getClientIp(req), }) - return NextResponse.json({ success: true, agentId: agent.id, status: 'running' }) + return NextResponse.json({ + success: true, + agentId: agent.id, + status: 'running', + creditCost, + }) } // ─── GET : poll current agent status ────────────────────────────────────── diff --git a/memento-note/app/api/cron/agents/route.ts b/memento-note/app/api/cron/agents/route.ts index 17eeff7..1163e36 100644 --- a/memento-note/app/api/cron/agents/route.ts +++ b/memento-note/app/api/cron/agents/route.ts @@ -47,7 +47,7 @@ export async function POST(request: NextRequest) { orderBy: { nextRun: 'asc' }, }) - // One-time repair: set nextRun for enabled scheduled agents stuck with null + // Repair: set nextRun for enabled scheduled agents stuck with null const unscheduled = await prisma.agent.findMany({ where: { isEnabled: true, @@ -56,6 +56,7 @@ export async function POST(request: NextRequest) { }, select: { id: true, + userId: true, frequency: true, scheduledTime: true, scheduledDay: true, @@ -63,6 +64,7 @@ export async function POST(request: NextRequest) { }, take: 50, }) + const repairedDue: typeof dueAgents = [] for (const a of unscheduled) { const nextRun = calculateNextRun({ frequency: a.frequency, @@ -70,10 +72,32 @@ export async function POST(request: NextRequest) { scheduledDay: a.scheduledDay, timezone: a.timezone, }) - await prisma.agent.update({ where: { id: a.id }, data: { nextRun } }) + // Si le créneau calculé est déjà passé / immédiat → exécuter ce cycle + const effective = !nextRun || nextRun <= now ? now : nextRun + await prisma.agent.update({ where: { id: a.id }, data: { nextRun: effective } }) + if (effective <= now) { + repairedDue.push({ + id: a.id, + userId: a.userId, + frequency: a.frequency, + scheduledTime: a.scheduledTime, + scheduledDay: a.scheduledDay, + timezone: a.timezone, + nextRun: effective, + }) + } } - if (dueAgents.length === 0) { + const queue = [...dueAgents] + const seen = new Set(dueAgents.map((a) => a.id)) + for (const a of repairedDue) { + if (!seen.has(a.id)) { + queue.push(a) + seen.add(a.id) + } + } + + if (queue.length === 0) { return NextResponse.json({ success: true, executed: 0, @@ -83,8 +107,8 @@ export async function POST(request: NextRequest) { const results: { id: string; success: boolean; error?: string }[] = [] - // Execute agents sequentially (max 3 per cycle) - for (const agent of dueAgents.slice(0, 3)) { + // Execute agents sequentially (max 5 per cycle — rattrapage + due) + for (const agent of queue.slice(0, 5)) { try { const { executeAgent } = await import('@/lib/ai/services/agent-executor.service') const result = await executeAgent(agent.id, agent.userId) diff --git a/memento-note/app/layout.tsx b/memento-note/app/layout.tsx index 56ee33e..7483568 100644 --- a/memento-note/app/layout.tsx +++ b/memento-note/app/layout.tsx @@ -93,8 +93,15 @@ export default async function RootLayout({ const htmlTheme = serverHtmlThemeState(resolvedTheme) const serverAccent = userSettings.accentColor ?? '#A47148' const serverTheme = resolvedTheme + const wantsDarkClass = + resolvedTheme === 'dark' || + resolvedTheme === 'midnight' || + // auto : le serveur ne connaît pas prefers-color-scheme → laisser le script client + false const htmlStyle = { '--color-brand-accent': serverAccent, + // Aide le navigateur à peindre scrollbars/inputs dans le bon mode + colorScheme: wantsDarkClass || resolvedTheme === 'dark' || resolvedTheme === 'midnight' ? 'dark' : resolvedTheme === 'light' ? 'light' : undefined, } as CSSProperties return ( diff --git a/memento-note/components/chart-suggestions-dialog.tsx b/memento-note/components/chart-suggestions-dialog.tsx index d55e3b7..092fe5f 100644 --- a/memento-note/components/chart-suggestions-dialog.tsx +++ b/memento-note/components/chart-suggestions-dialog.tsx @@ -64,7 +64,7 @@ export function ChartSuggestionsDialog({ console.error('[ChartSuggestionsDialog] Error:', err) setResponse({ suggestions: [], - analyzedText: textToAnalyze?.substring(0, 100) || '', + analyzedText: (selection || content || '').substring(0, 100), detectedData: 'Error occurred', hasData: false, error: err.message || 'Network error - check console', @@ -124,7 +124,7 @@ export function ChartSuggestionsDialog({
-

Chart Suggestions

+

{t('chart.suggestionsTitle') || 'Suggestions de graphiques'}

{loading ? ( @@ -137,6 +137,9 @@ export function ChartSuggestionsDialog({ t('chart.noDataDetected') )}

+

+ {t('chart.creditCostHint') || 'Coût estimé : 2 crédits IA'} +

diff --git a/memento-note/components/theme-initializer.tsx b/memento-note/components/theme-initializer.tsx index 12d2a89..9c950b9 100644 --- a/memento-note/components/theme-initializer.tsx +++ b/memento-note/components/theme-initializer.tsx @@ -40,9 +40,21 @@ export function ThemeInitializer({ theme, fontSize, fontFamily, accentColor }: T const localTheme = localStorage.getItem('theme-preference') const effectiveTheme = normalizeThemeId(localTheme || theme || 'light') - // Persiste aussi le cookie pour les prochains rendus serveur (anti-flash navigation) + // Appliquer le thème AVANT paint (cookie + class dark atomique) + applyDocumentTheme(effectiveTheme) persistThemePreference(effectiveTheme) + // color-scheme navigateur (inputs, scrollbars) — évite flash chrome clair + try { + const dark = + effectiveTheme === 'dark' || + effectiveTheme === 'midnight' || + (effectiveTheme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) + root.style.colorScheme = dark ? 'dark' : 'light' + } catch { + /* ignore */ + } + applyFontSize(fontSize) const localFontFamily = localStorage.getItem('font-family') diff --git a/memento-note/docs/dashboard-audit-2026-07-16.md b/memento-note/docs/dashboard-audit-2026-07-16.md new file mode 100644 index 0000000..f023ca4 --- /dev/null +++ b/memento-note/docs/dashboard-audit-2026-07-16.md @@ -0,0 +1,36 @@ +# Audit dashboard Second Brain (`/home`) — 2026-07-16 + +## Portée + +Revue code de `dashboard-view.tsx` + widgets catalogue / paths / briefing. + +## Points sains + +| Zone | Constat | +|------|---------| +| Layout v5 configurable | Catalogue widgets + `/api/dashboard/layout` | +| Prochaines pistes | Fast paths + enrich async `/api/briefing/paths` | +| Revue quotidienne | Checklist interactive (pas revue auto) | +| Quotas | `emitAiUsageChanged` + consent IA sur actions | +| Aide « ? » | `DashboardWidgetHelp` i18n | +| Empty states | Widgets catalogue gèrent listes vides | + +## Risques / écarts + +| # | Problème | Sévérité | Action | +|---|----------|----------|--------| +| 1 | Briefing dépend du cron / première visite : widgets peuvent rester vides temporairement | Moyenne | UX « chargement / aucune donnée » déjà partielle — dogfood | +| 2 | Bridges / clusters sémantiques absurdes cross-domaine | Moyenne | Produit insights (hors dashboard pur) | +| 3 | Widget usage encore mentalité « seaux » vs solde crédits | Basse | `DashboardUsageWidget` lit `/api/usage/current` (balance multi) — OK si API à jour | +| 4 | Agent suggestions vides si cron agent-suggestions off | Basse | Ops CRON_SECRET | + +## Verdict + +Dashboard **utilisable** et aligné prototype sur l’architecture. Pas de bug bloquant code identifié dans cette passe. +**Reste dogfood** : première visite, dark mode, clics paths, quotas 402. + +## Suite recommandée + +1. Dogfood 10 min sur compte réel +2. Si widget usage affiche encore d’anciens seaux → forcer refresh API (déjà multi-feature dans usage-current) +3. Prioriser pertinence bridges (insights) plutôt que refonte dashboard diff --git a/memento-note/lib/ai/services/notebook-wizard.service.ts b/memento-note/lib/ai/services/notebook-wizard.service.ts index c3d1305..eca080a 100644 --- a/memento-note/lib/ai/services/notebook-wizard.service.ts +++ b/memento-note/lib/ai/services/notebook-wizard.service.ts @@ -42,7 +42,8 @@ const LANG_NAMES: Record = { function wordTargetsForLevel(level?: string): { min: number; max: number; label: string } { const l = (level || '').toLowerCase() if (/expert|avancé|advanced|fort/.test(l)) { - return { min: 400, max: 800, label: 'expert (400–800 words per note)' } + // Borné pour éviter timeouts JSON / mode expert qui plantait + return { min: 350, max: 700, label: 'expert (350–700 words per note, dense)' } } if (/intermédiaire|intermediate|moyen/.test(l)) { return { min: 300, max: 550, label: 'intermediate (300–550 words per note)' } @@ -240,7 +241,13 @@ Return ONLY: Rules: - Exactly ${count} titles, progressive learning order. - Titles in ${this.langName(lang)} only. -- notebookName ≠ full user sentence.` +- notebookName ≠ full user sentence (3–8 words, concrete, topic-specific). +- FORBIDDEN generic notebook names / chapter patterns (any language): + "Fondements", "Foundations", "Maîtrise", "Mastery", "Introduction", + "Basics", "Overview", "Getting started", "Chapitre 1", "Chapter 1", + "Partie 1", "Bases", "Notions de base", "Synthèse finale" alone. +- Prefer specific titles that name the real subject (e.g. topic keywords + angle). +- Each title must be unique and non-interchangeable with another topic.` const raw = await provider.generateText(prompt) const parsed = this.parseJsonObject(raw) @@ -250,10 +257,41 @@ Rules: while (titles.length < count) { titles.push(lang === 'fr' ? `Chapitre ${titles.length + 1}` : `Chapter ${titles.length + 1}`) } - return { - notebookName: String(parsed?.notebookName || '').trim().slice(0, 80), - titles, + let notebookName = String(parsed?.notebookName || '').trim().slice(0, 80) + if (this.isGenericNotebookName(notebookName, topic)) { + notebookName = this.deriveNotebookName(topic, titles.map((t) => ({ title: t, content: '' })), lang) } + const cleanedTitles = titles.map((t, i) => + this.isGenericChapterTitle(t) ? this.fallbackChapterTitle(topic, i, lang) : t, + ) + return { + notebookName, + titles: cleanedTitles, + } + } + + private isGenericNotebookName(name: string, topic: string): boolean { + const n = (name || '').trim().toLowerCase() + if (!n || n.length < 3) return true + if (n === topic.trim().toLowerCase() && topic.trim().split(/\s+/).length > 10) return true + const banned = + /^(fondements?|foundations?|ma[iî]trise|mastery|introduction|bases?|basics?|overview|getting started|notions de base|synth[eè]se|summary)$/i + return banned.test(n) + } + + private isGenericChapterTitle(title: string): boolean { + const t = (title || '').trim() + if (!t) return true + return /^(chapitre|chapter|partie|part|section)\s*\d+$/i.test(t) + || /^(fondements?|foundations?|ma[iî]trise|introduction|bases?|basics?|overview)$/i.test(t) + } + + private fallbackChapterTitle(topic: string, index: number, lang: string): string { + const short = topic.trim().slice(0, 48) || (lang === 'fr' ? 'Sujet' : 'Topic') + const anglesFr = ['Concepts clés', 'Méthodes', 'Exemples guidés', 'Pièges fréquents', 'Mise en pratique', 'Bilan'] + const anglesEn = ['Key concepts', 'Methods', 'Guided examples', 'Common pitfalls', 'Practice', 'Recap'] + const angles = lang === 'fr' ? anglesFr : anglesEn + return `${angles[index % angles.length]} — ${short}` } private async generateOneNote( diff --git a/memento-note/lib/ai/services/slide-deck-generator.service.ts b/memento-note/lib/ai/services/slide-deck-generator.service.ts index f7e722e..4f6ae23 100644 --- a/memento-note/lib/ai/services/slide-deck-generator.service.ts +++ b/memento-note/lib/ai/services/slide-deck-generator.service.ts @@ -229,11 +229,13 @@ Tu produis UNIQUEMENT un outline JSON (pas le corps final des slides). Règles (DeckForge + think-cell): - Max ${maxSlides} slides -- Headline ASSERTIVE, max 10 mots (pas "Contexte", "Introduction") -- keyPoints: 2–5 faits de la note par slide +- Headline ASSERTIVE, max 10 mots (INTERDIT: "Contexte", "Introduction", "Présentation", "Overview", "Points clés", "Conclusion" seuls) +- keyPoints: 2–5 FAITS CONCRETS tirés de la note (chiffres, noms, formules) — jamais de filler +- Chaque slide = UNE idée actionnable (titre = insight, pas libellé de section) - Arc pédagogique pour cours: title → définitions/équations → propriétés → exemples → summary - Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary - Slide 1 = title, dernière = summary +- INTERDIT slides vides, listes de 1 mot, ou répéter le titre de la note ${mathRule} Réponds en JSON OutlineSchema.` } @@ -242,11 +244,13 @@ Output ONLY outline JSON (not full slide bodies). Rules: - Max ${maxSlides} slides -- ASSERTIVE headlines, max 10 words -- keyPoints: 2–5 facts from the note per slide +- ASSERTIVE headlines, max 10 words (FORBIDDEN alone: Context, Introduction, Overview, Key points, Conclusion) +- keyPoints: 2–5 CONCRETE facts from the note (numbers, names, formulas) — no filler +- One actionable idea per slide (title = insight, not section label) - Pedagogical arc for lessons: title → definitions/equations → properties → examples → summary - Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary - First=title, last=summary +- FORBIDDEN empty slides, one-word lists, or repeating the note title alone ${mathRule} JSON OutlineSchema only.` } diff --git a/memento-note/lib/apply-document-theme.ts b/memento-note/lib/apply-document-theme.ts index 2fbe0b1..53071c4 100644 --- a/memento-note/lib/apply-document-theme.ts +++ b/memento-note/lib/apply-document-theme.ts @@ -74,6 +74,12 @@ export function applyDocumentTheme(theme: string): void { root.classList.remove('dark') } + try { + root.style.colorScheme = wantsDark ? 'dark' : 'light' + } catch { + /* ignore */ + } + // data-theme pour les palettes nommées uniquement if (t === 'auto' || t === 'dark' || t === 'light') { root.removeAttribute('data-theme') diff --git a/memento-note/lib/billing/credit-packs.ts b/memento-note/lib/billing/credit-packs.ts index a7b8c82..b7e5a3d 100644 --- a/memento-note/lib/billing/credit-packs.ts +++ b/memento-note/lib/billing/credit-packs.ts @@ -128,7 +128,8 @@ export async function getPackPublicPrices(): Promise { display, amount, currency, - configured: configured || isMock, + // true seulement si un vrai price_… est en admin/env (pas les mocks) + configured, } }), ) diff --git a/memento-note/lib/credits.ts b/memento-note/lib/credits.ts index 5b3379e..e210c19 100644 --- a/memento-note/lib/credits.ts +++ b/memento-note/lib/credits.ts @@ -50,7 +50,7 @@ export const CREDIT_COSTS: Record = { brainstorm_create: 5, brainstorm_expand: 1, brainstorm_enrich: 1, - suggest_charts: 1, + suggest_charts: 2, publish_enhance: 3, ai_flashcard: 3, voice_transcribe: 1, diff --git a/memento-note/lib/inline-boot-scripts.ts b/memento-note/lib/inline-boot-scripts.ts index 5d23e7e..00a024f 100644 --- a/memento-note/lib/inline-boot-scripts.ts +++ b/memento-note/lib/inline-boot-scripts.ts @@ -29,6 +29,7 @@ export const THEME_INIT_SCRIPT = `(function () { } if (wantsDark) root.classList.add('dark'); else root.classList.remove('dark'); + try { root.style.colorScheme = wantsDark ? 'dark' : 'light'; } catch (e) {} if (theme === 'auto' || theme === 'dark' || theme === 'light') { root.removeAttribute('data-theme'); } else { diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 62cda85..a98e4e1 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -3305,6 +3305,7 @@ "packMName": "Standard pack", "packLName": "Power pack", "buyPack": "Buy", + "packNotConfigured": "Stripe config required", "packCheckoutSuccess": "Credit pack added to your balance!", "packCheckoutFailed": "Could not start pack purchase. Check Stripe config or try again.", "creditsWhereTitle": "Credit usage by feature", @@ -4527,6 +4528,8 @@ "invalidDescription": "This chart block contains invalid or empty data.", "editSource": "Edit chart source", "suggestions": "Chart Suggestions", + "suggestionsTitle": "Chart suggestions", + "creditCostHint": "Estimated cost: 2 AI credits", "analyzing": "Analyzing your content for chart data…", "debugInfo": "Debug info", "escCancel": "Esc Cancel", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index 5435f7d..3182ba0 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -3311,6 +3311,7 @@ "packMName": "Pack standard", "packLName": "Pack intensif", "buyPack": "Acheter", + "packNotConfigured": "Config Stripe requise", "packCheckoutSuccess": "Pack de crédits ajouté à votre solde !", "packCheckoutFailed": "Échec de l’achat du pack. Vérifiez la config Stripe ou réessayez.", "creditsWhereTitle": "Utilisation des crédits par fonction", @@ -4533,6 +4534,8 @@ "invalidDescription": "Ce bloc de graphique contient des données invalides ou vides.", "editSource": "Modifier le code source", "suggestions": "Suggestions de graphiques", + "suggestionsTitle": "Suggestions de graphiques", + "creditCostHint": "Coût estimé : 2 crédits IA", "analyzing": "Analyse de votre contenu pour des données graphiques…", "debugInfo": "Infos de débogage", "escCancel": "Échap Annuler",