Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
VITE_AI_SERVER_URL=
VITE_AI_SYSTEM_PROMPT=
VITE_USAGE_STORAGE_KEY=
VITE_USAGE_PASSPHRASE=
10 changes: 5 additions & 5 deletions src/components/ChatSection/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLTextAreaElement>(null);
const hasInteractedRef = useRef(false);

Expand Down Expand Up @@ -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);
Expand All @@ -89,14 +89,14 @@ const ChatInput: React.FC = () => {
onKeyPress={handleKeyPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={loading}
disabled={loading || isLimitReached}
rows={1}
/>
<div
className={`flex border-l border-l-gray-25 h-full flex-shrink-0 items-center ${
loading || !inputValue.trim() ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
loading || isLimitReached || !inputValue.trim() ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
}`}
onClick={handleSendWrapper}
onClick={isLimitReached ? undefined : handleSendWrapper}
>
<PaperPlaneRight height={30} width={30} className="ml-3 text-neutral-90 hidden lg:flex" />
<PaperPlaneRight height={22} width={22} className="ml-3 text-neutral-90 flex lg:hidden" />
Expand Down
102 changes: 102 additions & 0 deletions src/components/ChatSection/hooks/useMessageLimit.ts
Original file line number Diff line number Diff line change
@@ -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<CryptoKey> {
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<string> {
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<UsageData | null> {
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<UsageData> {
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<void> {
const encrypted = await encryptData(data);
localStorage.setItem(STORAGE_KEY, encrypted);
}

export function useMessageLimit() {
const [usage, setUsage] = useState<UsageData>({ 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 };
}
11 changes: 9 additions & 2 deletions src/context/ChatContext.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
Expand All @@ -37,6 +39,7 @@ const ChatContext = createContext<ChatContextType | undefined>(undefined);
export const ChatProvider = ({ children }: { children: ReactNode }) => {
useTranslation('chat-bot');
const [chats, setChats] = useChatStorage();
const { isLimitReached, incrementCount } = useMessageLimit();
const [currentChatId, setCurrentChatId] = useState<number | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState('');
Expand All @@ -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[]) => {
Expand All @@ -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,
Expand Down Expand Up @@ -88,6 +93,7 @@ export const ChatProvider = ({ children }: { children: ReactNode }) => {
setHasStartedChat(true);
setInputValue('');
setLoading(true);
incrementCount();

try {
const allMessages = chatId === currentChatId ? [...messages, userMessage] : [userMessage];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -200,6 +206,7 @@ export const ChatProvider = ({ children }: { children: ReactNode }) => {
showYellowBanner,
loading,
isTyping,
isLimitReached,
setIsTyping,
setInputValue,
setIsChatActive,
Expand Down