Skip to content

Commit cf3342a

Browse files
authored
feat: free debate discovery and budget-limit copy (#70)
* feat: surface free debate on arrival and honest budget-wall copy * fix: address review - key status check, abort cleanup, honest mid-debate budget copy * style: format welcome hero effect
1 parent 5dd7ead commit cf3342a

5 files changed

Lines changed: 182 additions & 39 deletions

File tree

src/__tests__/api-key-errors.test.ts

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,50 @@
11
import { describe, expect, it } from "vitest"
22
import {
33
getApiKeyPromptMessage,
4+
getBudgetExceededMessage,
45
getMissingApiKeyMessage,
5-
parseNoKeyProvider,
6-
parseNoKeyProviderFromResponse,
6+
parse402Payload,
7+
parse402FromResponse,
78
} from "@/lib/api-key-errors"
89

910
describe("api key error helpers", () => {
10-
it("recognizes no_key responses with a known provider", () => {
11-
expect(parseNoKeyProvider({ error: "no_key", provider: "gemini" })).toBe("gemini")
12-
expect(parseNoKeyProvider({ error: "no_key", provider: "bogus" })).toBeNull()
13-
expect(parseNoKeyProvider({ error: "other", provider: "gemini" })).toBeNull()
11+
it("classifies no_key responses and keeps a known provider", () => {
12+
expect(parse402Payload({ error: "no_key", provider: "gemini" })).toEqual({
13+
kind: "no_key",
14+
provider: "gemini",
15+
})
16+
expect(parse402Payload({ error: "no_key", provider: "bogus" })).toEqual({
17+
kind: "no_key",
18+
provider: null,
19+
})
20+
expect(parse402Payload({ error: "other", provider: "gemini" })).toBeNull()
1421
})
1522

16-
it("returns null for malformed no_key payloads", () => {
17-
expect(parseNoKeyProvider(null)).toBeNull()
18-
expect(parseNoKeyProvider(undefined)).toBeNull()
19-
expect(parseNoKeyProvider("no_key")).toBeNull()
20-
expect(parseNoKeyProvider({ error: "no_key" })).toBeNull()
21-
expect(parseNoKeyProvider({ error: "no_key", provider: 123 })).toBeNull()
23+
it("classifies host_budget_exceeded responses", () => {
24+
expect(parse402Payload({ error: "host_budget_exceeded", provider: "claude" })).toEqual({
25+
kind: "host_budget_exceeded",
26+
provider: "claude",
27+
})
2228
})
2329

24-
it("parses no_key provider from a response body", async () => {
30+
it("returns null for malformed payloads", () => {
31+
expect(parse402Payload(null)).toBeNull()
32+
expect(parse402Payload(undefined)).toBeNull()
33+
expect(parse402Payload("no_key")).toBeNull()
34+
expect(parse402Payload({ error: "no_key" })).toEqual({ kind: "no_key", provider: null })
35+
expect(parse402Payload({ error: "no_key", provider: 123 })).toEqual({
36+
kind: "no_key",
37+
provider: null,
38+
})
39+
})
40+
41+
it("parses a 402 body from a response", async () => {
2542
const response = Response.json({ error: "no_key", provider: "claude" }, { status: 402 })
2643

27-
await expect(parseNoKeyProviderFromResponse(response)).resolves.toBe("claude")
44+
await expect(parse402FromResponse(response)).resolves.toEqual({
45+
kind: "no_key",
46+
provider: "claude",
47+
})
2848
})
2949

3050
it("returns null when response JSON parsing fails", async () => {
@@ -33,7 +53,13 @@ describe("api key error helpers", () => {
3353
headers: { "Content-Type": "application/json" },
3454
})
3555

36-
await expect(parseNoKeyProviderFromResponse(response)).resolves.toBeNull()
56+
await expect(parse402FromResponse(response)).resolves.toBeNull()
57+
})
58+
59+
it("keeps the free debate promise honest at the budget wall", () => {
60+
expect(getBudgetExceededMessage(true)).toContain("an unused free debate stays yours")
61+
expect(getBudgetExceededMessage(false)).toContain("come back tomorrow")
62+
expect(getBudgetExceededMessage(true, "ko")).toContain("아직 안 쓴 무료 토론")
3763
})
3864

3965
it("formats the user-facing Settings message", () => {

src/components/WelcomeHero.tsx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
"use client"
22

33
import type { CSSProperties } from "react"
4+
import { useEffect, useState } from "react"
45
import { motion } from "framer-motion"
6+
import { useSession } from "next-auth/react"
57
import { Provider, Locale } from "@/types"
8+
import { authEnabled } from "@/lib/deploy-config"
9+
import { isFirstRunKeyless } from "@/lib/client-api-keys"
610

711
const suggestions = {
812
en: [
@@ -21,6 +25,17 @@ const suggestions = {
2125

2226
const tryMeLabel = { en: "Try me", ko: "클릭해 보세요" }
2327

28+
const freeDebateHint = {
29+
en: {
30+
signin: "Sign in with Google - your first debate is free",
31+
ready: "Your free debate is ready - no API keys needed",
32+
},
33+
ko: {
34+
signin: "Google로 로그인하면 첫 토론이 무료예요",
35+
ready: "무료 토론이 준비돼 있어요 - API 키 없이 시작하세요",
36+
},
37+
}
38+
2439
const translations = {
2540
en: {
2641
title: "Quorum",
@@ -306,6 +321,45 @@ export default function WelcomeHero({
306321
}) {
307322
const t = translations[locale]
308323
const s = suggestions[locale]
324+
const { status } = useSession()
325+
const [freeHint, setFreeHint] = useState<"signin" | "ready" | null>(null)
326+
327+
useEffect(() => {
328+
if (!authEnabled()) return
329+
let cancelled = false
330+
const controller = new AbortController()
331+
const decide = async (): Promise<"signin" | "ready" | null> => {
332+
if (status === "unauthenticated") return isFirstRunKeyless(true) ? "signin" : null
333+
if (status !== "authenticated") return null
334+
const [grant, keyStatus] = await Promise.all([
335+
fetch("/api/free-debate", { signal: controller.signal }).then((r) =>
336+
r.ok ? r.json() : null
337+
),
338+
fetch("/api/user-api-keys", { signal: controller.signal }).then((r) =>
339+
r.ok ? r.json() : null
340+
),
341+
])
342+
if (!grant) return null
343+
// Saved keys win over the grant server-side, so "free debate ready" would lie to key holders.
344+
// Key status values are objects - check the configured flag, not truthiness.
345+
const hasKey = Boolean(
346+
keyStatus?.keys &&
347+
Object.values(keyStatus.keys).some(
348+
(k) => (k as { configured?: boolean } | null)?.configured
349+
)
350+
)
351+
return grant.remaining > 0 && !grant.active && !hasKey ? "ready" : null
352+
}
353+
decide()
354+
.then((hint) => {
355+
if (!cancelled) setFreeHint(hint)
356+
})
357+
.catch(() => {})
358+
return () => {
359+
cancelled = true
360+
controller.abort()
361+
}
362+
}, [status])
309363

310364
return (
311365
<div className="flex flex-col items-center justify-center min-h-[70vh] text-center px-4 sm:px-6 py-8 sm:py-12">
@@ -347,6 +401,20 @@ export default function WelcomeHero({
347401
{t.description}
348402
</motion.p>
349403

404+
{/* -- Free debate hint -- */}
405+
{freeHint && (
406+
<motion.div
407+
initial={{ opacity: 0, y: 8 }}
408+
animate={{ opacity: 1, y: 0 }}
409+
transition={{ delay: 0.9, duration: 0.4 }}
410+
>
411+
<span className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-4 py-1.5 text-[13px] font-medium text-emerald-600 dark:text-emerald-300">
412+
<span aria-hidden></span>
413+
{freeDebateHint[locale][freeHint]}
414+
</span>
415+
</motion.div>
416+
)}
417+
350418
{/* -- Model badges -- */}
351419
<motion.div
352420
initial={{ opacity: 0, scale: 0.95 }}

src/hooks/useDebateEngine.ts

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import { hasDirectUrlReference, prioritizePerplexity } from "@/lib/url-access"
88
import { waitForDrain } from "@/lib/drain-registry"
99
import {
1010
getApiKeyPromptMessage,
11+
getBudgetExceededMessage,
1112
getMissingApiKeyMessage,
12-
parseNoKeyProviderFromResponse,
13+
parse402FromResponse,
1314
} from "@/lib/api-key-errors"
1415
import { getClientKey, getAccessCode, isFirstRunKeyless } from "@/lib/client-api-keys"
1516
import { authEnabled } from "@/lib/deploy-config"
@@ -403,7 +404,15 @@ export function useDebateEngine(config: {
403404
}
404405

405406
if (res.status === 402) {
406-
const missingProvider = (await parseNoKeyProviderFromResponse(res)) ?? provider
407+
const blocked = await parse402FromResponse(res)
408+
if (blocked?.kind === "host_budget_exceeded") {
409+
// Daily host budget wall, not a key problem - the key toast would mislead.
410+
updatePlaceholder(getBudgetExceededMessage(!isAnonymousRef.current, locale))
411+
stopRef.current = true
412+
clearTypingIfCurrentSession()
413+
return null
414+
}
415+
const missingProvider = blocked?.provider ?? provider
407416
onApiKeyRequired?.(missingProvider)
408417
// A keyless first-run visitor gets a generic welcome; the panel's
409418
// lead provider (perplexity) is arbitrary, so don't single it out.
@@ -549,8 +558,11 @@ export function useDebateEngine(config: {
549558
.then(async (res) => {
550559
if (sessionIdRef.current !== sessionId || stopRef.current) return
551560
if (res.status === 402) {
552-
const missingProvider = await parseNoKeyProviderFromResponse(res)
553-
if (missingProvider) onApiKeyRequired?.(missingProvider)
561+
// Budget wall stays silent here - the chat calls surface it.
562+
const blocked = await parse402FromResponse(res)
563+
if (blocked?.kind === "no_key" && blocked.provider) {
564+
onApiKeyRequired?.(blocked.provider)
565+
}
554566
return
555567
}
556568
if (!res.ok) return
@@ -693,14 +705,21 @@ export function useDebateEngine(config: {
693705
logDebate("verdict:skipped-stopped", {})
694706
} else if (res.status === 402) {
695707
if (sessionIdRef.current !== thisSession) return
696-
const missingProvider = await parseNoKeyProviderFromResponse(res)
697-
if (missingProvider) onApiKeyRequired?.(missingProvider)
698-
else onApiKeyRequired?.("gemini")
699-
dispatch({
700-
type: "UPDATE_MESSAGE",
701-
id: analyzingMsg.id,
702-
content: SYSTEM_MESSAGES.missingConsensusKey(locale, !isAnonymousRef.current),
703-
})
708+
const blocked = await parse402FromResponse(res)
709+
if (blocked?.kind === "host_budget_exceeded") {
710+
dispatch({
711+
type: "UPDATE_MESSAGE",
712+
id: analyzingMsg.id,
713+
content: getBudgetExceededMessage(!isAnonymousRef.current, locale),
714+
})
715+
} else {
716+
onApiKeyRequired?.(blocked?.provider ?? "gemini")
717+
dispatch({
718+
type: "UPDATE_MESSAGE",
719+
id: analyzingMsg.id,
720+
content: SYSTEM_MESSAGES.missingConsensusKey(locale, !isAnonymousRef.current),
721+
})
722+
}
704723
} else if (!res.ok) {
705724
const failMsg = await consensusFailureMessage(res, locale)
706725
logDebate("verdict:failed", { status: res.status, message: failMsg })
@@ -822,9 +841,16 @@ export function useDebateEngine(config: {
822841
.then(async (res) => {
823842
if (res.status === 402) {
824843
if (sessionIdRef.current !== stoppedSession) return null
825-
const missingProvider = await parseNoKeyProviderFromResponse(res)
826-
if (missingProvider) onApiKeyRequired?.(missingProvider)
827-
else onApiKeyRequired?.("gemini")
844+
const blocked = await parse402FromResponse(res)
845+
if (blocked?.kind === "host_budget_exceeded") {
846+
dispatch({
847+
type: "UPDATE_MESSAGE",
848+
id: analyzingMsg.id,
849+
content: getBudgetExceededMessage(!isAnonymousRef.current, locale),
850+
})
851+
return null
852+
}
853+
onApiKeyRequired?.(blocked?.provider ?? "gemini")
828854
dispatch({
829855
type: "UPDATE_MESSAGE",
830856
id: analyzingMsg.id,

src/lib/api-key-errors.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,43 @@ const PROVIDER_NAMES: Record<Provider, string> = {
1111

1212
const PROVIDERS = new Set<Provider>(Object.keys(PROVIDER_NAMES) as Provider[])
1313

14-
export function parseNoKeyProvider(payload: unknown): Provider | null {
14+
export type Blocked402 = {
15+
kind: "no_key" | "host_budget_exceeded"
16+
provider: Provider | null
17+
}
18+
19+
export function parse402Payload(payload: unknown): Blocked402 | null {
1520
if (!payload || typeof payload !== "object") return null
1621
const obj = payload as Record<string, unknown>
17-
if (obj.error !== "no_key") return null
18-
if (typeof obj.provider !== "string") return null
19-
return PROVIDERS.has(obj.provider as Provider) ? (obj.provider as Provider) : null
22+
if (obj.error !== "no_key" && obj.error !== "host_budget_exceeded") return null
23+
const provider =
24+
typeof obj.provider === "string" && PROVIDERS.has(obj.provider as Provider)
25+
? (obj.provider as Provider)
26+
: null
27+
return { kind: obj.error, provider }
2028
}
2129

22-
export async function parseNoKeyProviderFromResponse(response: Response): Promise<Provider | null> {
30+
export async function parse402FromResponse(response: Response): Promise<Blocked402 | null> {
2331
try {
24-
return parseNoKeyProvider(await response.json())
32+
return parse402Payload(await response.json())
2533
} catch {
2634
return null
2735
}
2836
}
2937

38+
// The budget wall blocks before an UNUSED grant is consumed, but it can also
39+
// hit mid-debate after the grant was already claimed - promise only the unused case.
40+
export function getBudgetExceededMessage(signedIn: boolean, locale: Locale = "en"): string {
41+
if (signedIn) {
42+
return locale === "ko"
43+
? "오늘의 무료 토론이 모두 소진됐어요. 아직 안 쓴 무료 토론은 내일 그대로 쓸 수 있어요. Settings에서 API 키를 추가하면 바로 시작할 수 있어요."
44+
: "Today's free debates are maxed out - an unused free debate stays yours for tomorrow. Or add your own API key in Settings to start now."
45+
}
46+
return locale === "ko"
47+
? "오늘의 무료 토론이 모두 소진됐어요. 내일 다시 오거나 Settings에서 API 키를 추가해 시작하세요."
48+
: "Today's free debates are maxed out - come back tomorrow, or add your own API key in Settings to start now."
49+
}
50+
3051
export function getMissingApiKeyMessage(provider: Provider, locale: Locale = "en"): string {
3152
if (locale === "ko") {
3253
return `Settings에서 ${PROVIDER_NAMES[provider]} API 키를 추가해 토론을 시작하세요.`

src/lib/file-parser.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/** Client-side PDF/DOCX/Excel/text extraction for AI prompts. */
22

33
import type { Provider } from "@/types"
4-
import { parseNoKeyProviderFromResponse } from "@/lib/api-key-errors"
4+
import { parse402FromResponse } from "@/lib/api-key-errors"
55
import { getClientKey, getAccessCode } from "@/lib/client-api-keys"
66

77
const MAX_FILE_CHARS = 50000
@@ -250,8 +250,10 @@ async function parsePDF(
250250
})
251251

252252
if (res.status === 402) {
253-
const missingProvider = await parseNoKeyProviderFromResponse(res)
254-
if (missingProvider) options?.onApiKeyRequired?.(missingProvider)
253+
const blocked = await parse402FromResponse(res)
254+
if (blocked?.kind === "no_key" && blocked.provider) {
255+
options?.onApiKeyRequired?.(blocked.provider)
256+
}
255257
throw new Error("OCR API key required")
256258
}
257259
if (!res.ok) throw new Error(`OCR API error: ${res.status}`)

0 commit comments

Comments
 (0)