mirror of https://github.com/kortix-ai/suna.git
frontend model selection slight cleanup
This commit is contained in:
parent
fb30938caa
commit
d750a2241f
|
@ -84,7 +84,6 @@ ANTHROPIC_API_KEY=your-anthropic-key
|
|||
OPENAI_API_KEY=your-openai-key
|
||||
OPENROUTER_API_KEY=your-openrouter-key
|
||||
GEMINI_API_KEY=your-gemini-api-key
|
||||
MODEL_TO_USE=openrouter/moonshotai/kimi-k2
|
||||
|
||||
# Search and Web Scraping
|
||||
TAVILY_API_KEY=your-tavily-key
|
||||
|
|
|
@ -57,7 +57,7 @@ class ModelRegistry:
|
|||
id="xai/grok-4-fast-non-reasoning",
|
||||
name="Grok 4 Fast",
|
||||
provider=ModelProvider.XAI,
|
||||
aliases=["grok-4-fast-non-reasoning", "x-ai/grok-4-fast-non-reasoning", "openrouter/x-ai/grok-4-fast-non-reasoning", "Grok 4 Fast Non Reasoning"],
|
||||
aliases=["grok-4-fast-non-reasoning", "x-ai/grok-4-fast-non-reasoning", "openrouter/x-ai/grok-4-fast-non-reasoning", "Grok 4 Fast"],
|
||||
context_window=2_000_000,
|
||||
capabilities=[
|
||||
ModelCapability.CHAT,
|
||||
|
|
|
@ -16,11 +16,11 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useAvailableModels } from '@/hooks/react-query/subscriptions/use-billing';
|
||||
// Models now fetched via useModelSelection hook
|
||||
import type { Model } from '@/lib/api';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useModelSelection } from '@/components/thread/chat-input/_use-model-selection';
|
||||
import { useModelSelection } from '@/hooks/use-model-selection';
|
||||
|
||||
// Example task data with token usage
|
||||
const exampleTasks = [
|
||||
|
@ -137,14 +137,11 @@ const exampleTasks = [
|
|||
const DISABLE_EXAMPLES = true;
|
||||
|
||||
export default function PricingPage() {
|
||||
const {
|
||||
data: modelsResponse,
|
||||
isLoading: loading,
|
||||
error,
|
||||
refetch,
|
||||
} = useAvailableModels();
|
||||
|
||||
const { allModels } = useModelSelection();
|
||||
const {
|
||||
allModels,
|
||||
modelsData: modelsResponse,
|
||||
isLoading: loading
|
||||
} = useModelSelection();
|
||||
|
||||
const [selectedModelId, setSelectedModelId] = useState<string>(
|
||||
'anthropic/claude-sonnet-4-20250514',
|
||||
|
@ -223,7 +220,7 @@ export default function PricingPage() {
|
|||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (!modelsResponse && !loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
|
@ -233,14 +230,9 @@ export default function PricingPage() {
|
|||
Pricing Unavailable
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch model pricing'}
|
||||
Failed to fetch model pricing. Please refresh the page.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => refetch()} size="sm">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -16,14 +16,8 @@ import {
|
|||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useModelSelection,
|
||||
MODELS,
|
||||
DEFAULT_FREE_MODEL_ID,
|
||||
DEFAULT_PREMIUM_MODEL_ID
|
||||
} from '@/components/thread/chat-input/_use-model-selection';
|
||||
import { formatModelName, getPrefixedModelId } from '@/lib/stores/model-store';
|
||||
import { useAvailableModels } from '@/hooks/react-query/subscriptions/use-billing';
|
||||
import { useModelSelection } from '@/hooks/use-model-selection';
|
||||
import { formatModelName } from '@/lib/stores/model-store';
|
||||
import { isLocalMode } from '@/lib/config';
|
||||
import { CustomModelDialog, CustomModelFormData } from '@/components/thread/chat-input/custom-model-dialog';
|
||||
import { PaywallDialog } from '@/components/payment/paywall-dialog';
|
||||
|
@ -59,9 +53,9 @@ export function AgentModelSelector({
|
|||
customModels: storeCustomModels,
|
||||
addCustomModel: storeAddCustomModel,
|
||||
updateCustomModel: storeUpdateCustomModel,
|
||||
removeCustomModel: storeRemoveCustomModel
|
||||
removeCustomModel: storeRemoveCustomModel,
|
||||
modelsData // Now available directly from the hook
|
||||
} = useModelSelection();
|
||||
const { data: modelsData } = useAvailableModels();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
||||
|
@ -78,30 +72,8 @@ export function AgentModelSelector({
|
|||
|
||||
const customModels = storeCustomModels;
|
||||
|
||||
const normalizeModelId = (modelId?: string): string => {
|
||||
if (!modelId) return subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID;
|
||||
|
||||
if (modelsData?.models) {
|
||||
const exactMatch = modelsData.models.find(m => m.short_name === modelId);
|
||||
if (exactMatch) return exactMatch.short_name;
|
||||
|
||||
const fullMatch = modelsData.models.find(m => m.id === modelId);
|
||||
if (fullMatch) return fullMatch.short_name || fullMatch.id;
|
||||
|
||||
if (modelId.startsWith('openrouter/')) {
|
||||
const shortName = modelId.replace('openrouter/', '');
|
||||
const shortMatch = modelsData.models.find(m => m.short_name === shortName);
|
||||
if (shortMatch) return shortMatch.short_name;
|
||||
}
|
||||
}
|
||||
|
||||
return modelId;
|
||||
};
|
||||
|
||||
const normalizedValue = normalizeModelId(value);
|
||||
|
||||
// Use the prop value if provided, otherwise fall back to store value
|
||||
const selectedModel = normalizedValue || storeSelectedModel;
|
||||
const selectedModel = value || storeSelectedModel;
|
||||
|
||||
const enhancedModelOptions = useMemo(() => {
|
||||
const modelMap = new Map();
|
||||
|
@ -261,14 +233,12 @@ export function AgentModelSelector({
|
|||
|
||||
const handleSaveCustomModel = (formData: CustomModelFormData) => {
|
||||
const modelId = formData.id.trim();
|
||||
const displayId = modelId.startsWith('openrouter/') ? modelId.replace('openrouter/', '') : modelId;
|
||||
const modelLabel = formData.label.trim() || formatModelName(displayId);
|
||||
const modelLabel = formData.label.trim() || formatModelName(modelId);
|
||||
|
||||
if (!modelId) return;
|
||||
|
||||
const checkId = modelId;
|
||||
if (customModels.some(model =>
|
||||
model.id === checkId && (dialogMode === 'add' || model.id !== editingModelId))) {
|
||||
model.id === modelId && (dialogMode === 'add' || model.id !== editingModelId))) {
|
||||
console.error('A model with this ID already exists');
|
||||
return;
|
||||
}
|
||||
|
@ -302,8 +272,11 @@ export function AgentModelSelector({
|
|||
storeRemoveCustomModel(modelId);
|
||||
|
||||
if (selectedModel === modelId) {
|
||||
const defaultModel = subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID;
|
||||
onChange(defaultModel);
|
||||
// When deleting the currently selected custom model, let the hook determine the new default
|
||||
const firstAvailableModel = allModels.find(m => canAccessModel(m.id));
|
||||
if (firstAvailableModel) {
|
||||
onChange(firstAvailableModel.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -313,8 +286,8 @@ export function AgentModelSelector({
|
|||
const accessible = isCustom ? true : (isLocalMode() || canAccessModel(model.id));
|
||||
const isHighlighted = index === highlightedIndex;
|
||||
const isPremium = model.requiresSubscription;
|
||||
const isLowQuality = MODELS[model.id]?.lowQuality || false;
|
||||
const isRecommended = MODELS[model.id]?.recommended || false;
|
||||
const isLowQuality = false; // API models are quality controlled
|
||||
const isRecommended = model.recommended || false;
|
||||
|
||||
return (
|
||||
<Tooltip key={`model-${model.id}-${index}`}>
|
||||
|
@ -411,14 +384,12 @@ export function AgentModelSelector({
|
|||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<Cpu className="h-4 w-4" />
|
||||
{MODELS[selectedModel]?.lowQuality && (
|
||||
<AlertTriangle className="h-2.5 w-2.5 text-amber-500 absolute -top-1 -right-1" />
|
||||
)}
|
||||
{/* API models are quality controlled - no low quality warning needed */}
|
||||
</div>
|
||||
<span className="truncate">{selectedModelDisplay}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{MODELS[selectedModel]?.recommended && (
|
||||
{allModels.find(m => m.id === selectedModel)?.recommended && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-sm bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 font-medium">
|
||||
Recommended
|
||||
</span>
|
||||
|
@ -438,9 +409,7 @@ export function AgentModelSelector({
|
|||
>
|
||||
<div className="relative flex items-center justify-center">
|
||||
<Cpu className="h-4 w-4" />
|
||||
{MODELS[selectedModel]?.lowQuality && (
|
||||
<AlertTriangle className="h-2.5 w-2.5 text-amber-500 absolute -top-1 -right-1" />
|
||||
)}
|
||||
{/* API models are quality controlled - no low quality warning needed */}
|
||||
</div>
|
||||
<span className="text-sm">{selectedModelDisplay}</span>
|
||||
</Button>
|
||||
|
|
|
@ -1,375 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ExternalLink, Loader2, AlertCircle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { OpenInNewWindowIcon } from '@radix-ui/react-icons';
|
||||
import { useUsageLogs } from '@/hooks/react-query/subscriptions/use-billing';
|
||||
import { UsageLogEntry } from '@/lib/api';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
|
||||
|
||||
interface DailyUsage {
|
||||
date: string;
|
||||
logs: UsageLogEntry[];
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
requestCount: number;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
export default function UsageLogs({ accountId }: Props) {
|
||||
const [page, setPage] = useState(0);
|
||||
const [allLogs, setAllLogs] = useState<UsageLogEntry[]>([]);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const ITEMS_PER_PAGE = 1000;
|
||||
|
||||
// Use React Query hook for the current page
|
||||
const { data: currentPageData, isLoading, error, refetch } = useUsageLogs(page, ITEMS_PER_PAGE);
|
||||
|
||||
// Update accumulated logs when new data arrives
|
||||
useEffect(() => {
|
||||
if (currentPageData) {
|
||||
if (page === 0) {
|
||||
// First page - replace all logs
|
||||
setAllLogs(currentPageData.logs || []);
|
||||
} else {
|
||||
// Subsequent pages - append to existing logs
|
||||
setAllLogs(prev => [...prev, ...(currentPageData.logs || [])]);
|
||||
}
|
||||
setHasMore(currentPageData.has_more || false);
|
||||
}
|
||||
}, [currentPageData, page]);
|
||||
|
||||
const loadMore = () => {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const formatCost = (cost: number | string) => {
|
||||
if (typeof cost === 'string' || cost === 0) {
|
||||
return typeof cost === 'string' ? cost : '$0.0000';
|
||||
}
|
||||
return `$${cost.toFixed(4)}`;
|
||||
};
|
||||
|
||||
const formatCreditAmount = (amount: number) => {
|
||||
if (amount === 0) return null;
|
||||
return `$${amount.toFixed(4)}`;
|
||||
};
|
||||
|
||||
const formatDateOnly = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const handleThreadClick = (threadId: string, projectId: string) => {
|
||||
// Navigate to the thread using the correct project_id
|
||||
const threadUrl = `/projects/${projectId}/thread/${threadId}`;
|
||||
window.open(threadUrl, '_blank');
|
||||
};
|
||||
|
||||
// Group usage logs by date
|
||||
const groupLogsByDate = (logs: UsageLogEntry[]): DailyUsage[] => {
|
||||
const grouped = logs.reduce(
|
||||
(acc, log) => {
|
||||
const date = new Date(log.created_at).toDateString();
|
||||
|
||||
if (!acc[date]) {
|
||||
acc[date] = {
|
||||
date,
|
||||
logs: [],
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
requestCount: 0,
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
|
||||
acc[date].logs.push(log);
|
||||
acc[date].totalTokens += log.total_tokens;
|
||||
acc[date].totalCost +=
|
||||
typeof log.estimated_cost === 'number' ? log.estimated_cost : 0;
|
||||
acc[date].requestCount += 1;
|
||||
|
||||
if (!acc[date].models.includes(log.content.model)) {
|
||||
acc[date].models.push(log.content.model);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, DailyUsage>,
|
||||
);
|
||||
|
||||
return Object.values(grouped).sort(
|
||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
if (isLoading && page === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usage Logs</CardTitle>
|
||||
<CardDescription>Loading your token usage history...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usage Logs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<p className="text-sm text-destructive">
|
||||
Error: {error.message || 'Failed to load usage logs'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle local development mode message
|
||||
if (currentPageData?.message) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usage Logs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="p-4 bg-muted/30 border border-border rounded-lg text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentPageData.message}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const dailyUsage = groupLogsByDate(allLogs);
|
||||
const totalUsage = allLogs.reduce(
|
||||
(sum, log) =>
|
||||
sum + (typeof log.estimated_cost === 'number' ? log.estimated_cost : 0),
|
||||
0,
|
||||
);
|
||||
|
||||
// Get subscription limit from the first page data
|
||||
const subscriptionLimit = currentPageData?.subscription_limit || 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Show credit usage info if user has gone over limit */}
|
||||
{subscriptionLimit > 0 && totalUsage > subscriptionLimit && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Credits Being Used</AlertTitle>
|
||||
<AlertDescription>
|
||||
You've exceeded your monthly subscription limit of ${subscriptionLimit.toFixed(2)}.
|
||||
Additional usage is being deducted from your credit balance.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Usage Logs Accordion */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Daily Usage Logs</CardTitle>
|
||||
<CardDescription>
|
||||
<div className='flex justify-between items-center'>
|
||||
Your token usage organized by day, sorted by most recent.{" "}
|
||||
<Button variant='outline' asChild className='text-sm ml-4'>
|
||||
<Link href="/model-pricing">
|
||||
View Model Pricing <OpenInNewWindowIcon className='w-4 h-4' />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{dailyUsage.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">No usage logs found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{dailyUsage.map((day) => (
|
||||
<AccordionItem key={day.date} value={day.date}>
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex justify-between items-center w-full mr-4">
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">
|
||||
{formatDateOnly(day.date)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{day.requestCount} request
|
||||
{day.requestCount !== 1 ? 's' : ''} •{' '}
|
||||
{day.models.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono font-semibold">
|
||||
{formatCost(day.totalCost)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground font-mono">
|
||||
{day.totalTokens.toLocaleString()} tokens
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="rounded-md border mt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[180px] text-xs">Time</TableHead>
|
||||
<TableHead className="text-xs">Model</TableHead>
|
||||
<TableHead className="text-xs text-right">Prompt</TableHead>
|
||||
<TableHead className="text-xs text-right">Completion</TableHead>
|
||||
<TableHead className="text-xs text-right">Total</TableHead>
|
||||
<TableHead className="text-xs text-right">Cost</TableHead>
|
||||
<TableHead className="text-xs text-right">Payment</TableHead>
|
||||
<TableHead className="w-[100px] text-xs text-center">Thread</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{day.logs.map((log, index) => (
|
||||
<TableRow
|
||||
key={`${log.message_id}_${index}`}
|
||||
className="hover:bg-muted/50 group"
|
||||
>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{new Date(log.created_at).toLocaleTimeString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<Badge variant="secondary" className="font-mono text-xs">
|
||||
{log.content.model.replace('openrouter/', '').replace('anthropic/', '')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">
|
||||
{log.content.usage.prompt_tokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">
|
||||
{log.content.usage.completion_tokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">
|
||||
{log.total_tokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">
|
||||
{formatCost(log.estimated_cost)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs">
|
||||
{log.payment_method === 'credits' ? (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Credits
|
||||
</Badge>
|
||||
{log.credit_used && log.credit_used > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
-{formatCreditAmount(log.credit_used)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Subscription
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleThreadClick(log.thread_id, log.project_id)}
|
||||
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex justify-center pt-6">
|
||||
<Button
|
||||
onClick={loadMore}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load More'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -23,7 +23,7 @@ import {
|
|||
useStopAgentMutation,
|
||||
} from '@/hooks/react-query/threads/use-agent-run';
|
||||
import { useSharedSubscription } from '@/contexts/SubscriptionContext';
|
||||
import { SubscriptionStatus } from '@/components/thread/chat-input/_use-model-selection';
|
||||
export type SubscriptionStatus = 'no_subscription' | 'active';
|
||||
|
||||
import {
|
||||
UnifiedMessage,
|
||||
|
|
|
@ -1,224 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useSubscriptionData } from '@/contexts/SubscriptionContext';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { isLocalMode } from '@/lib/config';
|
||||
import { useAvailableModels } from '@/hooks/react-query/subscriptions/use-model';
|
||||
import {
|
||||
useModelStore,
|
||||
canAccessModel,
|
||||
formatModelName,
|
||||
getPrefixedModelId,
|
||||
type SubscriptionStatus,
|
||||
type ModelOption,
|
||||
type CustomModel
|
||||
} from '@/lib/stores/model-store';
|
||||
|
||||
export const useModelSelection = () => {
|
||||
const { data: subscriptionData } = useSubscriptionData();
|
||||
const { data: modelsData, isLoading: isLoadingModels } = useAvailableModels({
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const {
|
||||
selectedModel,
|
||||
customModels,
|
||||
hasHydrated,
|
||||
setSelectedModel,
|
||||
addCustomModel,
|
||||
updateCustomModel,
|
||||
removeCustomModel,
|
||||
setCustomModels,
|
||||
setHasHydrated,
|
||||
getDefaultModel,
|
||||
resetToDefault,
|
||||
} = useModelStore();
|
||||
|
||||
const subscriptionStatus: SubscriptionStatus = (subscriptionData?.subscription?.status === 'active' || subscriptionData?.subscription?.status === 'trialing')
|
||||
? 'active'
|
||||
: 'no_subscription';
|
||||
|
||||
useEffect(() => {
|
||||
if (isLocalMode() && hasHydrated && typeof window !== 'undefined') {
|
||||
try {
|
||||
const storedModels = localStorage.getItem('customModels');
|
||||
if (storedModels) {
|
||||
const parsedModels = JSON.parse(storedModels);
|
||||
if (Array.isArray(parsedModels)) {
|
||||
const validModels = parsedModels.filter((model: any) =>
|
||||
model && typeof model === 'object' &&
|
||||
typeof model.id === 'string' &&
|
||||
typeof model.label === 'string'
|
||||
);
|
||||
setCustomModels(validModels);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading custom models:', e);
|
||||
}
|
||||
}
|
||||
}, [isLocalMode, hasHydrated, setCustomModels]);
|
||||
|
||||
const MODEL_OPTIONS = useMemo(() => {
|
||||
let models: ModelOption[] = [];
|
||||
if (!modelsData?.models || isLoadingModels) {
|
||||
models = [
|
||||
{
|
||||
id: 'Kimi K2',
|
||||
label: 'Kimi K2',
|
||||
requiresSubscription: false,
|
||||
priority: 100,
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
id: 'Claude Sonnet 4',
|
||||
label: 'Claude Sonnet 4',
|
||||
requiresSubscription: true,
|
||||
priority: 100,
|
||||
recommended: true
|
||||
},
|
||||
];
|
||||
} else {
|
||||
models = modelsData.models.map(model => {
|
||||
const shortName = model.short_name || model.id;
|
||||
const displayName = model.display_name || shortName;
|
||||
|
||||
return {
|
||||
id: shortName,
|
||||
label: displayName,
|
||||
requiresSubscription: model.requires_subscription || false,
|
||||
priority: model.priority || 0,
|
||||
recommended: model.recommended || false,
|
||||
top: (model.priority || 0) >= 90,
|
||||
capabilities: model.capabilities || [],
|
||||
contextWindow: model.context_window || 128000
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (isLocalMode() && customModels.length > 0) {
|
||||
const customModelOptions = customModels.map(model => ({
|
||||
id: model.id,
|
||||
label: model.label || formatModelName(model.id),
|
||||
requiresSubscription: false,
|
||||
top: false,
|
||||
isCustom: true,
|
||||
priority: 30,
|
||||
}));
|
||||
|
||||
models = [...models, ...customModelOptions];
|
||||
}
|
||||
|
||||
const sortedModels = models.sort((a, b) => {
|
||||
if (a.recommended !== b.recommended) {
|
||||
return a.recommended ? -1 : 1;
|
||||
}
|
||||
|
||||
if (a.priority !== b.priority) {
|
||||
return (b.priority || 0) - (a.priority || 0);
|
||||
}
|
||||
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return sortedModels;
|
||||
}, [modelsData, isLoadingModels, customModels]);
|
||||
|
||||
const availableModels = useMemo(() => {
|
||||
return isLocalMode()
|
||||
? MODEL_OPTIONS
|
||||
: MODEL_OPTIONS.filter(model =>
|
||||
canAccessModel(subscriptionStatus, model.requiresSubscription)
|
||||
);
|
||||
}, [MODEL_OPTIONS, subscriptionStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHydrated || isLoadingModels || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isValidModel = MODEL_OPTIONS.some(model => model.id === selectedModel) ||
|
||||
(isLocalMode() && customModels.some(model => model.id === selectedModel));
|
||||
|
||||
if (!isValidModel) {
|
||||
console.log('🔧 ModelSelection: Invalid model detected, resetting to default');
|
||||
resetToDefault(subscriptionStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLocalMode()) {
|
||||
const modelOption = MODEL_OPTIONS.find(m => m.id === selectedModel);
|
||||
if (modelOption && !canAccessModel(subscriptionStatus, modelOption.requiresSubscription)) {
|
||||
console.log('🔧 ModelSelection: User lost access to model, resetting to default');
|
||||
resetToDefault(subscriptionStatus);
|
||||
}
|
||||
}
|
||||
}, [hasHydrated, selectedModel, subscriptionStatus, MODEL_OPTIONS, customModels, isLoadingModels, resetToDefault]);
|
||||
|
||||
const handleModelChange = (modelId: string) => {
|
||||
const isCustomModel = isLocalMode() && customModels.some(model => model.id === modelId);
|
||||
|
||||
const modelOption = MODEL_OPTIONS.find(option => option.id === modelId);
|
||||
|
||||
if (!modelOption && !isCustomModel) {
|
||||
resetToDefault(subscriptionStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCustomModel && !isLocalMode() &&
|
||||
!canAccessModel(subscriptionStatus, modelOption?.requiresSubscription ?? false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedModel(modelId);
|
||||
};
|
||||
|
||||
const getActualModelId = (modelId: string): string => {
|
||||
const isCustomModel = isLocalMode() && customModels.some(model => model.id === modelId);
|
||||
return isCustomModel ? getPrefixedModelId(modelId, true) : modelId;
|
||||
};
|
||||
|
||||
const refreshCustomModels = () => {
|
||||
if (isLocalMode() && typeof window !== 'undefined') {
|
||||
try {
|
||||
const storedModels = localStorage.getItem('customModels');
|
||||
if (storedModels) {
|
||||
const parsedModels = JSON.parse(storedModels);
|
||||
if (Array.isArray(parsedModels)) {
|
||||
const validModels = parsedModels.filter((model: any) =>
|
||||
model && typeof model === 'object' &&
|
||||
typeof model.id === 'string' &&
|
||||
typeof model.label === 'string'
|
||||
);
|
||||
setCustomModels(validModels);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading custom models:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
selectedModel,
|
||||
handleModelChange,
|
||||
setSelectedModel: handleModelChange,
|
||||
availableModels,
|
||||
allModels: MODEL_OPTIONS,
|
||||
customModels,
|
||||
addCustomModel,
|
||||
updateCustomModel,
|
||||
removeCustomModel,
|
||||
refreshCustomModels,
|
||||
getActualModelId,
|
||||
canAccessModel: (modelId: string) => {
|
||||
if (isLocalMode()) return true;
|
||||
const model = MODEL_OPTIONS.find(m => m.id === modelId);
|
||||
return model ? canAccessModel(subscriptionStatus, model.requiresSubscription) : false;
|
||||
},
|
||||
isSubscriptionRequired: (modelId: string) => {
|
||||
return MODEL_OPTIONS.find(m => m.id === modelId)?.requiresSubscription || false;
|
||||
},
|
||||
subscriptionStatus,
|
||||
};
|
||||
};
|
|
@ -1,504 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useSubscriptionData } from '@/contexts/SubscriptionContext';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { isLocalMode } from '@/lib/config';
|
||||
import { useAvailableModels } from '@/hooks/react-query/subscriptions/use-model';
|
||||
|
||||
export const STORAGE_KEY_MODEL = 'suna-preferred-model-v3';
|
||||
export const STORAGE_KEY_CUSTOM_MODELS = 'customModels';
|
||||
export const DEFAULT_PREMIUM_MODEL_ID = 'claude-sonnet-4';
|
||||
export const DEFAULT_FREE_MODEL_ID = 'moonshotai/kimi-k2';
|
||||
|
||||
export const testLocalStorage = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
const testKey = 'test-storage';
|
||||
const testValue = 'test-value';
|
||||
localStorage.setItem(testKey, testValue);
|
||||
const retrieved = localStorage.getItem(testKey);
|
||||
localStorage.removeItem(testKey);
|
||||
return retrieved === testValue;
|
||||
} catch (error) {
|
||||
console.error('localStorage test failed:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export type SubscriptionStatus = 'no_subscription' | 'active';
|
||||
|
||||
export interface ModelOption {
|
||||
id: string;
|
||||
label: string;
|
||||
requiresSubscription: boolean;
|
||||
description?: string;
|
||||
top?: boolean;
|
||||
isCustom?: boolean;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface CustomModel {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const MODELS = {
|
||||
'claude-sonnet-4': {
|
||||
tier: 'none',
|
||||
priority: 100,
|
||||
recommended: true,
|
||||
lowQuality: false
|
||||
},
|
||||
'gpt-5': {
|
||||
tier: 'premium',
|
||||
priority: 99,
|
||||
recommended: false,
|
||||
lowQuality: false
|
||||
},
|
||||
'google/gemini-2.5-pro': {
|
||||
tier: 'premium',
|
||||
priority: 96,
|
||||
recommended: false,
|
||||
lowQuality: false
|
||||
},
|
||||
'grok-4': {
|
||||
tier: 'premium',
|
||||
priority: 94,
|
||||
recommended: false,
|
||||
lowQuality: false
|
||||
},
|
||||
'sonnet-3.7': {
|
||||
tier: 'premium',
|
||||
priority: 93,
|
||||
recommended: false,
|
||||
lowQuality: false
|
||||
},
|
||||
'sonnet-3.5': {
|
||||
tier: 'premium',
|
||||
priority: 90,
|
||||
recommended: false,
|
||||
lowQuality: false
|
||||
},
|
||||
|
||||
'moonshotai/kimi-k2': {
|
||||
tier: 'none',
|
||||
priority: 100,
|
||||
recommended: true,
|
||||
lowQuality: false
|
||||
},
|
||||
'deepseek': {
|
||||
tier: 'none',
|
||||
priority: 95,
|
||||
recommended: false,
|
||||
lowQuality: false
|
||||
},
|
||||
'qwen3': {
|
||||
tier: 'none',
|
||||
priority: 90,
|
||||
recommended: false,
|
||||
lowQuality: false
|
||||
},
|
||||
'gpt-5-mini': {
|
||||
tier: 'none',
|
||||
priority: 85,
|
||||
recommended: false,
|
||||
lowQuality: false
|
||||
},
|
||||
};
|
||||
|
||||
export const canAccessModel = (
|
||||
subscriptionStatus: SubscriptionStatus,
|
||||
requiresSubscription: boolean,
|
||||
): boolean => {
|
||||
if (isLocalMode()) {
|
||||
return true;
|
||||
}
|
||||
return subscriptionStatus === 'active' || !requiresSubscription;
|
||||
};
|
||||
|
||||
export const formatModelName = (name: string): string => {
|
||||
return name
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
export const getPrefixedModelId = (modelId: string, isCustom: boolean): string => {
|
||||
if (isCustom && !modelId.startsWith('openrouter/')) {
|
||||
return `openrouter/${modelId}`;
|
||||
}
|
||||
return modelId;
|
||||
};
|
||||
|
||||
// Helper to get custom models from localStorage
|
||||
export const getCustomModels = (): CustomModel[] => {
|
||||
if (!isLocalMode() || typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const storedModels = localStorage.getItem(STORAGE_KEY_CUSTOM_MODELS);
|
||||
if (!storedModels) return [];
|
||||
|
||||
const parsedModels = JSON.parse(storedModels);
|
||||
if (!Array.isArray(parsedModels)) return [];
|
||||
|
||||
return parsedModels
|
||||
.filter((model: any) =>
|
||||
model && typeof model === 'object' &&
|
||||
typeof model.id === 'string' &&
|
||||
typeof model.label === 'string');
|
||||
} catch (e) {
|
||||
console.error('Error parsing custom models:', e);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to save model preference to localStorage safely
|
||||
const saveModelPreference = (modelId: string): void => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_MODEL, modelId);
|
||||
console.log('✅ useModelSelection: Saved model preference to localStorage:', modelId);
|
||||
} catch (error) {
|
||||
console.warn('❌ useModelSelection: Failed to save model preference to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const useModelSelectionOld = () => {
|
||||
const [selectedModel, setSelectedModel] = useState(DEFAULT_FREE_MODEL_ID);
|
||||
const [customModels, setCustomModels] = useState<CustomModel[]>([]);
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
|
||||
const { data: subscriptionData } = useSubscriptionData();
|
||||
const { data: modelsData, isLoading: isLoadingModels } = useAvailableModels({
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const subscriptionStatus: SubscriptionStatus = (subscriptionData?.status === 'active' || subscriptionData?.status === 'trialing')
|
||||
? 'active'
|
||||
: 'no_subscription';
|
||||
|
||||
// Function to refresh custom models from localStorage
|
||||
const refreshCustomModels = () => {
|
||||
if (isLocalMode() && typeof window !== 'undefined') {
|
||||
const freshCustomModels = getCustomModels();
|
||||
setCustomModels(freshCustomModels);
|
||||
}
|
||||
};
|
||||
|
||||
// Load custom models from localStorage
|
||||
useEffect(() => {
|
||||
refreshCustomModels();
|
||||
}, []);
|
||||
|
||||
// Generate model options list with consistent structure
|
||||
const MODEL_OPTIONS = useMemo(() => {
|
||||
let models = [];
|
||||
|
||||
// Default models if API data not available
|
||||
if (!modelsData?.models || isLoadingModels) {
|
||||
models = [
|
||||
{
|
||||
id: DEFAULT_FREE_MODEL_ID,
|
||||
label: 'KIMI K2',
|
||||
requiresSubscription: false,
|
||||
priority: 100,
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
id: DEFAULT_PREMIUM_MODEL_ID,
|
||||
label: 'Claude Sonnet 4',
|
||||
requiresSubscription: true,
|
||||
priority: 100,
|
||||
recommended: true
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// Process API-provided models - use clean data from new backend system
|
||||
models = modelsData.models.map(model => {
|
||||
// Use the clean data directly from the API (no more duplicates!)
|
||||
const shortName = model.short_name || model.id;
|
||||
const displayName = model.display_name || shortName;
|
||||
|
||||
return {
|
||||
id: shortName,
|
||||
label: displayName,
|
||||
requiresSubscription: model.requires_subscription || false,
|
||||
priority: model.priority || 0,
|
||||
recommended: model.recommended || false,
|
||||
top: (model.priority || 0) >= 90, // Mark high-priority models as "top"
|
||||
lowQuality: false, // All models in new system are quality controlled
|
||||
capabilities: model.capabilities || [],
|
||||
contextWindow: model.context_window || 128000
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Add custom models if in local mode
|
||||
if (isLocalMode() && customModels.length > 0) {
|
||||
const customModelOptions = customModels.map(model => ({
|
||||
id: model.id,
|
||||
label: model.label || formatModelName(model.id),
|
||||
requiresSubscription: false,
|
||||
top: false,
|
||||
isCustom: true,
|
||||
priority: 30, // Low priority by default
|
||||
lowQuality: false,
|
||||
recommended: false
|
||||
}));
|
||||
|
||||
models = [...models, ...customModelOptions];
|
||||
}
|
||||
|
||||
// Sort models consistently in one place:
|
||||
// 1. First by recommended (recommended first)
|
||||
// 2. Then by priority (higher first)
|
||||
// 3. Finally by name (alphabetical)
|
||||
const sortedModels = models.sort((a, b) => {
|
||||
// First by recommended status
|
||||
if (a.recommended !== b.recommended) {
|
||||
return a.recommended ? -1 : 1;
|
||||
}
|
||||
|
||||
// Then by priority (higher first)
|
||||
if (a.priority !== b.priority) {
|
||||
return b.priority - a.priority;
|
||||
}
|
||||
|
||||
// Finally by name
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
return sortedModels;
|
||||
}, [modelsData, isLoadingModels, customModels]);
|
||||
|
||||
// Get filtered list of models the user can access (no additional sorting)
|
||||
const availableModels = useMemo(() => {
|
||||
return isLocalMode()
|
||||
? MODEL_OPTIONS
|
||||
: MODEL_OPTIONS.filter(model =>
|
||||
canAccessModel(subscriptionStatus, model.requiresSubscription)
|
||||
);
|
||||
}, [MODEL_OPTIONS, subscriptionStatus]);
|
||||
|
||||
// Initialize selected model from localStorage ONLY ONCE
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || hasInitialized) return;
|
||||
|
||||
console.log('🔧 useModelSelection: Initializing model selection...');
|
||||
console.log('🔧 useModelSelection: isLoadingModels:', isLoadingModels);
|
||||
console.log('🔧 useModelSelection: subscriptionStatus:', subscriptionStatus);
|
||||
console.log('🔧 useModelSelection: localStorage test passed:', testLocalStorage());
|
||||
|
||||
try {
|
||||
const savedModel = localStorage.getItem(STORAGE_KEY_MODEL);
|
||||
console.log('🔧 useModelSelection: Saved model from localStorage:', savedModel);
|
||||
|
||||
// If we have a saved model, validate it's still available and accessible
|
||||
if (savedModel) {
|
||||
// Wait for models to load before validating
|
||||
if (isLoadingModels) {
|
||||
console.log('🔧 useModelSelection: Models still loading, using saved model temporarily:', savedModel);
|
||||
// Use saved model immediately while waiting for validation
|
||||
setSelectedModel(savedModel);
|
||||
setHasInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔧 useModelSelection: Available MODEL_OPTIONS:', MODEL_OPTIONS.map(m => ({ id: m.id, requiresSubscription: m.requiresSubscription })));
|
||||
|
||||
const modelOption = MODEL_OPTIONS.find(option => option.id === savedModel);
|
||||
const isCustomModel = isLocalMode() && customModels.some(model => model.id === savedModel);
|
||||
|
||||
console.log('🔧 useModelSelection: modelOption found:', modelOption);
|
||||
console.log('🔧 useModelSelection: isCustomModel:', isCustomModel);
|
||||
|
||||
// Check if saved model is still valid and accessible
|
||||
if (modelOption || isCustomModel) {
|
||||
const isAccessible = isLocalMode() ||
|
||||
canAccessModel(subscriptionStatus, modelOption?.requiresSubscription ?? false);
|
||||
|
||||
console.log('🔧 useModelSelection: isAccessible:', isAccessible);
|
||||
|
||||
if (isAccessible) {
|
||||
console.log('✅ useModelSelection: Using saved model:', savedModel);
|
||||
setSelectedModel(savedModel);
|
||||
setHasInitialized(true);
|
||||
return;
|
||||
} else {
|
||||
console.warn('⚠️ useModelSelection: Saved model not accessible with current subscription');
|
||||
}
|
||||
} else {
|
||||
// Model not found in current options, but preserve it anyway in case it's valid
|
||||
// This can happen during loading or if the API returns different models
|
||||
console.warn('⚠️ useModelSelection: Saved model not found in available options, but preserving:', savedModel);
|
||||
setSelectedModel(savedModel);
|
||||
setHasInitialized(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default model
|
||||
const defaultModel = subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID;
|
||||
console.log('🔧 useModelSelection: Using default model:', defaultModel);
|
||||
console.log('🔧 useModelSelection: Subscription status:', subscriptionStatus, '-> Default:', subscriptionStatus === 'active' ? 'PREMIUM (Claude Sonnet 4)' : 'FREE (KIMi K2)');
|
||||
setSelectedModel(defaultModel);
|
||||
saveModelPreference(defaultModel);
|
||||
setHasInitialized(true);
|
||||
|
||||
} catch (error) {
|
||||
console.warn('❌ useModelSelection: Failed to load preferences from localStorage:', error);
|
||||
const defaultModel = subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID;
|
||||
console.log('🔧 useModelSelection: Using fallback default model:', defaultModel);
|
||||
console.log('🔧 useModelSelection: Subscription status:', subscriptionStatus, '-> Fallback:', subscriptionStatus === 'active' ? 'PREMIUM (Claude Sonnet 4)' : 'FREE (KIMi K2)');
|
||||
setSelectedModel(defaultModel);
|
||||
saveModelPreference(defaultModel);
|
||||
setHasInitialized(true);
|
||||
}
|
||||
}, [subscriptionStatus, isLoadingModels, hasInitialized]);
|
||||
|
||||
// Re-validate saved model after loading completes
|
||||
useEffect(() => {
|
||||
if (!hasInitialized || typeof window === 'undefined' || isLoadingModels) return;
|
||||
|
||||
const savedModel = localStorage.getItem(STORAGE_KEY_MODEL);
|
||||
if (!savedModel || savedModel === selectedModel) return;
|
||||
|
||||
console.log('🔧 useModelSelection: Re-validating saved model after loading:', savedModel);
|
||||
|
||||
const modelOption = MODEL_OPTIONS.find(option => option.id === savedModel);
|
||||
const isCustomModel = isLocalMode() && customModels.some(model => model.id === savedModel);
|
||||
|
||||
// If the saved model is now invalid, switch to default
|
||||
if (!modelOption && !isCustomModel) {
|
||||
console.warn('⚠️ useModelSelection: Saved model is invalid after loading, switching to default');
|
||||
const defaultModel = subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID;
|
||||
setSelectedModel(defaultModel);
|
||||
saveModelPreference(defaultModel);
|
||||
} else if (modelOption && !isLocalMode()) {
|
||||
// Check subscription access for non-custom models
|
||||
const isAccessible = canAccessModel(subscriptionStatus, modelOption.requiresSubscription);
|
||||
if (!isAccessible) {
|
||||
console.warn('⚠️ useModelSelection: Saved model not accessible after subscription check, switching to default');
|
||||
const defaultModel = subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID;
|
||||
setSelectedModel(defaultModel);
|
||||
saveModelPreference(defaultModel);
|
||||
}
|
||||
}
|
||||
}, [isLoadingModels, hasInitialized, MODEL_OPTIONS, customModels, subscriptionStatus]);
|
||||
|
||||
// Re-validate current model when subscription status changes
|
||||
useEffect(() => {
|
||||
if (!hasInitialized || typeof window === 'undefined') return;
|
||||
|
||||
console.log('🔧 useModelSelection: Subscription status changed, re-validating current model...');
|
||||
console.log('🔧 useModelSelection: Current selected model:', selectedModel);
|
||||
console.log('🔧 useModelSelection: New subscription status:', subscriptionStatus);
|
||||
|
||||
// Skip validation if models are still loading
|
||||
if (isLoadingModels) return;
|
||||
|
||||
// Check if current model is still accessible
|
||||
const modelOption = MODEL_OPTIONS.find(option => option.id === selectedModel);
|
||||
const isCustomModel = isLocalMode() && customModels.some(model => model.id === selectedModel);
|
||||
|
||||
if (modelOption && !isCustomModel && !isLocalMode()) {
|
||||
const isAccessible = canAccessModel(subscriptionStatus, modelOption.requiresSubscription);
|
||||
|
||||
if (!isAccessible) {
|
||||
console.warn('⚠️ useModelSelection: Current model no longer accessible, switching to default');
|
||||
const defaultModel = subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID;
|
||||
console.log('🔧 useModelSelection: Subscription-based default switch:', subscriptionStatus === 'active' ? 'PREMIUM (Claude Sonnet 4)' : 'FREE (KIMi K2)');
|
||||
setSelectedModel(defaultModel);
|
||||
saveModelPreference(defaultModel);
|
||||
} else {
|
||||
console.log('✅ useModelSelection: Current model still accessible');
|
||||
}
|
||||
}
|
||||
}, [subscriptionStatus, selectedModel, hasInitialized, isLoadingModels]);
|
||||
|
||||
// Handle model selection change
|
||||
const handleModelChange = (modelId: string) => {
|
||||
console.log('🔧 useModelSelection: handleModelChange called with:', modelId);
|
||||
console.log('🔧 useModelSelection: Available MODEL_OPTIONS:', MODEL_OPTIONS.map(m => m.id));
|
||||
|
||||
// Refresh custom models from localStorage to ensure we have the latest
|
||||
if (isLocalMode()) {
|
||||
refreshCustomModels();
|
||||
}
|
||||
|
||||
// First check if it's a custom model in local mode
|
||||
const isCustomModel = isLocalMode() && customModels.some(model => model.id === modelId);
|
||||
|
||||
// Then check if it's in standard MODEL_OPTIONS
|
||||
const modelOption = MODEL_OPTIONS.find(option => option.id === modelId);
|
||||
|
||||
console.log('🔧 useModelSelection: modelOption found:', modelOption);
|
||||
console.log('🔧 useModelSelection: isCustomModel:', isCustomModel);
|
||||
|
||||
// Check if model exists in either custom models or standard options
|
||||
if (!modelOption && !isCustomModel) {
|
||||
console.warn('🔧 useModelSelection: Model not found in options:', modelId, MODEL_OPTIONS, isCustomModel, customModels);
|
||||
|
||||
// Reset to default model when the selected model is not found
|
||||
const defaultModel = isLocalMode() ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID;
|
||||
console.log('🔧 useModelSelection: Resetting to default model:', defaultModel);
|
||||
setSelectedModel(defaultModel);
|
||||
saveModelPreference(defaultModel);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check access permissions (except for custom models in local mode)
|
||||
if (!isCustomModel && !isLocalMode() &&
|
||||
!canAccessModel(subscriptionStatus, modelOption?.requiresSubscription ?? false)) {
|
||||
console.warn('🔧 useModelSelection: Model not accessible:', modelId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ useModelSelection: Setting model to:', modelId);
|
||||
setSelectedModel(modelId);
|
||||
saveModelPreference(modelId);
|
||||
console.log('✅ useModelSelection: Model change completed successfully');
|
||||
};
|
||||
|
||||
// Get the actual model ID to send to the backend
|
||||
const getActualModelId = (modelId: string): string => {
|
||||
// No need for automatic prefixing in most cases - just return as is
|
||||
return modelId;
|
||||
};
|
||||
|
||||
return {
|
||||
selectedModel,
|
||||
setSelectedModel: (modelId: string) => {
|
||||
handleModelChange(modelId);
|
||||
},
|
||||
subscriptionStatus,
|
||||
availableModels,
|
||||
allModels: MODEL_OPTIONS, // Already pre-sorted
|
||||
customModels,
|
||||
getActualModelId,
|
||||
refreshCustomModels,
|
||||
canAccessModel: (modelId: string) => {
|
||||
if (isLocalMode()) return true;
|
||||
const model = MODEL_OPTIONS.find(m => m.id === modelId);
|
||||
return model ? canAccessModel(subscriptionStatus, model.requiresSubscription) : false;
|
||||
},
|
||||
isSubscriptionRequired: (modelId: string) => {
|
||||
return MODEL_OPTIONS.find(m => m.id === modelId)?.requiresSubscription || false;
|
||||
},
|
||||
// Debug utility to check current state
|
||||
debugState: () => {
|
||||
console.log('🔧 useModelSelection Debug State:');
|
||||
console.log(' selectedModel:', selectedModel);
|
||||
console.log(' hasInitialized:', hasInitialized);
|
||||
console.log(' subscriptionStatus:', subscriptionStatus);
|
||||
console.log(' isLoadingModels:', isLoadingModels);
|
||||
console.log(' localStorage value:', localStorage.getItem(STORAGE_KEY_MODEL));
|
||||
console.log(' localStorage test passes:', testLocalStorage());
|
||||
console.log(' defaultModel would be:', subscriptionStatus === 'active' ? `${DEFAULT_PREMIUM_MODEL_ID} (Claude Sonnet 4)` : `${DEFAULT_FREE_MODEL_ID} (KIMi K2)`);
|
||||
console.log(' availableModels:', availableModels.map(m => ({ id: m.id, requiresSubscription: m.requiresSubscription })));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Export the new model selection hook
|
||||
export { useModelSelection } from './_use-model-selection-new';
|
||||
|
||||
// Export the hook but not any sorting logic - sorting is handled internally
|
|
@ -14,7 +14,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
|||
import { handleFiles } from './file-upload-handler';
|
||||
import { MessageInput } from './message-input';
|
||||
import { AttachmentGroup } from '../attachment-group';
|
||||
import { useModelSelection } from './_use-model-selection-new';
|
||||
import { useModelSelection } from '@/hooks/use-model-selection';
|
||||
import { useFileDelete } from '@/hooks/react-query/files';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ToolCallInput } from './floating-tool-preview';
|
||||
|
@ -239,12 +239,8 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
|||
message = message ? `${message}\n\n${fileInfo}` : fileInfo;
|
||||
}
|
||||
|
||||
let baseModelName = getActualModelId(selectedModel);
|
||||
let thinkingEnabled = false;
|
||||
if (selectedModel.endsWith('-thinking')) {
|
||||
baseModelName = getActualModelId(selectedModel.replace(/-thinking$/, ''));
|
||||
thinkingEnabled = true;
|
||||
}
|
||||
const baseModelName = getActualModelId(selectedModel);
|
||||
const thinkingEnabled = false; // Thinking mode removed - use API models directly
|
||||
|
||||
posthog.capture("task_prompt_submitted", { message });
|
||||
|
||||
|
|
|
@ -7,7 +7,8 @@ import { UploadedFile } from './chat-input';
|
|||
import { FileUploadHandler } from './file-upload-handler';
|
||||
import { VoiceRecorder } from './voice-recorder';
|
||||
import { UnifiedConfigMenu } from './unified-config-menu';
|
||||
import { canAccessModel, SubscriptionStatus } from './_use-model-selection';
|
||||
// Note: canAccessModel is now part of useModelSelection hook
|
||||
export type SubscriptionStatus = 'no_subscription' | 'active';
|
||||
import { isLocalMode } from '@/lib/config';
|
||||
import { TooltipContent } from '@/components/ui/tooltip';
|
||||
import { Tooltip } from '@/components/ui/tooltip';
|
||||
|
|
|
@ -17,8 +17,10 @@ import { cn } from '@/lib/utils';
|
|||
import { Cpu, Search, Check, ChevronDown, Plus, ExternalLink } from 'lucide-react';
|
||||
import { useAgents } from '@/hooks/react-query/agents/use-agents';
|
||||
import { KortixLogo } from '@/components/sidebar/kortix-logo';
|
||||
import type { ModelOption, SubscriptionStatus } from './_use-model-selection';
|
||||
import { MODELS } from './_use-model-selection';
|
||||
import type { ModelOption } from '@/hooks/use-model-selection';
|
||||
|
||||
export type SubscriptionStatus = 'no_subscription' | 'active';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { IntegrationsRegistry } from '@/components/agents/integrations-registry';
|
||||
|
|
|
@ -4,20 +4,10 @@ import { createMutationHook, createQueryHook } from '@/hooks/use-query';
|
|||
import {
|
||||
createCheckoutSession,
|
||||
checkBillingStatus,
|
||||
getAvailableModels,
|
||||
CreateCheckoutSessionRequest
|
||||
} from '@/lib/api';
|
||||
import { billingApi } from '@/lib/api-enhanced';
|
||||
import { modelKeys, usageKeys } from './keys';
|
||||
|
||||
export const useAvailableModels = createQueryHook(
|
||||
modelKeys.available,
|
||||
getAvailableModels,
|
||||
{
|
||||
staleTime: 10 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
// useAvailableModels has been moved to use-model-selection.ts for better consolidation
|
||||
|
||||
export const useBillingStatus = createQueryHook(
|
||||
['billing', 'status'],
|
||||
|
@ -46,13 +36,3 @@ export const useCreateCheckoutSession = createMutationHook(
|
|||
}
|
||||
);
|
||||
|
||||
export const useUsageLogs = (page: number = 0, itemsPerPage: number = 1000) =>
|
||||
createQueryHook(
|
||||
usageKeys.logs(page, itemsPerPage),
|
||||
() => billingApi.getUsageLogs(page, itemsPerPage),
|
||||
{
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
)();
|
|
@ -1,21 +0,0 @@
|
|||
import { createQueryHook } from "@/hooks/use-query";
|
||||
import { AvailableModelsResponse, getAvailableModels } from "@/lib/api";
|
||||
import { modelKeys } from "./keys";
|
||||
|
||||
export const useAvailableModels = createQueryHook<AvailableModelsResponse, Error>(
|
||||
modelKeys.available,
|
||||
getAvailableModels,
|
||||
{
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 2,
|
||||
select: (data) => {
|
||||
return {
|
||||
...data,
|
||||
models: [...data.models].sort((a, b) =>
|
||||
a.display_name.localeCompare(b.display_name)
|
||||
),
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
|
@ -0,0 +1,136 @@
|
|||
'use client';
|
||||
|
||||
import { useModelStore } from '@/lib/stores/model-store';
|
||||
import { useSubscriptionData } from '@/contexts/SubscriptionContext';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAvailableModels } from '@/lib/api';
|
||||
|
||||
export interface ModelOption {
|
||||
id: string;
|
||||
label: string;
|
||||
requiresSubscription: boolean;
|
||||
description?: string;
|
||||
priority?: number;
|
||||
recommended?: boolean;
|
||||
capabilities?: string[];
|
||||
contextWindow?: number;
|
||||
}
|
||||
|
||||
// Helper function to get default model from API data
|
||||
const getDefaultModel = (models: ModelOption[], hasActiveSubscription: boolean): string => {
|
||||
if (hasActiveSubscription) {
|
||||
// For premium users, find the first recommended model
|
||||
const recommendedModel = models.find(m => m.recommended);
|
||||
if (recommendedModel) return recommendedModel.id;
|
||||
}
|
||||
|
||||
// For free users, find the first non-subscription model with highest priority
|
||||
const freeModels = models.filter(m => !m.requiresSubscription);
|
||||
if (freeModels.length > 0) {
|
||||
const sortedFreeModels = freeModels.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||
return sortedFreeModels[0].id;
|
||||
}
|
||||
|
||||
// Fallback to first available model
|
||||
return models.length > 0 ? models[0].id : '';
|
||||
};
|
||||
|
||||
export const useModelSelection = () => {
|
||||
// Fetch models directly in this hook
|
||||
const { data: modelsData, isLoading } = useQuery({
|
||||
queryKey: ['models', 'available'],
|
||||
queryFn: getAvailableModels,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const { data: subscriptionData } = useSubscriptionData();
|
||||
const { selectedModel, setSelectedModel } = useModelStore();
|
||||
|
||||
// Transform API data to ModelOption format
|
||||
const availableModels = useMemo<ModelOption[]>(() => {
|
||||
if (!modelsData?.models) return [];
|
||||
|
||||
return modelsData.models.map(model => ({
|
||||
id: model.short_name || model.id,
|
||||
label: model.display_name || model.short_name || model.id,
|
||||
requiresSubscription: model.requires_subscription || false,
|
||||
priority: model.priority || 0,
|
||||
recommended: model.recommended || false,
|
||||
capabilities: model.capabilities || [],
|
||||
contextWindow: model.context_window || 128000,
|
||||
})).sort((a, b) => {
|
||||
// Sort by recommended first, then priority, then name
|
||||
if (a.recommended !== b.recommended) return a.recommended ? -1 : 1;
|
||||
if (a.priority !== b.priority) return b.priority - a.priority;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
}, [modelsData]);
|
||||
|
||||
// Get accessible models based on subscription
|
||||
const accessibleModels = useMemo(() => {
|
||||
const hasActiveSubscription = subscriptionData?.status === 'active' || subscriptionData?.status === 'trialing';
|
||||
return availableModels.filter(model => hasActiveSubscription || !model.requiresSubscription);
|
||||
}, [availableModels, subscriptionData]);
|
||||
|
||||
// Initialize selected model when data loads
|
||||
useEffect(() => {
|
||||
if (isLoading || !accessibleModels.length) return;
|
||||
|
||||
// If no model selected or selected model is not accessible, pick default from API data
|
||||
if (!selectedModel || !accessibleModels.some(m => m.id === selectedModel)) {
|
||||
const hasActiveSubscription = subscriptionData?.status === 'active' || subscriptionData?.status === 'trialing';
|
||||
const defaultModelId = getDefaultModel(availableModels, hasActiveSubscription);
|
||||
|
||||
// Make sure the default model is accessible
|
||||
const finalModel = accessibleModels.some(m => m.id === defaultModelId)
|
||||
? defaultModelId
|
||||
: accessibleModels[0]?.id;
|
||||
|
||||
if (finalModel) {
|
||||
console.log('🔧 useModelSelection: Setting API-determined default model:', finalModel);
|
||||
setSelectedModel(finalModel);
|
||||
}
|
||||
}
|
||||
}, [selectedModel, accessibleModels, availableModels, isLoading, setSelectedModel, subscriptionData]);
|
||||
|
||||
const handleModelChange = (modelId: string) => {
|
||||
const model = accessibleModels.find(m => m.id === modelId);
|
||||
if (model) {
|
||||
console.log('🔧 useModelSelection: Changing model to:', modelId);
|
||||
setSelectedModel(modelId);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
selectedModel,
|
||||
setSelectedModel: handleModelChange,
|
||||
availableModels: accessibleModels,
|
||||
allModels: availableModels, // For compatibility
|
||||
isLoading,
|
||||
modelsData, // Expose raw API data for components that need it
|
||||
subscriptionStatus: (subscriptionData?.status === 'active' || subscriptionData?.status === 'trialing') ? 'active' as const : 'no_subscription' as const,
|
||||
canAccessModel: (modelId: string) => {
|
||||
return accessibleModels.some(m => m.id === modelId);
|
||||
},
|
||||
isSubscriptionRequired: (modelId: string) => {
|
||||
const model = availableModels.find(m => m.id === modelId);
|
||||
return model?.requiresSubscription || false;
|
||||
},
|
||||
|
||||
// Compatibility stubs for custom models (not needed with API-driven approach)
|
||||
handleModelChange,
|
||||
customModels: [] as any[], // Empty array since we're not using custom models
|
||||
addCustomModel: (_model: any) => {}, // No-op
|
||||
updateCustomModel: (_id: string, _model: any) => {}, // No-op
|
||||
removeCustomModel: (_id: string) => {}, // No-op
|
||||
|
||||
// Get the actual model ID to send to the backend (no transformation needed now)
|
||||
getActualModelId: (modelId: string) => modelId,
|
||||
|
||||
// Refresh function for compatibility (no-op since we use API)
|
||||
refreshCustomModels: () => {},
|
||||
};
|
||||
};
|
|
@ -1,454 +0,0 @@
|
|||
import { createClient } from '@/lib/supabase/client';
|
||||
import { backendApi, supabaseClient } from './api-client';
|
||||
import { handleApiSuccess } from './error-handler';
|
||||
import {
|
||||
Project,
|
||||
Thread,
|
||||
Message,
|
||||
AgentRun,
|
||||
InitiateAgentResponse,
|
||||
HealthCheckResponse,
|
||||
FileInfo,
|
||||
CreateCheckoutSessionRequest,
|
||||
CreateCheckoutSessionResponse,
|
||||
CreatePortalSessionRequest,
|
||||
SubscriptionStatus,
|
||||
AvailableModelsResponse,
|
||||
BillingStatusResponse,
|
||||
BillingError,
|
||||
UsageLogsResponse
|
||||
} from './api';
|
||||
|
||||
export * from './api';
|
||||
|
||||
export const projectsApi = {
|
||||
async getAll(): Promise<Project[]> {
|
||||
const result = await supabaseClient.execute(
|
||||
async () => {
|
||||
const supabase = createClient();
|
||||
const { data: userData, error: userError } = await supabase.auth.getUser();
|
||||
|
||||
if (userError) {
|
||||
return { data: null, error: userError };
|
||||
}
|
||||
|
||||
if (!userData.user) {
|
||||
return { data: [], error: null };
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.eq('account_id', userData.user.id);
|
||||
|
||||
if (error) {
|
||||
if (error.code === '42501' && error.message.includes('has_role_on_account')) {
|
||||
return { data: [], error: null };
|
||||
}
|
||||
return { data: null, error };
|
||||
}
|
||||
|
||||
const mappedProjects: Project[] = (data || []).map((project) => ({
|
||||
id: project.project_id,
|
||||
name: project.name || '',
|
||||
description: project.description || '',
|
||||
created_at: project.created_at,
|
||||
updated_at: project.updated_at,
|
||||
sandbox: project.sandbox || {
|
||||
id: '',
|
||||
pass: '',
|
||||
vnc_preview: '',
|
||||
sandbox_url: '',
|
||||
},
|
||||
}));
|
||||
|
||||
return { data: mappedProjects, error: null };
|
||||
},
|
||||
{ operation: 'load projects', resource: 'projects' }
|
||||
);
|
||||
|
||||
return result.data || [];
|
||||
},
|
||||
|
||||
async getById(projectId: string): Promise<Project | null> {
|
||||
const result = await supabaseClient.execute(
|
||||
async () => {
|
||||
const supabase = createClient();
|
||||
const { data, error } = await supabase
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return { data: null, error: new Error(`Project not found: ${projectId}`) };
|
||||
}
|
||||
return { data: null, error };
|
||||
}
|
||||
|
||||
// Ensure sandbox is active if it exists
|
||||
if (data.sandbox?.id) {
|
||||
backendApi.post(`/project/${projectId}/sandbox/ensure-active`, undefined, {
|
||||
showErrors: false,
|
||||
errorContext: { silent: true }
|
||||
});
|
||||
}
|
||||
|
||||
const mappedProject: Project = {
|
||||
id: data.project_id,
|
||||
name: data.name || '',
|
||||
description: data.description || '',
|
||||
is_public: data.is_public || false,
|
||||
created_at: data.created_at,
|
||||
sandbox: data.sandbox || {
|
||||
id: '',
|
||||
pass: '',
|
||||
vnc_preview: '',
|
||||
sandbox_url: '',
|
||||
},
|
||||
};
|
||||
|
||||
return { data: mappedProject, error: null };
|
||||
},
|
||||
{ operation: 'load project', resource: `project ${projectId}` }
|
||||
);
|
||||
|
||||
return result.data || null;
|
||||
},
|
||||
|
||||
async create(projectData: { name: string; description: string }, accountId?: string): Promise<Project | null> {
|
||||
const result = await supabaseClient.execute(
|
||||
async () => {
|
||||
const supabase = createClient();
|
||||
|
||||
if (!accountId) {
|
||||
const { data: userData, error: userError } = await supabase.auth.getUser();
|
||||
if (userError) return { data: null, error: userError };
|
||||
if (!userData.user) return { data: null, error: new Error('You must be logged in to create a project') };
|
||||
accountId = userData.user.id;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('projects')
|
||||
.insert({
|
||||
name: projectData.name,
|
||||
description: projectData.description || null,
|
||||
account_id: accountId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) return { data: null, error };
|
||||
|
||||
const project: Project = {
|
||||
id: data.project_id,
|
||||
name: data.name,
|
||||
description: data.description || '',
|
||||
created_at: data.created_at,
|
||||
sandbox: { id: '', pass: '', vnc_preview: '' },
|
||||
};
|
||||
|
||||
return { data: project, error: null };
|
||||
},
|
||||
{ operation: 'create project', resource: 'project' }
|
||||
);
|
||||
|
||||
return result.data || null;
|
||||
},
|
||||
|
||||
async update(projectId: string, data: Partial<Project>): Promise<Project | null> {
|
||||
if (!projectId || projectId === '') {
|
||||
throw new Error('Cannot update project: Invalid project ID');
|
||||
}
|
||||
|
||||
const result = await supabaseClient.execute(
|
||||
async () => {
|
||||
const supabase = createClient();
|
||||
const { data: updatedData, error } = await supabase
|
||||
.from('projects')
|
||||
.update(data)
|
||||
.eq('project_id', projectId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) return { data: null, error };
|
||||
if (!updatedData) return { data: null, error: new Error('No data returned from update') };
|
||||
|
||||
// Dispatch custom event for project updates
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('project-updated', {
|
||||
detail: {
|
||||
projectId,
|
||||
updatedData: {
|
||||
id: updatedData.project_id,
|
||||
name: updatedData.name,
|
||||
description: updatedData.description,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const project: Project = {
|
||||
id: updatedData.project_id,
|
||||
name: updatedData.name,
|
||||
description: updatedData.description || '',
|
||||
created_at: updatedData.created_at,
|
||||
sandbox: updatedData.sandbox || {
|
||||
id: '',
|
||||
pass: '',
|
||||
vnc_preview: '',
|
||||
sandbox_url: '',
|
||||
},
|
||||
};
|
||||
|
||||
return { data: project, error: null };
|
||||
},
|
||||
{ operation: 'update project', resource: `project ${projectId}` }
|
||||
);
|
||||
return result.data || null;
|
||||
},
|
||||
|
||||
async delete(projectId: string): Promise<boolean> {
|
||||
const result = await supabaseClient.execute(
|
||||
async () => {
|
||||
const supabase = createClient();
|
||||
const { error } = await supabase
|
||||
.from('projects')
|
||||
.delete()
|
||||
.eq('project_id', projectId);
|
||||
|
||||
return { data: !error, error };
|
||||
},
|
||||
{ operation: 'delete project', resource: `project ${projectId}` }
|
||||
);
|
||||
return result.success;
|
||||
},
|
||||
};
|
||||
|
||||
export const threadsApi = {
|
||||
async getAll(projectId?: string): Promise<Thread[]> {
|
||||
const result = await supabaseClient.execute(
|
||||
async () => {
|
||||
const supabase = createClient();
|
||||
const { data: userData, error: userError } = await supabase.auth.getUser();
|
||||
|
||||
if (userError) return { data: null, error: userError };
|
||||
if (!userData.user) return { data: [], error: null };
|
||||
|
||||
let query = supabase.from('threads').select('*').eq('account_id', userData.user.id);
|
||||
|
||||
if (projectId) {
|
||||
query = query.eq('project_id', projectId);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) return { data: null, error };
|
||||
|
||||
const mappedThreads: Thread[] = (data || []).map((thread) => ({
|
||||
thread_id: thread.thread_id,
|
||||
project_id: thread.project_id,
|
||||
created_at: thread.created_at,
|
||||
updated_at: thread.updated_at,
|
||||
metadata: thread.metadata,
|
||||
}));
|
||||
|
||||
return { data: mappedThreads, error: null };
|
||||
},
|
||||
{ operation: 'load threads', resource: projectId ? `threads for project ${projectId}` : 'threads' }
|
||||
);
|
||||
|
||||
return result.data || [];
|
||||
},
|
||||
|
||||
async getById(threadId: string): Promise<Thread | null> {
|
||||
const result = await supabaseClient.execute(
|
||||
async () => {
|
||||
const supabase = createClient();
|
||||
const { data, error } = await supabase
|
||||
.from('threads')
|
||||
.select('*')
|
||||
.eq('thread_id', threadId)
|
||||
.single();
|
||||
|
||||
return { data, error };
|
||||
},
|
||||
{ operation: 'load thread', resource: `thread ${threadId}` }
|
||||
);
|
||||
|
||||
return result.data || null;
|
||||
},
|
||||
|
||||
async create(projectId: string): Promise<Thread | null> {
|
||||
const result = await supabaseClient.execute(
|
||||
async () => {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return { data: null, error: new Error('You must be logged in to create a thread') };
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('threads')
|
||||
.insert({
|
||||
project_id: projectId,
|
||||
account_id: user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return { data, error };
|
||||
},
|
||||
{ operation: 'create thread', resource: 'thread' }
|
||||
);
|
||||
return result.data || null;
|
||||
},
|
||||
};
|
||||
|
||||
export const agentApi = {
|
||||
async start(
|
||||
threadId: string,
|
||||
options?: {
|
||||
model_name?: string;
|
||||
enable_thinking?: boolean;
|
||||
reasoning_effort?: string;
|
||||
stream?: boolean;
|
||||
}
|
||||
): Promise<{ agent_run_id: string } | null> {
|
||||
const result = await backendApi.post(
|
||||
`/thread/${threadId}/agent/start`,
|
||||
options,
|
||||
{
|
||||
errorContext: { operation: 'start agent', resource: 'AI assistant' },
|
||||
timeout: 60000,
|
||||
}
|
||||
);
|
||||
return result.data || null;
|
||||
},
|
||||
|
||||
async stop(agentRunId: string): Promise<boolean> {
|
||||
const result = await backendApi.post(
|
||||
`/agent/${agentRunId}/stop`,
|
||||
undefined,
|
||||
{
|
||||
errorContext: { operation: 'stop agent', resource: 'AI assistant' },
|
||||
}
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
handleApiSuccess('AI assistant stopped');
|
||||
}
|
||||
|
||||
return result.success;
|
||||
},
|
||||
|
||||
async getStatus(agentRunId: string): Promise<AgentRun | null> {
|
||||
const result = await backendApi.get(
|
||||
`/agent/${agentRunId}/status`,
|
||||
{
|
||||
errorContext: { operation: 'get agent status', resource: 'AI assistant status' },
|
||||
showErrors: false,
|
||||
}
|
||||
);
|
||||
|
||||
return result.data || null;
|
||||
},
|
||||
|
||||
async getRuns(threadId: string): Promise<AgentRun[]> {
|
||||
const result = await backendApi.get(
|
||||
`/thread/${threadId}/agent/runs`,
|
||||
{
|
||||
errorContext: { operation: 'load agent runs', resource: 'conversation history' },
|
||||
}
|
||||
);
|
||||
|
||||
return result.data || [];
|
||||
},
|
||||
};
|
||||
|
||||
export const billingApi = {
|
||||
async getSubscription(): Promise<SubscriptionStatus | null> {
|
||||
const result = await backendApi.get(
|
||||
'/billing/subscription',
|
||||
{
|
||||
errorContext: { operation: 'load subscription', resource: 'billing information' },
|
||||
}
|
||||
);
|
||||
|
||||
return result.data || null;
|
||||
},
|
||||
|
||||
async checkStatus(): Promise<BillingStatusResponse | null> {
|
||||
const result = await backendApi.get(
|
||||
'/billing/status',
|
||||
{
|
||||
errorContext: { operation: 'check billing status', resource: 'account status' },
|
||||
}
|
||||
);
|
||||
|
||||
return result.data || null;
|
||||
},
|
||||
|
||||
async createCheckoutSession(request: CreateCheckoutSessionRequest): Promise<CreateCheckoutSessionResponse | null> {
|
||||
const result = await backendApi.post(
|
||||
'/billing/create-checkout-session',
|
||||
request,
|
||||
{
|
||||
errorContext: { operation: 'create checkout session', resource: 'billing' },
|
||||
}
|
||||
);
|
||||
|
||||
return result.data || null;
|
||||
},
|
||||
|
||||
async createPortalSession(request: CreatePortalSessionRequest): Promise<{ url: string } | null> {
|
||||
const result = await backendApi.post(
|
||||
'/billing/create-portal-session',
|
||||
request,
|
||||
{
|
||||
errorContext: { operation: 'create portal session', resource: 'billing portal' },
|
||||
}
|
||||
);
|
||||
|
||||
return result.data || null;
|
||||
},
|
||||
|
||||
async getAvailableModels(): Promise<AvailableModelsResponse | null> {
|
||||
const result = await backendApi.get(
|
||||
'/billing/available-models',
|
||||
{
|
||||
errorContext: { operation: 'load available models', resource: 'AI models' },
|
||||
}
|
||||
);
|
||||
|
||||
return result.data || null;
|
||||
},
|
||||
|
||||
async getUsageLogs(page: number = 0, itemsPerPage: number = 1000): Promise<UsageLogsResponse | null> {
|
||||
const result = await backendApi.get(
|
||||
`/billing/usage-logs?page=${page}&items_per_page=${itemsPerPage}`,
|
||||
{
|
||||
errorContext: { operation: 'load usage logs', resource: 'usage history' },
|
||||
}
|
||||
);
|
||||
|
||||
return result.data || null;
|
||||
},
|
||||
};
|
||||
|
||||
export const healthApi = {
|
||||
async check(): Promise<HealthCheckResponse | null> {
|
||||
const result = await backendApi.get(
|
||||
'/health',
|
||||
{
|
||||
errorContext: { operation: 'check system health', resource: 'system status' },
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
return result.data || null;
|
||||
},
|
||||
};
|
|
@ -1841,9 +1841,7 @@ export interface UsageLogEntry {
|
|||
export interface UsageLogsResponse {
|
||||
logs: UsageLogEntry[];
|
||||
has_more: boolean;
|
||||
message?: string;
|
||||
subscription_limit?: number;
|
||||
cumulative_cost?: number;
|
||||
total_count?: number;
|
||||
}
|
||||
|
||||
export interface BillingStatusResponse {
|
||||
|
@ -2185,7 +2183,6 @@ export const getAvailableModels = async (): Promise<AvailableModelsResponse> =>
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
export const checkBillingStatus = async (): Promise<BillingStatusResponse> => {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
|
|
|
@ -1,142 +1,33 @@
|
|||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { isLocalMode } from '@/lib/config';
|
||||
|
||||
export interface CustomModel {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ModelOption {
|
||||
id: string;
|
||||
label: string;
|
||||
requiresSubscription: boolean;
|
||||
description?: string;
|
||||
top?: boolean;
|
||||
isCustom?: boolean;
|
||||
priority?: number;
|
||||
recommended?: boolean;
|
||||
capabilities?: string[];
|
||||
contextWindow?: number;
|
||||
backendId?: string; // For mapping display names to backend IDs
|
||||
}
|
||||
|
||||
export type SubscriptionStatus = 'no_subscription' | 'active';
|
||||
|
||||
interface ModelStore {
|
||||
selectedModel: string;
|
||||
customModels: CustomModel[];
|
||||
hasHydrated: boolean;
|
||||
|
||||
setSelectedModel: (model: string) => void;
|
||||
addCustomModel: (model: CustomModel) => void;
|
||||
updateCustomModel: (id: string, model: CustomModel) => void;
|
||||
removeCustomModel: (id: string) => void;
|
||||
setCustomModels: (models: CustomModel[]) => void;
|
||||
setHasHydrated: (hydrated: boolean) => void;
|
||||
|
||||
getDefaultModel: (subscriptionStatus: SubscriptionStatus) => string;
|
||||
resetToDefault: (subscriptionStatus: SubscriptionStatus) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_FREE_MODEL_ID = 'moonshotai/kimi-k2';
|
||||
const DEFAULT_PREMIUM_MODEL_ID = 'claude-sonnet-4';
|
||||
|
||||
export const useModelStore = create<ModelStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
selectedModel: DEFAULT_FREE_MODEL_ID,
|
||||
customModels: [],
|
||||
hasHydrated: false,
|
||||
|
||||
(set) => ({
|
||||
selectedModel: '', // Will be set by the hook based on API data
|
||||
setSelectedModel: (model: string) => {
|
||||
console.log('🔧 ModelStore: Setting selected model to:', model);
|
||||
set({ selectedModel: model });
|
||||
},
|
||||
|
||||
addCustomModel: (model: CustomModel) => {
|
||||
const { customModels } = get();
|
||||
if (customModels.some(existing => existing.id === model.id)) {
|
||||
return;
|
||||
}
|
||||
const newCustomModels = [...customModels, model];
|
||||
set({ customModels: newCustomModels });
|
||||
},
|
||||
|
||||
updateCustomModel: (id: string, model: CustomModel) => {
|
||||
const { customModels } = get();
|
||||
const newCustomModels = customModels.map(existing =>
|
||||
existing.id === id ? model : existing
|
||||
);
|
||||
set({ customModels: newCustomModels });
|
||||
},
|
||||
|
||||
removeCustomModel: (id: string) => {
|
||||
const { customModels, selectedModel } = get();
|
||||
const newCustomModels = customModels.filter(model => model.id !== id);
|
||||
|
||||
const updates: Partial<ModelStore> = { customModels: newCustomModels };
|
||||
if (selectedModel === id) {
|
||||
updates.selectedModel = DEFAULT_FREE_MODEL_ID;
|
||||
}
|
||||
|
||||
set(updates);
|
||||
},
|
||||
|
||||
setCustomModels: (models: CustomModel[]) => {
|
||||
set({ customModels: models });
|
||||
},
|
||||
|
||||
setHasHydrated: (hydrated: boolean) => {
|
||||
set({ hasHydrated: hydrated });
|
||||
},
|
||||
|
||||
getDefaultModel: (subscriptionStatus: SubscriptionStatus) => {
|
||||
if (isLocalMode()) {
|
||||
return DEFAULT_PREMIUM_MODEL_ID;
|
||||
}
|
||||
return subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID;
|
||||
},
|
||||
|
||||
resetToDefault: (subscriptionStatus: SubscriptionStatus) => {
|
||||
const defaultModel = get().getDefaultModel(subscriptionStatus);
|
||||
set({ selectedModel: defaultModel });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'suna-model-selection-v2',
|
||||
name: 'suna-model-selection-v3',
|
||||
partialize: (state) => ({
|
||||
selectedModel: state.selectedModel,
|
||||
customModels: state.customModels,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state) {
|
||||
state.setHasHydrated(true);
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export const canAccessModel = (
|
||||
subscriptionStatus: SubscriptionStatus,
|
||||
requiresSubscription: boolean,
|
||||
): boolean => {
|
||||
if (isLocalMode()) {
|
||||
return true;
|
||||
}
|
||||
return subscriptionStatus === 'active' || !requiresSubscription;
|
||||
};
|
||||
|
||||
// Utility functions for compatibility
|
||||
export const formatModelName = (name: string): string => {
|
||||
return name
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
export const getPrefixedModelId = (modelId: string, isCustom: boolean): string => {
|
||||
if (isCustom && !modelId.startsWith('openrouter/')) {
|
||||
return `openrouter/${modelId}`;
|
||||
}
|
||||
return modelId;
|
||||
};
|
Loading…
Reference in New Issue