Add real-time balance tracking for PPQ provider

Implemented account balance display that updates after each query:

Features:
- Added getBalance() method to PPQ provider adapter
- Fetches balance from https://api.ppq.ai/credits/balance
- Displays balance in chat header with green formatting
- Updates automatically after each message is sent
- Shows loading spinner during balance refresh
- Handles multiple response formats gracefully
- Tooltip explains "updates after each query"

UI Changes:
- Balance shown as "Balance: $X.XX" in header
- Green color for positive balance visibility
- Positioned between title and token counter
- Only shown when balance is available

Technical Details:
- POST request with Bearer token authentication
- Flexible response parsing (balance/credits/amount fields)
- Error handling with console warnings
- Optional feature (gracefully handles providers without balance support)

This helps users track their PPQ credits in real-time while chatting.
This commit is contained in:
Claude
2026-01-16 10:57:41 +00:00
parent ac7b1847f6
commit ec56183aeb
3 changed files with 98 additions and 1 deletions

View File

@@ -170,6 +170,22 @@ export function LLMChatViewer({ conversationId }: LLMChatViewerProps) {
const [loadingState] = useState<ChatLoadingState>("success");
const [isSending, setIsSending] = useState(false);
const [balance, setBalance] = useState<number | null>(null);
const [loadingBalance, setLoadingBalance] = useState(false);
// Fetch balance when provider changes
useEffect(() => {
if (provider && provider.getBalance) {
setLoadingBalance(true);
provider
.getBalance()
.then((bal) => setBalance(bal))
.catch(() => setBalance(null))
.finally(() => setLoadingBalance(false));
} else {
setBalance(null);
}
}, [provider]);
// Update conversation model when provider changes
useEffect(() => {
@@ -274,6 +290,16 @@ export function LLMChatViewer({ conversationId }: LLMChatViewerProps) {
totalCost: prev.totalCost + (response.cost || 0),
};
});
// Refresh balance after successful message
if (provider.getBalance) {
provider
.getBalance()
.then((bal) => setBalance(bal))
.catch(() => {
/* ignore errors */
});
}
} catch (error) {
console.error("Failed to send message:", error);
// Add error message
@@ -307,7 +333,30 @@ export function LLMChatViewer({ conversationId }: LLMChatViewerProps) {
<div className="flex items-center gap-2">
<Bot className="size-4" />
<span className="text-sm font-semibold">{conversation.title}</span>
<div className="ml-auto flex items-center gap-2">
<div className="ml-auto flex items-center gap-3">
{/* Balance Display */}
{balance !== null && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-xs">
<span className="text-muted-foreground">Balance:</span>
<span className="font-medium text-green-600 dark:text-green-400">
${balance.toFixed(2)}
</span>
{loadingBalance && (
<Loader2 className="size-3 animate-spin text-muted-foreground" />
)}
</div>
</TooltipTrigger>
<TooltipContent>
<p>Account balance (updates after each query)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Token Counter */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -305,4 +305,47 @@ export class PPQProviderAdapter implements LLMProviderAdapter {
// Rough estimate: ~4 characters per token
return Math.ceil(text.length / 4);
}
async getBalance(): Promise<number | null> {
try {
// PPQ balance endpoint
// Documentation: https://ppq.ai/api-topups
const response = await fetch(`${this.baseUrl}/credits/balance`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
// Try with empty body first - Bearer auth might be sufficient
body: JSON.stringify({}),
});
if (!response.ok) {
console.warn(`Failed to fetch balance: ${response.status}`);
return null;
}
const data = await response.json();
// Handle various possible response formats
if (typeof data === "number") {
return data;
}
if (data.balance !== undefined) {
return data.balance;
}
if (data.credits !== undefined) {
return data.credits;
}
if (data.amount !== undefined) {
return data.amount;
}
console.warn("Unknown balance response format:", data);
return null;
} catch (error) {
console.error("Failed to get balance:", error);
return null;
}
}
}

View File

@@ -125,4 +125,9 @@ export interface LLMProviderAdapter {
* Count tokens in text
*/
countTokens?(text: string, model: string): Promise<number>;
/**
* Get account balance (if supported)
*/
getBalance?(): Promise<number | null>;
}