From 5bd64fcb526f789d8c8494c0b083f8853a3e5250 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 20:32:32 +0000 Subject: [PATCH 1/9] WIP: per-session quota for Crossfire and Sparring, refunds for failed runs --- .../features/sparring/SparringSetup.tsx | 23 +- .../features/subscription/SessionCostNote.tsx | 28 ++ src/components/features/subscription/index.ts | 1 + src/hooks/use-session-quota.ts | 41 +++ src/lib/ai/client.ts | 2 + src/lib/ai/error-display.ts | 8 +- src/lib/ai/errors.ts | 15 + src/lib/ai/features/crossfire.ts | 4 +- src/lib/ai/features/session-quota.ts | 62 +++++ src/lib/ai/request.ts | 16 ++ src/locales/en.json | 22 +- src/locales/es.json | 22 +- src/locales/hu.json | 22 +- src/pages/AskPage.tsx | 20 +- src/pages/CrossfirePage.tsx | 44 ++- src/pages/SparringPage.tsx | 24 +- .../crossfire/crossfire-daily-limit.test.tsx | 28 ++ src/test/pages/CrossfireVoice.test.tsx | 26 +- src/test/pages/SparringVoice.test.tsx | 4 + .../functions/_shared/entitlements.test.ts | 22 +- supabase/functions/_shared/entitlements.ts | 202 ++++++++++---- supabase/functions/_shared/usage-limits.ts | 22 ++ supabase/functions/ai-stream/index.ts | 31 ++- supabase/functions/ai/index.ts | 83 +++++- supabase/functions/ai/provider-errors_test.ts | 33 ++- supabase/functions/ai/quota-sessions_test.ts | 260 ++++++++++++++++++ supabase/functions/ai/stream.test.ts | 2 + supabase/functions/ai/voice.test.ts | 2 + .../20261022190000_ai_quota_sessions.sql | 207 ++++++++++++++ supabase/tests/account_deletion_test.sql | 1 + supabase/tests/ai_quota_sessions_test.sql | 141 ++++++++++ 31 files changed, 1272 insertions(+), 146 deletions(-) create mode 100644 src/components/features/subscription/SessionCostNote.tsx create mode 100644 src/hooks/use-session-quota.ts create mode 100644 src/lib/ai/features/session-quota.ts create mode 100644 supabase/functions/ai/quota-sessions_test.ts create mode 100644 supabase/migrations/20261022190000_ai_quota_sessions.sql create mode 100644 supabase/tests/ai_quota_sessions_test.sql diff --git a/src/components/features/sparring/SparringSetup.tsx b/src/components/features/sparring/SparringSetup.tsx index df651d15e..cc72c423f 100644 --- a/src/components/features/sparring/SparringSetup.tsx +++ b/src/components/features/sparring/SparringSetup.tsx @@ -21,6 +21,7 @@ import { import type { SparringDifficulty, SparringFormat, SparringSide } from '@/lib/ai/schemas/sparring'; import { VoiceModeToggle } from '@/components/features/voice/VoiceModeToggle'; import { useVoiceAvailability } from '@/lib/voice/availability'; +import { SessionCostNote } from '@/components/features/subscription/SessionCostNote'; export interface SparringSetupValues { motion: string; @@ -39,8 +40,12 @@ interface SparringSetupProps { /** Fill the motion from the Current Debate. */ debatePrefill?: boolean; onStart: (values: SparringSetupValues) => void; - /** AI requests left today, or null when unknown. A round short of requests gets a warning. */ + /** AI requests the whole round costs (every reply and the judge), or null when unknown. */ + roundCost?: number | null; + /** AI requests left today, or null when unknown. A round that does not fit gets a warning. */ remainingRequests?: number | null; + /** Why the server would refuse a new round (weekly allowance, trial ended), translated. */ + blockedReason?: string | null; isStarting?: boolean; initialValues?: Partial; } @@ -51,7 +56,7 @@ const DIFFICULTIES: Array<{ id: SparringDifficulty; name: string }> = [ { id: 'hard', name: 'Ruthless' }, ]; -export function SparringSetup({ onStart, remainingRequests = null, isStarting = false, initialValues, banner, debatePrefill = false, voiceAllowed = true }: SparringSetupProps) { +export function SparringSetup({ onStart, roundCost = null, remainingRequests = null, blockedReason = null, isStarting = false, initialValues, banner, debatePrefill = false, voiceAllowed = true }: SparringSetupProps) { const { t } = useTranslation(); const [motion, setMotion] = useState(initialValues?.motion ?? ''); const motionRef = useRef(motion); @@ -67,9 +72,9 @@ export function SparringSetup({ onStart, remainingRequests = null, isStarting = const selectedFormat = useMemo(() => SPARRING_FORMATS.find((item) => item.id === format) ?? SPARRING_FORMATS[0], [format]); const plannedTurns = useMemo(() => buildSparringTurns(format, userSide), [format, userSide]); const aiRequests = plannedTurns.filter((turn) => turn.speaker === 'ai').length; - const totalRequests = aiRequests + 1; - // An estimate (the server can raise a limit the browser cannot see), so it warns rather than blocks. - const requestShortfall = remainingRequests !== null && remainingRequests < totalRequests; + // The whole round (every reply and the judge) is charged once, by model weight. + const cost = roundCost ?? 1; + const requestShortfall = remainingRequests !== null && remainingRequests < cost; const canStart = motion.trim().length >= 8 && !isStarting && isOnline; const handleSubmit = (event: React.FormEvent) => { @@ -243,8 +248,8 @@ export function SparringSetup({ onStart, remainingRequests = null, isStarting =

{t('sparring.setup.aiRequests', 'AI requests')}

-

{totalRequests}

-

{t('sparring.setup.aiRequestBreakdown', '{{count}} replies + 1 judge', { count: aiRequests })}

+

{cost}

+

{t('sparring.setup.aiRequestBreakdown', { count: aiRequests })}

@@ -263,9 +268,11 @@ export function SparringSetup({ onStart, remainingRequests = null, isStarting =

{remainingRequests === 0 ? t('dailyLimit.sparringNone') - : t('dailyLimit.sparringShort', { needed: totalRequests, remaining: remainingRequests })} + : t('dailyLimit.sparringShort', { needed: cost, remaining: remainingRequests })}

)} + {/* The shortfall note above already explains a day that is used up. */} + {!isOnline &&

{t('sparring.setup.offlineCannotStart', 'Reconnect to start a round. Your setup is kept while you are offline.')}

}

{t('sparring.setup.draftNotice', 'Your draft is saved locally as you type. You can pause without losing your place.')}

diff --git a/src/components/features/subscription/SessionCostNote.tsx b/src/components/features/subscription/SessionCostNote.tsx new file mode 100644 index 000000000..87e209141 --- /dev/null +++ b/src/components/features/subscription/SessionCostNote.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from 'react-i18next'; +import { cn } from '@/lib/utils'; + +interface SessionCostNoteProps { + units: number | null; + remaining: number | null; + /** Why a new round would be refused, already translated. Shown instead of the cost. */ + blocked?: string | null; + className?: string; +} + +/** "This round costs 1 AI request, however many turns it takes. You have N left today." */ +export function SessionCostNote({ units, remaining, blocked = null, className }: SessionCostNoteProps) { + const { t } = useTranslation(); + if (blocked) { + return ( +

+ {blocked} +

+ ); + } + return ( +

+ {t('sessionQuota.cost', { count: units ?? 1 })} + {remaining !== null && ` ${t('sessionQuota.remaining', { count: remaining })}`} +

+ ); +} diff --git a/src/components/features/subscription/index.ts b/src/components/features/subscription/index.ts index a5ac7f632..9c55cd365 100644 --- a/src/components/features/subscription/index.ts +++ b/src/components/features/subscription/index.ts @@ -2,3 +2,4 @@ export { PricingModal } from './PricingModal'; export { LimitReachedModal } from './LimitReachedModal'; export { ModelRestrictedModal } from './ModelRestrictedModal'; export { SubscriptionErrorModals } from './SubscriptionErrorModals'; +export { SessionCostNote } from './SessionCostNote'; diff --git a/src/hooks/use-session-quota.ts b/src/hooks/use-session-quota.ts new file mode 100644 index 000000000..00e2c90c2 --- /dev/null +++ b/src/hooks/use-session-quota.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { checkSessionQuota, isAISubscriptionError, sessionQuotaError, sessionQuotaRemaining, type AIError } from '@/lib/ai/client'; +import type { ProviderConfig } from '@/lib/ai/providers/types'; +import { useAuth } from '@/lib/supabase/AuthProvider'; + +export interface SessionQuotaState { + /** Quota units a whole round costs on this model, or null while unknown. */ + units: number | null; + /** AI requests left today, or null while unknown. */ + remaining: number | null; + /** Why a new round would be refused (daily limit, weekly allowance, trial ended), translated; null when it fits or is unknown. */ + blocked: string | null; + /** Reads the quota again; the error a new round would meet, or null when it fits or is unknown. */ + checkBeforeStart: () => Promise; +} + +/** + * What a new Crossfire or Sparring round costs on this model and whether + * today's quota covers it, from the server. Never blocks on an unknown answer: + * the round's own requests are still checked. + */ +export function useSessionQuota(feature: 'crossfire' | 'sparring', config: ProviderConfig, enabled = true): SessionQuotaState { + const { user } = useAuth(); + const query = useQuery({ + queryKey: ['session-quota', user?.id, feature, config.model], + queryFn: () => checkSessionQuota(feature, config), + enabled: enabled && Boolean(user?.id), + staleTime: 15_000, + }); + const { refetch } = query; + const checkBeforeStart = useCallback(async () => sessionQuotaError((await refetch()).data ?? null), [refetch]); + const quota = query.data ?? null; + const refusal = sessionQuotaError(quota); + return { + units: quota?.units ?? null, + remaining: sessionQuotaRemaining(quota), + blocked: isAISubscriptionError(refusal) ? refusal.message : null, + checkBeforeStart, + }; +} diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts index ecf60ea6b..db7b827c1 100644 --- a/src/lib/ai/client.ts +++ b/src/lib/ai/client.ts @@ -43,4 +43,6 @@ export { factoids } from './features/factoids'; export { askChat, boundAskMessage, boundAskHistory, isAskMessageWithinBudget } from './features/ask'; export type { AskChatOptions } from './features/ask'; export { generateSparringTurn, judgeSparringSession } from './features/sparring'; +export { checkSessionQuota, sessionQuotaError, sessionQuotaRemaining } from './features/session-quota'; +export type { SessionQuota } from './features/session-quota'; export type { SparringTurnResult } from './features/sparring'; diff --git a/src/lib/ai/error-display.ts b/src/lib/ai/error-display.ts index 186a2af74..f900af39d 100644 --- a/src/lib/ai/error-display.ts +++ b/src/lib/ai/error-display.ts @@ -1,5 +1,5 @@ import i18n from '@/i18n'; -import { AIProviderError, isAINetworkError, isAISubscriptionError } from './errors'; +import { AIError, AIProviderError, isAINetworkError, isAISubscriptionError } from './errors'; import { subscriptionLimitMessage } from './limit-message'; const PROVIDER_LABELS: Record = { @@ -21,6 +21,12 @@ function providerLabel(provider?: string, message = ''): string { export { subscriptionLimitMessage }; export function getUserFacingAIError(error: unknown): string { + const message = describeAIError(error); + // The server gave the request back, so say so: a failed run costs nothing. + return error instanceof AIError && error.notCharged ? `${message} ${i18n.t('aiErrors.notCharged')}` : message; +} + +function describeAIError(error: unknown): string { if (isAINetworkError(error)) return i18n.t('aiErrors.offline'); if (isAISubscriptionError(error)) return subscriptionLimitMessage(error); diff --git a/src/lib/ai/errors.ts b/src/lib/ai/errors.ts index d3996e304..cb8159743 100644 --- a/src/lib/ai/errors.ts +++ b/src/lib/ai/errors.ts @@ -8,6 +8,8 @@ export class AIError extends Error { readonly statusCode?: number; readonly requestId?: string; readonly details?: Record; + /** The server gave this failed request's quota back, or it ran in a session that had already paid. */ + notCharged = false; constructor( message: string, @@ -295,6 +297,19 @@ export function mapAIError( provider: string, model: string, payload?: Record | null +): AIError { + const mapped = mapAIErrorPayload(error, requestId, provider, model, payload); + // The server says when a failed request cost nothing (it gave the quota back). + if (payload?.charged === false) mapped.notCharged = true; + return mapped; +} + +function mapAIErrorPayload( + error: { message: string; context?: unknown }, + requestId: string, + provider: string, + model: string, + payload?: Record | null ): AIError { const payloadMessage = typeof payload?.error === 'string' ? payload.error : undefined; const message = payloadMessage || error.message || 'Unknown error'; diff --git a/src/lib/ai/features/crossfire.ts b/src/lib/ai/features/crossfire.ts index 9b20d59da..70cf76167 100644 --- a/src/lib/ai/features/crossfire.ts +++ b/src/lib/ai/features/crossfire.ts @@ -31,7 +31,7 @@ function validateCrossfireTurnReferences(input: CrossfireAIInput, debrief: Cross export async function crossfire( rawInput: CrossfireAIInput, config: ProviderConfig, - options: Pick = {}, + options: Pick = {}, ): Promise<(CrossfireReply & { signalToken?: string; voice?: VoicePayload }) | CrossfireDebrief> { const input = CrossfireAIInputSchema.safeParse(rawInput); if (!input.success) { @@ -52,6 +52,8 @@ export async function crossfire( debateId: options.debateId, voice: options.voice && input.data.operation === 'next-turn', live: options.live, + // The round is charged once: every turn and the debrief name it. + quotaSession: options.quotaSession, // Turns keep the round's Review choices; the debrief ends the round. keepContextChoices: input.data.operation === 'next-turn', transformResponse: (rawData) => { diff --git a/src/lib/ai/features/session-quota.ts b/src/lib/ai/features/session-quota.ts new file mode 100644 index 000000000..cbc0fb644 --- /dev/null +++ b/src/lib/ai/features/session-quota.ts @@ -0,0 +1,62 @@ +import type { ProviderConfig } from '../providers/types'; +import { getActiveAccessToken, invokeAuthenticatedFunction } from '@/lib/supabase/client'; +import { AIModelRestrictionError, AISubscriptionError, type AIError } from '../errors'; +import { localizedLimitMessage } from '../limit-message'; + +/** What a new Crossfire or Sparring round costs, and whether today's quota covers it. */ +export interface SessionQuota { + allowed: boolean; + /** Why the server would refuse: limit_reached, weekly_budget_reached, trial_expired, upgrade_required, banned. */ + code?: string; + limit?: number; + current?: number; + /** Quota units the whole round costs: one use, weighted by the model. */ + units: number; +} + +function numberOrUndefined(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +/** + * Asks the server what a round on this model costs and what is left today, + * without reserving anything. Null when it cannot tell (signed out, offline, + * or a server from before session charging); the round's own requests are + * still checked, so null never blocks a start. + */ +export async function checkSessionQuota(feature: 'crossfire' | 'sparring', config: ProviderConfig): Promise { + const accessToken = getActiveAccessToken(); + if (!accessToken) return null; + try { + const { data, error } = await invokeAuthenticatedFunction>('ai', { + headers: { Authorization: `Bearer ${accessToken}` }, + body: { feature, model: config.model, quotaCheck: true }, + }); + const quota = !error && data?.quota && typeof data.quota === 'object' ? data.quota as Record : null; + if (!quota || typeof quota.allowed !== 'boolean') return null; + return { + allowed: quota.allowed, + code: typeof quota.code === 'string' ? quota.code : undefined, + limit: numberOrUndefined(quota.limit), + current: numberOrUndefined(quota.current), + units: numberOrUndefined(quota.units) ?? 1, + }; + } catch { + return null; + } +} + +/** Requests left today by this check, or null when unknown. */ +export function sessionQuotaRemaining(quota: SessionQuota | null | undefined): number | null { + if (!quota || quota.limit === undefined || quota.current === undefined) return null; + return Math.max(0, quota.limit - quota.current); +} + +/** The error a refused round would have met, for the same limit dialog every tool uses. */ +export function sessionQuotaError(quota: SessionQuota | null | undefined): AIError | null { + if (!quota || quota.allowed) return null; + if (quota.code === 'upgrade_required') return new AIModelRestrictionError('This model requires a Pro subscription.'); + if (quota.code !== 'limit_reached' && quota.code !== 'weekly_budget_reached' && quota.code !== 'trial_expired') return null; + const details = { code: quota.code, limit: quota.limit, current: quota.current, units: quota.units }; + return new AISubscriptionError(localizedLimitMessage('', details), { statusCode: 402, ...details }); +} diff --git a/src/lib/ai/request.ts b/src/lib/ai/request.ts index 06dc7be68..6d6da1ff0 100644 --- a/src/lib/ai/request.ts +++ b/src/lib/ai/request.ts @@ -41,6 +41,8 @@ import { readLiveResponse, type LiveGeneration } from './live'; export const REQUEST_TIMEOUT_MS = 200 * 1000; const MAX_CLIENT_ERROR_BODY_BYTES = 16_000; const MAX_CLIENT_REQUEST_BODY_BYTES = 190_000; +// The session ids the ai function accepts (parseQuotaSessionKey). +const QUOTA_SESSION_KEY = /^[A-Za-z0-9._:-]{1,128}$/; export interface AIRequestOptions { includeActiveSources?: boolean; @@ -60,6 +62,12 @@ export interface AIRequestOptions { voice?: boolean; /** Show the answer while the model writes it. */ live?: LiveGeneration; + /** + * The Crossfire round this request belongs to. The server charges a round + * once, however many turns it takes (Sparring rounds are named by their + * input's sessionId instead). + */ + quotaSession?: string; } /** @@ -184,6 +192,8 @@ export async function callSupabaseAI( }); }; + // The server said this failure cost nothing (refunded, or a paid session). + let notCharged = false; const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort(); @@ -222,6 +232,10 @@ export async function callSupabaseAI( workspaceContext: options.workspaceContext, // Beside `input`, so a server from before voice rounds ignores it. ...(options.voice ? { voice: true } : {}), + // Beside `input` for the same reason: older servers charge per call. An id + // the server would reject (a round restored from an old draft) is left + // out, and that round is charged per call. + ...(options.quotaSession && QUOTA_SESSION_KEY.test(options.quotaSession) ? { quotaSession: options.quotaSession } : {}), }; if (utf8ByteLength(JSON.stringify(requestBody)) > MAX_CLIENT_REQUEST_BODY_BYTES) { @@ -262,6 +276,7 @@ export async function callSupabaseAI( // Handle response with error field (from edge function) if (data?.error) { + notCharged = data.charged === false; const errorMessage = data.error as string; const errorCode = data.code as string | undefined; @@ -353,6 +368,7 @@ export async function callSupabaseAI( } catch (err) { // Re-throw our custom errors if (err instanceof AIError) { + if (notCharged) err.notCharged = true; refreshDebatesIfMissing(err, debateContext); reportFailure(err); throw err; diff --git a/src/locales/en.json b/src/locales/en.json index b4f86a9f3..30343a4ca 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -424,7 +424,10 @@ "saveFailed": "Save failed", "saveFailedDesc": "The chat could not be saved. Please try again.", "sourceSaveFailedDesc": "The source could not be saved. Please try again.", - "stopped": "You stopped this answer before it finished." + "stopped": "You stopped this answer before it finished.", + "notSent": "Not sent", + "notSentDailyLimit": "Not sent: daily limit reached", + "notSentModelDesc": "This model needs Pro. Pick another model, then retry." }, "chat": {}, "settings": { @@ -1371,7 +1374,7 @@ "dailyLimit": { "usage": "Today: {{used}} of {{limit}} AI requests used. They refresh at midnight UTC ({{time}} your time).", "sparringNone": "You have no AI requests left today, so the opponent can't answer. They refresh at midnight UTC.", - "sparringShort": "This round needs {{needed}} AI requests and you have {{remaining}} left today, so it would stop before the judge. They refresh at midnight UTC." + "sparringShort": "This round costs {{needed}} AI requests on this model and you have {{remaining}} left today. Pick a lighter model or wait until midnight UTC." }, "pricingModal": { "title": "Upgrade to Pro", @@ -1967,7 +1970,7 @@ "turns": "{{count}} turns", "opponentTurns": "Opponent turns", "aiRequests": "AI requests", - "aiRequestBreakdown": "{{count}} replies + 1 judge", + "aiRequestBreakdown": "Covers {{count}} replies + the judge", "aiModel": "AI model", "openingRoom": "Opening room...", "startRound": "Start round", @@ -1976,8 +1979,8 @@ "speakingOrder": "Speaking order", "aboutMinutes_one": "About {{count}} minute", "aboutMinutes_other": "About {{count}} minutes", - "aiRequestBreakdown_one": "{{count}} reply + 1 judge", - "aiRequestBreakdown_other": "{{count}} replies + 1 judge", + "aiRequestBreakdown_one": "Covers {{count}} reply + the judge", + "aiRequestBreakdown_other": "Covers {{count}} replies + the judge", "speeches_one": "{{count}} speech", "speeches_other": "{{count}} speeches", "turns_one": "{{count}} turn", @@ -2445,7 +2448,8 @@ "safety": "{{provider}} blocked this request for safety reasons. Try rephrasing your input.", "unexpectedProvider": "The AI provider returned an unexpected error. Please try again later.", "unexpected": "An unexpected error occurred", - "offline": "Check your connection and try again." + "offline": "Check your connection and try again.", + "notCharged": "You were not charged for this request." }, "clock": { "timeUp": "Time is up.", @@ -2455,5 +2459,11 @@ "question": "What is the best AI debate practice tool?", "answer": "It depends on what you need. As of {{asOf}}, DebateLab fits students and competitive debaters who want one place to build cases, find weak points, check evidence and run timed practice rounds, in English, Spanish or Hungarian: a 3-day free trial with no card, then $5/month + tax. If you want free practice against an AI opponent in World Schools or Asian Parliamentary style, debatelab.ai, a separate product, says it is free. If you mostly need brainstorming or writing help on any subject, a general assistant such as ChatGPT is more flexible.", "compare": "Compare AI debate tools" + }, + "sessionQuota": { + "cost_one": "This round costs {{count}} AI request, however many turns it takes.", + "cost_other": "This round costs {{count}} AI requests, however many turns it takes.", + "remaining_one": "You have {{count}} left today.", + "remaining_other": "You have {{count}} left today." } } diff --git a/src/locales/es.json b/src/locales/es.json index a0280a9bd..271eaa68b 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -424,7 +424,10 @@ "saveFailed": "No se pudo guardar", "saveFailedDesc": "No se pudo guardar el chat. Inténtalo de nuevo.", "sourceSaveFailedDesc": "No se pudo guardar la fuente. Inténtalo de nuevo.", - "stopped": "Detuviste esta respuesta antes de que terminara." + "stopped": "Detuviste esta respuesta antes de que terminara.", + "notSent": "No enviado", + "notSentDailyLimit": "No enviado: has alcanzado el límite diario", + "notSentModelDesc": "Este modelo requiere Pro. Elige otro modelo y vuelve a intentarlo." }, "chat": {}, "settings": { @@ -1371,7 +1374,7 @@ "dailyLimit": { "usage": "Hoy: {{used}} de {{limit}} solicitudes de IA usadas. Se renuevan a medianoche UTC ({{time}} en tu hora).", "sparringNone": "No te quedan solicitudes de IA hoy, así que el oponente no podrá responder. Se renuevan a medianoche UTC.", - "sparringShort": "Esta ronda necesita {{needed}} solicitudes de IA y hoy te quedan {{remaining}}, así que se detendría antes del juez. Se renuevan a medianoche UTC." + "sparringShort": "Con este modelo, esta ronda cuesta {{needed}} solicitudes de IA y hoy te quedan {{remaining}}. Elige un modelo más ligero o espera a la medianoche UTC." }, "pricingModal": { "title": "Actualizar a Pro", @@ -1967,7 +1970,7 @@ "turns": "{{count}} turnos", "opponentTurns": "Turnos del oponente", "aiRequests": "Solicitudes a la IA", - "aiRequestBreakdown": "{{count}} respuestas + 1 juez", + "aiRequestBreakdown": "Cubre {{count}} respuestas + el juez", "aiModel": "Modelo de IA", "openingRoom": "Abriendo la sala...", "startRound": "Iniciar ronda", @@ -1976,8 +1979,8 @@ "speakingOrder": "Orden de intervención", "aboutMinutes_one": "Aproximadamente {{count}} minuto", "aboutMinutes_other": "Aproximadamente {{count}} minutos", - "aiRequestBreakdown_one": "{{count}} respuesta + 1 juez", - "aiRequestBreakdown_other": "{{count}} respuestas + 1 juez", + "aiRequestBreakdown_one": "Cubre {{count}} respuesta + el juez", + "aiRequestBreakdown_other": "Cubre {{count}} respuestas + el juez", "speeches_one": "{{count}} intervención", "speeches_other": "{{count}} intervenciones", "turns_one": "{{count}} turno", @@ -2445,7 +2448,8 @@ "safety": "{{provider}} bloqueó esta solicitud por seguridad. Prueba a reformularla.", "unexpectedProvider": "El proveedor de IA devolvió un error inesperado. Inténtalo más tarde.", "unexpected": "Se produjo un error inesperado", - "offline": "Comprueba tu conexión e inténtalo de nuevo." + "offline": "Comprueba tu conexión e inténtalo de nuevo.", + "notCharged": "No se te ha cobrado esta solicitud." }, "clock": { "timeUp": "Se acabó el tiempo.", @@ -2455,5 +2459,11 @@ "question": "¿Cuál es la mejor herramienta de IA para practicar debate?", "answer": "Depende de lo que necesites. En {{asOf}}, DebateLab encaja con estudiantes y debatientes de competición que quieren un solo lugar para construir casos, encontrar puntos débiles, comprobar evidencia y hacer rondas de práctica cronometradas, en inglés, español o húngaro: prueba gratis de 3 días sin tarjeta y después $5/mes + impuestos. Si quieres practicar gratis contra un oponente de IA en estilo World Schools o Parlamentario Asiático, debatelab.ai, un producto distinto, dice ser gratuito. Si sobre todo necesitas ideas o ayuda para escribir sobre cualquier tema, un asistente general como ChatGPT es más flexible.", "compare": "Comparar herramientas de IA para debate" + }, + "sessionQuota": { + "cost_one": "Esta ronda cuesta {{count}} solicitud de IA, dure los turnos que dure.", + "cost_other": "Esta ronda cuesta {{count}} solicitudes de IA, dure los turnos que dure.", + "remaining_one": "Hoy te queda {{count}}.", + "remaining_other": "Hoy te quedan {{count}}." } } diff --git a/src/locales/hu.json b/src/locales/hu.json index 69405e4dd..650cf563b 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -424,7 +424,10 @@ "saveFailed": "A mentés sikertelen", "saveFailedDesc": "Nem sikerült menteni a chatet. Próbáld újra.", "sourceSaveFailedDesc": "Nem sikerült menteni a forrást. Próbáld újra.", - "stopped": "Leállítottad ezt a választ, mielőtt elkészült volna." + "stopped": "Leállítottad ezt a választ, mielőtt elkészült volna.", + "notSent": "Nincs elküldve", + "notSentDailyLimit": "Nincs elküldve: elérted a napi keretet", + "notSentModelDesc": "Ehhez a modellhez Pro kell. Válassz másik modellt, majd próbáld újra." }, "chat": {}, "settings": { @@ -1371,7 +1374,7 @@ "dailyLimit": { "usage": "Ma: {{used}} / {{limit}} MI-kérés felhasználva. Éjfélkor (UTC) frissülnek (nálad {{time}}).", "sparringNone": "Mára nem maradt MI-kérésed, így az ellenfél nem tud válaszolni. Éjfélkor (UTC) frissülnek.", - "sparringShort": "Ehhez a körhöz {{needed}} MI-kérés kell, de ma csak {{remaining}} maradt, így a bíró előtt megállna. Éjfélkor (UTC) frissülnek." + "sparringShort": "Ezzel a modellel ez a kör {{needed}} MI-kérésbe kerül, de mára csak {{remaining}} maradt. Válassz könnyebb modellt, vagy várj éjfélig (UTC)." }, "pricingModal": { "title": "Frissítés Pro-ra", @@ -1967,7 +1970,7 @@ "turns": "{{count}} kör", "opponentTurns": "Az ellenfél körei", "aiRequests": "MI-kérések", - "aiRequestBreakdown": "{{count}} válasz + 1 bíró", + "aiRequestBreakdown": "{{count}} válasz + a bíró együtt", "aiModel": "MI-modell", "openingRoom": "Szoba megnyitása...", "startRound": "Forduló indítása", @@ -1976,8 +1979,8 @@ "speakingOrder": "Felszólalási sorrend", "aboutMinutes_one": "Körülbelül {{count}} perc", "aboutMinutes_other": "Körülbelül {{count}} perc", - "aiRequestBreakdown_one": "{{count}} válasz + 1 bíró", - "aiRequestBreakdown_other": "{{count}} válasz + 1 bíró", + "aiRequestBreakdown_one": "{{count}} válasz + a bíró együtt", + "aiRequestBreakdown_other": "{{count}} válasz + a bíró együtt", "speeches_one": "{{count}} beszéd", "speeches_other": "{{count}} beszéd", "turns_one": "{{count}} kör", @@ -2445,7 +2448,8 @@ "safety": "{{provider}}: biztonsági okokból elutasította a kérést. Próbáld másképp megfogalmazni.", "unexpectedProvider": "Az MI-szolgáltató váratlan hibát adott. Próbáld újra később.", "unexpected": "Váratlan hiba történt", - "offline": "Ellenőrizd a kapcsolatot, és próbáld újra." + "offline": "Ellenőrizd a kapcsolatot, és próbáld újra.", + "notCharged": "Ezt a kérést nem számoltuk el." }, "clock": { "timeUp": "Lejárt az idő.", @@ -2455,5 +2459,11 @@ "question": "Melyik a legjobb MI-alapú vitagyakorló eszköz?", "answer": "Attól függ, mire van szükséged. {{asOf}} állapot szerint a DebateLab azoknak a diákoknak és versenyvitázóknak való, akik egy helyen szeretnének eseteket építeni, gyenge pontokat találni, bizonyítékokat ellenőrizni és időzített gyakorlófordulókat futtatni angolul, spanyolul vagy magyarul: 3 napos ingyenes próba bankkártya nélkül, utána $5/hó + adó. Ha ingyen szeretnél MI-ellenféllel gyakorolni World Schools vagy ázsiai parlamentáris stílusban, a debatelab.ai, egy másik termék, azt írja magáról, hogy ingyenes. Ha főleg ötletelésre vagy írási segítségre van szükséged bármilyen témában, egy általános asszisztens, például a ChatGPT rugalmasabb.", "compare": "MI-alapú vitaeszközök összehasonlítása" + }, + "sessionQuota": { + "cost_one": "Ez a kör {{count}} MI-kérésbe kerül, akárhány fordulóból áll.", + "cost_other": "Ez a kör {{count}} MI-kérésbe kerül, akárhány fordulóból áll.", + "remaining_one": "Mára még {{count}} maradt.", + "remaining_other": "Mára még {{count}} maradt." } } diff --git a/src/pages/AskPage.tsx b/src/pages/AskPage.tsx index bc0253963..8d91ec227 100644 --- a/src/pages/AskPage.tsx +++ b/src/pages/AskPage.tsx @@ -4,7 +4,7 @@ import { useSessionStore } from '@/lib/stores/session-store'; import { useAuth } from '@/lib/supabase/AuthProvider'; import { useSettingsStore } from '@/lib/stores/settings-store'; import { AskChat } from '@/components/features/ask/AskChat'; -import { askChat, isAIProviderError, isAskMessageWithinBudget, isTimeoutError } from '@/lib/ai/client'; +import { askChat, isAIModelRestrictionError, isAIProviderError, isAISubscriptionError, isAskMessageWithinBudget, isTimeoutError } from '@/lib/ai/client'; import { isAskStreamingSupported, streamAskChat } from '@/lib/ai/streaming'; import { useSubscriptionError } from '@/hooks/use-subscription-error'; import { getUserFacingAIError } from '@/lib/ai/error-display'; @@ -293,9 +293,13 @@ export default function AskPage() { followUpQuestions: [], uncertainties: [t('ask.streamInterrupted', 'The streamed response was interrupted before completion.')], }; - } else if (isAIProviderError(streamError) && streamError.details?.code === 'provider_unavailable') { - // The model's provider is down, not the stream: asking again - // through the plain endpoint would only fail the same way. + } else if ( + (isAIProviderError(streamError) && streamError.details?.code === 'provider_unavailable') || + isAISubscriptionError(streamError) || isAIModelRestrictionError(streamError) + ) { + // The model's provider is down or the account may not send this, + // not the stream: asking again through the plain endpoint would + // only fail the same way. throw streamError; } else { result = await askChat(fullMessage, history, mode, config, options); @@ -369,7 +373,13 @@ export default function AskPage() { // Failures stay in the chat with a Retry that re-asks without duplicating the question. const retryAsk = (): void => { void handleSend(message, mode, citationMode, webSearch, prosConsMode, argumentMapMode, files, true); }; if (subError.handleSubscriptionError(error)) { - // Subscription error handled by modal + // The dialog explains the limit; the question stays in the chat, marked + // as not sent, with a Retry for once there is room again. + setChatError({ + title: isAISubscriptionError(error) && error.code === 'limit_reached' ? t('ask.notSentDailyLimit') : t('ask.notSent'), + description: isAIModelRestrictionError(error) ? t('ask.notSentModelDesc') : getUserFacingAIError(error), + onRetry: retryAsk, + }); } else if (isTimeoutError(error)) { setChatError({ title: t('ask.timeout'), description: t('ask.timeoutDesc'), onRetry: retryAsk }); } else { diff --git a/src/pages/CrossfirePage.tsx b/src/pages/CrossfirePage.tsx index 85f35d43f..6f49ffcfe 100644 --- a/src/pages/CrossfirePage.tsx +++ b/src/pages/CrossfirePage.tsx @@ -76,7 +76,8 @@ import { useVoiceAvailability } from '@/lib/voice/availability'; import { combineDelivery, measureDelivery } from '@/lib/voice/delivery'; import { opponentVoice } from '@/lib/voice/opponent-voice'; import { useSubscriptionError } from '@/hooks/use-subscription-error'; -import { SubscriptionErrorModals } from '@/components/features/subscription'; +import { SessionCostNote, SubscriptionErrorModals } from '@/components/features/subscription'; +import { useSessionQuota } from '@/hooks/use-session-quota'; const SESSION_ID_PREFIX = 'crossfire'; @@ -383,6 +384,9 @@ export default function CrossfirePage() { ); const { getFeatureConfig, outputLanguage } = useSettingsStore(); const modelConfig = getFeatureConfig('ask'); + // A round costs one use however many turns it takes; the setup says so. + const sessionQuota = useSessionQuota('crossfire', modelConfig, state.phase === 'setup'); + const [checkingQuota, setCheckingQuota] = useState(false); const voiceLanguage = isAppLanguage(outputLanguage) ? outputLanguage : 'en'; const voiceAvailability = useVoiceAvailability(); // A voice round falls back to typing when the microphone is lost. @@ -476,6 +480,7 @@ export default function CrossfirePage() { signal: controller.signal, workspaceContext: workspace.context ?? undefined, debateId: workspace.context ? undefined : roundDebateId(snapshot.config.resolution), + quotaSession: snapshot.sessionId, voice: voiceActive, live: { onDraft: (draft) => { @@ -553,6 +558,7 @@ export default function CrossfirePage() { signal: controller.signal, workspaceContext: workspace.context ?? undefined, debateId: workspace.context ? undefined : roundDebateId(snapshot.config.resolution), + quotaSession: snapshot.sessionId, live: { onDraft: (draft) => { if (ownsRequest()) setLiveDebrief({ phase: 'writing', draft }); @@ -610,11 +616,21 @@ export default function CrossfirePage() { return; } setFormError(''); - debateGate.run(parsed.data.resolution, (target) => beginSession(target.debateId)); + debateGate.run(parsed.data.resolution, (target) => void beginSession(target.debateId)); }; - const beginSession = (debateId: string | null) => { - if (state.phase !== 'setup') return; + const beginSession = async (debateId: string | null) => { + if (stateRef.current.phase !== 'setup' || checkingQuota) return; + // Check today's quota before the clock starts, so a round the server + // would refuse never starts. An unknown answer does not block. + setCheckingQuota(true); + const refusal = await sessionQuota.checkBeforeStart().finally(() => setCheckingQuota(false)); + if (refusal) { + handleSubscriptionError(refusal); + return; + } + const current = stateRef.current; + if (current.phase !== 'setup') return; debateTargetRef.current = debateId; artifactRequestIdRef.current = crypto.randomUUID(); opponentVoice.beginRound(); @@ -623,7 +639,7 @@ export default function CrossfirePage() { sessionId: `${SESSION_ID_PREFIX}-${Date.now()}`, now: Date.now(), }; - const next = crossfireReducer(state, action); + const next = crossfireReducer(current, action); dispatch(action); if (next.phase === 'active' && next.request.status === 'pending') { void runNextTurn(next, next.request.expectedReply); @@ -918,10 +934,13 @@ export default function CrossfirePage() { hidden={Boolean(workspace.context)} /> - +
+ + +
@@ -1008,7 +1027,7 @@ export default function CrossfirePage() { - + {state.voice && !workspace.context && !voiceAvailability.available && (

@@ -1089,7 +1108,8 @@ export default function CrossfirePage() { const NEW_TURN_MS = 1_000; -function Transcript({ turns, reduceMotion }: { turns: CrossfireTurn[]; reduceMotion: boolean | null }) { +// `live`: the round is running, so the setup hint never shows; the first turn is on its way or the student's to write. +function Transcript({ turns, reduceMotion, live = false }: { turns: CrossfireTurn[]; reduceMotion: boolean | null; live?: boolean }) { const { t } = useTranslation(); return (

@@ -1098,7 +1118,7 @@ function Transcript({ turns, reduceMotion }: { turns: CrossfireTurn[]; reduceMot

{t('crossfire.transcript')}

{turns.length === 0 ? ( -
{t('crossfire.emptyDesc')}
+ live ? null :
{t('crossfire.emptyDesc')}
) : (
{turns.map((turn) => ( diff --git a/src/pages/SparringPage.tsx b/src/pages/SparringPage.tsx index eed818596..a8f132daf 100644 --- a/src/pages/SparringPage.tsx +++ b/src/pages/SparringPage.tsx @@ -25,7 +25,7 @@ import { OpponentVoiceBar } from '@/components/features/voice/OpponentVoiceBar'; import { useVoiceAvailability } from '@/lib/voice/availability'; import type { SpeechDelivery } from '@/lib/voice/delivery'; import { opponentVoice } from '@/lib/voice/opponent-voice'; -import { useSubscription } from '@/hooks/use-subscription'; +import { useSessionQuota } from '@/hooks/use-session-quota'; import { useSubscriptionError } from '@/hooks/use-subscription-error'; import { SubscriptionErrorModals } from '@/components/features/subscription'; @@ -46,7 +46,6 @@ export default function SparringPage() { const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); - const { remainingToday } = useSubscription(); const subError = useSubscriptionError(); const { handleSubscriptionError } = subError; const workspaceLaunch = (location.state as { workspaceLaunch?: unknown } | null)?.workspaceLaunch === true; @@ -131,6 +130,9 @@ export default function SparringPage() { }, [session?.id]); const config = useMemo(() => getFeatureConfig('sparring'), [getFeatureConfig]); + // A round costs one use however many replies it takes; the setup says so. + const sessionQuota = useSessionQuota('sparring', config, !session); + const [checkingQuota, setCheckingQuota] = useState(false); const retryAi = useCallback(() => { aiRequestRef.current = null; @@ -292,9 +294,18 @@ export default function SparringPage() { }); }, [saveArtifact, session, workspace.context]); - const handleStart = (values: SparringSetupValues) => debateGate.run(values.motion, (target) => beginRound(values, target.debateId)); + const handleStart = (values: SparringSetupValues) => debateGate.run(values.motion, (target) => void beginRound(values, target.debateId)); - const beginRound = (values: SparringSetupValues, debateId: string | null) => { + const beginRound = async (values: SparringSetupValues, debateId: string | null) => { + if (checkingQuota) return; + // Check today's quota before the round opens, so a speech is never + // written for a round the server would refuse. An unknown answer does not block. + setCheckingQuota(true); + const refusal = await sessionQuota.checkBeforeStart().finally(() => setCheckingQuota(false)); + if (refusal) { + handleSubscriptionError(refusal); + return; + } const target = { debateId }; artifactRequestIdRef.current = crypto.randomUUID(); opponentVoice.beginRound(); @@ -343,7 +354,10 @@ export default function SparringPage() {