fix: brainstorm infinite loop, ghost cursor, embedding ::vector cast, semantic search, billing stats, usage meter accordion
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
- Fix useBrainstormSocket: stable guestId via useRef, remove setState in cleanup - Fix GhostCursor: direct DOM manipulation via refs, no useState re-renders - Fix all SQL embedding queries: add ::vector cast on text columns - Fix embedding truncation to 15000 chars (under 8192 token limit) - Fix NoteEmbedding INSERT: remove non-existent updatedAt column - Fix billing page: show all quota stats in grid instead of single metric - Fix usage meter: accordion expand/collapse, per-feature detail - Fix semantic search: rebuild 103 note embeddings, ::vector cast on vectorSearch - Fix brainstorm expand/manual-idea/create: ::vector cast on embedding SQL
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sparkles, X, Crown } from 'lucide-react';
|
||||
import { Sparkles, ChevronDown, X, Crown } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
|
||||
interface QuotaData {
|
||||
@@ -17,27 +18,31 @@ interface UsageMeterProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatRemaining(value: number): string {
|
||||
if (!Number.isFinite(value)) return '∞';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function UsageMeter({ className }: UsageMeterProps) {
|
||||
const { t } = useLanguage();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['usage', 'current'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/usage/current');
|
||||
if (!res.ok) throw new Error('Failed to fetch quotas');
|
||||
const data = await res.json();
|
||||
return data.quotas as Record<string, QuotaData>;
|
||||
const json = await res.json();
|
||||
return { quotas: json.quotas as Record<string, QuotaData>, tier: json.tier as string };
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
staleTime: 5000,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
useEffect(() => {
|
||||
const handler = () => queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
|
||||
window.addEventListener('ai-usage-changed', handler);
|
||||
return () => window.removeEventListener('ai-usage-changed', handler);
|
||||
}, [queryClient]);
|
||||
|
||||
if (isLoading || !data || !data.quotas) {
|
||||
return (
|
||||
<div className={cn('px-2 py-2', className)}>
|
||||
<div className="h-8 rounded-lg bg-paper/50 animate-pulse" />
|
||||
@@ -45,97 +50,151 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const features = [
|
||||
{ key: 'semantic_search', labelKey: 'usageMeter.featureSearch' as const },
|
||||
{ key: 'auto_tag', labelKey: 'usageMeter.featureTags' as const },
|
||||
{ key: 'auto_title', labelKey: 'usageMeter.featureTitles' as const },
|
||||
] as const;
|
||||
const isProPlus = data.tier && data.tier !== 'BASIC';
|
||||
|
||||
const featureQuotas = features.map((f) => {
|
||||
const quota = data[f.key];
|
||||
return {
|
||||
...f,
|
||||
label: t(f.labelKey),
|
||||
used: quota?.used ?? 0,
|
||||
limit: quota?.limit ?? 0,
|
||||
remaining: quota?.remaining ?? 0,
|
||||
};
|
||||
});
|
||||
const featureLabels: Record<string, string> = {
|
||||
semantic_search: t('usageMeter.featureSearch'),
|
||||
auto_tag: t('usageMeter.featureTags'),
|
||||
auto_title: t('usageMeter.featureTitles'),
|
||||
reformulate: t('usageMeter.featureReformulate'),
|
||||
chat: t('usageMeter.featureChat'),
|
||||
brainstorm_create: t('usageMeter.featureBrainstormCreate'),
|
||||
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
|
||||
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
|
||||
};
|
||||
|
||||
const featureQuotas = Object.entries(data.quotas)
|
||||
.filter(([_, q]) => q.limit > 0)
|
||||
.map(([key, quota]) => ({
|
||||
key,
|
||||
label: featureLabels[key] || key,
|
||||
used: quota.used,
|
||||
limit: quota.limit,
|
||||
remaining: quota.remaining,
|
||||
}));
|
||||
|
||||
const isUnlimited = featureQuotas.every((f) => !Number.isFinite(f.limit));
|
||||
|
||||
const totalRemaining = featureQuotas.reduce(
|
||||
(sum, f) => sum + (Number.isFinite(f.remaining) ? f.remaining : 0),
|
||||
0,
|
||||
const totalUsed = featureQuotas.reduce(
|
||||
(sum, f) => sum + (Number.isFinite(f.used) ? f.used : 0), 0,
|
||||
);
|
||||
const totalLimit = featureQuotas.reduce(
|
||||
(sum, f) => sum + (Number.isFinite(f.limit) ? f.limit : 0),
|
||||
0,
|
||||
(sum, f) => sum + (Number.isFinite(f.limit) ? f.limit : 0), 0,
|
||||
);
|
||||
|
||||
const used = totalLimit - totalRemaining;
|
||||
const percentage = totalLimit > 0 ? Math.min((used / totalLimit) * 100, 100) : 0;
|
||||
const totalRemaining = totalLimit - totalUsed;
|
||||
const totalPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
|
||||
const isExhausted = !isUnlimited && totalRemaining <= 0;
|
||||
const isLow = !isUnlimited && percentage >= 75 && !isExhausted;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-2 py-2 w-full">
|
||||
<div className="p-4 bg-slate-50 dark:bg-white/5 border border-border rounded-2xl space-y-3 group hover:shadow-lg transition-all duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles
|
||||
size={12}
|
||||
className={
|
||||
isExhausted
|
||||
? 'text-rose-400 fill-rose-400/20'
|
||||
: isUnlimited
|
||||
? 'text-emerald-400 fill-emerald-400/20'
|
||||
: 'text-brand-accent fill-brand-accent/20'
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] font-bold text-ink/70">{t('usageMeter.packName')}</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium',
|
||||
isExhausted
|
||||
? 'text-rose-500'
|
||||
: isUnlimited
|
||||
? 'text-emerald-500'
|
||||
: isLow
|
||||
? 'text-amber-500'
|
||||
: 'text-concrete',
|
||||
)}
|
||||
>
|
||||
{isUnlimited
|
||||
? t('usageMeter.unlimited')
|
||||
: t('usageMeter.remaining', { count: totalRemaining })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isUnlimited && (
|
||||
<div className="h-1.5 w-full bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-300',
|
||||
isExhausted
|
||||
? 'bg-rose-400'
|
||||
: isLow
|
||||
? 'bg-amber-400'
|
||||
: 'bg-brand-accent',
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-slate-50 dark:bg-white/5 border border-border rounded-2xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => router.push('/settings/billing')}
|
||||
className="w-full py-2 bg-brand-accent/10 hover:bg-brand-accent text-brand-accent hover:text-white text-[9px] font-bold uppercase tracking-widest rounded-xl transition-all duration-300 border border-brand-accent/20"
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full p-3 flex items-center gap-2 hover:bg-slate-100 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
{t('usageMeter.upgradePricing') || 'Passer à Pro'}
|
||||
<Sparkles
|
||||
size={12}
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
isExhausted
|
||||
? 'text-rose-400 fill-rose-400/20'
|
||||
: isUnlimited
|
||||
? 'text-emerald-400 fill-emerald-400/20'
|
||||
: 'text-brand-accent fill-brand-accent/20'
|
||||
)}
|
||||
/>
|
||||
<span className="text-[11px] font-bold text-ink/70">{t('usageMeter.packName')}</span>
|
||||
|
||||
{!isUnlimited && (
|
||||
<>
|
||||
<div className="flex-1 h-1 bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden mx-2">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
totalPct >= 90 ? 'bg-rose-400' : totalPct >= 70 ? 'bg-amber-400' : 'bg-brand-accent',
|
||||
)}
|
||||
style={{ width: `${Math.min(totalPct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[10px] font-medium tabular-nums shrink-0',
|
||||
totalPct >= 90 ? 'text-rose-500' : totalPct >= 70 ? 'text-amber-500' : 'text-concrete'
|
||||
)}>
|
||||
{totalRemaining}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isUnlimited && (
|
||||
<span className="text-[10px] font-medium text-emerald-500 ml-auto">{t('usageMeter.unlimited')}</span>
|
||||
)}
|
||||
|
||||
{isProPlus && !isUnlimited && (
|
||||
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest ml-auto">{data.tier}</span>
|
||||
)}
|
||||
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={cn(
|
||||
'text-concrete shrink-0 transition-transform duration-200',
|
||||
expanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
<div className="border-t border-border/40 pt-2" />
|
||||
{featureQuotas.map((f) => {
|
||||
const fPct = Number.isFinite(f.limit) && f.limit > 0 ? (f.used / f.limit) * 100 : 0
|
||||
return (
|
||||
<div key={f.key} className="space-y-1">
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-concrete truncate">{f.label}</span>
|
||||
<span className={cn(
|
||||
'font-medium tabular-nums',
|
||||
fPct >= 90 ? 'text-rose-500' : fPct >= 70 ? 'text-amber-500' : 'text-ink/60'
|
||||
)}>
|
||||
{!Number.isFinite(f.remaining) ? '∞' : f.remaining}/{!Number.isFinite(f.limit) ? '∞' : f.limit}
|
||||
</span>
|
||||
</div>
|
||||
{Number.isFinite(f.limit) && (
|
||||
<div className="h-1 w-full bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-300',
|
||||
fPct >= 90 ? 'bg-rose-400' : fPct >= 70 ? 'bg-amber-400' : 'bg-brand-accent',
|
||||
)}
|
||||
style={{ width: `${Math.min(fPct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{!isProPlus && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); router.push('/settings/billing'); }}
|
||||
className="w-full mt-2 py-2 bg-brand-accent/10 hover:bg-brand-accent text-brand-accent hover:text-white text-[9px] font-bold uppercase tracking-widest rounded-xl transition-all duration-300 border border-brand-accent/20"
|
||||
>
|
||||
{t('usageMeter.upgradePricing') || 'Passer à Pro'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user