fix: batch priorités — thème, scroll, wizard, agents, slides, charts, packs
Anti-flash dark (color-scheme + applyDocumentTheme), scroll pages publiques, prompts wizard/slides moins génériques, cron agents rattrapage nextRun null, slides carnet via notebookId, graphiques à 2 crédits + hint, audit dashboard, packs Stripe marqués non configurés sans price ID.
This commit is contained in:
@@ -19,8 +19,8 @@ export default async function PublicLayout({ children }: { children: React.React
|
|||||||
<PublicProviders initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
<PublicProviders initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
||||||
<div
|
<div
|
||||||
data-public-scroll-root
|
data-public-scroll-root
|
||||||
className="fixed inset-0 z-0 overflow-y-auto overflow-x-hidden overscroll-y-contain"
|
className="fixed inset-0 z-[1] h-[100dvh] w-full overflow-y-auto overflow-x-hidden overscroll-y-contain"
|
||||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
style={{ WebkitOverflowScrolling: 'touch', touchAction: 'pan-y' }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { auth } from '@/auth'
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { QuotaExceededError } from '@/lib/entitlements'
|
import { QuotaExceededError } from '@/lib/entitlements'
|
||||||
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
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 type { FeatureName } from '@/lib/quota-utils'
|
||||||
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
|
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
|
||||||
import {
|
import {
|
||||||
@@ -42,8 +42,10 @@ export async function POST(req: NextRequest) {
|
|||||||
const userId = session.user.id
|
const userId = session.user.id
|
||||||
|
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
const { noteId, type, theme, style, language, template, purpose, audience, slideCount } = body as {
|
const { noteId, notebookId, type, theme, style, language, template, purpose, audience, slideCount } = body as {
|
||||||
noteId: string
|
noteId?: string
|
||||||
|
/** Si fourni (slides), génère un deck multi-notes du carnet */
|
||||||
|
notebookId?: string
|
||||||
type: GenerateType
|
type: GenerateType
|
||||||
theme?: string
|
theme?: string
|
||||||
style?: string
|
style?: string
|
||||||
@@ -54,21 +56,47 @@ export async function POST(req: NextRequest) {
|
|||||||
slideCount?: number
|
slideCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
|
if ((!noteId && !notebookId) || !type || !TYPE_DEFAULTS[type]) {
|
||||||
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
|
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 featureKey = (type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate') as FeatureName
|
||||||
const creditCost =
|
const creditCost =
|
||||||
type === 'slide-generator'
|
type === 'slide-generator'
|
||||||
? slideGenerateCreditCost(slideCount)
|
? notebookId
|
||||||
|
? notebookSlideCreditCost({ slideCount, noteCount: noteCountForPack })
|
||||||
|
: slideGenerateCreditCost(slideCount)
|
||||||
: resolveCreditCost(featureKey)
|
: resolveCreditCost(featureKey)
|
||||||
try {
|
try {
|
||||||
await reserveAiUsageOrThrow(userId, featureKey, {
|
await reserveAiUsageOrThrow(userId, featureKey, {
|
||||||
amount: creditCost,
|
amount: creditCost,
|
||||||
slideCount: type === 'slide-generator' ? slideCount : undefined,
|
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',
|
lane: 'chat',
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -86,13 +114,29 @@ export async function POST(req: NextRequest) {
|
|||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
|
||||||
const note = await prisma.note.findFirst({
|
let note: { id: string; title: string | null; notebookId: string | null } | null = null
|
||||||
|
if (noteId) {
|
||||||
|
note = await prisma.note.findFirst({
|
||||||
where: { id: noteId, userId },
|
where: { id: noteId, userId },
|
||||||
select: { id: true, title: true, notebookId: true },
|
select: { id: true, title: true, notebookId: true },
|
||||||
})
|
})
|
||||||
if (!note) {
|
if (!note) {
|
||||||
return NextResponse.json({ error: 'Note introuvable' }, { status: 404 })
|
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]
|
const defaults = TYPE_DEFAULTS[type]
|
||||||
const isEn = language === 'English'
|
const isEn = language === 'English'
|
||||||
@@ -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}`
|
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'
|
const agentName = type === 'slide-generator'
|
||||||
? `${isEn ? 'Slides' : 'Présentation'} — ${(note.title || 'Note').substring(0, 40)}`
|
? `${isEn ? 'Slides' : 'Présentation'} — ${(note.title || 'Note').substring(0, 40)}`
|
||||||
: `${isEn ? 'Diagram' : 'Diagramme'} — ${(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({
|
const agent = await prisma.agent.create({
|
||||||
data: {
|
data: {
|
||||||
name: agentName,
|
name: agentName,
|
||||||
@@ -149,8 +198,9 @@ export async function POST(req: NextRequest) {
|
|||||||
maxSteps: defaults.maxSteps,
|
maxSteps: defaults.maxSteps,
|
||||||
frequency: 'one-shot',
|
frequency: 'one-shot',
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
sourceNoteIds: JSON.stringify([noteId]),
|
sourceNoteIds: noteId ? JSON.stringify([noteId]) : JSON.stringify([]),
|
||||||
targetNotebookId: note.notebookId ?? undefined,
|
sourceNotebookId: notebookId ? notebookId : undefined,
|
||||||
|
targetNotebookId: sourceNotebookId,
|
||||||
slideTheme: theme ?? 'keynote',
|
slideTheme: theme ?? 'keynote',
|
||||||
slideStyle: style ?? 'soft',
|
slideStyle: style ?? 'soft',
|
||||||
userId,
|
userId,
|
||||||
@@ -168,11 +218,22 @@ export async function POST(req: NextRequest) {
|
|||||||
userId,
|
userId,
|
||||||
action: 'AI_REQUEST',
|
action: 'AI_REQUEST',
|
||||||
resource: featureKey,
|
resource: featureKey,
|
||||||
metadata: { agentId: agent.id, noteId, featureKey },
|
metadata: {
|
||||||
|
agentId: agent.id,
|
||||||
|
noteId: noteId ?? null,
|
||||||
|
notebookId: notebookId ?? null,
|
||||||
|
featureKey,
|
||||||
|
creditCost,
|
||||||
|
},
|
||||||
ip: getClientIp(req),
|
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 ──────────────────────────────────────
|
// ─── GET : poll current agent status ──────────────────────────────────────
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export async function POST(request: NextRequest) {
|
|||||||
orderBy: { nextRun: 'asc' },
|
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({
|
const unscheduled = await prisma.agent.findMany({
|
||||||
where: {
|
where: {
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
@@ -56,6 +56,7 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
userId: true,
|
||||||
frequency: true,
|
frequency: true,
|
||||||
scheduledTime: true,
|
scheduledTime: true,
|
||||||
scheduledDay: true,
|
scheduledDay: true,
|
||||||
@@ -63,6 +64,7 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
take: 50,
|
take: 50,
|
||||||
})
|
})
|
||||||
|
const repairedDue: typeof dueAgents = []
|
||||||
for (const a of unscheduled) {
|
for (const a of unscheduled) {
|
||||||
const nextRun = calculateNextRun({
|
const nextRun = calculateNextRun({
|
||||||
frequency: a.frequency,
|
frequency: a.frequency,
|
||||||
@@ -70,10 +72,32 @@ export async function POST(request: NextRequest) {
|
|||||||
scheduledDay: a.scheduledDay,
|
scheduledDay: a.scheduledDay,
|
||||||
timezone: a.timezone,
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
executed: 0,
|
executed: 0,
|
||||||
@@ -83,8 +107,8 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const results: { id: string; success: boolean; error?: string }[] = []
|
const results: { id: string; success: boolean; error?: string }[] = []
|
||||||
|
|
||||||
// Execute agents sequentially (max 3 per cycle)
|
// Execute agents sequentially (max 5 per cycle — rattrapage + due)
|
||||||
for (const agent of dueAgents.slice(0, 3)) {
|
for (const agent of queue.slice(0, 5)) {
|
||||||
try {
|
try {
|
||||||
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
|
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
|
||||||
const result = await executeAgent(agent.id, agent.userId)
|
const result = await executeAgent(agent.id, agent.userId)
|
||||||
|
|||||||
@@ -93,8 +93,15 @@ export default async function RootLayout({
|
|||||||
const htmlTheme = serverHtmlThemeState(resolvedTheme)
|
const htmlTheme = serverHtmlThemeState(resolvedTheme)
|
||||||
const serverAccent = userSettings.accentColor ?? '#A47148'
|
const serverAccent = userSettings.accentColor ?? '#A47148'
|
||||||
const serverTheme = resolvedTheme
|
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 = {
|
const htmlStyle = {
|
||||||
'--color-brand-accent': serverAccent,
|
'--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
|
} as CSSProperties
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function ChartSuggestionsDialog({
|
|||||||
console.error('[ChartSuggestionsDialog] Error:', err)
|
console.error('[ChartSuggestionsDialog] Error:', err)
|
||||||
setResponse({
|
setResponse({
|
||||||
suggestions: [],
|
suggestions: [],
|
||||||
analyzedText: textToAnalyze?.substring(0, 100) || '',
|
analyzedText: (selection || content || '').substring(0, 100),
|
||||||
detectedData: 'Error occurred',
|
detectedData: 'Error occurred',
|
||||||
hasData: false,
|
hasData: false,
|
||||||
error: err.message || 'Network error - check console',
|
error: err.message || 'Network error - check console',
|
||||||
@@ -124,7 +124,7 @@ export function ChartSuggestionsDialog({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<BarChart3 className="w-5 h-5 text-blue-500" />
|
<BarChart3 className="w-5 h-5 text-blue-500" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">Chart Suggestions</h2>
|
<h2 className="text-lg font-semibold">{t('chart.suggestionsTitle') || 'Suggestions de graphiques'}</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@@ -137,6 +137,9 @@ export function ChartSuggestionsDialog({
|
|||||||
t('chart.noDataDetected')
|
t('chart.noDataDetected')
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||||
|
{t('chart.creditCostHint') || 'Coût estimé : 2 crédits IA'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -576,8 +576,10 @@ export function BillingPlans() {
|
|||||||
>
|
>
|
||||||
{packLoading === pack.id ? (
|
{packLoading === pack.id ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin mx-auto" />
|
<Loader2 className="h-4 w-4 animate-spin mx-auto" />
|
||||||
) : (
|
) : pack.configured ? (
|
||||||
t('billing.buyPack')
|
t('billing.buyPack')
|
||||||
|
) : (
|
||||||
|
t('billing.packNotConfigured') || 'Config Stripe requise'
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,9 +40,21 @@ export function ThemeInitializer({ theme, fontSize, fontFamily, accentColor }: T
|
|||||||
const localTheme = localStorage.getItem('theme-preference')
|
const localTheme = localStorage.getItem('theme-preference')
|
||||||
const effectiveTheme = normalizeThemeId(localTheme || theme || 'light')
|
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)
|
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)
|
applyFontSize(fontSize)
|
||||||
|
|
||||||
const localFontFamily = localStorage.getItem('font-family')
|
const localFontFamily = localStorage.getItem('font-family')
|
||||||
|
|||||||
36
memento-note/docs/dashboard-audit-2026-07-16.md
Normal file
36
memento-note/docs/dashboard-audit-2026-07-16.md
Normal file
@@ -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
|
||||||
@@ -42,7 +42,8 @@ const LANG_NAMES: Record<string, string> = {
|
|||||||
function wordTargetsForLevel(level?: string): { min: number; max: number; label: string } {
|
function wordTargetsForLevel(level?: string): { min: number; max: number; label: string } {
|
||||||
const l = (level || '').toLowerCase()
|
const l = (level || '').toLowerCase()
|
||||||
if (/expert|avancé|advanced|fort/.test(l)) {
|
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)) {
|
if (/intermédiaire|intermediate|moyen/.test(l)) {
|
||||||
return { min: 300, max: 550, label: 'intermediate (300–550 words per note)' }
|
return { min: 300, max: 550, label: 'intermediate (300–550 words per note)' }
|
||||||
@@ -240,7 +241,13 @@ Return ONLY:
|
|||||||
Rules:
|
Rules:
|
||||||
- Exactly ${count} titles, progressive learning order.
|
- Exactly ${count} titles, progressive learning order.
|
||||||
- Titles in ${this.langName(lang)} only.
|
- 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 raw = await provider.generateText(prompt)
|
||||||
const parsed = this.parseJsonObject(raw)
|
const parsed = this.parseJsonObject(raw)
|
||||||
@@ -250,10 +257,41 @@ Rules:
|
|||||||
while (titles.length < count) {
|
while (titles.length < count) {
|
||||||
titles.push(lang === 'fr' ? `Chapitre ${titles.length + 1}` : `Chapter ${titles.length + 1}`)
|
titles.push(lang === 'fr' ? `Chapitre ${titles.length + 1}` : `Chapter ${titles.length + 1}`)
|
||||||
}
|
}
|
||||||
return {
|
let notebookName = String(parsed?.notebookName || '').trim().slice(0, 80)
|
||||||
notebookName: String(parsed?.notebookName || '').trim().slice(0, 80),
|
if (this.isGenericNotebookName(notebookName, topic)) {
|
||||||
titles,
|
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(
|
private async generateOneNote(
|
||||||
|
|||||||
@@ -229,11 +229,13 @@ Tu produis UNIQUEMENT un outline JSON (pas le corps final des slides).
|
|||||||
|
|
||||||
Règles (DeckForge + think-cell):
|
Règles (DeckForge + think-cell):
|
||||||
- Max ${maxSlides} slides
|
- Max ${maxSlides} slides
|
||||||
- Headline ASSERTIVE, max 10 mots (pas "Contexte", "Introduction")
|
- Headline ASSERTIVE, max 10 mots (INTERDIT: "Contexte", "Introduction", "Présentation", "Overview", "Points clés", "Conclusion" seuls)
|
||||||
- keyPoints: 2–5 faits de la note par slide
|
- 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
|
- 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
|
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
|
||||||
- Slide 1 = title, dernière = summary
|
- Slide 1 = title, dernière = summary
|
||||||
|
- INTERDIT slides vides, listes de 1 mot, ou répéter le titre de la note
|
||||||
${mathRule}
|
${mathRule}
|
||||||
Réponds en JSON OutlineSchema.`
|
Réponds en JSON OutlineSchema.`
|
||||||
}
|
}
|
||||||
@@ -242,11 +244,13 @@ Output ONLY outline JSON (not full slide bodies).
|
|||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Max ${maxSlides} slides
|
- Max ${maxSlides} slides
|
||||||
- ASSERTIVE headlines, max 10 words
|
- ASSERTIVE headlines, max 10 words (FORBIDDEN alone: Context, Introduction, Overview, Key points, Conclusion)
|
||||||
- keyPoints: 2–5 facts from the note per slide
|
- 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
|
- Pedagogical arc for lessons: title → definitions/equations → properties → examples → summary
|
||||||
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
|
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
|
||||||
- First=title, last=summary
|
- First=title, last=summary
|
||||||
|
- FORBIDDEN empty slides, one-word lists, or repeating the note title alone
|
||||||
${mathRule}
|
${mathRule}
|
||||||
JSON OutlineSchema only.`
|
JSON OutlineSchema only.`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ export function applyDocumentTheme(theme: string): void {
|
|||||||
root.classList.remove('dark')
|
root.classList.remove('dark')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
root.style.colorScheme = wantsDark ? 'dark' : 'light'
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
// data-theme pour les palettes nommées uniquement
|
// data-theme pour les palettes nommées uniquement
|
||||||
if (t === 'auto' || t === 'dark' || t === 'light') {
|
if (t === 'auto' || t === 'dark' || t === 'light') {
|
||||||
root.removeAttribute('data-theme')
|
root.removeAttribute('data-theme')
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ export async function getPackPublicPrices(): Promise<PackPublicPrice[]> {
|
|||||||
display,
|
display,
|
||||||
amount,
|
amount,
|
||||||
currency,
|
currency,
|
||||||
configured: configured || isMock,
|
// true seulement si un vrai price_… est en admin/env (pas les mocks)
|
||||||
|
configured,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const CREDIT_COSTS: Record<string, number> = {
|
|||||||
brainstorm_create: 5,
|
brainstorm_create: 5,
|
||||||
brainstorm_expand: 1,
|
brainstorm_expand: 1,
|
||||||
brainstorm_enrich: 1,
|
brainstorm_enrich: 1,
|
||||||
suggest_charts: 1,
|
suggest_charts: 2,
|
||||||
publish_enhance: 3,
|
publish_enhance: 3,
|
||||||
ai_flashcard: 3,
|
ai_flashcard: 3,
|
||||||
voice_transcribe: 1,
|
voice_transcribe: 1,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const THEME_INIT_SCRIPT = `(function () {
|
|||||||
}
|
}
|
||||||
if (wantsDark) root.classList.add('dark');
|
if (wantsDark) root.classList.add('dark');
|
||||||
else root.classList.remove('dark');
|
else root.classList.remove('dark');
|
||||||
|
try { root.style.colorScheme = wantsDark ? 'dark' : 'light'; } catch (e) {}
|
||||||
if (theme === 'auto' || theme === 'dark' || theme === 'light') {
|
if (theme === 'auto' || theme === 'dark' || theme === 'light') {
|
||||||
root.removeAttribute('data-theme');
|
root.removeAttribute('data-theme');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3305,6 +3305,7 @@
|
|||||||
"packMName": "Standard pack",
|
"packMName": "Standard pack",
|
||||||
"packLName": "Power pack",
|
"packLName": "Power pack",
|
||||||
"buyPack": "Buy",
|
"buyPack": "Buy",
|
||||||
|
"packNotConfigured": "Stripe config required",
|
||||||
"packCheckoutSuccess": "Credit pack added to your balance!",
|
"packCheckoutSuccess": "Credit pack added to your balance!",
|
||||||
"packCheckoutFailed": "Could not start pack purchase. Check Stripe config or try again.",
|
"packCheckoutFailed": "Could not start pack purchase. Check Stripe config or try again.",
|
||||||
"creditsWhereTitle": "Credit usage by feature",
|
"creditsWhereTitle": "Credit usage by feature",
|
||||||
@@ -4527,6 +4528,8 @@
|
|||||||
"invalidDescription": "This chart block contains invalid or empty data.",
|
"invalidDescription": "This chart block contains invalid or empty data.",
|
||||||
"editSource": "Edit chart source",
|
"editSource": "Edit chart source",
|
||||||
"suggestions": "Chart Suggestions",
|
"suggestions": "Chart Suggestions",
|
||||||
|
"suggestionsTitle": "Chart suggestions",
|
||||||
|
"creditCostHint": "Estimated cost: 2 AI credits",
|
||||||
"analyzing": "Analyzing your content for chart data…",
|
"analyzing": "Analyzing your content for chart data…",
|
||||||
"debugInfo": "Debug info",
|
"debugInfo": "Debug info",
|
||||||
"escCancel": "Esc Cancel",
|
"escCancel": "Esc Cancel",
|
||||||
|
|||||||
@@ -3311,6 +3311,7 @@
|
|||||||
"packMName": "Pack standard",
|
"packMName": "Pack standard",
|
||||||
"packLName": "Pack intensif",
|
"packLName": "Pack intensif",
|
||||||
"buyPack": "Acheter",
|
"buyPack": "Acheter",
|
||||||
|
"packNotConfigured": "Config Stripe requise",
|
||||||
"packCheckoutSuccess": "Pack de crédits ajouté à votre solde !",
|
"packCheckoutSuccess": "Pack de crédits ajouté à votre solde !",
|
||||||
"packCheckoutFailed": "Échec de l’achat du pack. Vérifiez la config Stripe ou réessayez.",
|
"packCheckoutFailed": "Échec de l’achat du pack. Vérifiez la config Stripe ou réessayez.",
|
||||||
"creditsWhereTitle": "Utilisation des crédits par fonction",
|
"creditsWhereTitle": "Utilisation des crédits par fonction",
|
||||||
@@ -4533,6 +4534,8 @@
|
|||||||
"invalidDescription": "Ce bloc de graphique contient des données invalides ou vides.",
|
"invalidDescription": "Ce bloc de graphique contient des données invalides ou vides.",
|
||||||
"editSource": "Modifier le code source",
|
"editSource": "Modifier le code source",
|
||||||
"suggestions": "Suggestions de graphiques",
|
"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…",
|
"analyzing": "Analyse de votre contenu pour des données graphiques…",
|
||||||
"debugInfo": "Infos de débogage",
|
"debugInfo": "Infos de débogage",
|
||||||
"escCancel": "Échap Annuler",
|
"escCancel": "Échap Annuler",
|
||||||
|
|||||||
Reference in New Issue
Block a user