diff --git a/.env.template b/.env.template index d14ecd3..e63cda2 100644 --- a/.env.template +++ b/.env.template @@ -1,2 +1,4 @@ VITE_AI_SERVER_URL= VITE_AI_SYSTEM_PROMPT= +VITE_USAGE_STORAGE_KEY= +VITE_USAGE_PASSPHRASE= diff --git a/src/components/ChatSection/ChatInput.tsx b/src/components/ChatSection/ChatInput.tsx index 3d20458..d15ce45 100644 --- a/src/components/ChatSection/ChatInput.tsx +++ b/src/components/ChatSection/ChatInput.tsx @@ -7,7 +7,7 @@ import { useBrandText } from '../../utils/format-text'; const ChatInput: React.FC = () => { const { t } = useTranslation('chat-bot'); - const { inputValue, isChatActive, setInputValue, handleSend, setIsChatActive, loading } = useChatContext(); + const { inputValue, isChatActive, setInputValue, handleSend, setIsChatActive, loading, isLimitReached } = useChatContext(); const textareaRef = useRef(null); const hasInteractedRef = useRef(false); @@ -79,7 +79,7 @@ const ChatInput: React.FC = () => { ref={textareaRef} className="min-h-[40px] max-h-[200px] text-base w-full resize-none py-2 pr-4 lg:pr-0 outline-none focus:outline-none overflow-y-auto scrollbar-hide" style={{ height: 'auto' }} - placeholder={loading ? t('Thinking') : t('SearchBar')} + placeholder={isLimitReached ? t('DailyLimit') : loading ? t('Thinking') : t('SearchBar')} value={inputValue} onChange={(e) => { setInputValue(e.target.value); @@ -89,14 +89,14 @@ const ChatInput: React.FC = () => { onKeyPress={handleKeyPress} onFocus={handleFocus} onBlur={handleBlur} - disabled={loading} + disabled={loading || isLimitReached} rows={1} />
diff --git a/src/components/ChatSection/hooks/useMessageLimit.ts b/src/components/ChatSection/hooks/useMessageLimit.ts new file mode 100644 index 0000000..a3d5c3f --- /dev/null +++ b/src/components/ChatSection/hooks/useMessageLimit.ts @@ -0,0 +1,102 @@ +import { useState, useCallback } from 'react'; + +const STORAGE_KEY = 'internxt-ai-usage'; +const DAILY_LIMIT = 40; +const PASSPHRASE = import.meta.env.VITE_USAGE_PASSPHRASE as string; + +interface UsageData { + count: number; + date: string; +} + +function getTodayKey(): string { + return new Date().toISOString().split('T')[0]; +} + +async function getDerivedKey(): Promise { + const enc = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + enc.encode(PASSPHRASE), + { name: 'PBKDF2' }, + false, + ['deriveKey'], + ); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: enc.encode(STORAGE_KEY), iterations: 100000, hash: 'SHA-256' }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +} + +async function encryptData(data: UsageData): Promise { + const key = await getDerivedKey(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(JSON.stringify(data)); + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded); + const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(ciphertext), iv.byteLength); + return btoa(String.fromCharCode(...combined)); +} + +async function decryptData(raw: string): Promise { + try { + const key = await getDerivedKey(); + const combined = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0)); + const iv = combined.slice(0, 12); + const ciphertext = combined.slice(12); + const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); + return JSON.parse(new TextDecoder().decode(plaintext)) as UsageData; + } catch { + return null; + } +} + +async function getUsageData(): Promise { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { count: 0, date: getTodayKey() }; + const data = await decryptData(raw); + if (!data || data.date !== getTodayKey()) { + return { count: 0, date: getTodayKey() }; + } + return data; + } catch { + return { count: 0, date: getTodayKey() }; + } +} + +async function saveUsageData(data: UsageData): Promise { + const encrypted = await encryptData(data); + localStorage.setItem(STORAGE_KEY, encrypted); +} + +export function useMessageLimit() { + const [usage, setUsage] = useState({ count: 0, date: getTodayKey() }); + const [initialized, setInitialized] = useState(false); + + const initialize = useCallback(async () => { + if (initialized) return; + const data = await getUsageData(); + setUsage(data); + setInitialized(true); + }, [initialized]); + + if (!initialized) { + initialize(); + } + + const isLimitReached = usage.count >= DAILY_LIMIT; + + const incrementCount = useCallback(async () => { + const current = await getUsageData(); + const newUsage: UsageData = { count: current.count + 1, date: getTodayKey() }; + await saveUsageData(newUsage); + setUsage(newUsage); + }, []); + + return { messageCount: usage.count, isLimitReached, incrementCount }; +} diff --git a/src/context/ChatContext.tsx b/src/context/ChatContext.tsx index dc3de20..98e7cd0 100644 --- a/src/context/ChatContext.tsx +++ b/src/context/ChatContext.tsx @@ -1,6 +1,7 @@ import { createContext, type ReactNode, useState, useCallback } from 'react'; import type { Chat, Message } from '../types/chat.types'; import { useChatStorage } from '../components/ChatSection/hooks/useChatStorage'; +import { useMessageLimit } from '../components/ChatSection/hooks/useMessageLimit'; import { useTranslation } from 'react-i18next'; import { aiService } from '../services/ai'; @@ -17,6 +18,7 @@ interface ChatContextType { showYellowBanner: boolean; loading: boolean; isTyping: boolean; + isLimitReached: boolean; setIsTyping: (typing: boolean) => void; setInputValue: (value: string) => void; setIsChatActive: (active: boolean) => void; @@ -37,6 +39,7 @@ const ChatContext = createContext(undefined); export const ChatProvider = ({ children }: { children: ReactNode }) => { useTranslation('chat-bot'); const [chats, setChats] = useChatStorage(); + const { isLimitReached, incrementCount } = useMessageLimit(); const [currentChatId, setCurrentChatId] = useState(null); const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); @@ -48,6 +51,8 @@ export const ChatProvider = ({ children }: { children: ReactNode }) => { const [showYellowBanner, setShowYellowBanner] = useState(true); const [loading, setLoading] = useState(false); const [isTyping, setIsTyping] = useState(false); + + const isMessageAllowed = inputValue.trim() && !loading && !isLimitReached; const convertMessagesToAIFormat = (messages: Message[]) => { @@ -58,7 +63,7 @@ export const ChatProvider = ({ children }: { children: ReactNode }) => { }; const handleSend = useCallback(async () => { - if (inputValue.trim() && !loading) { + if (isMessageAllowed) { const userMessage: Message = { type: 'user', text: inputValue, @@ -88,6 +93,7 @@ export const ChatProvider = ({ children }: { children: ReactNode }) => { setHasStartedChat(true); setInputValue(''); setLoading(true); + incrementCount(); try { const allMessages = chatId === currentChatId ? [...messages, userMessage] : [userMessage]; @@ -125,7 +131,7 @@ export const ChatProvider = ({ children }: { children: ReactNode }) => { setLoading(false); } } - }, [inputValue, currentChatId, chats, messages, loading, setChats]); + }, [inputValue, currentChatId, chats, messages, loading, isLimitReached, incrementCount, setChats]); const handleNewChat = useCallback(() => { setHasStartedChat(false); @@ -200,6 +206,7 @@ export const ChatProvider = ({ children }: { children: ReactNode }) => { showYellowBanner, loading, isTyping, + isLimitReached, setIsTyping, setInputValue, setIsChatActive,