[V0.1 WORKING] Added chat, profile, & calendar screen implementations.
This commit is contained in:
Binary file not shown.
@@ -14,7 +14,7 @@ def lifespan_factory() -> Callable[[FastAPI], _AsyncGeneratorContextManager[Any]
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
Base.metadata.drop_all(bind=get_engine())
|
||||
# Base.metadata.drop_all(bind=get_engine())
|
||||
Base.metadata.create_all(bind=get_engine())
|
||||
yield
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -14,4 +14,11 @@ class CalendarEventResponse(CalendarEventCreate):
|
||||
user_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
from_attributes = True
|
||||
|
||||
class CalendarEventUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
start: datetime | None = None
|
||||
end: datetime | None = None
|
||||
location: str | None = None
|
||||
Binary file not shown.
BIN
backend/modules/nlp/__pycache__/schemas.cpython-312.pyc
Normal file
BIN
backend/modules/nlp/__pycache__/schemas.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -1,53 +1,74 @@
|
||||
# modules/nlp/api.py
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_db
|
||||
from core.exceptions import bad_request_exception
|
||||
|
||||
from modules.auth.dependencies import get_current_user
|
||||
from modules.auth.models import User
|
||||
from modules.nlp.service import process_request, ask_ai
|
||||
from modules.nlp.schemas import ProcessCommandRequest
|
||||
from modules.calendar.service import create_calendar_event, get_calendar_events, update_calendar_event, delete_calendar_event
|
||||
from modules.calendar.schemas import CalendarEventCreate
|
||||
|
||||
from modules.calendar.schemas import CalendarEventCreate, CalendarEventUpdate
|
||||
|
||||
router = APIRouter(prefix="/nlp", tags=["nlp"])
|
||||
|
||||
@router.post("/process-command")
|
||||
def process_command(user_input: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
def process_command(request_data: ProcessCommandRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""
|
||||
Process the user command and return the appropriate action.
|
||||
Process the user command, execute the action, and return a user-friendly response.
|
||||
"""
|
||||
command = process_request(user_input)
|
||||
|
||||
if "error" in command:
|
||||
raise bad_request_exception(command["error"])
|
||||
|
||||
match command["intent"]:
|
||||
case "ask_ai":
|
||||
result = ask_ai(**command["params"])
|
||||
return {"action": "ai_response", "details": result}
|
||||
|
||||
case "get_calendar_events":
|
||||
result = get_calendar_events(db, current_user.id, **command["params"])
|
||||
return {"action": "calendar_events_retrieved", "details": result}
|
||||
|
||||
case "add_calendar_event":
|
||||
event = CalendarEventCreate(**command["params"])
|
||||
result = create_calendar_event(db, current_user.id, event)
|
||||
return {"action": "calendar_event_created", "details": result}
|
||||
user_input = request_data.user_input
|
||||
command_data = process_request(user_input)
|
||||
intent = command_data["intent"]
|
||||
params = command_data["params"]
|
||||
response_text = command_data["response_text"]
|
||||
|
||||
case "update_calendar_event":
|
||||
event = CalendarEventCreate(**command["params"])
|
||||
result = update_calendar_event(db, current_user.id, 0, event_data=event) ## PLACEHOLDER
|
||||
return {"action": "calendar_event_updated", "details": result}
|
||||
|
||||
case "delete_calendar_event":
|
||||
result = update_calendar_event(db, current_user.id, 0) ## PLACEHOLDER
|
||||
return {"action": "calendar_event_deleted", "details": result}
|
||||
if intent == "error":
|
||||
raise HTTPException(status_code=400, detail=response_text)
|
||||
|
||||
if intent == "clarification_needed":
|
||||
return {"response": response_text}
|
||||
|
||||
if intent == "unknown":
|
||||
return {"response": response_text}
|
||||
|
||||
try:
|
||||
match intent:
|
||||
case "ask_ai":
|
||||
ai_answer = ask_ai(**params)
|
||||
return {"response": ai_answer}
|
||||
|
||||
case "unknown":
|
||||
return {"action": "unknown_command", "details": command["params"]}
|
||||
case _:
|
||||
raise bad_request_exception(400, detail="Unrecognized command")
|
||||
case "get_calendar_events":
|
||||
result = get_calendar_events(db, current_user.id, **params)
|
||||
return {"response": response_text, "details": result}
|
||||
|
||||
case "add_calendar_event":
|
||||
event = CalendarEventCreate(**params)
|
||||
result = create_calendar_event(db, current_user.id, event)
|
||||
return {"response": response_text, "details": result}
|
||||
|
||||
case "update_calendar_event":
|
||||
event_id = params.pop('event_id', None)
|
||||
if event_id is None:
|
||||
raise HTTPException(status_code=400, detail="Event ID is required for update.")
|
||||
event_data = CalendarEventUpdate(**params)
|
||||
result = update_calendar_event(db, current_user.id, event_id, event_data=event_data)
|
||||
return {"response": response_text, "details": result}
|
||||
|
||||
case "delete_calendar_event":
|
||||
event_id = params.get('event_id')
|
||||
if event_id is None:
|
||||
raise HTTPException(status_code=400, detail="Event ID is required for delete.")
|
||||
result = delete_calendar_event(db, current_user.id, event_id)
|
||||
return {"response": response_text, "details": {"deleted": True, "event_id": event_id}}
|
||||
|
||||
case _:
|
||||
print(f"Warning: Unhandled intent '{intent}' reached api.py match statement.")
|
||||
raise HTTPException(status_code=500, detail="An unexpected error occurred processing the command.")
|
||||
|
||||
except HTTPException as http_exc:
|
||||
raise http_exc
|
||||
except Exception as e:
|
||||
print(f"Error executing intent '{intent}': {e}")
|
||||
raise HTTPException(status_code=500, detail="Sorry, I encountered an error while trying to perform that action.")
|
||||
5
backend/modules/nlp/schemas.py
Normal file
5
backend/modules/nlp/schemas.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# modules/nlp/schemas.py
|
||||
from pydantic import BaseModel
|
||||
|
||||
class ProcessCommandRequest(BaseModel):
|
||||
user_input: str
|
||||
@@ -10,25 +10,53 @@ client = genai.Client(api_key="AIzaSyBrte_mETZJce8qE6cRTSz_fHOjdjlShBk")
|
||||
|
||||
### Base prompt for MAIA, used for inital user requests
|
||||
SYSTEM_PROMPT = """
|
||||
You are MAIA - My AI Assistant. Your job is to parse user requests into structured JSON commands.
|
||||
You are MAIA - My AI Assistant. Your job is to parse user requests into structured JSON commands and generate a user-facing response text.
|
||||
|
||||
Available functions:
|
||||
1. ask_ai(request: str). If the intent of the request is a simple question (e.x. What is the weather like today?), you should call this function, and forward the user's request as the parameter.
|
||||
2. get_calendar_events(start: Optional[datetime], end: Optional[datetime])
|
||||
3. add_calendar_event(title: str, description: str, start: datetime, end: Optional[datetime], location: str)
|
||||
4. update_calendar_event(event_id: int, title: Optional[str], description: Optional[str], start: Optional[datetime], end: Optional[datetime], location: Optional[str])
|
||||
5. delete_calendar_event(event_id: int)
|
||||
Available functions/intents:
|
||||
1. ask_ai(request: str): Use for simple questions (e.g., weather, facts). Forward the user's request.
|
||||
2. get_calendar_events(start: Optional[datetime], end: Optional[datetime]): Retrieve calendar events.
|
||||
3. add_calendar_event(title: str, description: str, start: datetime, end: Optional[datetime], location: str): Add a new event.
|
||||
4. update_calendar_event(event_id: int, title: Optional[str], description: Optional[str], start: Optional[datetime], end: Optional[datetime], location: Optional[str]): Update an existing event. Requires event_id.
|
||||
5. delete_calendar_event(event_id: int): Delete an event. Requires event_id.
|
||||
6. clarification_needed(request: str): Use this if the user's request is ambiguous or lacks necessary information (like event_id for update/delete). The original user request should be passed in the 'request' parameter.
|
||||
|
||||
Respond **ONLY** with JSON like this:
|
||||
**IMPORTANT:** Respond ONLY with JSON containing BOTH "intent" and "params", AND a "response_text" field.
|
||||
- "response_text" should be a friendly, user-facing message confirming the action taken, providing the answer, or asking for clarification.
|
||||
|
||||
Examples:
|
||||
|
||||
User: Add a meeting tomorrow at 3pm about project X
|
||||
MAIA:
|
||||
{
|
||||
"intent": "add_calendar_event",
|
||||
"params": {
|
||||
"title": "Team Meeting",
|
||||
"description": "Discuss project updates",
|
||||
"start": "2025-04-16 15:00:00.000000+00:00",
|
||||
"end": "2025-04-16 16:00:00.000000+00:00",
|
||||
"location": "Office"
|
||||
}
|
||||
"title": "Meeting",
|
||||
"description": "Project X",
|
||||
"start": "2025-04-19 15:00:00.000000+00:00",
|
||||
"end": null,
|
||||
"location": null
|
||||
},
|
||||
"response_text": "Okay, I've added a meeting about Project X to your calendar for tomorrow at 3 PM."
|
||||
}
|
||||
|
||||
User: What's the weather like?
|
||||
MAIA:
|
||||
{
|
||||
"intent": "ask_ai",
|
||||
"params": {
|
||||
"request": "What's the weather like?"
|
||||
},
|
||||
"response_text": "Let me check the weather for you."
|
||||
}
|
||||
|
||||
User: Delete the team sync event.
|
||||
MAIA:
|
||||
{
|
||||
"intent": "clarification_needed",
|
||||
"params": {
|
||||
"request": "Delete the team sync event."
|
||||
},
|
||||
"response_text": "Okay, I can help with that. Could you please provide the ID or more specific details about the 'team sync' event you want me to delete?"
|
||||
}
|
||||
|
||||
The datetime right now is """+str(datetime.now(timezone.utc))+""".
|
||||
@@ -45,6 +73,7 @@ Here is the user request:
|
||||
def process_request(request: str):
|
||||
"""
|
||||
Process the user request using the Google GenAI API.
|
||||
Expects a JSON response with intent, params, and response_text.
|
||||
"""
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
@@ -52,41 +81,25 @@ def process_request(request: str):
|
||||
config={
|
||||
"temperature": 0.3, # Less creativity, more factual
|
||||
"response_mime_type": "application/json",
|
||||
# "response_schema": { ### NOT WORKING
|
||||
# "type": "object",
|
||||
# "properties": {
|
||||
# "intent": {
|
||||
# "type": "string",
|
||||
# "enum": [
|
||||
# "get_calendar_events",
|
||||
# "add_calendar_event",
|
||||
# "update_calendar_event",
|
||||
# "delete_calendar_event"
|
||||
# ]
|
||||
# },
|
||||
# "params": {
|
||||
# "type": "object",
|
||||
# "properties": {
|
||||
# "title": {"type": "string"},
|
||||
# "description": {"type": "string"},
|
||||
# "start": {"type": "string", "format": "date-time"},
|
||||
# "end": {"type": "string", "format": "date-time"},
|
||||
# "location": {"type": "string"},
|
||||
# "event_id": {"type": "integer"},
|
||||
|
||||
# },
|
||||
# }
|
||||
# },
|
||||
# "required": ["intent", "params"]
|
||||
# }
|
||||
}
|
||||
)
|
||||
|
||||
# Parse the JSON response
|
||||
try:
|
||||
return json.loads(response.text)
|
||||
except ValueError:
|
||||
raise ValueError("Invalid JSON response from AI")
|
||||
parsed_response = json.loads(response.text)
|
||||
# Validate required fields
|
||||
if not all(k in parsed_response for k in ("intent", "params", "response_text")):
|
||||
raise ValueError("AI response missing required fields (intent, params, response_text)")
|
||||
return parsed_response
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
print(f"Error parsing AI response: {e}")
|
||||
print(f"Raw AI response: {response.text}")
|
||||
# Return a structured error that the API layer can handle
|
||||
return {
|
||||
"intent": "error",
|
||||
"params": {},
|
||||
"response_text": "Sorry, I had trouble understanding that request or formulating a response. Could you please try rephrasing?"
|
||||
}
|
||||
|
||||
def ask_ai(request: str):
|
||||
"""
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import apiClient from './client';
|
||||
|
||||
|
||||
export const healthCheck = async (): Promise<{ status: string }> => {
|
||||
try {
|
||||
const response = await apiClient.get('/health');
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error("Error fetching backend health:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export const getCalendarEvents = async (start?: Date, end?: Date): Promise<Calen
|
||||
params.end = end.toISOString();
|
||||
}
|
||||
const response = await apiClient.get('/calendar/events', { params });
|
||||
console.log("[CAM] Got calendar:", response);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching calendar events", error);
|
||||
|
||||
@@ -54,8 +54,8 @@ apiClient.interceptors.response.use(
|
||||
async (error: AxiosError) => { // Explicitly type error as AxiosError
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Check if the error has a response object (i.e., server responded with error status)
|
||||
if (error.response) {
|
||||
// Check if the error has a response object AND an original request config
|
||||
if (error.response && originalRequest) { // <-- Added check for originalRequest
|
||||
// Server responded with an error status code (4xx, 5xx)
|
||||
console.error('[API Client] Response Error Status:', error.response.status);
|
||||
console.error('[API Client] Response Error Data:', error.response.data);
|
||||
@@ -64,39 +64,58 @@ apiClient.interceptors.response.use(
|
||||
if (error.response.status === 401) {
|
||||
console.warn('[API Client] Unauthorized (401). Token might be expired or invalid.');
|
||||
|
||||
if (!originalRequest?._retry) {
|
||||
originalRequest._retry = true; // Mark the request as retried to avoid infinite loops
|
||||
if (originalRequest.url === '/auth/refresh') {
|
||||
console.error('[API Client] Refresh token attempt failed with 401. Not retrying.');
|
||||
// Clear token and reject without retry
|
||||
if (Platform.OS === 'web') {
|
||||
await AsyncStorage.removeItem(TOKEN_KEY);
|
||||
} else {
|
||||
await SecureStore.deleteItemAsync(TOKEN_KEY).catch(() => {}); // Ignore delete error
|
||||
}
|
||||
delete apiClient.defaults.headers.common['Authorization'];
|
||||
return Promise.reject(error); // Reject immediately
|
||||
}
|
||||
|
||||
// Proceed with refresh logic only if it wasn't the refresh endpoint that failed
|
||||
// and if originalRequest exists (already checked above)
|
||||
if (!originalRequest._retry) { // Now TS knows _retry exists due to declaration file
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
console.log('[API Client] Attempting token refresh...');
|
||||
const refreshResponse = await apiClient.post('/auth/refresh', {}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (refreshResponse.status === 200) {
|
||||
const newToken = refreshResponse.data?.accessToken;
|
||||
if (refreshResponse.status === 200) {
|
||||
const newToken = refreshResponse.data?.access_token;
|
||||
|
||||
if (newToken) {
|
||||
console.log('[API Client] Token refreshed successfully.');
|
||||
console.log('[API Client] Token refreshed successfully.');
|
||||
// Save the new token
|
||||
if (Platform.OS === 'web') {
|
||||
await AsyncStorage.setItem(TOKEN_KEY, newToken);
|
||||
} else {
|
||||
await SecureStore.setItemAsync(TOKEN_KEY, newToken);
|
||||
}
|
||||
|
||||
// Save the new token
|
||||
if (Platform.OS === 'web') {
|
||||
await AsyncStorage.setItem(TOKEN_KEY, newToken);
|
||||
} else {
|
||||
await SecureStore.setItemAsync(TOKEN_KEY, newToken);
|
||||
}
|
||||
// Update the Authorization header for future requests
|
||||
apiClient.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
|
||||
// Safely update original request headers
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
|
||||
}
|
||||
|
||||
// Update the Authorization header for future requests
|
||||
apiClient.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
|
||||
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
|
||||
|
||||
// Retry the original request with the new token
|
||||
return apiClient(originalRequest);
|
||||
// Retry the original request (originalRequest is guaranteed to exist here)
|
||||
return apiClient(originalRequest);
|
||||
} else {
|
||||
console.error('[API Client] Invalid token structure received during refresh:', refreshResponse.data);
|
||||
throw new Error('Invalid token received from server.');
|
||||
}
|
||||
}
|
||||
} catch (refreshError) {
|
||||
} catch (refreshError: any) {
|
||||
console.error('[API Client] Token refresh failed:', refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
setAuthToken(null);
|
||||
delete apiClient.defaults.headers.common['Authorization'];
|
||||
await deleteToken(); // Use helper
|
||||
// Optional backend logout call
|
||||
await apiClient.post("/auth/logout");
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
@@ -192,7 +192,6 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// --- useAuth and AuthLoadingScreen remain the same ---
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
|
||||
@@ -36,11 +36,12 @@ const CalendarScreen = () => {
|
||||
// Store events keyed by date *for the list display*
|
||||
const [eventsByDate, setEventsByDate] = useState<{ [key: string]: CalendarEvent[] }>({});
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// --- Fetching Logic ---
|
||||
const fetchEventsForMonth = useCallback(async (date: Date | DateData) => {
|
||||
console.log("[CAM] fetchevents start");
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const targetYear = 'year' in date ? date.year : getYear(date);
|
||||
@@ -71,18 +72,28 @@ const CalendarScreen = () => {
|
||||
// Process events for the daily list view
|
||||
const newEventsByDate: { [key: string]: CalendarEvent[] } = {};
|
||||
fetchedEvents.forEach(event => {
|
||||
const startDate = parseISO(event.start);
|
||||
const endDate = parseISO(event.end);
|
||||
// --- Check for valid START date string ---
|
||||
if (typeof event.start !== 'string') {
|
||||
console.warn(`Event ${event.id} has invalid start date type:`, event.start);
|
||||
return; // Skip this event
|
||||
}
|
||||
// --- End check ---
|
||||
|
||||
if (!isValid(startDate) || !isValid(endDate)) {
|
||||
console.warn(`Invalid date found in event ${event.id}`);
|
||||
const startDate = parseISO(event.start);
|
||||
// --- Check if START date is valid after parsing ---
|
||||
if (!isValid(startDate)) {
|
||||
console.warn(`Invalid start date found in event ${event.id}:`, event.start);
|
||||
return; // Skip invalid events
|
||||
}
|
||||
|
||||
// Ensure end date is not before start date
|
||||
const end = endDate < startDate ? startDate : endDate;
|
||||
// --- Handle potentially null END date ---
|
||||
// If end is null or not a string, treat it as the same as start date
|
||||
const endDate = typeof event.end === 'string' && isValid(parseISO(event.end)) ? parseISO(event.end) : startDate;
|
||||
|
||||
const intervalDates = eachDayOfInterval({ start: startDate, end: end });
|
||||
// Ensure end date is not before start date (could happen with invalid data or timezone issues)
|
||||
const endForInterval = endDate < startDate ? startDate : endDate;
|
||||
|
||||
const intervalDates = eachDayOfInterval({ start: startDate, end: endForInterval });
|
||||
intervalDates.forEach(dayInInterval => {
|
||||
const dateKey = format(dayInInterval, 'yyyy-MM-dd');
|
||||
if (!newEventsByDate[dateKey]) {
|
||||
@@ -103,14 +114,80 @@ const CalendarScreen = () => {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
console.log("[CAM] isLoading:", isLoading);
|
||||
}
|
||||
}, [isLoading, currentMonthData]); // Include dependencies
|
||||
|
||||
// --- Initial Fetch ---
|
||||
useEffect(() => {
|
||||
const initialDate = parseISO(todayString);
|
||||
fetchEventsForMonth(initialDate);
|
||||
}, [fetchEventsForMonth, todayString]);
|
||||
const performInitialLoad = async () => {
|
||||
console.log("[CalendarScreen] Performing initial load.");
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const initialDate = parseISO(todayString);
|
||||
const targetYear = getYear(initialDate);
|
||||
const targetMonth = getMonth(initialDate) + 1;
|
||||
|
||||
setCurrentMonthData({
|
||||
year: targetYear,
|
||||
month: targetMonth,
|
||||
dateString: todayString,
|
||||
day: initialDate.getDate(),
|
||||
timestamp: initialDate.getTime(),
|
||||
});
|
||||
|
||||
try {
|
||||
const fetchedEvents = await getCalendarEvents(targetYear, targetMonth);
|
||||
const newEventsByDate: {[key: string]: CalendarEvent[] } = {};
|
||||
|
||||
fetchedEvents.forEach(event => {
|
||||
// --- Check for valid START date string ---
|
||||
if (typeof event.start !== 'string') {
|
||||
console.warn(`Event ${event.id} has invalid start date type during initial load:`, event.start);
|
||||
return; // Skip this event
|
||||
}
|
||||
// --- End check ---
|
||||
|
||||
const startDate = parseISO(event.start);
|
||||
// --- Check if START date is valid after parsing ---
|
||||
if (!isValid(startDate)) {
|
||||
console.warn(`Invalid start date found in event ${event.id} during initial load:`, event.start);
|
||||
return; // Skip invalid events
|
||||
}
|
||||
|
||||
// --- Handle potentially null END date ---
|
||||
// If end is null or not a string, treat it as the same as start date
|
||||
const endDate = typeof event.end === 'string' && isValid(parseISO(event.end)) ? parseISO(event.end) : startDate;
|
||||
|
||||
// Ensure end date is not before start date
|
||||
const endForInterval = endDate < startDate ? startDate : endDate;
|
||||
|
||||
const intervalDates = eachDayOfInterval({ start: startDate, end: endForInterval });
|
||||
intervalDates.forEach(dayInInterval => {
|
||||
const dateKey = format(dayInInterval, 'yyyy-MM-dd');
|
||||
if (!newEventsByDate[dateKey]) {
|
||||
newEventsByDate[dateKey] = [];
|
||||
}
|
||||
// Avoid duplicates if an event is already added for this key
|
||||
if (!newEventsByDate[dateKey].some(e => e.id === event.id)) {
|
||||
newEventsByDate[dateKey].push(event);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setRawEvents(fetchedEvents);
|
||||
setEventsByDate(newEventsByDate);
|
||||
} catch (err) {
|
||||
setError('Failed to load initial calendar events.');
|
||||
setRawEvents([]);
|
||||
setEventsByDate({});
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
performInitialLoad();
|
||||
}, [todayString]);
|
||||
|
||||
// --- Callbacks for Calendar ---
|
||||
const onDayPress = useCallback((day: DateData) => {
|
||||
@@ -119,6 +196,7 @@ const CalendarScreen = () => {
|
||||
|
||||
const onMonthChange = useCallback((month: DateData) => {
|
||||
if (!currentMonthData || month.year !== currentMonthData.year || month.month !== currentMonthData.month) {
|
||||
console.log("[CAM] CAlling fetchevents");
|
||||
fetchEventsForMonth(month);
|
||||
} else {
|
||||
setCurrentMonthData(month); // Just update the current data if same month
|
||||
@@ -130,18 +208,29 @@ const CalendarScreen = () => {
|
||||
const marks: { [key: string]: MarkingProps } = {}; // Use MarkingProps type
|
||||
|
||||
rawEvents.forEach(event => {
|
||||
const startDate = parseISO(event.start);
|
||||
const endDate = parseISO(event.end);
|
||||
const eventColor = event.color || theme.colors.primary; // Use event color or default
|
||||
// --- Check for valid START date string ---
|
||||
if (typeof event.start !== 'string') {
|
||||
console.warn(`Event ${event.id} has invalid start date type in markedDates:`, event.start);
|
||||
return; // Skip this event
|
||||
}
|
||||
// --- End check ---
|
||||
|
||||
if (!isValid(startDate) || !isValid(endDate)) {
|
||||
const startDate = parseISO(event.start);
|
||||
// --- Check if START date is valid after parsing ---
|
||||
if (!isValid(startDate)) {
|
||||
console.warn(`Invalid start date found for marking in event ${event.id}:`, event.start);
|
||||
return; // Skip invalid events
|
||||
}
|
||||
|
||||
// Ensure end date is not before start date
|
||||
const end = endDate < startDate ? startDate : endDate;
|
||||
// --- Handle potentially null END date ---
|
||||
// If end is null or not a string, treat it as the same as start date
|
||||
const endDate = typeof event.end === 'string' && isValid(parseISO(event.end)) ? parseISO(event.end) : startDate;
|
||||
const eventColor = event.color || theme.colors.primary; // Use event color or default
|
||||
|
||||
const intervalDates = eachDayOfInterval({ start: startDate, end: end });
|
||||
// Ensure end date is not before start date
|
||||
const endForInterval = endDate < startDate ? startDate : endDate;
|
||||
|
||||
const intervalDates = eachDayOfInterval({ start: startDate, end: endForInterval });
|
||||
|
||||
intervalDates.forEach((dateInInterval, index) => {
|
||||
const dateString = format(dateInInterval, 'yyyy-MM-dd');
|
||||
@@ -172,9 +261,6 @@ const CalendarScreen = () => {
|
||||
// Ensure start/end flags aren't overwritten by non-start/end marks
|
||||
startingDay: marks[dateString]?.startingDay || marking.startingDay,
|
||||
endingDay: marks[dateString]?.endingDay || marking.endingDay,
|
||||
// We might need a more complex strategy if multiple periods
|
||||
// with different colors overlap on the same day.
|
||||
// For now, the last event processed might "win" the color.
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -182,43 +268,50 @@ const CalendarScreen = () => {
|
||||
// Add selected day marking (merge with period marking)
|
||||
if (selectedDate) {
|
||||
marks[selectedDate] = {
|
||||
...(marks[selectedDate] || {}), // Keep existing period/dot marks
|
||||
...(marks[selectedDate] || {}),
|
||||
selected: true,
|
||||
// Keep the period color if it exists, otherwise use selection color
|
||||
color: marks[selectedDate]?.color || theme.colors.primary, // Period wins color? or selection? Choose one. Here period wins.
|
||||
// selectedColor: theme.colors.secondary, // Or use a distinct selection highlight color?
|
||||
// Ensure text color is appropriate for selected state
|
||||
color: marks[selectedDate]?.color || theme.colors.primary,
|
||||
textColor: theme.colors.onPrimary || '#ffffff',
|
||||
// If selected, don't let it look like starting/ending unless it truly is
|
||||
startingDay: marks[selectedDate]?.startingDay && marks[selectedDate]?.selected,
|
||||
endingDay: marks[selectedDate]?.endingDay && marks[selectedDate]?.selected,
|
||||
};
|
||||
}
|
||||
|
||||
// Add today marking (merge with period/selection marking)
|
||||
// Period marking visually indicates today already if colored. Add dot?
|
||||
marks[todayString] = {
|
||||
...(marks[todayString] || {}),
|
||||
// marked: true, // 'marked' is implicit with period marking color
|
||||
dotColor: theme.colors.secondary, // Add a distinct dot for today?
|
||||
// Or rely on the 'todayTextColor' in the theme prop
|
||||
};
|
||||
marks[todayString] = {
|
||||
...(marks[todayString] || {}),
|
||||
dotColor: theme.colors.secondary,
|
||||
};
|
||||
|
||||
return marks;
|
||||
}, [rawEvents, selectedDate, theme.colors, theme.dark, todayString]); // Include theme.dark if colors change
|
||||
|
||||
// --- Render Event Item ---
|
||||
const renderEventItem = ({ item }: { item: CalendarEvent }) => {
|
||||
// --- Check for valid START date string ---
|
||||
if (typeof item.start !== 'string') {
|
||||
console.warn(`Event ${item.id} has invalid start date type for rendering:`, item.start);
|
||||
return null; // Don't render item with invalid start date
|
||||
}
|
||||
const startDate = parseISO(item.start);
|
||||
const endDate = parseISO(item.end);
|
||||
if (!isValid(startDate)) {
|
||||
console.warn(`Invalid start date found for rendering event ${item.id}:`, item.start);
|
||||
return null; // Don't render item with invalid start date
|
||||
}
|
||||
|
||||
// --- Handle potentially null END date ---
|
||||
const hasValidEndDate = typeof item.end === 'string' && isValid(parseISO(item.end));
|
||||
const endDate = hasValidEndDate ? parseISO(item.end) : startDate;
|
||||
|
||||
let description = item.description || '';
|
||||
if (isValid(startDate)) {
|
||||
// Show date range if it spans multiple days or specific time if single day
|
||||
if (!isSameDay(startDate, endDate)) {
|
||||
description = `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}${item.description ? `\n${item.description}` : ''}`;
|
||||
} else {
|
||||
description = `Time: ${format(startDate, 'p')}${item.description ? `\n${item.description}` : ''}`; // 'p' is locale-specific time format
|
||||
}
|
||||
const timePrefix = `Time: ${format(startDate, 'p')}`;
|
||||
const dateRangePrefix = `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`;
|
||||
|
||||
// Show date range only if end date is valid and different from start date
|
||||
if (hasValidEndDate && !isSameDay(startDate, endDate)) {
|
||||
description = `${dateRangePrefix}${item.description ? `\n${item.description}` : ''}`;
|
||||
} else {
|
||||
// Otherwise, show start time
|
||||
description = `${timePrefix}${item.description ? `\n${item.description}` : ''}`;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -229,7 +322,7 @@ const CalendarScreen = () => {
|
||||
style={styles.eventItem}
|
||||
titleStyle={{ color: theme.colors.text }}
|
||||
descriptionStyle={{ color: theme.colors.textSecondary }}
|
||||
descriptionNumberOfLines={3} // Allow more lines for range/details
|
||||
descriptionNumberOfLines={3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,192 @@
|
||||
// src/screens/DashboardScreen.tsx
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Text, useTheme } from 'react-native-paper';
|
||||
// src/screens/ChatScreen.tsx
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { View, StyleSheet, FlatList, KeyboardAvoidingView, Platform, TextInput as RNTextInput, NativeSyntheticEvent, TextInputKeyPressEventData } from 'react-native';
|
||||
import { Text, useTheme, TextInput, Button, IconButton, PaperProvider } from 'react-native-paper';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import apiClient from '../api/client'; // Import the apiClient
|
||||
|
||||
const DashboardScreen = () => {
|
||||
// Define the structure for a message
|
||||
interface Message {
|
||||
id: string;
|
||||
text: string;
|
||||
sender: 'user' | 'ai';
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
const ChatScreen = () => {
|
||||
const theme = useTheme();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false); // To show activity indicator while AI responds
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
|
||||
// Function to handle sending a message
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmedText = inputText.trim();
|
||||
if (!trimmedText) return; // Don't send empty messages
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString() + '-user',
|
||||
text: trimmedText,
|
||||
sender: 'user',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages(prevMessages => [...prevMessages, userMessage]);
|
||||
setInputText('');
|
||||
setIsLoading(true);
|
||||
|
||||
// Scroll to bottom after sending user message
|
||||
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100);
|
||||
|
||||
// --- Call Backend API ---
|
||||
try {
|
||||
console.log(`[ChatScreen] Sending to /nlp/process-command: ${trimmedText}`);
|
||||
const response = await apiClient.post<{ response: string }>('/nlp/process-command', { user_input: trimmedText });
|
||||
console.log("[ChatScreen] Received response:", response.data);
|
||||
|
||||
const aiResponse: Message = {
|
||||
id: Date.now().toString() + '-ai',
|
||||
// Assuming the backend returns the response text in a 'response' field
|
||||
text: response.data.response || "Sorry, I didn't get a valid response.",
|
||||
sender: 'ai',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages(prevMessages => [...prevMessages, aiResponse]);
|
||||
} catch (error: any) {
|
||||
console.error("Failed to get AI response:", error.response?.data || error.message || error);
|
||||
const errorResponse: Message = {
|
||||
id: Date.now().toString() + '-error',
|
||||
text: 'Sorry, I encountered an error trying to reach MAIA.',
|
||||
sender: 'ai',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages(prevMessages => [...prevMessages, errorResponse]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
// Scroll to bottom after receiving AI message
|
||||
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100);
|
||||
}
|
||||
// --- End API Call ---
|
||||
|
||||
}, [inputText]); // Keep inputText as dependency
|
||||
|
||||
const handleKeyPress = (e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||
if (e.nativeEvent.key === 'Enter' && !(e.nativeEvent as any).shiftKey) {
|
||||
e.preventDefault(); // Prevent new line
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
// Render individual message item
|
||||
const renderMessage = ({ item }: { item: Message }) => {
|
||||
const isUser = item.sender === 'user';
|
||||
return (
|
||||
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.aiBubble]}>
|
||||
<Text style={{ color: isUser ? theme.colors.onPrimary : theme.colors.onSurface }}>
|
||||
{item.text}
|
||||
</Text>
|
||||
{/* Optional: Add timestamp */}
|
||||
{/* <Text style={[styles.timestamp, { color: isUser ? theme.colors.onPrimary : theme.colors.onSurfaceVariant }]}>
|
||||
{item.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text> */}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16, backgroundColor: theme.colors.background },
|
||||
text: { fontSize: 20, color: theme.colors.text }
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
listContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
messageList: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 8,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: theme.colors.outlineVariant,
|
||||
backgroundColor: theme.colors.elevation.level2, // Slightly elevated background
|
||||
},
|
||||
textInput: {
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
backgroundColor: theme.colors.surface, // Use surface color for input background
|
||||
},
|
||||
messageBubble: {
|
||||
maxWidth: '80%',
|
||||
padding: 10,
|
||||
borderRadius: 15,
|
||||
marginBottom: 10,
|
||||
},
|
||||
userBubble: {
|
||||
alignSelf: 'flex-end',
|
||||
backgroundColor: theme.colors.primary,
|
||||
borderBottomRightRadius: 5,
|
||||
},
|
||||
aiBubble: {
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: theme.colors.surfaceVariant,
|
||||
borderBottomLeftRadius: 5,
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 10,
|
||||
marginTop: 4,
|
||||
alignSelf: 'flex-end',
|
||||
opacity: 0.7,
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>Chat</Text>
|
||||
</View>
|
||||
<SafeAreaView style={styles.container} edges={['bottom', 'left', 'right']}>
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0} // Adjust as needed
|
||||
>
|
||||
<View style={styles.listContainer}>
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={messages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.messageList}
|
||||
onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: false })} // Scroll on initial load/size change
|
||||
onLayout={() => flatListRef.current?.scrollToEnd({ animated: false })} // Scroll on layout change
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
style={styles.textInput}
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
placeholder="Type your message..."
|
||||
mode="outlined" // Or "flat"
|
||||
multiline
|
||||
onKeyPress={handleKeyPress}
|
||||
blurOnSubmit={false}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<IconButton
|
||||
icon="send"
|
||||
size={24}
|
||||
onPress={handleSend}
|
||||
disabled={!inputText.trim() || isLoading}
|
||||
mode="contained"
|
||||
iconColor={theme.colors.onPrimary}
|
||||
containerColor={theme.colors.primary}
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardScreen;
|
||||
export default ChatScreen;
|
||||
@@ -1,19 +1,124 @@
|
||||
// src/screens/DashboardScreen.tsx
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Text, useTheme } from 'react-native-paper';
|
||||
// src/screens/ProfileScreen.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet, ActivityIndicator, Alert } from 'react-native';
|
||||
import { Text, Button, useTheme, Card, Title, Paragraph } from 'react-native-paper';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
const DashboardScreen = () => {
|
||||
// Define an interface for the user data structure
|
||||
interface UserProfile {
|
||||
username: string;
|
||||
name: string;
|
||||
uuid: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const ProfileScreen = () => {
|
||||
const theme = useTheme();
|
||||
const { logout } = useAuth(); // Get the logout function
|
||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserProfile = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiClient.get<UserProfile>('/user/me');
|
||||
setUserProfile(response.data);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to fetch user profile:", err);
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to load profile.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserProfile();
|
||||
}, []); // Empty dependency array means this runs once on mount
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
// Navigation to login screen will likely happen automatically
|
||||
// due to the state change in AuthProvider re-rendering the navigator
|
||||
} catch (err) {
|
||||
console.error("Logout failed:", err);
|
||||
Alert.alert("Logout Error", "Could not log out. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16, backgroundColor: theme.colors.background },
|
||||
text: { fontSize: 20, color: theme.colors.text }
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
backgroundColor: theme.colors.background,
|
||||
justifyContent: 'space-between', // Pushes logout button to bottom
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1, // Takes up available space
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center', // Center content vertically
|
||||
},
|
||||
card: {
|
||||
width: '100%',
|
||||
maxWidth: 400, // Optional: constrain width on larger screens
|
||||
marginBottom: 20,
|
||||
backgroundColor: theme.colors.surface, // Use theme surface color
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
color: theme.colors.error,
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
logoutButton: {
|
||||
marginTop: 'auto', // Pushes button to the bottom within its container
|
||||
marginBottom: 20, // Add some space at the very bottom
|
||||
},
|
||||
// Removed text style as Paper components handle theme text color
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={[styles.container, { justifyContent: 'center', alignItems: 'center' }]}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>Profile</Text>
|
||||
<View style={styles.contentContainer}>
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
|
||||
{userProfile ? (
|
||||
<Card style={styles.card}>
|
||||
<Card.Content>
|
||||
<Title style={{ color: theme.colors.primary }}>User Profile</Title>
|
||||
<Paragraph>Username: {userProfile.username}</Paragraph>
|
||||
<Paragraph>Name: {userProfile.name}</Paragraph>
|
||||
<Paragraph>UUID: {userProfile.uuid}</Paragraph>
|
||||
<Paragraph>Role: {userProfile.role}</Paragraph>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
) : (
|
||||
!error && <Text>No profile data available.</Text> // Show if no error but no data
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
mode="contained" // Use contained style for primary actions
|
||||
onPress={handleLogout}
|
||||
style={styles.logoutButton}
|
||||
icon="logout" // Add an icon
|
||||
color={theme.colors.error} // Use error color for logout/destructive action
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardScreen;
|
||||
export default ProfileScreen;
|
||||
|
||||
8
interfaces/nativeapp/src/types/axios.d.ts
vendored
Normal file
8
interfaces/nativeapp/src/types/axios.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
// src/types/axios.d.ts
|
||||
import 'axios';
|
||||
|
||||
declare module 'axios' {
|
||||
export interface InternalAxiosRequestConfig {
|
||||
_retry?: boolean; // Add our custom property as optional
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user