mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
feat: show out-of-credits dialog instead of raw API error (#568)
* feat: show billing error dialog on out-of-credits errors * fix: suppress chat dump and toast when billing dialog shows
This commit is contained in:
parent
0a3fc3736f
commit
5be0a11f98
4 changed files with 121 additions and 68 deletions
|
|
@ -63,6 +63,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
|||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { BillingErrorDialog } from "@/components/billing-error-dialog"
|
||||
import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error"
|
||||
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
||||
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||
|
|
@ -793,7 +795,25 @@ function App() {
|
|||
// Chat state
|
||||
const [, setMessage] = useState<string>('')
|
||||
const [conversation, setConversation] = useState<ConversationItem[]>([])
|
||||
const [billingErrorMatch, setBillingErrorMatch] = useState<BillingErrorMatch | null>(null)
|
||||
const [billingErrorOpen, setBillingErrorOpen] = useState(false)
|
||||
const lastHandledBillingErrorIdRef = useRef<string | null>(null)
|
||||
const [currentAssistantMessage, setCurrentAssistantMessage] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
for (let i = conversation.length - 1; i >= 0; i--) {
|
||||
const item = conversation[i]
|
||||
if (!isErrorMessage(item)) continue
|
||||
if (item.id === lastHandledBillingErrorIdRef.current) return
|
||||
const match = matchBillingError(item.message)
|
||||
if (match) {
|
||||
lastHandledBillingErrorIdRef.current = item.id
|
||||
setBillingErrorMatch(match)
|
||||
setBillingErrorOpen(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
}, [conversation])
|
||||
const [, setModelUsage] = useState<LanguageModelUsage | null>(null)
|
||||
const [runId, setRunId] = useState<string | null>(null)
|
||||
const runIdRef = useRef<string | null>(null)
|
||||
|
|
@ -2271,7 +2291,9 @@ function App() {
|
|||
message: event.error,
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
toast.error(event.error.split('\n')[0] || 'Model error')
|
||||
if (!matchBillingError(event.error)) {
|
||||
toast.error(event.error.split('\n')[0] || 'Model error')
|
||||
}
|
||||
console.error('Run error:', event.error)
|
||||
break
|
||||
}
|
||||
|
|
@ -4629,6 +4651,9 @@ function App() {
|
|||
}
|
||||
|
||||
if (isErrorMessage(item)) {
|
||||
if (matchBillingError(item.message)) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
||||
<MessageContent className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-destructive">
|
||||
|
|
@ -5399,6 +5424,11 @@ function App() {
|
|||
/>
|
||||
</SidebarSectionProvider>
|
||||
<Toaster />
|
||||
<BillingErrorDialog
|
||||
open={billingErrorOpen}
|
||||
match={billingErrorMatch}
|
||||
onOpenChange={setBillingErrorOpen}
|
||||
/>
|
||||
<OnboardingModal
|
||||
open={showOnboarding}
|
||||
onComplete={handleOnboardingComplete}
|
||||
|
|
|
|||
61
apps/x/apps/renderer/src/components/billing-error-dialog.tsx
Normal file
61
apps/x/apps/renderer/src/components/billing-error-dialog.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { BillingErrorMatch } from "@/lib/billing-error"
|
||||
|
||||
interface BillingRowboatAccount {
|
||||
config?: {
|
||||
appUrl?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
interface BillingErrorDialogProps {
|
||||
open: boolean
|
||||
match: BillingErrorMatch | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function BillingErrorDialog({ open, match, onOpenChange }: BillingErrorDialogProps) {
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
window.ipc
|
||||
.invoke('account:getRowboat', null)
|
||||
.then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null))
|
||||
.catch(() => {})
|
||||
}, [open])
|
||||
|
||||
if (!match) return null
|
||||
|
||||
const handleUpgrade = () => {
|
||||
if (appUrl) window.open(`${appUrl}?intent=upgrade`)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{match.title}</DialogTitle>
|
||||
<DialogDescription>{match.subtitle}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button onClick={handleUpgrade} disabled={!appUrl}>
|
||||
{match.cta}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ import {
|
|||
parseAttachedFiles,
|
||||
toToolState,
|
||||
} from '@/lib/chat-conversation'
|
||||
import { matchBillingError } from '@/lib/billing-error'
|
||||
|
||||
const streamdownComponents = { pre: MarkdownPreOverride }
|
||||
|
||||
|
|
@ -85,60 +86,6 @@ function AutoScrollPre({ className, children }: { className?: string; children:
|
|||
)
|
||||
}
|
||||
|
||||
/* ─── Billing error helpers ─── */
|
||||
|
||||
const BILLING_ERROR_PATTERNS = [
|
||||
{
|
||||
pattern: /upgrade required/i,
|
||||
title: 'A subscription is required',
|
||||
subtitle: 'Get started with a plan to access AI features in Rowboat.',
|
||||
cta: 'Subscribe',
|
||||
},
|
||||
{
|
||||
pattern: /not enough credits/i,
|
||||
title: 'You\'ve run out of credits',
|
||||
subtitle: 'Upgrade your plan for more credits. Free usage resets daily at 00:00 UTC.',
|
||||
cta: 'Upgrade plan',
|
||||
},
|
||||
{
|
||||
pattern: /subscription not active/i,
|
||||
title: 'Your subscription is inactive',
|
||||
subtitle: 'Reactivate your subscription to continue using AI features.',
|
||||
cta: 'Reactivate',
|
||||
},
|
||||
] as const
|
||||
|
||||
function matchBillingError(message: string) {
|
||||
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
|
||||
}
|
||||
|
||||
interface BillingRowboatAccount {
|
||||
config?: {
|
||||
appUrl?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
function BillingErrorCTA({ label }: { label: string }) {
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.ipc.invoke('account:getRowboat', null)
|
||||
.then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
if (!appUrl) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => window.open(`${appUrl}?intent=upgrade`)}
|
||||
className="mt-1 rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-medium text-amber-100 transition-colors hover:bg-amber-500/30"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 360
|
||||
const MAX_WIDTH = 1600
|
||||
const MIN_MAIN_PANE_WIDTH = 420
|
||||
|
|
@ -491,19 +438,8 @@ export function ChatSidebar({
|
|||
}
|
||||
|
||||
if (isErrorMessage(item)) {
|
||||
const billingError = matchBillingError(item.message)
|
||||
if (billingError) {
|
||||
return (
|
||||
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
||||
<MessageContent className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-amber-200">{billingError.title}</p>
|
||||
<p className="text-xs text-amber-300/80">{billingError.subtitle}</p>
|
||||
<BillingErrorCTA label={billingError.cta} />
|
||||
</div>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
if (matchBillingError(item.message)) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
||||
|
|
|
|||
26
apps/x/apps/renderer/src/lib/billing-error.ts
Normal file
26
apps/x/apps/renderer/src/lib/billing-error.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
export const BILLING_ERROR_PATTERNS = [
|
||||
{
|
||||
pattern: /upgrade required/i,
|
||||
title: 'A subscription is required',
|
||||
subtitle: 'Get started with a plan to access AI features in Rowboat.',
|
||||
cta: 'Subscribe',
|
||||
},
|
||||
{
|
||||
pattern: /not enough credits/i,
|
||||
title: "You've run out of credits",
|
||||
subtitle: 'Upgrade your plan for more credits. Free usage resets daily at 00:00 UTC.',
|
||||
cta: 'Upgrade plan',
|
||||
},
|
||||
{
|
||||
pattern: /subscription not active/i,
|
||||
title: 'Your subscription is inactive',
|
||||
subtitle: 'Reactivate your subscription to continue using AI features.',
|
||||
cta: 'Reactivate',
|
||||
},
|
||||
] as const
|
||||
|
||||
export type BillingErrorMatch = (typeof BILLING_ERROR_PATTERNS)[number]
|
||||
|
||||
export function matchBillingError(message: string): BillingErrorMatch | null {
|
||||
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue