diff --git a/web-ui/Providers.tsx b/web-ui/Providers.tsx index 5fcf513d..e0716d1e 100644 --- a/web-ui/Providers.tsx +++ b/web-ui/Providers.tsx @@ -2,11 +2,14 @@ import { RainbowProvider } from "./src/shared/providers/RainbowProvider"; import { BrokerProvider } from "./src/shared/providers/BrokerProvider"; +import { DepositGuardProvider } from "./src/shared/providers/DepositGuardProvider"; export function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/web-ui/src/app/inference/chat/components/OptimizedChatPage.tsx b/web-ui/src/app/inference/chat/components/OptimizedChatPage.tsx index e82a1f09..3793dcae 100644 --- a/web-ui/src/app/inference/chat/components/OptimizedChatPage.tsx +++ b/web-ui/src/app/inference/chat/components/OptimizedChatPage.tsx @@ -3,7 +3,9 @@ import * as React from "react"; import { useState, useEffect, useRef, useCallback } from "react"; import { useAccount } from "wagmi"; +import { useConnectModal } from "@rainbow-me/rainbowkit"; import { useBroker } from "@/shared/providers/BrokerProvider"; +import { useDepositGuard } from "@/shared/providers/DepositGuardProvider"; import { useChatHistory } from "../../../../shared/hooks/useChatHistory"; import { useProviderSearch } from "../../hooks/useProviderSearch"; import { useStreamingState } from "../../../../shared/hooks/useStreamingState"; @@ -27,23 +29,14 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import type { Provider } from "../../../../shared/types/broker"; - - - -interface Message { - role: "system" | "user" | "assistant"; - content: string; - timestamp?: number; - chatId?: string; - isVerified?: boolean | null; - isVerifying?: boolean; -} +import type { Provider, Message } from "../../../../shared/types/broker"; export function OptimizedChatPage() { const { isConnected, address } = useAccount(); - const { broker, isInitializing, ledgerInfo, refreshLedgerInfo } = useBroker(); + const { broker, readOnlyBroker, isInitializing, ledgerInfo, refreshLedgerInfo } = useBroker(); + const { openConnectModal } = useConnectModal(); + const { requestDeposit } = useDepositGuard(); const { toast } = useToast(); // Use toast for non-blocking errors @@ -67,7 +60,7 @@ export function OptimizedChatPage() { providerPendingRefund, setSelectedProvider, refreshProviderBalance, - } = useProviderManagement(broker); + } = useProviderManagement(broker, readOnlyBroker); // Provider dropdown state (UI only) const [isDropdownOpen, setIsDropdownOpen] = useState(false); @@ -171,6 +164,8 @@ export function OptimizedChatPage() { setErrorWithTimeout, isUserScrollingRef, messagesEndRef, + openConnectModal, + requestDeposit, }); // Handle editing a user message - truncates conversation and resends @@ -445,38 +440,6 @@ export function OptimizedChatPage() { // Note: handleDeposit is now handled globally in LayoutContent - if (!isConnected) { - return ( -
-
-
-
- - - -
-
-

- Wallet Not Connected -

-

- Please connect your wallet to access AI inference features. -

-
-
- ); - } - return (
@@ -564,7 +527,10 @@ export function OptimizedChatPage() { providerBalanceNeuron={providerBalanceNeuron} providerPendingRefund={providerPendingRefund} onAddFunds={() => { - // Use the existing top-up modal logic + if (!broker) { + openConnectModal?.(); + return; + } setShowTopUpModal(true); }} /> diff --git a/web-ui/src/app/inference/chat/components/ProviderSelector.tsx b/web-ui/src/app/inference/chat/components/ProviderSelector.tsx index 384d859e..7e54e70e 100644 --- a/web-ui/src/app/inference/chat/components/ProviderSelector.tsx +++ b/web-ui/src/app/inference/chat/components/ProviderSelector.tsx @@ -1,6 +1,7 @@ 'use client' import React, { useState, useEffect, useMemo } from 'react' +import { useAccount } from 'wagmi' import type { Provider } from '../../../../shared/types/broker' import { OFFICIAL_PROVIDERS } from '../../constants/providers' import { copyToClipboard } from '@/lib/utils' @@ -17,6 +18,7 @@ import { getModelHealthStatus, getHealthStatusColor, getHealthStatusText, + type ProviderHealthStatus, } from '@/shared/hooks/useProviderHealth' import { formatNumber } from '@/shared/utils/formatNumber' @@ -78,7 +80,7 @@ function MobileProviderCard({ isSelected: boolean isRecentlyUsed: boolean onSelect: () => void - healthData: Map + healthData: Map isLoadingHealth: boolean }) { const isTeeVerified = @@ -217,16 +219,14 @@ export function ProviderSelector({ onAddFunds, }: ProviderSelectorProps) { const isMobile = useIsMobile() + const { isConnected } = useAccount() const [mobileSearchQuery, setMobileSearchQuery] = useState('') // Get health data using SWR hook (automatically cached across components) const { healthData, isLoading: isLoadingHealth } = useProviderHealth() // Recently used providers set for quick lookup - const recentlyUsedSet = useMemo(() => { - const used = getRecentlyUsedProviders() - return new Set(used) - }, []) + const recentlyUsedSet = new Set(getRecentlyUsedProviders()) // Filter out unverified providers - only show verified providers that can be used const verifiedProviders = useMemo(() => { @@ -715,7 +715,13 @@ export function ProviderSelector({ )} {/* Right Section: Balance and Add Funds */}
-
+ Wallet not connected + + ) : ( + <> +
Add Funds + + )}
)} diff --git a/web-ui/src/app/inference/chat/components/TopUpModal.tsx b/web-ui/src/app/inference/chat/components/TopUpModal.tsx index cc27199f..88e780f1 100644 --- a/web-ui/src/app/inference/chat/components/TopUpModal.tsx +++ b/web-ui/src/app/inference/chat/components/TopUpModal.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { useState } from "react"; +import type { ZGComputeNetworkBroker } from '@0glabs/0g-serving-broker'; import { a0giToNeuron } from "../../../../shared/utils/currency"; import { formatNumber } from "../../../../shared/utils/formatNumber"; import { @@ -44,7 +45,7 @@ interface LedgerInfo { interface TopUpModalProps { isOpen: boolean; onClose: () => void; - broker: any; // TODO: Replace with proper broker type when available + broker: ZGComputeNetworkBroker | null; selectedProvider: Provider | null; topUpAmount: string; setTopUpAmount: (amount: string) => void; @@ -53,7 +54,7 @@ interface TopUpModalProps { providerBalance: number | null; providerPendingRefund: number | null; ledgerInfo: LedgerInfo | null; - refreshLedgerInfo: () => Promise; + refreshLedgerInfo: () => Promise; refreshProviderBalance: () => Promise; setErrorWithTimeout: (error: string | null) => void; } @@ -275,6 +276,8 @@ export function TopUpModal({ + )} +
+ + {/* Service type filter */} + + + + + + Service Type + + {SERVICE_TYPE_OPTIONS.map((option) => ( + onServiceTypeFilterChange(option.value)} + className={serviceTypeFilter === option.value ? 'bg-purple-50 text-purple-700' : ''} + > + {option.label} + + ))} + + +
+ + {/* Results count and clear */} +
+ + Showing {resultCount} of {totalCount} models + + {hasActiveFilters && ( + + )} +
+
+ ) +} diff --git a/web-ui/src/app/inference/components/OptimizedInferencePage.tsx b/web-ui/src/app/inference/components/OptimizedInferencePage.tsx index c058a0f7..edc3141b 100644 --- a/web-ui/src/app/inference/components/OptimizedInferencePage.tsx +++ b/web-ui/src/app/inference/components/OptimizedInferencePage.tsx @@ -2,21 +2,24 @@ import * as React from 'react' import { useState, useCallback, useMemo } from 'react' -import { useAccount, useChainId } from 'wagmi' +import { useChainId } from 'wagmi' import { useBroker } from '@/shared/providers/BrokerProvider' import { useOptimizedDataFetching } from '@/shared/hooks/useOptimizedDataFetching' -import type { Provider } from '@/shared/types/broker' +import type { Provider, ModelSummary } from '@/shared/types/broker' import { OFFICIAL_PROVIDERS } from '../constants/providers' import { transformBrokerServicesToProviders } from '../utils/providerTransform' +import { aggregateProvidersByModel } from '../utils/modelAggregation' import { useNavigation } from '@/shared/components/navigation/OptimizedNavigation' import { TooltipProvider } from '@/components/ui/tooltip' import { StateDisplay, NoticeBar } from '@/components/ui/state-display' import { ProviderCard } from './ProviderCard' +import { ModelCard } from './ModelCard' import { BuildDrawer } from './BuildDrawer' -import { ProviderFilters, type VerificationFilter, type ServiceTypeFilter, type SortOption } from './ProviderFilters' -import { Cpu } from 'lucide-react' +import { ProviderFilters, type VerificationFilter, type SortOption } from './ProviderFilters' +import { ModelFilters, type ModelServiceTypeFilter } from './ModelFilters' +import { Cpu, ArrowLeft } from 'lucide-react' +import { Button } from '@/components/ui/button' -// Helper to get recently used providers from localStorage const getRecentlyUsedProviders = (): string[] => { if (typeof window === 'undefined') return [] try { @@ -27,138 +30,116 @@ const getRecentlyUsedProviders = (): string[] => { } } +const getSelectedModelFromUrl = (): string | null => { + if (typeof window === 'undefined') return null + const params = new URLSearchParams(window.location.search) + return params.get('model') +} + export function OptimizedInferencePage() { - const { isConnected } = useAccount() const chainId = useChainId() - const { broker, isInitializing } = useBroker() + const { broker, readOnlyBroker, isInitializing } = useBroker() const { setIsNavigating, setTargetRoute, setTargetPageType } = useNavigation() const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [selectedProviderForBuild, setSelectedProviderForBuild] = useState(null) - // Filter and search state - const [searchQuery, setSearchQuery] = useState('') + // Read ?model param from URL + const [selectedModel, setSelectedModel] = useState(getSelectedModelFromUrl) + + // Model list filter state + const [modelSearchQuery, setModelSearchQuery] = useState('') + const [modelServiceTypeFilter, setModelServiceTypeFilter] = useState('all') + + // Provider list filter state (when viewing a specific model's providers) + const [providerSearchQuery, setProviderSearchQuery] = useState('') const [verificationFilter, setVerificationFilter] = useState('all') - const [serviceTypeFilter, setServiceTypeFilter] = useState('all') const [sortOption, setSortOption] = useState('name-asc') // Optimized providers data fetching with chain awareness + const activeBroker = broker || readOnlyBroker const { data: providers, loading: providersLoading, error: providersError, } = useOptimizedDataFetching({ fetchFn: async () => { - if (!broker) throw new Error('Broker not available') + if (!activeBroker) throw new Error('Broker not available') try { - const services = await broker.inference.listService() + const services = await activeBroker.inference.listService() return transformBrokerServicesToProviders(services) } catch { return [] } }, cacheKey: 'inference-providers', - cacheTTL: 2 * 60 * 1000, // 2 minutes cache - dependencies: [broker], - skip: !broker, + cacheTTL: 2 * 60 * 1000, + dependencies: [activeBroker], + skip: !activeBroker, chainId, }) - // Use native navigation instead of Next.js router to avoid RSC .txt navigation issues in static export - const handleChatWithProvider = useCallback( - (provider: Provider) => { - const chatUrl = `/inference/chat?provider=${encodeURIComponent(provider.address)}` - setIsNavigating(true) - setTargetRoute('Chat') - setTargetPageType('chat') - setTimeout(() => { - window.location.href = chatUrl - }, 50) - }, - [setIsNavigating, setTargetRoute, setTargetPageType] - ) - - const handleBuildWithProvider = useCallback((provider: Provider) => { - setSelectedProviderForBuild(provider) - setIsDrawerOpen(true) - }, []) - - const handleCloseDrawer = useCallback(() => { - setIsDrawerOpen(false) - setSelectedProviderForBuild(null) - }, []) + // Aggregate providers into model summaries + const allModelSummaries = useMemo(() => { + return aggregateProvidersByModel(providers || []) + }, [providers]) - // Navigate to image generation page - const handleImageGenWithProvider = useCallback( - (provider: Provider) => { - const imageGenUrl = `/inference/image-gen?provider=${encodeURIComponent(provider.address)}` - setIsNavigating(true) - setTargetRoute('Image Generation') - setTargetPageType('image-gen') - setTimeout(() => { - window.location.href = imageGenUrl - }, 50) - }, - [setIsNavigating, setTargetRoute, setTargetPageType] - ) + // Filter model summaries + const filteredModels = useMemo(() => { + let result = allModelSummaries - // Navigate to speech-to-text page - const handleSpeechToTextWithProvider = useCallback( - (provider: Provider) => { - const sttUrl = `/inference/speech-to-text?provider=${encodeURIComponent(provider.address)}` - setIsNavigating(true) - setTargetRoute('Speech to Text') - setTargetPageType('speech-to-text') - setTimeout(() => { - window.location.href = sttUrl - }, 50) - }, - [setIsNavigating, setTargetRoute, setTargetPageType] - ) + if (modelSearchQuery.trim()) { + const query = modelSearchQuery.toLowerCase() + result = result.filter( + (m) => + m.displayName.toLowerCase().includes(query) || + m.model.toLowerCase().includes(query) + ) + } - // Navigate to image-edit page - const handleImageEditWithProvider = useCallback( - (provider: Provider) => { - const editUrl = `/inference/image-edit?provider=${encodeURIComponent(provider.address)}` - setIsNavigating(true) - setTargetRoute('Image Editing') - setTargetPageType('image-edit') - setTimeout(() => { - window.location.href = editUrl - }, 50) - }, - [setIsNavigating, setTargetRoute, setTargetPageType] - ) + if (modelServiceTypeFilter !== 'all') { + result = result.filter((m) => m.serviceType === modelServiceTypeFilter) + } - // Filter and sort providers + return result + }, [allModelSummaries, modelSearchQuery, modelServiceTypeFilter]) + + // Get providers for the selected model + const selectedModelProviders = useMemo(() => { + if (!selectedModel || !providers) return [] + return providers.filter((p) => p.model === selectedModel) + }, [selectedModel, providers]) + + // Find display name for the selected model + const selectedModelDisplayName = useMemo(() => { + if (!selectedModel) return '' + const summary = allModelSummaries.find((m) => m.model === selectedModel) + return summary?.displayName || selectedModel + }, [selectedModel, allModelSummaries]) + + // Filter and sort providers for the selected model view const filteredAndSortedProviders = useMemo(() => { - let result = providers || [] + let result = selectedModelProviders - // Search filter - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase() + if (providerSearchQuery.trim()) { + const query = providerSearchQuery.toLowerCase() result = result.filter( (p) => p.name.toLowerCase().includes(query) || - p.address.toLowerCase().includes(query) || - p.model?.toLowerCase().includes(query) + p.address.toLowerCase().includes(query) ) } - // Verification filter if (verificationFilter === 'verified') { result = result.filter((p) => p.teeSignerAcknowledged === true) } else if (verificationFilter === 'unverified') { result = result.filter((p) => p.teeSignerAcknowledged !== true) } - // Service type filter - if (serviceTypeFilter !== 'all') { - result = result.filter((p) => p.serviceType === serviceTypeFilter) - } + // Helper to calculate total price + const getTotalPrice = (p: Provider) => (p.inputPrice || 0) + (p.outputPrice || 0) - // Sort const recentlyUsed = getRecentlyUsedProviders() result = [...result].sort((a, b) => { switch (sortOption) { @@ -167,36 +148,27 @@ export function OptimizedInferencePage() { case 'name-desc': return b.name.localeCompare(a.name) case 'price-asc': - const priceA = (a.inputPrice || 0) + (a.outputPrice || 0) - const priceB = (b.inputPrice || 0) + (b.outputPrice || 0) - return priceA - priceB + return getTotalPrice(a) - getTotalPrice(b) case 'price-desc': - const priceA2 = (a.inputPrice || 0) + (a.outputPrice || 0) - const priceB2 = (b.inputPrice || 0) + (b.outputPrice || 0) - return priceB2 - priceA2 - case 'recently-used': + return getTotalPrice(b) - getTotalPrice(a) + case 'recently-used': { const indexA = recentlyUsed.indexOf(a.address) const indexB = recentlyUsed.indexOf(b.address) - // Providers in recently used list come first if (indexA === -1 && indexB === -1) return 0 if (indexA === -1) return 1 if (indexB === -1) return -1 return indexA - indexB + } default: return 0 } }) return result - }, [providers, searchQuery, verificationFilter, serviceTypeFilter, sortOption]) + }, [selectedModelProviders, providerSearchQuery, verificationFilter, sortOption]) - // Recently used providers set for quick lookup - const recentlyUsedSet = useMemo(() => { - const used = getRecentlyUsedProviders() - return new Set(used) - }, []) + const recentlyUsedSet = new Set(getRecentlyUsedProviders()) - // Find the cheapest provider (by total input + output price) const cheapestProviderAddress = useMemo(() => { if (!filteredAndSortedProviders.length) return null @@ -204,7 +176,6 @@ export function OptimizedInferencePage() { let minPrice = Infinity for (const provider of filteredAndSortedProviders) { - // Only consider providers with pricing info if (provider.inputPrice !== undefined || provider.outputPrice !== undefined) { const totalPrice = (provider.inputPrice || 0) + (provider.outputPrice || 0) if (totalPrice < minPrice) { @@ -217,40 +188,99 @@ export function OptimizedInferencePage() { return cheapest?.address || null }, [filteredAndSortedProviders]) - // Wallet not connected state - if (!isConnected) { - return ( -
- -
- ) - } + // Navigation handlers + const handleModelClick = useCallback((model: ModelSummary) => { + const url = `/inference?model=${encodeURIComponent(model.model)}` + setSelectedModel(model.model) + setProviderSearchQuery('') + setVerificationFilter('all') + setSortOption('name-asc') + window.history.pushState({}, '', url) + }, []) + + const handleBackToModels = useCallback(() => { + setSelectedModel(null) + window.history.pushState({}, '', '/inference') + }, []) - const isLoading = isInitializing + // Listen to browser back/forward + React.useEffect(() => { + const handlePopState = () => { + setSelectedModel(getSelectedModelFromUrl()) + } + window.addEventListener('popstate', handlePopState) + return () => window.removeEventListener('popstate', handlePopState) + }, []) + + const handleChatWithProvider = useCallback( + (provider: Provider) => { + const chatUrl = `/inference/chat?provider=${encodeURIComponent(provider.address)}` + setIsNavigating(true) + setTargetRoute('Chat') + setTargetPageType('chat') + setTimeout(() => { + window.location.href = chatUrl + }, 50) + }, + [setIsNavigating, setTargetRoute, setTargetPageType] + ) + + const handleBuildWithProvider = useCallback((provider: Provider) => { + setSelectedProviderForBuild(provider) + setIsDrawerOpen(true) + }, []) + + const handleCloseDrawer = useCallback(() => { + setIsDrawerOpen(false) + setSelectedProviderForBuild(null) + }, []) + + const handleImageGenWithProvider = useCallback( + (provider: Provider) => { + const imageGenUrl = `/inference/image-gen?provider=${encodeURIComponent(provider.address)}` + setIsNavigating(true) + setTargetRoute('Image Generation') + setTargetPageType('image-gen') + setTimeout(() => { + window.location.href = imageGenUrl + }, 50) + }, + [setIsNavigating, setTargetRoute, setTargetPageType] + ) + + const handleSpeechToTextWithProvider = useCallback( + (provider: Provider) => { + const sttUrl = `/inference/speech-to-text?provider=${encodeURIComponent(provider.address)}` + setIsNavigating(true) + setTargetRoute('Speech to Text') + setTargetPageType('speech-to-text') + setTimeout(() => { + window.location.href = sttUrl + }, 50) + }, + [setIsNavigating, setTargetRoute, setTargetPageType] + ) + + const handleImageEditWithProvider = useCallback( + (provider: Provider) => { + const editUrl = `/inference/image-edit?provider=${encodeURIComponent(provider.address)}` + setIsNavigating(true) + setTargetRoute('Image Editing') + setTargetPageType('image-edit') + setTimeout(() => { + window.location.href = editUrl + }, 50) + }, + [setIsNavigating, setTargetRoute, setTargetPageType] + ) + + const isLoading = isInitializing && !readOnlyBroker const allProviders = providers || [] const hasError = providersError && !providers return (
- {/* Header */} -
-
-
- -
-
-

AI Providers

-

- Choose from decentralized providers to access AI services -

-
-
-
- {/* Error notice */} {hasError && ( - ) : ( + ) : selectedModel ? ( + // ===== Provider List View (filtered by model) ===== <> - {/* Filters */} + {/* Breadcrumb / Back navigation */} +
+ +
+
+ +
+
+

+ {selectedModelDisplayName} +

+

+ {selectedModelProviders.length} provider{selectedModelProviders.length !== 1 ? 's' : ''} available +

+
+
+
+ + {/* Provider filters (without service type since already filtered by model) */} {/* Provider cards grid */} @@ -306,24 +362,79 @@ export function OptimizedInferencePage() { })}
- {/* Empty state after filtering */} - {filteredAndSortedProviders.length === 0 && allProviders.length > 0 && ( + {/* Empty state */} + {filteredAndSortedProviders.length === 0 && selectedModelProviders.length > 0 && ( )} + + {selectedModelProviders.length === 0 && ( + + )} - )} + ) : ( + // ===== Model List View ===== + <> + {/* Header */} +
+
+
+ +
+
+

AI Models

+

+ Browse available models and choose a provider +

+
+
+
- {/* Empty state when no providers at all */} - {!isLoading && allProviders.length === 0 && ( - + {/* Model filters */} + + + {/* Model cards grid */} +
+ {filteredModels.map((model) => ( + + ))} +
+ + {/* Empty states */} + {filteredModels.length === 0 && allModelSummaries.length > 0 && ( + + )} + + {allProviders.length === 0 && !isLoading && ( + + )} + )} {/* Build drawer */} diff --git a/web-ui/src/app/inference/components/ProviderFilters.tsx b/web-ui/src/app/inference/components/ProviderFilters.tsx index 33d1acce..44e747b2 100644 --- a/web-ui/src/app/inference/components/ProviderFilters.tsx +++ b/web-ui/src/app/inference/components/ProviderFilters.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { Search, Filter, ChevronDown, X } from 'lucide-react' +import { Search, ChevronDown, X } from 'lucide-react' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { @@ -14,7 +14,7 @@ import { } from '@/components/ui/dropdown-menu' export type VerificationFilter = 'all' | 'verified' | 'unverified' -export type ServiceTypeFilter = 'all' | 'chatbot' | 'text-to-image' | 'speech-to-text' +export type ServiceTypeFilter = 'all' | 'chatbot' | 'text-to-image' | 'image-editing' | 'speech-to-text' export type SortOption = 'name-asc' | 'name-desc' | 'price-asc' | 'price-desc' | 'recently-used' interface ProviderFiltersProps { @@ -22,8 +22,9 @@ interface ProviderFiltersProps { onSearchChange: (query: string) => void verificationFilter: VerificationFilter onVerificationFilterChange: (filter: VerificationFilter) => void - serviceTypeFilter: ServiceTypeFilter - onServiceTypeFilterChange: (filter: ServiceTypeFilter) => void + serviceTypeFilter?: ServiceTypeFilter + onServiceTypeFilterChange?: (filter: ServiceTypeFilter) => void + hideServiceType?: boolean sortOption: SortOption onSortChange: (sort: SortOption) => void resultCount: number @@ -40,6 +41,7 @@ const SERVICE_TYPE_OPTIONS: { value: ServiceTypeFilter; label: string }[] = [ { value: 'all', label: 'All Services' }, { value: 'chatbot', label: 'Chatbot' }, { value: 'text-to-image', label: 'Text to Image' }, + { value: 'image-editing', label: 'Image Editing' }, { value: 'speech-to-text', label: 'Speech to Text' }, ] @@ -56,8 +58,9 @@ export function ProviderFilters({ onSearchChange, verificationFilter, onVerificationFilterChange, - serviceTypeFilter, + serviceTypeFilter = 'all', onServiceTypeFilterChange, + hideServiceType = false, sortOption, onSortChange, resultCount, @@ -68,7 +71,7 @@ export function ProviderFilters({ const clearAllFilters = () => { onSearchChange('') onVerificationFilterChange('all') - onServiceTypeFilterChange('all') + onServiceTypeFilterChange?.('all') onSortChange('name-asc') } @@ -140,6 +143,7 @@ export function ProviderFilters({ {/* Service type filter */} + {!hideServiceType && onServiceTypeFilterChange && ( + ))} + + + {/* Custom amount */} +
+ +
+ handleCustomInput(e.target.value)} + disabled={isLoading} + className="pr-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + placeholder={`Min ${minimumDeposit}`} + /> + + 0G + +
+
+ Minimum: {minimumDeposit} 0G + + Wallet: {walletBalance.toFixed(4)} 0G + +
+ {isOverBalance && ( +

+ Insufficient wallet balance +

+ )} +
+ + {/* Error */} + {error && ( +
+

{error}

+
+ )} + + {/* Actions */} +
+ + +
+ + + ); +} diff --git a/web-ui/src/shared/components/layout/LayoutContent.tsx b/web-ui/src/shared/components/layout/LayoutContent.tsx index 9bd046ab..2af9c90e 100644 --- a/web-ui/src/shared/components/layout/LayoutContent.tsx +++ b/web-ui/src/shared/components/layout/LayoutContent.tsx @@ -1,30 +1,9 @@ "use client"; -import React, { useState, useEffect, useRef } from "react"; +import React from "react"; import { usePathname } from "next/navigation"; -import { useAccount, useDisconnect, useChainId, useSwitchChain } from "wagmi"; -import { useBroker } from "../../providers/BrokerProvider"; import { NavigationProvider, useNavigation } from "../navigation/OptimizedNavigation"; import SimpleLoader from "../ui/SimpleLoader"; -import { copyToClipboard } from "@/lib/utils"; -import { zgTestnet, zgMainnet } from "../../config/wagmi"; -import { formatBlockchainError } from "../../utils/blockchainErrors"; -import { MINIMUM_DEPOSITS } from "../../constants/limits"; - -// Preset amounts for initial deposit (network-aware) -const MAINNET_DEPOSIT_PRESETS = [ - { value: 3, label: '3 0G' }, - { value: 10, label: '10 0G' }, - { value: 25, label: '25 0G' }, - { value: 50, label: '50 0G' }, -]; - -const TESTNET_DEPOSIT_PRESETS = [ - { value: 0.1, label: '0.1 0G' }, - { value: 1, label: '1 0G' }, - { value: 5, label: '5 0G' }, - { value: 10, label: '10 0G' }, -]; interface LayoutContentProps { children: React.ReactNode; @@ -60,367 +39,12 @@ MainContentArea.displayName = 'MainContentArea'; export const LayoutContent: React.FC = ({ children }) => { const pathname = usePathname(); const isHomePage = pathname === "/"; - const { isConnected, address } = useAccount(); - const { disconnect } = useDisconnect(); - const chainId = useChainId(); - const { switchChain, isPending: isSwitchingNetwork } = useSwitchChain(); - const { broker, isInitializing, isChainSwitching } = useBroker(); - - const [showDepositModal, setShowDepositModal] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - // Network-aware default values - const isTestnet = chainId === zgTestnet.id; - const minimumDeposit = isTestnet ? MINIMUM_DEPOSITS.INITIAL_TESTNET : MINIMUM_DEPOSITS.INITIAL_MAINNET; - const defaultDeposit = isTestnet ? `${MINIMUM_DEPOSITS.INITIAL_TESTNET}` : `${MINIMUM_DEPOSITS.INITIAL_MAINNET}`; - const defaultPreset = minimumDeposit; - const depositPresets = isTestnet ? TESTNET_DEPOSIT_PRESETS : MAINNET_DEPOSIT_PRESETS; - - const [initialDeposit, setInitialDeposit] = useState(defaultDeposit); - const [selectedPreset, setSelectedPreset] = useState(defaultPreset); - - const handlePresetClick = (presetValue: number) => { - setSelectedPreset(presetValue); - setInitialDeposit(presetValue.toString()); - setError(null); - }; - - const handleCustomInput = (value: string) => { - setInitialDeposit(value); - setSelectedPreset(null); - setError(null); - }; - - const lastCheckedStateRef = useRef<{ - pathname: string; - chainId: number; - checkedAt: number; - } | null>(null); - - const previousChainIdRef = useRef(undefined); - const [isLocalChainSwitching, setIsLocalChainSwitching] = useState(false); - - useEffect(() => { - if (previousChainIdRef.current === undefined) { - previousChainIdRef.current = chainId; - } - }, [chainId]); - - useEffect(() => { - if (previousChainIdRef.current !== undefined && previousChainIdRef.current !== chainId) { - setIsLocalChainSwitching(true); - setShowDepositModal(false); - lastCheckedStateRef.current = null; - - // Reset deposit values to network-appropriate defaults - setInitialDeposit(defaultDeposit); - setSelectedPreset(defaultPreset); - - const resetTimer = setTimeout(() => { - setIsLocalChainSwitching(false); - }, 2000); - - return () => clearTimeout(resetTimer); - } - - previousChainIdRef.current = chainId; - }, [chainId, defaultDeposit, defaultPreset]); - - useEffect(() => { - const checkLedger = async () => { - if (broker && isConnected && !isHomePage && !isInitializing && !isChainSwitching && !isLocalChainSwitching) { - const now = Date.now(); - const lastChecked = lastCheckedStateRef.current; - if (lastChecked && lastChecked.chainId !== chainId && (now - lastChecked.checkedAt) < 3000) { - return; - } - - if (lastChecked && - lastChecked.pathname === pathname && - lastChecked.chainId === chainId && - (now - lastChecked.checkedAt) < 5000) { - return; - } - - try { - // Check if broker.ledger.ledger exists (nested ledger accessor) - const ledgerAccessor = (broker.ledger as any)?.ledger; - if (!ledgerAccessor?.getLedgerWithDetail) { - setShowDepositModal(true); - return; - } - const { ledgerInfo } = await ledgerAccessor.getLedgerWithDetail(); - // ledgerInfo[0] is totalBalance in neuron units - const totalBalance = ledgerInfo ? BigInt(ledgerInfo[0]) : BigInt(0); - if (totalBalance === BigInt(0)) { - setShowDepositModal(true); - } else { - setShowDepositModal(false); - } - - lastCheckedStateRef.current = { - pathname, - chainId, - checkedAt: now, - }; - } catch (error) { - if (error instanceof Error && error.message.includes('network changed')) { - return; - } - - setShowDepositModal(true); - - lastCheckedStateRef.current = { - pathname, - chainId, - checkedAt: now, - }; - } - } - }; - - checkLedger(); - }, [broker, isConnected, isHomePage, chainId, pathname, isInitializing, isChainSwitching, isLocalChainSwitching]); - - useEffect(() => { - if (!isConnected) { - setShowDepositModal(false); - setError(null); - } - }, [isConnected]); - - useEffect(() => { - if (isConnected) { - setError(null); - setIsLoading(false); - lastCheckedStateRef.current = null; - } - }, [chainId, isConnected]); - - useEffect(() => { - if (isInitializing || isChainSwitching || isLocalChainSwitching) { - setShowDepositModal(false); - } - }, [isInitializing, isChainSwitching, isLocalChainSwitching]); - - const handleCreateAccount = async () => { - if (!broker) return; - - const depositAmount = parseFloat(initialDeposit); - if (isNaN(depositAmount) || depositAmount < minimumDeposit) { - setError(`Minimum deposit is ${minimumDeposit} 0G`); - return; - } - - setIsLoading(true); - setError(null); - try { - await broker.ledger.addLedger(depositAmount); - setShowDepositModal(false); - setInitialDeposit(defaultDeposit); - setSelectedPreset(defaultPreset); - } catch (err: unknown) { - const errorMessage = formatBlockchainError(err); - setError(errorMessage); - } finally { - setIsLoading(false); - } - }; - - const handleDisconnectWallet = () => { - disconnect(); - setError(null); - setShowDepositModal(false); - }; - - const handleCopyAddress = async () => { - if (!address) return; - await copyToClipboard(address); - }; - - const formatAddress = (addr: string) => { - return `${addr.slice(0, 6)}...${addr.slice(-4)}`; - }; - - const handleSwitchNetwork = (targetChainId: number) => { - if (switchChain && chainId !== targetChainId) { - setError(null); - switchChain({ chainId: targetChainId }); - } - }; - - const currentNetwork = chainId === zgMainnet.id ? zgMainnet : zgTestnet; - const isMainnet = chainId === zgMainnet.id; return ( {children} - - {/* Global Account Creation Modal */} - {showDepositModal && !isInitializing && !isChainSwitching && !isLocalChainSwitching && ( -
-
-
-

- Create Your Account -

-
- - {/* Network Switcher */} -
- -
- - -
-

- Current: {currentNetwork.name} -

-
- - {/* Wallet Info */} - {address && ( -
-
-
{formatAddress(address)}
-
-
- - -
-
- )} - - {/* Quick preset amounts */} -
- -
- {depositPresets.map((preset) => ( - - ))} -
-
- - {/* Custom amount input */} -
- -
- handleCustomInput(e.target.value)} - className="w-full px-4 py-3 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - placeholder={`Enter amount (min ${minimumDeposit})`} - /> - - 0G - -
-

- Minimum deposit: {minimumDeposit} 0G -

-
- - {/* Error Display */} - {error && ( -
-
- - - -
-

Account Creation Failed

-

{error}

-
-
-
- )} - - -
-
- )}
); }; diff --git a/web-ui/src/shared/constants/deposits.ts b/web-ui/src/shared/constants/deposits.ts new file mode 100644 index 00000000..767c11a6 --- /dev/null +++ b/web-ui/src/shared/constants/deposits.ts @@ -0,0 +1,18 @@ +/** + * Deposit preset amounts for different networks + */ + +export const DEPOSIT_PRESETS = { + mainnet: [ + { value: 3, label: "3 0G" }, + { value: 10, label: "10 0G" }, + { value: 25, label: "25 0G" }, + { value: 50, label: "50 0G" }, + ], + testnet: [ + { value: 0.1, label: "0.1 0G" }, + { value: 1, label: "1 0G" }, + { value: 5, label: "5 0G" }, + { value: 10, label: "10 0G" }, + ], +} as const; diff --git a/web-ui/src/shared/hooks/useBrokerOperations.ts b/web-ui/src/shared/hooks/useBrokerOperations.ts index 4ce76699..ad2b0cc5 100644 --- a/web-ui/src/shared/hooks/useBrokerOperations.ts +++ b/web-ui/src/shared/hooks/useBrokerOperations.ts @@ -26,7 +26,7 @@ */ import { useCallback } from 'react' -import { useBroker } from '../providers/BrokerProvider' +import { useBroker, type LedgerInfo } from '../providers/BrokerProvider' import { a0giToNeuron, neuronToA0gi } from '../utils/currency' import { formatBlockchainError } from '../utils/blockchainErrors' @@ -44,7 +44,7 @@ export interface BrokerOperationsReturn { // Provider operations verifyProvider: (provider: string) => Promise<{ success: boolean - report?: any + report?: unknown }> // API key operations @@ -171,7 +171,7 @@ export function useBrokerOperations(): BrokerOperationsReturn { // Refresh both ledger and provider balance in parallel (from TopUpModal pattern) // This ensures UI stays in sync with blockchain state - const refreshTasks = [refreshLedgerInfo()] + const refreshTasks: Promise[] = [refreshLedgerInfo()] if (onRefreshProvider) { refreshTasks.push(onRefreshProvider()) } diff --git a/web-ui/src/shared/hooks/useChainRestore.ts b/web-ui/src/shared/hooks/useChainRestore.ts new file mode 100644 index 00000000..880df5df --- /dev/null +++ b/web-ui/src/shared/hooks/useChainRestore.ts @@ -0,0 +1,100 @@ +import { useEffect, useRef } from 'react'; + +interface UseChainRestoreOptions { + isConnected: boolean; + currentChainId: number; + switchChain?: (params: { chainId: number }) => void; +} + +interface UseChainRestoreReturn { + shouldSkipInit: boolean; + hasRestored: boolean; +} + +const PREFERRED_CHAIN_KEY = '0g_preferred_chain_id'; + +/** + * Hook to handle chain restoration after wallet reconnection + * + * Behavior: + * - On first connection after wallet reconnect: restore previously used chain from localStorage + * - Returns shouldSkipInit=true during restoration to prevent wrong-chain broker initialization + * - Persists chain preference to localStorage when connected + * + * @param options - Configuration options + * @returns Object with shouldSkipInit and hasRestored flags + */ +export function useChainRestore({ + isConnected, + currentChainId, + switchChain, +}: UseChainRestoreOptions): UseChainRestoreReturn { + const chainRestoreCheckedRef = useRef(false); + const skipChainRestoreRef = useRef(false); + const hasRestoredChainRef = useRef(false); + + // Effect: Manage chain-restore state based on connection status + useEffect(() => { + if (!isConnected) { + chainRestoreCheckedRef.current = false; + skipChainRestoreRef.current = false; + } else if (!chainRestoreCheckedRef.current) { + chainRestoreCheckedRef.current = true; + try { + const stored = localStorage.getItem(PREFERRED_CHAIN_KEY); + skipChainRestoreRef.current = !!stored && Number(stored) !== currentChainId; + } catch { + skipChainRestoreRef.current = false; + } + } else { + skipChainRestoreRef.current = false; + } + }, [isConnected, currentChainId]); + + // Effect: Restore preferred chain after wallet reconnection + useEffect(() => { + if (!isConnected) { + hasRestoredChainRef.current = false; + return; + } + if (hasRestoredChainRef.current) return; + + const restore = async () => { + try { + const stored = localStorage.getItem(PREFERRED_CHAIN_KEY); + if (!stored) { + hasRestoredChainRef.current = true; + return; + } + + const preferredChainId = Number(stored); + if (preferredChainId && preferredChainId !== currentChainId && switchChain) { + switchChain({ chainId: preferredChainId }); + } + + hasRestoredChainRef.current = true; + } catch (err) { + console.warn('[useChainRestore] Failed to restore chain:', err); + hasRestoredChainRef.current = true; + } + }; + + restore(); + }, [isConnected, currentChainId, switchChain]); + + // Effect: Persist preferred chain (after restoration completes) + useEffect(() => { + if (!isConnected || !hasRestoredChainRef.current) return; + + try { + localStorage.setItem(PREFERRED_CHAIN_KEY, String(currentChainId)); + } catch { + // localStorage unavailable + } + }, [currentChainId, isConnected]); + + return { + shouldSkipInit: skipChainRestoreRef.current, + hasRestored: hasRestoredChainRef.current, + }; +} diff --git a/web-ui/src/shared/hooks/useMessageHandling.ts b/web-ui/src/shared/hooks/useMessageHandling.ts index f68d6e3b..3c762cd0 100644 --- a/web-ui/src/shared/hooks/useMessageHandling.ts +++ b/web-ui/src/shared/hooks/useMessageHandling.ts @@ -1,13 +1,6 @@ import { useRef, useCallback } from 'react'; - -interface Message { - role: "system" | "user" | "assistant"; - content: string; - timestamp?: number; - chatId?: string; - isVerified?: boolean | null; - isVerifying?: boolean; -} +import type { ZGComputeNetworkBroker } from '@0glabs/0g-serving-broker'; +import type { Message } from '../types/broker'; interface ServiceMetadata { endpoint: string; @@ -30,7 +23,7 @@ interface ChatHistory { } interface MessageHandlingConfig { - broker: any; + broker: ZGComputeNetworkBroker | null; selectedProvider: Provider | null; serviceMetadata: ServiceMetadata | null; chatHistory: ChatHistory; @@ -43,6 +36,8 @@ interface MessageHandlingConfig { setErrorWithTimeout: (error: string | null) => void; isUserScrollingRef: React.RefObject; messagesEndRef: React.RefObject; + openConnectModal?: () => void; + requestDeposit?: () => Promise; } export function useMessageHandling(config: MessageHandlingConfig) { @@ -60,6 +55,8 @@ export function useMessageHandling(config: MessageHandlingConfig) { setErrorWithTimeout, isUserScrollingRef, messagesEndRef, + openConnectModal, + requestDeposit, } = config; // AbortController for stopping generation @@ -75,86 +72,49 @@ export function useMessageHandling(config: MessageHandlingConfig) { } }, [setIsLoading, setIsStreaming]); - const sendMessage = async () => { - if (!inputMessage.trim() || !selectedProvider || !broker) { - return; - } - - // Create user message - const userMessage: Message = { - role: "user", - content: inputMessage, - timestamp: Date.now(), - }; - - // Add user message to UI immediately - setMessages((prev) => [...prev, userMessage]); - - // Save user message to database and get session ID - let currentSessionForAssistant: string | null = null; - try { - currentSessionForAssistant = await chatHistory.addMessage({ - role: userMessage.role, - content: userMessage.content, - chat_id: undefined, - is_verified: null, - is_verifying: false, - }); - } catch (err) { - // Silent fail for database operations + const ensureReady = useCallback(async (): Promise => { + if (!broker) { + if (openConnectModal) openConnectModal(); + return false; } - - // Reset input and start loading - setInputMessage(""); - setIsLoading(true); - setIsStreaming(true); - setErrorWithTimeout(null); - - // Reset textarea height - setTimeout(() => { - const textarea = document.querySelector('textarea') as HTMLTextAreaElement; - if (textarea) { - textarea.style.height = '40px'; + if (requestDeposit) { + try { + await requestDeposit(); + } catch { + return false; } - }, 0); - + } + return true; + }, [broker, openConnectModal, requestDeposit]); + + // Shared streaming request logic used by both sendMessage and resendMessage + const executeStreamingRequest = useCallback(async ( + activeBroker: ZGComputeNetworkBroker, + provider: Provider, + messagesToSend: Array<{ role: string; content: string }>, + sessionId: string | null, + ) => { let firstContentReceived = false; - // Create new AbortController for this request abortControllerRef.current = new AbortController(); const signal = abortControllerRef.current.signal; try { - // Get service metadata let currentMetadata = serviceMetadata; if (!currentMetadata) { - currentMetadata = await broker.inference.getServiceMetadata( - selectedProvider.address + currentMetadata = await activeBroker.inference.getServiceMetadata( + provider.address ); if (!currentMetadata) { throw new Error("Failed to get service metadata"); } } - // Prepare messages for API - const messagesToSend = [ - ...messages - .filter((m) => m.role !== "system") - .map((m) => ({ role: m.role, content: m.content })), - { role: userMessage.role, content: userMessage.content }, - ]; - - // Get request headers - let headers; - try { - headers = await broker.inference.getRequestHeaders( - selectedProvider.address, - JSON.stringify(messagesToSend) - ); - } catch (headerError) { - throw headerError; - } - // Send request to service + const headers = await activeBroker.inference.getRequestHeaders( + provider.address, + JSON.stringify(messagesToSend) + ); + const { endpoint, model } = currentMetadata; const response = await fetch(`${endpoint}/chat/completions`, { method: "POST", @@ -163,20 +123,14 @@ export function useMessageHandling(config: MessageHandlingConfig) { ...headers, }, body: JSON.stringify({ - messages: [ - ...messages - .filter((m) => m.role !== "system") - .map((m) => ({ role: m.role, content: m.content })), - { role: userMessage.role, content: userMessage.content }, - ], + messages: messagesToSend, model: model, stream: true, }), - signal, // Add abort signal + signal, }); if (!response.ok) { - // Handle error response let errorMessage = `HTTP error! status: ${response.status}`; try { const errorBody = await response.text(); @@ -199,7 +153,6 @@ export function useMessageHandling(config: MessageHandlingConfig) { throw new Error("Failed to get response reader"); } - // Initialize streaming response const assistantMessage: Message = { role: "assistant", content: "", @@ -210,7 +163,6 @@ export function useMessageHandling(config: MessageHandlingConfig) { setMessages((prev) => [...prev, assistantMessage]); - // Process streaming response const decoder = new TextDecoder(); let buffer = ""; let chatId = response.headers.get("ZG-Res-Key") || ""; @@ -238,12 +190,11 @@ export function useMessageHandling(config: MessageHandlingConfig) { const content = parsed.choices?.[0]?.delta?.content; if (content) { - // Hide loading indicator on first content received if (!firstContentReceived) { setIsLoading(false); firstContentReceived = true; } - + completeContent += content; setMessages((prev) => prev.map((msg, index) => @@ -259,7 +210,6 @@ export function useMessageHandling(config: MessageHandlingConfig) { ) ); - // Auto-scroll during streaming setTimeout(() => { if (!isUserScrollingRef.current) { messagesEndRef.current?.scrollIntoView({ @@ -294,41 +244,36 @@ export function useMessageHandling(config: MessageHandlingConfig) { ); // Save assistant message to database - if (completeContent.trim() && currentSessionForAssistant) { + if (completeContent.trim() && sessionId) { try { const { dbManager } = await import('../lib/database'); - await dbManager.saveMessage(currentSessionForAssistant, { + await dbManager.saveMessage(sessionId, { role: "assistant", content: completeContent, timestamp: Date.now(), chat_id: chatId, is_verified: null, is_verifying: false, - provider_address: selectedProvider?.address || '', + provider_address: provider.address, }); - } catch (err) { + } catch { // Silent fail for database operations } } - // Ensure loading is stopped if (!firstContentReceived) { setIsLoading(false); } setIsStreaming(false); abortControllerRef.current = null; } catch (err: unknown) { - // Check if it was aborted by user if (err instanceof Error && err.name === 'AbortError') { - // User stopped generation - don't show error - // Keep the partial message if any setIsLoading(false); setIsStreaming(false); abortControllerRef.current = null; return; } - // Handle other errors let errorMessage = "Failed to send message. Please try again."; if (err instanceof Error) { @@ -345,26 +290,76 @@ export function useMessageHandling(config: MessageHandlingConfig) { setErrorWithTimeout(`Chat error: ${errorMessage}`); - // Remove the loading message if it exists setMessages((prev) => prev.filter((msg) => msg.role !== "assistant" || msg.content !== "") ); - // Ensure loading is stopped if (!firstContentReceived) { setIsLoading(false); } setIsStreaming(false); abortControllerRef.current = null; } - }; + }, [serviceMetadata, setMessages, setIsLoading, setIsStreaming, + setErrorWithTimeout, isUserScrollingRef, messagesEndRef]); + + const sendMessage = useCallback(async () => { + if (!inputMessage.trim() || !selectedProvider) return; + if (!(await ensureReady())) return; + if (!broker) return; + + const userMessage: Message = { + role: "user", + content: inputMessage, + timestamp: Date.now(), + }; - const verifyResponse = async (message: Message, messageIndex: number) => { + setMessages((prev) => [...prev, userMessage]); + + let sessionId: string | null = null; + try { + sessionId = await chatHistory.addMessage({ + role: userMessage.role, + content: userMessage.content, + chat_id: undefined, + is_verified: null, + is_verifying: false, + }); + } catch { + // Silent fail for database operations + } + + setInputMessage(""); + setIsLoading(true); + setIsStreaming(true); + setErrorWithTimeout(null); + + // TODO: Move textarea height reset to the ChatInput component via callback/ref + // instead of querying the DOM directly from a data hook + setTimeout(() => { + const textarea = document.querySelector('textarea') as HTMLTextAreaElement; + if (textarea) { + textarea.style.height = '40px'; + } + }, 0); + + const messagesToSend = [ + ...messages + .filter((m) => m.role !== "system") + .map((m) => ({ role: m.role, content: m.content })), + { role: userMessage.role, content: userMessage.content }, + ]; + + await executeStreamingRequest(broker, selectedProvider, messagesToSend, sessionId); + }, [messages, inputMessage, ensureReady, selectedProvider, broker, chatHistory, + setMessages, setInputMessage, setIsLoading, setIsStreaming, setErrorWithTimeout, + executeStreamingRequest]); + + const verifyResponse = useCallback(async (message: Message, messageIndex: number) => { if (!broker || !selectedProvider || !message.chatId) { return; } - // Set verifying state setMessages((prev) => { const updated = prev.map((msg, index) => index === messageIndex @@ -378,17 +373,15 @@ export function useMessageHandling(config: MessageHandlingConfig) { await new Promise((resolve) => setTimeout(resolve, 100)); try { - // Verify response with minimum loading time const [isValid] = await Promise.all([ broker.inference.processResponse( selectedProvider.address, message.chatId, message.content ), - new Promise((resolve) => setTimeout(resolve, 1000)), // Minimum 1 second loading + new Promise((resolve) => setTimeout(resolve, 1000)), ]); - // Update verification result setMessages((prev) => { const updated = prev.map((msg, index) => index === messageIndex @@ -398,7 +391,6 @@ export function useMessageHandling(config: MessageHandlingConfig) { return updated; }); } catch (err: unknown) { - // Mark as verification failed await new Promise((resolve) => setTimeout(resolve, 1000)); setMessages((prev) => { const updated = prev.map((msg, index) => @@ -409,28 +401,25 @@ export function useMessageHandling(config: MessageHandlingConfig) { return updated; }); } - }; + }, [broker, selectedProvider, setMessages]); // Resend a message with given content and context (for edit/regenerate) const resendMessage = useCallback(async (content: string, contextMessages: Message[]) => { - if (!content.trim() || !selectedProvider || !broker) { - return; - } + if (!content.trim() || !selectedProvider) return; + if (!(await ensureReady())) return; + if (!broker) return; - // Create user message const userMessage: Message = { role: "user", content: content, timestamp: Date.now(), }; - // Add user message to UI setMessages([...contextMessages, userMessage]); - // Save user message to database and get session ID - let currentSessionForAssistant: string | null = null; + let sessionId: string | null = null; try { - currentSessionForAssistant = await chatHistory.addMessage({ + sessionId = await chatHistory.addMessage({ role: userMessage.role, content: userMessage.content, chat_id: undefined, @@ -441,237 +430,20 @@ export function useMessageHandling(config: MessageHandlingConfig) { // Silent fail for database operations } - // Start loading setIsLoading(true); setIsStreaming(true); setErrorWithTimeout(null); - let firstContentReceived = false; - - // Create new AbortController for this request - abortControllerRef.current = new AbortController(); - const signal = abortControllerRef.current.signal; - - try { - // Get service metadata - let currentMetadata = serviceMetadata; - if (!currentMetadata) { - currentMetadata = await broker.inference.getServiceMetadata( - selectedProvider.address - ); - if (!currentMetadata) { - throw new Error("Failed to get service metadata"); - } - } - - // Prepare messages for API (context + new user message) - const messagesToSend = [ - ...contextMessages - .filter((m) => m.role !== "system") - .map((m) => ({ role: m.role, content: m.content })), - { role: userMessage.role, content: userMessage.content }, - ]; - - // Get request headers - let headers; - try { - headers = await broker.inference.getRequestHeaders( - selectedProvider.address, - JSON.stringify(messagesToSend) - ); - } catch (headerError) { - throw headerError; - } - - // Send request to service - const { endpoint, model } = currentMetadata; - const response = await fetch(`${endpoint}/chat/completions`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...headers, - }, - body: JSON.stringify({ - messages: messagesToSend, - model: model, - stream: true, - }), - signal, - }); - - if (!response.ok) { - let errorMessage = `HTTP error! status: ${response.status}`; - try { - const errorBody = await response.text(); - if (errorBody) { - try { - const errorJson = JSON.parse(errorBody); - errorMessage = JSON.stringify(errorJson, null, 2); - } catch { - errorMessage = errorBody; - } - } - } catch { - // Keep original message if can't read body - } - throw new Error(errorMessage); - } + const messagesToSend = [ + ...contextMessages + .filter((m) => m.role !== "system") + .map((m) => ({ role: m.role, content: m.content })), + { role: userMessage.role, content: userMessage.content }, + ]; - const reader = response.body?.getReader(); - if (!reader) { - throw new Error("Failed to get response reader"); - } - - // Initialize streaming response - const assistantMessage: Message = { - role: "assistant", - content: "", - timestamp: Date.now(), - isVerified: null, - isVerifying: false, - }; - - setMessages((prev) => [...prev, assistantMessage]); - - // Process streaming response - const decoder = new TextDecoder(); - let buffer = ""; - let chatId = response.headers.get("ZG-Res-Key") || ""; - let completeContent = ""; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (line.startsWith("data: ")) { - const data = line.slice(6); - if (data === "[DONE]") continue; - - try { - const parsed = JSON.parse(data); - if (!chatId && parsed.id) { - chatId = parsed.id; - } - - const content = parsed.choices?.[0]?.delta?.content; - if (content) { - if (!firstContentReceived) { - setIsLoading(false); - firstContentReceived = true; - } - - completeContent += content; - setMessages((prev) => - prev.map((msg, index) => - index === prev.length - 1 - ? { - ...msg, - content: completeContent, - chatId, - isVerified: msg.isVerified, - isVerifying: msg.isVerifying, - } - : msg - ) - ); - - setTimeout(() => { - if (!isUserScrollingRef.current) { - messagesEndRef.current?.scrollIntoView({ - behavior: "smooth", - }); - } - }, 50); - } - } catch { - // Skip invalid JSON - } - } - } - } - } finally { - reader.releaseLock(); - } - - // Update final message - setMessages((prev) => - prev.map((msg, index) => - index === prev.length - 1 - ? { - ...msg, - content: completeContent, - chatId, - isVerified: msg.isVerified || null, - isVerifying: msg.isVerifying || false, - } - : msg - ) - ); - - // Save assistant message to database - if (completeContent.trim() && currentSessionForAssistant) { - try { - const { dbManager } = await import('../lib/database'); - await dbManager.saveMessage(currentSessionForAssistant, { - role: "assistant", - content: completeContent, - timestamp: Date.now(), - chat_id: chatId, - is_verified: null, - is_verifying: false, - provider_address: selectedProvider?.address || '', - }); - } catch { - // Silent fail for database operations - } - } - - if (!firstContentReceived) { - setIsLoading(false); - } - setIsStreaming(false); - abortControllerRef.current = null; - } catch (err: unknown) { - if (err instanceof Error && err.name === 'AbortError') { - setIsLoading(false); - setIsStreaming(false); - abortControllerRef.current = null; - return; - } - - let errorMessage = "Failed to send message. Please try again."; - - if (err instanceof Error) { - errorMessage = err.message; - } else if (typeof err === 'string') { - errorMessage = err; - } else if (err && typeof err === 'object') { - try { - errorMessage = JSON.stringify(err, null, 2); - } catch { - errorMessage = String(err); - } - } - - setErrorWithTimeout(`Chat error: ${errorMessage}`); - - setMessages((prev) => - prev.filter((msg) => msg.role !== "assistant" || msg.content !== "") - ); - - if (!firstContentReceived) { - setIsLoading(false); - } - setIsStreaming(false); - abortControllerRef.current = null; - } - }, [broker, selectedProvider, serviceMetadata, chatHistory, setMessages, setIsLoading, setIsStreaming, setErrorWithTimeout, isUserScrollingRef, messagesEndRef]); + await executeStreamingRequest(broker, selectedProvider, messagesToSend, sessionId); + }, [ensureReady, broker, selectedProvider, chatHistory, setMessages, + setIsLoading, setIsStreaming, setErrorWithTimeout, executeStreamingRequest]); return { sendMessage, @@ -679,4 +451,4 @@ export function useMessageHandling(config: MessageHandlingConfig) { stopGeneration, resendMessage, }; -} \ No newline at end of file +} diff --git a/web-ui/src/shared/hooks/useReadOnlyBroker.ts b/web-ui/src/shared/hooks/useReadOnlyBroker.ts new file mode 100644 index 00000000..554f6fc9 --- /dev/null +++ b/web-ui/src/shared/hooks/useReadOnlyBroker.ts @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ZGComputeNetworkReadOnlyBroker } from '@0glabs/0g-serving-broker'; +import { createZGComputeNetworkReadOnlyBroker } from '@0glabs/0g-serving-broker'; +import { zgMainnet, zgTestnet } from '../config/wagmi'; + +function getRpcUrl(chainId: number): string { + if (chainId === zgTestnet.id) { + return zgTestnet.rpcUrls.default.http[0]; + } + if (chainId !== zgMainnet.id) { + console.warn(`[useReadOnlyBroker] Unknown chainId ${chainId}, falling back to mainnet RPC`); + } + return zgMainnet.rpcUrls.default.http[0]; +} + +interface UseReadOnlyBrokerOptions { + chainId: number; + enabled?: boolean; +} + +/** + * Hook to manage read-only broker for browsing services without wallet connection + * + * @param options - Configuration options + * @returns Read-only broker instance or null + */ +export function useReadOnlyBroker({ + chainId, + enabled = true, +}: UseReadOnlyBrokerOptions): ZGComputeNetworkReadOnlyBroker | null { + const [readOnlyBroker, setReadOnlyBroker] = useState(null); + const readOnlyChainIdRef = useRef(undefined); + + useEffect(() => { + if (!enabled) return; + + const targetChainId = chainId || zgMainnet.id; + if (readOnlyChainIdRef.current === targetChainId && readOnlyBroker) return; + + readOnlyChainIdRef.current = targetChainId; + + let cancelled = false; + const init = async () => { + try { + const rpcUrl = getRpcUrl(targetChainId); + const instance = await createZGComputeNetworkReadOnlyBroker(rpcUrl, targetChainId); + if (!cancelled) setReadOnlyBroker(instance); + } catch (err) { + console.warn('[useReadOnlyBroker] Init failed:', err); + } + }; + init(); + + return () => { + cancelled = true; + }; + }, [chainId, enabled, readOnlyBroker]); + + return readOnlyBroker; +} diff --git a/web-ui/src/shared/hooks/useWalletGuard.ts b/web-ui/src/shared/hooks/useWalletGuard.ts new file mode 100644 index 00000000..6a5bd525 --- /dev/null +++ b/web-ui/src/shared/hooks/useWalletGuard.ts @@ -0,0 +1,40 @@ +import { useCallback } from 'react' +import { useConnectModal } from '@rainbow-me/rainbowkit' +import { useBroker } from '@/shared/providers/BrokerProvider' + +/** + * Hook to guard actions that require a connected wallet + * + * @returns {Object} - Guard utilities + * @returns {Function} requireWallet - Check if wallet is connected, prompt if not + * @returns {boolean} isWalletConnected - Current wallet connection status + * + * @example + * const { requireWallet } = useWalletGuard() + * + * const handleAction = async () => { + * if (!requireWallet()) return + * // ... perform action + * } + */ +export function useWalletGuard() { + const { broker } = useBroker() + const { openConnectModal } = useConnectModal() + + /** + * Check if wallet is connected. If not, open connect modal. + * @returns {boolean} - true if wallet is connected, false otherwise + */ + const requireWallet = useCallback(() => { + if (!broker) { + openConnectModal?.() + return false + } + return true + }, [broker, openConnectModal]) + + return { + requireWallet, + isWalletConnected: !!broker, + } +} diff --git a/web-ui/src/shared/providers/BrokerProvider.tsx b/web-ui/src/shared/providers/BrokerProvider.tsx index 9c309b53..7e4c1f1f 100644 --- a/web-ui/src/shared/providers/BrokerProvider.tsx +++ b/web-ui/src/shared/providers/BrokerProvider.tsx @@ -1,15 +1,18 @@ "use client"; import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' -import { useAccount, useWalletClient, useChainId } from 'wagmi' -import type { ZGComputeNetworkBroker } from '@0glabs/0g-serving-broker' -import { createZGComputeNetworkBroker } from '@0glabs/0g-serving-broker' +import { useAccount, useWalletClient, useChainId, useSwitchChain } from 'wagmi' +import type { ZGComputeNetworkBroker, ZGComputeNetworkReadOnlyBroker } from '@0glabs/0g-serving-broker' +import { createZGComputeNetworkBroker, createZGComputeNetworkReadOnlyBroker } from '@0glabs/0g-serving-broker' import type { JsonRpcSigner } from 'ethers' import { BrowserProvider } from 'ethers' import { APP_CONSTANTS } from '../constants/app' import { errorHandler } from '../utils/errorHandling' import { neuronToA0giString } from '../utils/currency' import { clearChainCache, setCurrentChainInCache } from '../utils/chainCache' +import { zgMainnet, zgTestnet } from '../config/wagmi' +import { useChainRestore } from '../hooks/useChainRestore' +import { useReadOnlyBroker } from '../hooks/useReadOnlyBroker' interface InferenceInfo { provider: string @@ -33,24 +36,24 @@ export interface LedgerInfo { export interface BrokerContextValue { broker: ZGComputeNetworkBroker | null + readOnlyBroker: ZGComputeNetworkReadOnlyBroker | null isInitializing: boolean - isChainSwitching: boolean error: string | null ledgerInfo: LedgerInfo | null initializeBroker: () => Promise - refreshLedgerInfo: () => Promise + refreshLedgerInfo: () => Promise addLedger: (balance: number) => Promise depositFund: (amount: number) => Promise } const defaultBrokerValue: BrokerContextValue = { broker: null, + readOnlyBroker: null, isInitializing: true, - isChainSwitching: false, error: null, ledgerInfo: null, initializeBroker: async () => {}, - refreshLedgerInfo: async () => {}, + refreshLedgerInfo: async () => null, addLedger: async () => { throw new Error('BrokerProvider not mounted') }, depositFund: async () => { throw new Error('BrokerProvider not mounted') }, } @@ -62,9 +65,9 @@ export function useBroker(): BrokerContextValue { } function processLedgerData( - rawLedgerInfo: any, - infers: any[] | undefined, - fines: any[] | undefined | null, + rawLedgerInfo: [bigint, bigint], + infers: Array<[string, bigint, bigint]> | undefined, + fines: Array<[string, bigint, bigint]> | undefined | null, ): LedgerInfo { const totalBigInt = BigInt(rawLedgerInfo[0]) const lockedBigInt = BigInt(rawLedgerInfo[1]) @@ -101,113 +104,167 @@ function processLedgerData( } } +/** + * Create broker instance with abort support and chain validation + * + * @param walletClient - Wallet client from wagmi + * @param expectedChainId - Expected chain ID to validate against + * @param signal - AbortSignal for cancellation + * @returns Promise resolving to broker instance + * @throws Error if signer creation fails or chain mismatch detected + */ +async function createBrokerWithAbort( + walletClient: any, + expectedChainId: number, + signal: AbortSignal +): Promise { + // Initial delay + await new Promise((resolve) => setTimeout(resolve, 500)) + if (signal.aborted) throw new Error('Aborted') + + // Create signer with retry logic + let signer: JsonRpcSigner | undefined + let signerChainId: number | undefined + const provider = new BrowserProvider(walletClient) + const maxRetries = APP_CONSTANTS.BLOCKCHAIN.MAX_SIGNER_RETRIES + + for (let retryCount = 0; retryCount < maxRetries; retryCount++) { + if (signal.aborted) throw new Error('Aborted') + + try { + signer = await provider.getSigner() + await signer.getAddress() + const network = await provider.getNetwork() + signerChainId = Number(network.chainId) + break + } catch (signerError) { + if (retryCount >= maxRetries - 1) { + throw signerError + } + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + } + + if (!signer) { + throw new Error('Failed to create signer') + } + + // Validate chain ID + if (signerChainId !== expectedChainId) { + throw new Error( + `Chain mismatch: expected ${expectedChainId}, got ${signerChainId}` + ) + } + + if (signal.aborted) throw new Error('Aborted') + + // Create broker instance + const brokerInstance = await createZGComputeNetworkBroker( + signer as Parameters[0] + ) + + if (signal.aborted) throw new Error('Aborted') + + return brokerInstance +} + export function BrokerProvider({ children }: { children: React.ReactNode }) { const { isConnected } = useAccount() const { data: walletClient } = useWalletClient() const chainId = useChainId() + const { switchChain } = useSwitchChain() const [broker, setBroker] = useState(null) const [isInitializing, setIsInitializing] = useState(false) const [error, setError] = useState(null) const [ledgerInfo, setLedgerInfo] = useState(null) - const [isChainSwitching, setIsChainSwitching] = useState(false) - // Cancellation token: each initializeBroker call gets a unique id; - // stale calls check this before writing state. - const initIdRef = useRef(null) + // Cancellation token: AbortController for cancelling in-flight initialization + const abortControllerRef = useRef(null) // Track current chainId to detect changes (useRef to avoid extra renders) const currentChainIdRef = useRef(undefined) + // Always-current chainId ref — updated during render so async code + // can compare the broker's chain against the latest value. + const chainIdRef = useRef(chainId) + chainIdRef.current = chainId + + // Chain restoration hook + const { shouldSkipInit } = useChainRestore({ + isConnected, + currentChainId: chainId, + switchChain, + }) + + // Read-only broker hook + const readOnlyBroker = useReadOnlyBroker({ + chainId, + enabled: !shouldSkipInit || !isConnected, + }) + const initializeBroker = useCallback(async () => { if (!walletClient || !isConnected) { setIsInitializing(false) return } - const thisInitId = Symbol() - initIdRef.current = thisInitId + // Create new AbortController for this initialization + const abortController = new AbortController() + abortControllerRef.current = abortController setIsInitializing(true) setError(null) try { - await new Promise((resolve) => setTimeout(resolve, 500)) - - if (initIdRef.current !== thisInitId) return - - let provider: BrowserProvider - let signer: JsonRpcSigner | undefined - let retryCount = 0 - const maxRetries = APP_CONSTANTS.BLOCKCHAIN.MAX_SIGNER_RETRIES - - while (retryCount < maxRetries) { - try { - provider = new BrowserProvider(walletClient) - signer = await provider.getSigner() - - await signer.getAddress() - await provider.getNetwork() - - break - } catch (signerError) { - retryCount++ - - if (retryCount >= maxRetries) { - throw signerError - } - - await new Promise((resolve) => setTimeout(resolve, 1000)) - - if (initIdRef.current !== thisInitId) return - } - } - - if (initIdRef.current !== thisInitId) return - - if (!signer) { - throw new Error('Failed to create signer') - } - - const brokerInstance = await createZGComputeNetworkBroker( - signer as any // TODO: Fix this type assertion when 0g-serving-broker types are available + // Create broker with abort support and chain validation + const brokerInstance = await createBrokerWithAbort( + walletClient, + chainIdRef.current, + abortController.signal ) - if (initIdRef.current !== thisInitId) return + if (abortController.signal.aborted) return - // Fetch ledger using the instance directly (bypasses stale broker closure) + // Fetch ledger info (non-fatal if it fails) try { const { ledgerInfo: raw, infers, fines } = await brokerInstance.ledger.ledger.getLedgerWithDetail() - if (initIdRef.current !== thisInitId) return - setLedgerInfo(processLedgerData(raw, infers, fines)) + if (abortController.signal.aborted) return + setLedgerInfo(processLedgerData(raw as [bigint, bigint], infers, fines)) } catch { // Ledger fetch failed but broker is still usable } - if (initIdRef.current !== thisInitId) return + if (abortController.signal.aborted) return - setBroker(brokerInstance as unknown as ZGComputeNetworkBroker) + setBroker(brokerInstance) } catch (err: unknown) { - if (initIdRef.current !== thisInitId) return + if (abortController.signal.aborted) return const appError = errorHandler.handle(err, 'BrokerInitialization') setError(appError.userMessage) } finally { - if (initIdRef.current === thisInitId) { + if (!abortController.signal.aborted) { setIsInitializing(false) } } }, [walletClient, isConnected]) - const refreshLedgerInfo = useCallback(async () => { - if (!broker) return + const refreshLedgerInfo = useCallback(async (): Promise => { + if (!broker) return null try { const { ledgerInfo: raw, infers, fines } = await broker.ledger.ledger.getLedgerWithDetail() - setLedgerInfo(processLedgerData(raw, infers, fines)) + const processed = processLedgerData(raw as [bigint, bigint], infers, fines) + setLedgerInfo(processed) + return processed } catch (err: unknown) { - setLedgerInfo(null) + if (err instanceof Error && err.message.includes('Account does not exist')) { + setLedgerInfo(null) + return null + } + throw err } }, [broker]) @@ -249,93 +306,81 @@ export function BrokerProvider({ children }: { children: React.ReactNode }) { [broker, refreshLedgerInfo] ) - // Auto-initialize when wallet connects with retry mechanism - useEffect(() => { - if (isConnected && walletClient && !broker && !isChainSwitching) { - let retryTimerId: ReturnType | undefined - let cancelled = false - - const initWithRetry = async () => { - try { - await initializeBroker() - } catch { - if (!cancelled) { - retryTimerId = setTimeout(() => { - initializeBroker() - }, 2000) - } - } - } - initWithRetry() - - return () => { - cancelled = true - if (retryTimerId !== undefined) clearTimeout(retryTimerId) - } - } - }, [isConnected, walletClient, broker, isChainSwitching, initializeBroker]) - - // Reset state when wallet disconnects (or on initial load without wallet) + // Reset state when wallet disconnects useEffect(() => { if (!isConnected) { setBroker(null) setLedgerInfo(null) setError(null) setIsInitializing(false) - setIsChainSwitching(false) currentChainIdRef.current = undefined } }, [isConnected]) - // Update cache with current chain + // Update chain cache useEffect(() => { setCurrentChainInCache(chainId) }, [chainId]) - // Reset broker and reinitialize when chain changes + // Auto-initialize broker and handle chain switching useEffect(() => { + if (shouldSkipInit || !isConnected || !walletClient) return + const prevChainId = currentChainIdRef.current - if (prevChainId !== undefined && chainId !== prevChainId && isConnected && walletClient) { - setIsChainSwitching(true) + // Handle chain change + if (prevChainId !== undefined && chainId !== prevChainId) { + // Abort any in-flight initialization + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + // Clear state setLedgerInfo(null) setBroker(null) setError(null) - clearChainCache(prevChainId) + } + + // Update current chain tracking + currentChainIdRef.current = chainId - currentChainIdRef.current = chainId + // Initialize if no broker exists + if (!broker) { + let retryTimerId: ReturnType | undefined + let cancelled = false - const reinitialize = async () => { + const initWithRetry = async () => { try { await initializeBroker() - } catch (err) { - console.error('Failed to reinitialize broker after chain switch:', err) - } finally { - setIsChainSwitching(false) + } catch { + if (!cancelled) { + retryTimerId = setTimeout(() => { + initializeBroker() + }, 2000) + } } } + initWithRetry() - const timerId = setTimeout(reinitialize, 1000) - - return () => clearTimeout(timerId) - } else if (prevChainId === undefined && isConnected) { - currentChainIdRef.current = chainId + return () => { + cancelled = true + if (retryTimerId !== undefined) clearTimeout(retryTimerId) + } } - }, [chainId, isConnected, walletClient, initializeBroker]) + }, [shouldSkipInit, isConnected, walletClient, chainId, broker, initializeBroker]) const value = useMemo(() => ({ broker, + readOnlyBroker, isInitializing, - isChainSwitching, error, ledgerInfo, initializeBroker, refreshLedgerInfo, addLedger, depositFund, - }), [broker, isInitializing, isChainSwitching, error, ledgerInfo, + }), [broker, readOnlyBroker, isInitializing, error, ledgerInfo, initializeBroker, refreshLedgerInfo, addLedger, depositFund]) return ( diff --git a/web-ui/src/shared/providers/DepositGuardProvider.tsx b/web-ui/src/shared/providers/DepositGuardProvider.tsx new file mode 100644 index 00000000..e1385dea --- /dev/null +++ b/web-ui/src/shared/providers/DepositGuardProvider.tsx @@ -0,0 +1,136 @@ +"use client"; + +import React, { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { useChainId } from "wagmi"; +import { useBroker } from "./BrokerProvider"; +import { DepositModal } from "../components/DepositModal"; +import type { LedgerInfo } from "./BrokerProvider"; + +// Returns true only when ledgerInfo definitively shows zero balance. +// Returns false for null (unknown state) to avoid false positives during +// loading or network failures. +function needsDeposit(ledgerInfo: LedgerInfo | null): boolean { + if (!ledgerInfo) return false; + return ledgerInfo.totalBalance === "0"; +} + +interface DepositGuardContextValue { + requestDeposit: () => Promise; +} + +const DepositGuardContext = createContext({ + requestDeposit: () => Promise.resolve(), +}); + +export function useDepositGuard(): DepositGuardContextValue { + return useContext(DepositGuardContext); +} + +export function DepositGuardProvider({ + children, +}: { + children: React.ReactNode; +}) { + const { broker, isInitializing, ledgerInfo } = useBroker(); + const chainId = useChainId(); + + const [showModal, setShowModal] = useState(false); + const [isDepositing, setIsDepositing] = useState(false); + + // Track whether the proactive prompt was dismissed this session per chain + const dismissedRef = useRef(false); + const lastProactiveChainRef = useRef(undefined); + + // Pending promise for action-required mode + const pendingRef = useRef<{ + resolve: () => void; + reject: (e: Error) => void; + } | null>(null); + + // Proactive trigger: show dismissable modal after wallet connects with zero balance + useEffect(() => { + if (!broker || isInitializing) return; + + // Reset dismissed state when chain actually changes + if (lastProactiveChainRef.current !== undefined && lastProactiveChainRef.current !== chainId) { + dismissedRef.current = false; + } + lastProactiveChainRef.current = chainId; + + if (dismissedRef.current) return; + if (!needsDeposit(ledgerInfo)) return; + + // Only proactively prompt if ledgerInfo is definitively zero-balance + // (not null, which could be a fetch failure for new accounts) + if (ledgerInfo && ledgerInfo.totalBalance === "0") { + setShowModal(true); + } + }, [broker, isInitializing, ledgerInfo, chainId]); + + // Action-required: returns a Promise that resolves after successful deposit + const requestDeposit = useCallback((): Promise => { + // Explicitly handle null case - account state unknown + if (!ledgerInfo) { + console.warn('[DepositGuard] Account state unknown, cannot determine deposit need'); + return Promise.resolve(); + } + + if (!needsDeposit(ledgerInfo)) return Promise.resolve(); + + // Reject any existing pending promise before creating a new one + if (pendingRef.current) { + pendingRef.current.reject(new Error("superseded")); + pendingRef.current = null; + } + + return new Promise((resolve, reject) => { + pendingRef.current = { resolve, reject }; + setShowModal(true); + }); + }, [ledgerInfo]); + + const handleDeposit = useCallback(() => { + pendingRef.current?.resolve(); + pendingRef.current = null; + dismissedRef.current = true; + setIsDepositing(false); + setShowModal(false); + }, []); + + const handleCancel = useCallback(() => { + // Block cancel while a transaction is in progress + if (isDepositing) return; + + pendingRef.current?.reject(new Error("cancelled")); + pendingRef.current = null; + dismissedRef.current = true; + setShowModal(false); + }, [isDepositing]); + + // Cleanup pending promise on unmount + useEffect(() => { + return () => { + pendingRef.current?.reject(new Error("unmounted")); + pendingRef.current = null; + }; + }, []); + + return ( + + {children} + + + ); +} diff --git a/web-ui/src/shared/types/broker.ts b/web-ui/src/shared/types/broker.ts index ffb81b27..7a39f124 100644 --- a/web-ui/src/shared/types/broker.ts +++ b/web-ui/src/shared/types/broker.ts @@ -2,6 +2,8 @@ * Type definitions for the 0G Broker system */ +export type ServiceType = 'chatbot' | 'text-to-image' | 'image-editing' | 'speech-to-text' + export interface ServiceInfo { provider: string; model: string; @@ -20,12 +22,23 @@ export interface ServiceMetadata { outputPrice?: bigint; } -export interface LedgerInfo { +export interface RawLedgerData { ledgerInfo: [bigint, bigint]; infers: Array<[string, bigint, bigint]>; fines: Array<[string, bigint, bigint]>; } +export interface ModelSummary { + model: string; + displayName: string; + serviceType: ServiceType; + providerCount: number; + verifiedCount: number; + inputPriceRange: { min: number; max: number } | null; + outputPriceRange: { min: number; max: number } | null; + providers: Provider[]; +} + export interface Provider { address: string; model: string; @@ -38,7 +51,7 @@ export interface Provider { inputPriceNeuron?: bigint; outputPriceNeuron?: bigint; teeSignerAcknowledged?: boolean; - serviceType?: string; // Added for UI conditional rendering + serviceType?: ServiceType; // Health status from compute-status API healthStatus?: 'healthy' | 'warning' | 'critical' | 'unknown'; uptime?: number; // percentage