From ce15b171b079d0b941fe78b25d5f97d10ac024ad Mon Sep 17 00:00:00 2001 From: Alexander Zarzhitski Date: Mon, 16 Feb 2026 08:45:38 +0100 Subject: [PATCH 01/10] Add Smart Tutor Chat Agent feature Implement AI-powered chat tutor for language learning with: - Database: chats, chat_messages, and word_pairs tables with RLS - Backend: ChatTutorCrew with router, translation, and vocabulary agents - API: Chat endpoints for messaging, chat management, and history - Frontend: Full chat UI with sidebar, messages, and adaptive components - Features: Translation, vocabulary suggestions, domain boundary enforcement The chat agent responds to language-learning queries only and politely declines off-topic requests. Structured responses enable adaptive UI rendering for word suggestions and save confirmations. --- ai/run.py | 339 +++++++++++++++++- ai/src/crews/chat_tutor_crew/__init__.py | 3 + .../crews/chat_tutor_crew/config/agents.yaml | 34 ++ .../crews/chat_tutor_crew/config/tasks.yaml | 57 +++ ai/src/crews/chat_tutor_crew/crew.py | 49 +++ ai/src/crews/chat_tutor_crew/schemas.py | 24 ++ .../20250215000000_create_chat_tables.sql | 86 +++++ web/Dockerfile | 5 +- web/package.json | 1 + web/src/App.tsx | 2 + web/src/components/app-sidebar.tsx | 6 + web/src/components/chat/chat-input.tsx | 60 ++++ web/src/components/chat/chat-message.tsx | 67 ++++ web/src/components/chat/chat-sidebar.tsx | 87 +++++ .../components/chat/word-suggestion-card.tsx | 81 +++++ web/src/components/ui/scroll-area.tsx | 46 +++ web/src/components/ui/textarea.tsx | 24 ++ web/src/hooks/use-chat.ts | 154 ++++++++ web/src/lib/ai-service.ts | 181 ++++++++++ web/src/pages/chat.tsx | 122 +++++++ 20 files changed, 1421 insertions(+), 7 deletions(-) create mode 100644 ai/src/crews/chat_tutor_crew/__init__.py create mode 100644 ai/src/crews/chat_tutor_crew/config/agents.yaml create mode 100644 ai/src/crews/chat_tutor_crew/config/tasks.yaml create mode 100644 ai/src/crews/chat_tutor_crew/crew.py create mode 100644 ai/src/crews/chat_tutor_crew/schemas.py create mode 100644 supabase/migrations/20250215000000_create_chat_tables.sql create mode 100644 web/src/components/chat/chat-input.tsx create mode 100644 web/src/components/chat/chat-message.tsx create mode 100644 web/src/components/chat/chat-sidebar.tsx create mode 100644 web/src/components/chat/word-suggestion-card.tsx create mode 100644 web/src/components/ui/scroll-area.tsx create mode 100644 web/src/components/ui/textarea.tsx create mode 100644 web/src/hooks/use-chat.ts create mode 100644 web/src/pages/chat.tsx diff --git a/ai/run.py b/ai/run.py index d6cff3c..2a67672 100644 --- a/ai/run.py +++ b/ai/run.py @@ -9,12 +9,14 @@ from flask_cors import CORS from supabase import create_client, Client -from crews.random_phrase_crew.crew import RandomPhraseCrew -from crews.random_phrase_crew.schemas import PhraseOutput -from crews.pronunciation_tips_crew.crew import PronunciationTipsCrew -from crews.pronunciation_tips_crew.schemas import PronunciationTipsOutput +from src.crews.random_phrase_crew.crew import RandomPhraseCrew +from src.crews.random_phrase_crew.schemas import PhraseOutput +from src.crews.pronunciation_tips_crew.crew import PronunciationTipsCrew +from src.crews.pronunciation_tips_crew.schemas import PronunciationTipsOutput +from src.crews.chat_tutor_crew.crew import ChatTutorCrew +from src.crews.chat_tutor_crew.schemas import TutorResponse -from lib.tracer import traceable +from src.lib.tracer import traceable # Configure logging logging.basicConfig( @@ -398,6 +400,333 @@ async def get_pronunciation_tips(): return jsonify({"error": f"An error occurred: {str(e)}"}), 500 +async def handle_tool_call(tool_name: str, arguments: dict, user_id: str) -> str: + """ + Handle tool invocations from the AI agent. + + Args: + tool_name: Name of the tool to call + arguments: Arguments to pass to the tool + user_id: The user's UUID + + Returns: + Result message from the tool execution + """ + if tool_name == "save_word_pair": + source_word = arguments.get("source_word") + translated_word = arguments.get("translated_word") + context_sentence = arguments.get("context_sentence") + + if not source_word or not translated_word: + return "Error: Both source_word and translated_word are required to save a word pair." + + # Check for duplicates + existing = supabase_admin.table("word_pairs").select("*") \ + .eq("user_id", user_id) \ + .eq("source_word", source_word) \ + .eq("translated_word", translated_word) \ + .execute() + + if existing.data: + return f"'{source_word}' is already in your flashcard deck!" + + # Insert new word pair + supabase_admin.table("word_pairs").insert({ + "user_id": user_id, + "source_word": source_word, + "translated_word": translated_word, + "context_sentence": context_sentence + }).execute() + + return f"Done! I've added '{source_word} → {translated_word}' to your flashcard deck." + + return f"Unknown tool: {tool_name}" + + +@app.route("/api/chat/message", methods=["POST"]) +@require_auth +async def send_chat_message(): + """ + Send a message to the chat tutor and get an AI response. + + Request body: + { + "chat_id": "uuid", + "message": "user message" + } + + Headers: + Authorization: Bearer + + Response: + { + "id": "message_id", + "chat_id": "chat_id", + "role": "assistant", + "content": {...}, + "created_at": "timestamp" + } + """ + try: + data = request.get_json() + + if not data or "message" not in data: + return jsonify({"error": "Request body must include 'message' field"}), 400 + + message = data.get("message", "").strip() + chat_id = data.get("chat_id") + + if not message: + return jsonify({"error": "'message' cannot be empty"}), 400 + + user_id = request.user.id + + # Fetch conversation history if chat_id is provided + conversation_history = [] + if chat_id: + messages_response = supabase_admin.table("chat_messages").select("*") \ + .eq("chat_id", chat_id) \ + .limit(20) \ + .execute() + + # Reverse to get chronological order + conversation_history = list(reversed(messages_response.data)) + + # Fetch user context + user_context, target_language = await get_user_context(user_id) + + # Format conversation history for the AI + history_text = "" + for msg in conversation_history[-10:]: # Last 10 messages for context + role = msg.get("role", "user") + content = msg.get("content", {}) + if isinstance(content, dict): + content_str = content.get("content", str(content)) + else: + content_str = str(content) + history_text += f"{role.capitalize()}: {content_str}\n" + + # Run the ChatTutorCrew + inputs = { + 'user_message': jsonify(message).get_data(as_text=True), + 'target_language': jsonify(target_language if target_language else "None").get_data(as_text=True), + 'user_context': jsonify(user_context if user_context else "").get_data(as_text=True), + 'conversation_history': jsonify(history_text if history_text else "No previous messages").get_data(as_text=True) + } + + result = await ChatTutorCrew().crew().kickoff_async(inputs=inputs) + + # Extract the response + if hasattr(result, 'pydantic'): + tutor_response: TutorResponse = result.pydantic + else: + # Fallback for unexpected result format + tutor_response = TutorResponse( + response_type="text", + content=str(result), + data=None, + tool_calls=None + ) + + # Handle tool calls if present + if tutor_response.tool_calls: + for tool_call in tutor_response.tool_calls: + tool_result = await handle_tool_call( + tool_call.name, + tool_call.arguments, + user_id + ) + logger.info(f"Tool {tool_call.name} result: {tool_result}") + + # Save user message to database + if not chat_id: + # Create new chat if needed + chat_response = supabase_admin.table("chats").insert({ + "user_id": user_id, + "title": message[:50] + "..." if len(message) > 50 else message + }).execute() + chat_id = chat_response.data[0]["id"] + + # Save user message + supabase_admin.table("chat_messages").insert({ + "chat_id": chat_id, + "role": "user", + "content": {"content": message} + }).execute() + + # Save assistant response + response_data = supabase_admin.table("chat_messages").insert({ + "chat_id": chat_id, + "role": "assistant", + "content": tutor_response.model_dump() + }).execute() + + # Update chat's updated_at timestamp + supabase_admin.table("chats").update({ + "updated_at": "now()" + }).eq("id", chat_id).execute() + + # Return the assistant message + assistant_message = response_data.data[0] + return jsonify(assistant_message), 200 + + except Exception as e: + logger.error(f"Error sending chat message: {e}", exc_info=True) + return jsonify({"error": f"An error occurred: {str(e)}"}), 500 + + +@app.route("/api/chats", methods=["GET"]) +@require_auth +async def get_chats(): + """ + Get all chats for the current user. + + Headers: + Authorization: Bearer + + Response: + [ + { + "id": "uuid", + "user_id": "uuid", + "title": "chat title", + "created_at": "timestamp", + "updated_at": "timestamp" + } + ] + """ + try: + user_id = request.user.id + + response = supabase_admin.table("chats").select("*") \ + .eq("user_id", user_id) \ + .execute() + + return jsonify(response.data), 200 + + except Exception as e: + logger.error(f"Error fetching chats: {e}", exc_info=True) + return jsonify({"error": f"An error occurred: {str(e)}"}), 500 + + +@app.route("/api/chats", methods=["POST"]) +@require_auth +async def create_chat(): + """ + Create a new chat. + + Request body: + { + "title": "optional title" + } + + Headers: + Authorization: Bearer + + Response: + { + "id": "uuid", + "user_id": "uuid", + "title": "title or null", + "created_at": "timestamp", + "updated_at": "timestamp" + } + """ + try: + data = request.get_json() or {} + title = data.get("title") + user_id = request.user.id + + response = supabase_admin.table("chats").insert({ + "user_id": user_id, + "title": title + }).execute() + + return jsonify(response.data[0]), 201 + + except Exception as e: + logger.error(f"Error creating chat: {e}", exc_info=True) + return jsonify({"error": f"An error occurred: {str(e)}"}), 500 + + +@app.route("/api/chats/", methods=["DELETE"]) +@require_auth +async def delete_chat(chat_id: str): + """ + Delete a chat and all its messages. + + Headers: + Authorization: Bearer + + Response: + {"success": true} + """ + try: + user_id = request.user.id + + # Verify the chat belongs to the user + chat_response = supabase_admin.table("chats").select("*") \ + .eq("id", chat_id) \ + .eq("user_id", user_id) \ + .execute() + + if not chat_response.data: + return jsonify({"error": "Chat not found"}), 404 + + # Delete the chat (messages will be cascaded) + supabase_admin.table("chats").delete().eq("id", chat_id).execute() + + return jsonify({"success": True}), 200 + + except Exception as e: + logger.error(f"Error deleting chat: {e}", exc_info=True) + return jsonify({"error": f"An error occurred: {str(e)}"}), 500 + + +@app.route("/api/chats//messages", methods=["GET"]) +@require_auth +async def get_chat_messages(chat_id: str): + """ + Get all messages for a chat. + + Headers: + Authorization: Bearer + + Response: + [ + { + "id": "uuid", + "chat_id": "uuid", + "role": "user" | "assistant" | "system", + "content": {...}, + "created_at": "timestamp" + } + ] + """ + try: + user_id = request.user.id + + # Verify the chat belongs to the user + chat_response = supabase_admin.table("chats").select("*") \ + .eq("id", chat_id) \ + .eq("user_id", user_id) \ + .execute() + + if not chat_response.data: + return jsonify({"error": "Chat not found"}), 404 + + # Fetch messages + messages_response = supabase_admin.table("chat_messages").select("*") \ + .eq("chat_id", chat_id) \ + .execute() + + return jsonify(messages_response.data), 200 + + except Exception as e: + logger.error(f"Error fetching chat messages: {e}", exc_info=True) + return jsonify({"error": f"An error occurred: {str(e)}"}), 500 + + if __name__ == "__main__": # Run the Flask app port = int(os.getenv("PORT", 8000)) diff --git a/ai/src/crews/chat_tutor_crew/__init__.py b/ai/src/crews/chat_tutor_crew/__init__.py new file mode 100644 index 0000000..ff8211d --- /dev/null +++ b/ai/src/crews/chat_tutor_crew/__init__.py @@ -0,0 +1,3 @@ +from .crew import ChatTutorCrew + +__all__ = ['ChatTutorCrew'] diff --git a/ai/src/crews/chat_tutor_crew/config/agents.yaml b/ai/src/crews/chat_tutor_crew/config/agents.yaml new file mode 100644 index 0000000..26318a3 --- /dev/null +++ b/ai/src/crews/chat_tutor_crew/config/agents.yaml @@ -0,0 +1,34 @@ +router_agent: + role: > + Language Tutor Router + goal: > + Understand the user's intent and route to the appropriate specialist + or respond directly to general language learning questions + backstory: > + You are an intelligent router for a language learning platform. You excel at + understanding user intent and determining the best way to help them. You can + recognize when users need translations, vocabulary suggestions, or general + language guidance. You only respond to language-related questions and politely + redirect off-topic conversations back to language learning. + +translation_agent: + role: > + Translation Specialist + goal: > + Provide accurate translations and language explanations + backstory: > + You are a skilled translator and language educator with expertise in multiple + languages. You provide clear, accurate translations with helpful context about + grammar, usage, and cultural nuances. You understand the challenges of language + learners and explain concepts in an accessible way. + +vocabulary_agent: + role: > + Vocabulary Curator + goal: > + Suggest new vocabulary words with context and examples + backstory: > + You are a vocabulary expert who selects words based on the user's target language + and proficiency level. You provide words that are practical, useful, and come + with clear translations and example sentences. You help users build their + vocabulary systematically. diff --git a/ai/src/crews/chat_tutor_crew/config/tasks.yaml b/ai/src/crews/chat_tutor_crew/config/tasks.yaml new file mode 100644 index 0000000..56b536e --- /dev/null +++ b/ai/src/crews/chat_tutor_crew/config/tasks.yaml @@ -0,0 +1,57 @@ +route_task: + description: > + Analy the user's message and determine the appropriate response. + + User message: {user_message} + Target language: {target_language} + User context: {user_context} + + Conversation history: + {conversation_history} + + Your task: + 1. Understand what the user is asking for + 2. Check if this is a language-learning related request + 3. If it's NOT language-related, politely refuse and redirect + 4. If it IS language-related, respond appropriately: + - Translation requests: Provide the translation with explanation + - Vocabulary requests: Suggest a word with translation and example + - Grammar questions: Explain the grammar concept + - General help: Guide the user on language learning + 5. If the user asks to save a word pair, include a tool_call for "save_word_pair" + + Important boundaries: + - Only respond to language-learning questions + - Politely decline off-topic requests (e.g., weather, news, general knowledge) + - When declining, suggest a language-related alternative + + You must return your response as a JSON object with the following structure: + { + "response_type": "text" | "word_suggestion" | "save_confirmation" | "error", + "content": "Your response text here", + "data": { + "word": "source_word (for word_suggestion)", + "translation": "translated_word (for word_suggestion)", + "example": "example_sentence (for word_suggestion)" + }, + "tool_calls": [ + { + "name": "save_word_pair", + "arguments": { + "source_word": "word in target language", + "translated_word": "word in user's native language", + "context_sentence": "example using the word" + } + } + ] + } + + response_type values: + - "text": Regular response (translation, explanation, etc.) + - "word_suggestion": When suggesting a new vocabulary word + - "save_confirmation": When confirming a word was saved + - "error": When declining an off-topic request + expected_output: > + A JSON object with response_type, content, optional data (for word suggestions), + and optional tool_calls (for saving words). + agent: router_agent diff --git a/ai/src/crews/chat_tutor_crew/crew.py b/ai/src/crews/chat_tutor_crew/crew.py new file mode 100644 index 0000000..2e8e3c7 --- /dev/null +++ b/ai/src/crews/chat_tutor_crew/crew.py @@ -0,0 +1,49 @@ +from crewai import Agent, Crew, Process, Task +from crewai.project import CrewBase, agent, crew, task +from crewai.agents.agent_builder.base_agent import BaseAgent +from typing import List, Optional +from src.crews.base.llm import DEFAULT_LLM +from src.crews.chat_tutor_crew.schemas import TutorResponse + + +@CrewBase +class ChatTutorCrew(): + """Crew for handling chat interactions with the language tutor""" + agents: List[BaseAgent] + tasks: List[Task] + + @agent + def router_agent(self) -> Agent: + return Agent( + config=self.agents_config['router_agent'], + llm=DEFAULT_LLM + ) + + @agent + def translation_agent(self) -> Agent: + return Agent( + config=self.agents_config['translation_agent'], + llm=DEFAULT_LLM + ) + + @agent + def vocabulary_agent(self) -> Agent: + return Agent( + config=self.agents_config['vocabulary_agent'], + llm=DEFAULT_LLM + ) + + @task + def route_task(self) -> Task: + return Task( + config=self.tasks_config['route_task'], + output_pydantic=TutorResponse + ) + + @crew + def crew(self) -> Crew: + return Crew( + agents=self.agents, + tasks=self.tasks, + process=Process.sequential + ) diff --git a/ai/src/crews/chat_tutor_crew/schemas.py b/ai/src/crews/chat_tutor_crew/schemas.py new file mode 100644 index 0000000..f392eaf --- /dev/null +++ b/ai/src/crews/chat_tutor_crew/schemas.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel, Field +from typing import Optional, Literal, List, Dict, Any + + +class ToolCall(BaseModel): + """Represents a tool call from the AI agent""" + name: str = Field(description="Name of the tool to call (e.g., 'save_word_pair')") + arguments: Dict[str, Any] = Field(description="Arguments to pass to the tool") + + +class TutorResponse(BaseModel): + """Structured response from the chat tutor""" + response_type: Literal["text", "word_suggestion", "save_confirmation", "error"] = Field( + description="Type of response - determines UI rendering" + ) + content: str = Field(description="The main text content of the response") + data: Optional[Dict[str, Any]] = Field( + None, + description="Additional data for special response types (word, translation, example for word_suggestion)" + ) + tool_calls: Optional[List[ToolCall]] = Field( + None, + description="List of tool calls the agent wants to make" + ) diff --git a/supabase/migrations/20250215000000_create_chat_tables.sql b/supabase/migrations/20250215000000_create_chat_tables.sql new file mode 100644 index 0000000..fe27ed8 --- /dev/null +++ b/supabase/migrations/20250215000000_create_chat_tables.sql @@ -0,0 +1,86 @@ +-- Chat tables for Smart Tutor Chat feature +-- These tables store chat sessions and messages between users and the AI tutor + +-- Create chats table +create table public.chats ( + id uuid default gen_random_uuid() primary key, + user_id uuid references auth.users(id) on delete cascade not null, + title text, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- Enable RLS on chats +alter table public.chats enable row level security; + +-- RLS policies for chats +create policy "Users can view their own chats" + on public.chats for select using (auth.uid() = user_id); + +create policy "Users can insert their own chats" + on public.chats for insert with check (auth.uid() = user_id); + +create policy "Users can update their own chats" + on public.chats for update using (auth.uid() = user_id); + +create policy "Users can delete their own chats" + on public.chats for delete using (auth.uid() = user_id); + +-- Create chat_messages table +create table public.chat_messages ( + id uuid default gen_random_uuid() primary key, + chat_id uuid references public.chats(id) on delete cascade not null, + role text not null check (role in ('user', 'assistant', 'system')), + content jsonb not null, + created_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- Enable RLS on chat_messages +alter table public.chat_messages enable row level security; + +-- RLS policies for chat_messages +create policy "Users can view messages in their chats" + on public.chat_messages for select + using (exists ( + select 1 from public.chats where chats.id = chat_messages.chat_id and chats.user_id = auth.uid() + )); + +create policy "Users can insert messages in their chats" + on public.chat_messages for insert + with check (exists ( + select 1 from public.chats where chats.id = chat_messages.chat_id and chats.user_id = auth.uid() + )); + +-- Create indexes for faster queries +create index idx_chats_user_id_updated_at on public.chats(user_id, updated_at desc); +create index idx_chat_messages_chat_id_created_at on public.chat_messages(chat_id, created_at); + +-- Create word_pairs table for flashcard deck +create table public.word_pairs ( + id uuid default gen_random_uuid() primary key, + user_id uuid references auth.users(id) on delete cascade not null, + source_word text not null, + translated_word text not null, + context_sentence text, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + unique(user_id, source_word, translated_word) +); + +-- Enable RLS on word_pairs +alter table public.word_pairs enable row level security; + +-- RLS policies for word_pairs +create policy "Users can view their own word pairs" + on public.word_pairs for select using (auth.uid() = user_id); + +create policy "Users can insert their own word pairs" + on public.word_pairs for insert with check (auth.uid() = user_id); + +create policy "Users can update their own word pairs" + on public.word_pairs for update using (auth.uid() = user_id); + +create policy "Users can delete their own word pairs" + on public.word_pairs for delete using (auth.uid() = user_id); + +-- Create index for faster word pair queries +create index idx_word_pairs_user_id on public.word_pairs(user_id); diff --git a/web/Dockerfile b/web/Dockerfile index 6fac758..4214338 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -13,7 +13,8 @@ COPY package.json pnpm-lock.yaml* ./ FROM base AS development # Install all dependencies (including devDependencies) -RUN pnpm install --frozen-lockfile +# Use --no-frozen-lockfile for development to allow adding new dependencies +RUN pnpm install --no-frozen-lockfile # Copy source code COPY . . @@ -27,7 +28,7 @@ CMD ["pnpm", "run", "dev", "--host", "0.0.0.0"] FROM base AS builder # Install all dependencies -RUN pnpm install --frozen-lockfile +RUN pnpm install --no-frozen-lockfile # Copy source code COPY . . diff --git a/web/package.json b/web/package.json index 19675ad..8e749cc 100644 --- a/web/package.json +++ b/web/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", diff --git a/web/src/App.tsx b/web/src/App.tsx index 08534fd..5c5ef1a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import SignUpPage from './pages/signup' import WordsPage from './pages/words' import RandomPhrasePage from './pages/random-phrase' import SettingsPage from './pages/settings' +import ChatPage from './pages/chat' const queryClient = new QueryClient({ defaultOptions: { @@ -37,6 +38,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx index 2a4521d..5ee8bcd 100644 --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -4,6 +4,7 @@ import { IconDatabase, IconInnerShadowTop, IconSparkles, + IconMessage, } from "@tabler/icons-react" import { NavDocuments } from "@/components/nav-documents" @@ -44,6 +45,11 @@ const data = { url: "/random-phrase", icon: IconSparkles, }, + { + name: "Chat", + url: "/chat", + icon: IconMessage, + }, ], } diff --git a/web/src/components/chat/chat-input.tsx b/web/src/components/chat/chat-input.tsx new file mode 100644 index 0000000..0b26d1b --- /dev/null +++ b/web/src/components/chat/chat-input.tsx @@ -0,0 +1,60 @@ +import { useState, useRef, useEffect } from 'react' +import { IconSend } from '@tabler/icons-react' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' + +interface ChatInputProps { + onSend: (message: string) => void + disabled?: boolean +} + +export function ChatInput({ onSend, disabled = false }: ChatInputProps) { + const [message, setMessage] = useState('') + const textareaRef = useRef(null) + + const handleSend = () => { + const trimmed = message.trim() + if (trimmed && !disabled) { + onSend(trimmed) + setMessage('') + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + // Auto-focus on mount + useEffect(() => { + textareaRef.current?.focus() + }, []) + + return ( +
+
+