fix(quotas): unifier le décompte IA (BYOK, rollback) et combler les fuites
Centralise la réserve via ai-quota, corrige admin unavailable (-1), brancher les routes sans quota et le host-pays brainstorm, avec usage-meter élargi, noms de clusters, MCP et ajustements dashboard/insights. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { auth } from '@/auth'
|
||||
import { autoLabelCreationService } from '@/lib/ai/services'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
|
||||
/**
|
||||
* POST /api/ai/auto-labels - Suggest new labels for a notebook
|
||||
@@ -59,11 +60,16 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Get label suggestions
|
||||
const suggestions = await autoLabelCreationService.suggestLabels(
|
||||
notebookId,
|
||||
const suggestions = await withAiQuota(
|
||||
session.user.id,
|
||||
language
|
||||
'auto_tag',
|
||||
() =>
|
||||
autoLabelCreationService.suggestLabels(
|
||||
notebookId,
|
||||
session.user!.id,
|
||||
language,
|
||||
),
|
||||
{ lane: 'tags' },
|
||||
)
|
||||
|
||||
if (!suggestions) {
|
||||
@@ -79,8 +85,10 @@ export async function POST(request: NextRequest) {
|
||||
data: suggestions,
|
||||
})
|
||||
} catch (error) {
|
||||
const quotaResp = handleQuotaHttpError(error)
|
||||
if (quotaResp) return quotaResp
|
||||
console.error('[/api/ai/auto-labels] POST failed:', error)
|
||||
return NextResponse.json({ success: true, data: null })
|
||||
return NextResponse.json({ success: false, error: 'auto_labels_failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { batchOrganizationService } from '@/lib/ai/services'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
|
||||
/**
|
||||
* POST /api/ai/batch-organize - Create organization plan for notes in Inbox
|
||||
@@ -40,6 +41,8 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||
|
||||
// Create organization plan
|
||||
const plan = await batchOrganizationService.createOrganizationPlan(
|
||||
session.user.id,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { auth } from '@/auth'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { describeImages } from '@/lib/ai/services/image-description.service'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -11,7 +12,6 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// GDPR AI Consent check
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
}
|
||||
@@ -27,23 +27,31 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'imageUrls must be a non-empty array' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await describeImages(
|
||||
imageUrls,
|
||||
mode === 'title' ? 'title' : 'description',
|
||||
language || 'fr'
|
||||
const isTitleMode = mode === 'title'
|
||||
const feature = isTitleMode ? 'auto_title' : 'reformulate'
|
||||
const lane = isTitleMode ? 'tags' : 'chat'
|
||||
|
||||
const result = await withAiQuota(
|
||||
session.user.id,
|
||||
feature,
|
||||
() => describeImages(
|
||||
imageUrls,
|
||||
isTitleMode ? 'title' : 'description',
|
||||
language || 'fr'
|
||||
),
|
||||
{ lane },
|
||||
)
|
||||
|
||||
// For title mode, return suggestions in same format as /api/ai/title-suggestions
|
||||
if (mode === 'title' && result.suggestions) {
|
||||
if (isTitleMode && result.suggestions) {
|
||||
return NextResponse.json({ suggestions: result.suggestions })
|
||||
}
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const quotaResp = handleQuotaHttpError(error)
|
||||
if (quotaResp) return quotaResp
|
||||
console.error('[describe-image] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to describe image' },
|
||||
{ status: 500 }
|
||||
)
|
||||
const message = error instanceof Error ? error.message : 'Failed to describe image'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
|
||||
/**
|
||||
* POST /api/ai/echo/fusion
|
||||
@@ -34,7 +35,6 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch the notes
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
id: { in: noteIds },
|
||||
@@ -55,11 +55,9 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Get AI provider
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
|
||||
// Build fusion prompt
|
||||
const notesDescriptions = notes.map((note, index) => {
|
||||
return `Note ${index + 1}: "${note.title || 'Untitled'}"
|
||||
${note.content}`
|
||||
@@ -90,21 +88,21 @@ Output format:
|
||||
|
||||
Begin:`
|
||||
|
||||
try {
|
||||
const fusedContent = await provider.generateText(fusionPrompt)
|
||||
|
||||
return NextResponse.json({
|
||||
fusedNote: fusedContent,
|
||||
notesCount: notes.length
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate fusion' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
const fusedContent = await withAiQuota(
|
||||
session.user.id,
|
||||
'reformulate',
|
||||
() => provider.generateText(fusionPrompt),
|
||||
{ lane: 'chat' },
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
fusedNote: fusedContent,
|
||||
notesCount: notes.length
|
||||
})
|
||||
} catch (error) {
|
||||
const quotaResp = handleQuotaHttpError(error)
|
||||
if (quotaResp) return quotaResp
|
||||
console.error('[/api/ai/echo/fusion] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process fusion request' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
|
||||
/**
|
||||
* GET /api/ai/echo
|
||||
@@ -27,6 +28,7 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Get next insight (respects frequency limits)
|
||||
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||
const insight = await memoryEchoService.getNextInsight(session.user.id)
|
||||
|
||||
if (!insight) {
|
||||
@@ -41,6 +43,9 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ insight })
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof QuotaExceededError) {
|
||||
return NextResponse.json(error.toJSON(), { status: 429 })
|
||||
}
|
||||
console.error('[/api/ai/echo] GET error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Memory Echo insight' },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { auth } from '@/auth'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -32,7 +33,6 @@ export async function POST(request: NextRequest) {
|
||||
let prompt: string
|
||||
|
||||
if (mode === 'complete') {
|
||||
// Add missing info from resource without rewriting the existing note
|
||||
prompt = `You are an expert note editor. Your task is to enrich an existing note by adding relevant information from a provided resource, WITHOUT modifying or rewriting the existing content.
|
||||
|
||||
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
|
||||
@@ -56,7 +56,6 @@ INSTRUCTIONS:
|
||||
- Format the new content consistently with the existing note style and the requested FORMAT RULE
|
||||
- Respond ONLY with the enriched note content, no explanations`
|
||||
} else {
|
||||
// Merge: intelligently rewrite integrating both sources
|
||||
prompt = `You are an expert note writer. Your task is to intelligently merge an existing note with a resource into a single, coherent, well-structured document.
|
||||
|
||||
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
|
||||
@@ -81,14 +80,19 @@ INSTRUCTIONS:
|
||||
- Respond ONLY with the merged content, no meta-commentary or explanations`
|
||||
}
|
||||
|
||||
const enrichedContent = await provider.generateText(prompt)
|
||||
const enrichedContent = await withAiQuota(
|
||||
session.user.id,
|
||||
'reformulate',
|
||||
() => provider.generateText(prompt),
|
||||
{ lane: 'chat' },
|
||||
)
|
||||
|
||||
return NextResponse.json({ enrichedContent: enrichedContent.trim() })
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const quotaResp = handleQuotaHttpError(error)
|
||||
if (quotaResp) return quotaResp
|
||||
console.error('[enrich-from-resource] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to enrich content' },
|
||||
{ status: 500 }
|
||||
)
|
||||
const message = error instanceof Error ? error.message : 'Failed to enrich content'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { notebookSummaryService } from '@/lib/ai/services'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
|
||||
/**
|
||||
* POST /api/ai/notebook-summary - Generate summary for a notebook
|
||||
@@ -51,6 +52,8 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||
|
||||
// Generate summary
|
||||
const summary = await notebookSummaryService.generateSummary(
|
||||
notebookId,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { notebookOrganizerService } from '@/lib/ai/services/notebook-organizer.service'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
import { syncNoteLabels } from '@/app/actions/notes'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -17,19 +17,6 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'notebookId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
const isTierLocked = err.currentQuota === 0
|
||||
return NextResponse.json(
|
||||
{ error: isTierLocked ? 'feature_locked' : 'quota_exceeded', errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded' },
|
||||
{ status: 402 },
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { notebookId, trashedAt: null, userId: session.user.id },
|
||||
select: { id: true, title: true, content: true },
|
||||
@@ -46,13 +33,20 @@ export async function POST(request: NextRequest) {
|
||||
contentPreview: n.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300),
|
||||
}))
|
||||
|
||||
const result = await notebookOrganizerService.analyze(notesForAnalysis)
|
||||
|
||||
const result = await withAiQuota(
|
||||
session.user.id,
|
||||
'reformulate',
|
||||
() => notebookOrganizerService.analyze(notesForAnalysis),
|
||||
{ lane: 'chat' },
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const quotaResp = handleQuotaHttpError(error)
|
||||
if (quotaResp) return quotaResp
|
||||
console.error('[Notebook Organizer] Error:', error)
|
||||
return NextResponse.json({ error: error.message || 'Failed to organize notebook' }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : 'Failed to organize notebook'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { rateLimit } from '@/lib/rate-limit'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { searchOverviewService } from '@/lib/ai/services/search-overview.service'
|
||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -15,11 +15,19 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'query and results required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const overview = await searchOverviewService.generate(query, results, language)
|
||||
const overview = await withAiQuota(
|
||||
session.user.id,
|
||||
'semantic_search',
|
||||
() => searchOverviewService.generate(query, results, language),
|
||||
{ lane: 'chat' },
|
||||
)
|
||||
|
||||
return NextResponse.json(overview)
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const quotaResp = handleQuotaHttpError(error)
|
||||
if (quotaResp) return quotaResp
|
||||
console.error('[Search Overview] Error:', error)
|
||||
return NextResponse.json({ error: error.message, hasRelevantInfo: false, answer: '' }, { status: 500 })
|
||||
const message = error instanceof Error ? error.message : 'Search overview failed'
|
||||
return NextResponse.json({ error: message, hasRelevantInfo: false, answer: '' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -21,7 +22,6 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'noteContent is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Minimum content length for suggestion (20 words as per specs)
|
||||
const wordCount = noteContent.trim().split(/\s+/).length
|
||||
if (wordCount < 20) {
|
||||
return NextResponse.json({
|
||||
@@ -31,18 +31,25 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
// Get suggestion from AI service
|
||||
const suggestedNotebook = await notebookSuggestionService.suggestNotebook(
|
||||
noteContent,
|
||||
const suggestedNotebook = await withAiQuota(
|
||||
session.user.id,
|
||||
language
|
||||
'auto_tag',
|
||||
() => notebookSuggestionService.suggestNotebook(
|
||||
noteContent,
|
||||
session.user!.id,
|
||||
language
|
||||
),
|
||||
{ lane: 'tags' },
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
suggestion: suggestedNotebook,
|
||||
confidence: suggestedNotebook ? 0.8 : 0 // Placeholder confidence score
|
||||
confidence: suggestedNotebook ? 0.8 : 0
|
||||
})
|
||||
} catch (error) {
|
||||
const quotaResp = handleQuotaHttpError(error)
|
||||
if (quotaResp) return quotaResp
|
||||
console.error('[/api/ai/suggest-notebook] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate suggestion' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { rateLimit } from '@/lib/rate-limit'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
|
||||
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { auth } from '@/auth'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||
import { z } from 'zod'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
|
||||
@@ -61,19 +62,16 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, session.user.id)
|
||||
if (!willUseByok) {
|
||||
try {
|
||||
await reserveUsageOrThrow(session.user.id, 'auto_title')
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
return NextResponse.json(err.toJSON(), { status: 402 })
|
||||
}
|
||||
if (err instanceof QuotaServiceUnavailableError || process.env.NODE_ENV === 'production') {
|
||||
return NextResponse.json({ error: 'QUOTA_SERVICE_UNAVAILABLE' }, { status: 503 })
|
||||
}
|
||||
console.error('[/api/ai/title-suggestions] Quota check error (fail-open):', err)
|
||||
try {
|
||||
await reserveAiUsageOrThrow(session.user.id, 'auto_title', { lane: 'tags' })
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
return NextResponse.json(err.toJSON(), { status: 402 })
|
||||
}
|
||||
if (err instanceof QuotaServiceUnavailableError || process.env.NODE_ENV === 'production') {
|
||||
return NextResponse.json({ error: 'QUOTA_SERVICE_UNAVAILABLE' }, { status: 503 })
|
||||
}
|
||||
console.error('[/api/ai/title-suggestions] Quota check error (fail-open):', err)
|
||||
}
|
||||
|
||||
// Détecter la langue du contenu (simple détection basée sur les caractères et mots)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { auth } from '@/auth'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -18,12 +19,10 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const { text } = await request.json()
|
||||
|
||||
// Validation
|
||||
if (!text || typeof text !== 'string') {
|
||||
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate word count
|
||||
const wordCount = text.split(/\s+/).length
|
||||
if (wordCount < 10) {
|
||||
return NextResponse.json(
|
||||
@@ -42,11 +41,9 @@ export async function POST(request: NextRequest) {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
|
||||
// Detect language from text
|
||||
const hasFrench = /[àâäéèêëïîôùûüÿç]/i.test(text)
|
||||
const responseLanguage = hasFrench ? 'French' : 'English'
|
||||
|
||||
// Build prompt to transform text to Markdown
|
||||
const prompt = hasFrench
|
||||
? `Tu es un expert en Markdown. Transforme ce texte ${responseLanguage} en Markdown bien formaté.
|
||||
|
||||
@@ -77,19 +74,22 @@ ${text}
|
||||
|
||||
Respond ONLY with the transformed Markdown text, no explanations.`
|
||||
|
||||
|
||||
const transformedText = await provider.generateText(prompt)
|
||||
const transformedText = await withAiQuota(
|
||||
session.user.id,
|
||||
'reformulate',
|
||||
() => provider.generateText(prompt),
|
||||
{ lane: 'chat' },
|
||||
)
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
originalText: text,
|
||||
transformedText: transformedText,
|
||||
transformedText,
|
||||
language: responseLanguage
|
||||
})
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to transform text to Markdown' },
|
||||
{ status: 500 }
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
const quotaResp = handleQuotaHttpError(error)
|
||||
if (quotaResp) return quotaResp
|
||||
const message = error instanceof Error ? error.message : 'Failed to transform text to Markdown'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { auth } from '@/auth'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -24,12 +25,18 @@ export async function POST(request: NextRequest) {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||
|
||||
const prompt = `Translate the following text to ${targetLanguage}. Return ONLY the translated text, no explanation, no preamble, no quotes:\n\n${text}`
|
||||
|
||||
const translatedText = await provider.generateText(prompt)
|
||||
|
||||
return NextResponse.json({ translatedText: translatedText.trim() })
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message || 'Translation failed' }, { status: 500 })
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof QuotaExceededError) {
|
||||
return NextResponse.json(error.toJSON(), { status: 429 })
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Translation failed'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { verifyParticipant } from '@/lib/brainstorm-collab'
|
||||
import { billingOwnerFromSession, verifyParticipant } from '@/lib/brainstorm-collab'
|
||||
import { withSessionAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
|
||||
function localeToLanguageName(locale?: string): string {
|
||||
const map: Record<string, string> = {
|
||||
@@ -53,6 +54,11 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'No ideas to summarize' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { billingOwnerId, isGuestActor } = billingOwnerFromSession(
|
||||
brainstormSession.userId,
|
||||
session.user.id,
|
||||
)
|
||||
|
||||
const ideasText = ideas
|
||||
.map((idea, i) => `${i + 1}. [Wave ${idea.waveNumber}] ${idea.title}: ${idea.description}`)
|
||||
.join('\n')
|
||||
@@ -79,15 +85,27 @@ Write a concise synthesis (4-6 sentences) that:
|
||||
Be direct and action-oriented. No bullet points, just flowing prose.`
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const { result: summary } = await runLaneWithBillingUser(
|
||||
'tags',
|
||||
config,
|
||||
const summary = await withSessionAiQuota(
|
||||
billingOwnerId,
|
||||
session.user.id,
|
||||
(provider) => provider.generateText(prompt),
|
||||
isGuestActor,
|
||||
'brainstorm_enrich',
|
||||
async () => {
|
||||
const { result } = await runLaneWithBillingUser(
|
||||
'tags',
|
||||
config,
|
||||
billingOwnerId,
|
||||
(provider) => provider.generateText(prompt),
|
||||
)
|
||||
return result
|
||||
},
|
||||
{ lane: 'tags' },
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, data: { summary: summary.trim() } })
|
||||
} catch (error) {
|
||||
const quotaResp = handleQuotaHttpError(error)
|
||||
if (quotaResp) return quotaResp
|
||||
console.error('Error summarizing brainstorm:', error)
|
||||
return NextResponse.json({ error: 'Failed to summarize' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function GET() {
|
||||
notebookId: true, updatedAt: true, createdAt: true,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 6,
|
||||
take: 8,
|
||||
}),
|
||||
|
||||
prisma.note.count({
|
||||
|
||||
@@ -5,6 +5,8 @@ import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { redis } from '@/lib/redis'
|
||||
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
|
||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
import { QuotaExceededError } from '@/lib/entitlements'
|
||||
|
||||
const CACHE_TTL_SEC = 3600
|
||||
|
||||
@@ -70,7 +72,12 @@ Write "summary" and "topTopic" in ${localeLabel} (locale code: ${locale}). Keep
|
||||
Notes:
|
||||
${snippets.slice(0, 3000)}`
|
||||
|
||||
const result = await provider.generateText(prompt)
|
||||
const result = await withAiQuota(
|
||||
userId,
|
||||
'reformulate',
|
||||
() => provider.generateText(prompt),
|
||||
{ lane: 'chat' },
|
||||
)
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/)
|
||||
if (!jsonMatch) {
|
||||
return NextResponse.json({ available: false, reason: 'Parse error' })
|
||||
@@ -81,6 +88,11 @@ ${snippets.slice(0, 3000)}`
|
||||
try { await redis.setex(cacheKey, CACHE_TTL_SEC, JSON.stringify(payload)) } catch {}
|
||||
return NextResponse.json(payload)
|
||||
} catch (error) {
|
||||
const quotaResp = handleQuotaHttpError(error)
|
||||
if (quotaResp) return quotaResp
|
||||
if (error instanceof QuotaExceededError) {
|
||||
return NextResponse.json(error.toJSON(), { status: 402 })
|
||||
}
|
||||
console.error('[briefing/sentiment]', error)
|
||||
return NextResponse.json({ available: false, reason: 'Analysis failed' })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { streamText, UIMessage, stepCountIs } from 'ai'
|
||||
import { resolveAiRouteWithTiming, formatAiRouteDebug } from '@/lib/ai/router'
|
||||
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
|
||||
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
|
||||
@@ -9,7 +9,8 @@ import { auth } from '@/auth'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
||||
import { toolRegistry } from '@/lib/ai/tools'
|
||||
import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||
import { reserveAiUsageOrThrow, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||
import { ByokUnavailableError } from '@/lib/byok'
|
||||
import { trackFeatureUsage } from '@/lib/usage-tracker'
|
||||
import { readFile } from 'fs/promises'
|
||||
@@ -72,11 +73,7 @@ export async function POST(req: Request) {
|
||||
|
||||
// 1.5 Quota check (per-provider BYOK bypass — only when BYOK will be used for resolved provider)
|
||||
try {
|
||||
const sysConfigEarly = await getSystemConfig()
|
||||
const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId)
|
||||
if (!willUseByok) {
|
||||
await reserveUsageOrThrow(userId, 'chat')
|
||||
}
|
||||
await reserveAiUsageOrThrow(userId, 'chat', { lane: 'chat' })
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
return Response.json(err.toJSON(), { status: 402 })
|
||||
|
||||
@@ -18,6 +18,9 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Fix any leftover "Cluster N" placeholders from pre-save naming bug
|
||||
await clusteringService.ensureClusterNames(userId)
|
||||
|
||||
// Check for stored results (even if stale/périmés)
|
||||
const stored = await clusteringService.getStoredClusters(userId)
|
||||
const lite = request.nextUrl.searchParams.get('lite') === '1'
|
||||
@@ -51,7 +54,11 @@ export async function GET(request: NextRequest) {
|
||||
bridgeScore: b.bridgeScore,
|
||||
clustersConnected,
|
||||
clusterNames: clustersConnected.map(
|
||||
cid => cached.find(c => c.clusterId === cid)?.name || `Cluster ${cid}`,
|
||||
cid =>
|
||||
clusteringService.displayName(
|
||||
cached.find(c => c.clusterId === cid)?.name,
|
||||
cid,
|
||||
),
|
||||
),
|
||||
note: bridgeNoteDetailsMap.get(b.noteId),
|
||||
}
|
||||
@@ -112,8 +119,11 @@ export async function GET(request: NextRequest) {
|
||||
noteId: b.noteId,
|
||||
bridgeScore: b.bridgeScore,
|
||||
clustersConnected,
|
||||
clusterNames: clustersConnected.map(
|
||||
cid => cached.find(c => c.clusterId === cid)?.name || `Cluster ${cid}`
|
||||
clusterNames: clustersConnected.map(cid =>
|
||||
clusteringService.displayName(
|
||||
cached.find(c => c.clusterId === cid)?.name,
|
||||
cid,
|
||||
),
|
||||
),
|
||||
note: bridgeNoteDetailsMap.get(b.noteId)
|
||||
}
|
||||
@@ -199,12 +209,10 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Generate cluster names with AI
|
||||
for (const cluster of results.clusters) {
|
||||
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, userId)
|
||||
}
|
||||
// 2. Name from in-memory members (DB members do not exist until save)
|
||||
await clusteringService.nameClustersFromResults(userId, results)
|
||||
|
||||
// 3. Save clustering results
|
||||
// 3. Save clustering results (with real names)
|
||||
await clusteringService.saveClusteringResults(userId, results)
|
||||
|
||||
// 4. Detect and save bridge notes
|
||||
|
||||
@@ -102,10 +102,8 @@ export async function GET(request: NextRequest) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Generate cluster names
|
||||
for (const cluster of clusterResults.clusters) {
|
||||
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, user.id)
|
||||
}
|
||||
// Name from in-memory members (before save — generateClusterName needs DB rows)
|
||||
await clusteringService.nameClustersFromResults(user.id, clusterResults)
|
||||
|
||||
// Save results
|
||||
await clusteringService.saveClusteringResults(user.id, clusterResults)
|
||||
@@ -174,9 +172,9 @@ export async function POST(request: NextRequest) {
|
||||
if (userId) {
|
||||
// Process specific user
|
||||
const clusterResults = await clusteringService.clusterNotes(userId)
|
||||
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
|
||||
|
||||
await clusteringService.nameClustersFromResults(userId, clusterResults)
|
||||
await clusteringService.saveClusteringResults(userId, clusterResults)
|
||||
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
|
||||
await bridgeNotesService.saveBridgeNotes(userId, bridgeNotes)
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -202,9 +200,9 @@ export async function POST(request: NextRequest) {
|
||||
for (const user of users) {
|
||||
try {
|
||||
const clusterResults = await clusteringService.clusterNotes(user.id)
|
||||
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(user.id)
|
||||
|
||||
await clusteringService.nameClustersFromResults(user.id, clusterResults)
|
||||
await clusteringService.saveClusteringResults(user.id, clusterResults)
|
||||
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(user.id)
|
||||
await bridgeNotesService.saveBridgeNotes(user.id, bridgeNotes)
|
||||
|
||||
results.processed++
|
||||
|
||||
@@ -77,12 +77,12 @@ export async function POST(req: NextRequest) {
|
||||
redis.get(`${key}:tokens`),
|
||||
]);
|
||||
|
||||
const periodStart = new Date(`${period}-01`);
|
||||
const periodEnd = new Date(
|
||||
periodStart.getFullYear(),
|
||||
periodStart.getMonth() + 1,
|
||||
const periodStart = new Date(`${period}-01T00:00:00.000Z`);
|
||||
const periodEnd = new Date(Date.UTC(
|
||||
periodStart.getUTCFullYear(),
|
||||
periodStart.getUTCMonth() + 1,
|
||||
1,
|
||||
);
|
||||
));
|
||||
|
||||
await prisma.usageLog.upsert({
|
||||
where: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMobileUserId } from '@/lib/mobile-auth'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const userId = await getMobileUserId(req)
|
||||
@@ -18,6 +19,15 @@ export async function POST(req: NextRequest) {
|
||||
const apiKey = config.OPENAI_API_KEY
|
||||
if (!apiKey) return NextResponse.json({ error: 'Service non disponible' }, { status: 503 })
|
||||
|
||||
try {
|
||||
await reserveUsageOrThrow(userId, 'voice_transcribe')
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
return NextResponse.json(err.toJSON(), { status: 429 })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const whisperForm = new FormData()
|
||||
whisperForm.append('file', file, 'audio.m4a')
|
||||
whisperForm.append('model', 'whisper-1')
|
||||
|
||||
Reference in New Issue
Block a user