From fd8830ea8488032f201dd3280dccb61ee73c54f9 Mon Sep 17 00:00:00 2001 From: blessing-sanusi Date: Tue, 5 Aug 2025 15:30:54 -0500 Subject: [PATCH 1/2] create plan page --- src/backend/app_kernel.py | 192 ++++++++++- src/backend/models/messages_kernel.py | 6 + src/frontend/src/App.tsx | 3 +- .../src/components/content/HomeInput.tsx | 52 ++- .../src/components/errors/RAIErrorCard.css | 197 +++++++++++ .../src/components/errors/RAIErrorCard.tsx | 105 ++++++ src/frontend/src/components/errors/index.ts | 2 + src/frontend/src/hooks/index.ts | 1 + src/frontend/src/hooks/useRAIErrorHandling.ts | 57 ++++ src/frontend/src/pages/PlanCreatePage.tsx | 311 ++++++++++++++++++ src/frontend/src/pages/PlanPage.tsx | 42 ++- src/frontend/src/pages/index.tsx | 3 +- .../src/services/StreamingService.tsx | 158 +++++++++ src/frontend/src/services/index.ts | 1 + src/frontend/src/styles/PlanCreatePage.css | 14 + 15 files changed, 1129 insertions(+), 15 deletions(-) create mode 100644 src/frontend/src/components/errors/RAIErrorCard.css create mode 100644 src/frontend/src/components/errors/RAIErrorCard.tsx create mode 100644 src/frontend/src/components/errors/index.ts create mode 100644 src/frontend/src/hooks/index.ts create mode 100644 src/frontend/src/hooks/useRAIErrorHandling.ts create mode 100644 src/frontend/src/pages/PlanCreatePage.tsx create mode 100644 src/frontend/src/services/StreamingService.tsx create mode 100644 src/frontend/src/styles/PlanCreatePage.css diff --git a/src/backend/app_kernel.py b/src/backend/app_kernel.py index f01119e22..2f67625e5 100644 --- a/src/backend/app_kernel.py +++ b/src/backend/app_kernel.py @@ -3,6 +3,7 @@ import json import logging import os +import re import uuid from typing import Dict, List, Optional @@ -27,6 +28,7 @@ from models.messages_kernel import ( AgentMessage, AgentType, + GeneratePlanRequest, HumanClarification, HumanFeedback, InputTask, @@ -186,14 +188,22 @@ async def input_task_endpoint(input_task: InputTask, request: Request): track_event_if_configured( "RAI failed", { - "status": "Plan not created", + "status": "Plan not created - RAI validation failed", "description": input_task.description, "session_id": input_task.session_id, }, ) return { - "status": "Plan not created", + "status": "RAI_VALIDATION_FAILED", + "message": "Content Safety Check Failed", + "detail": "Your request contains content that doesn't meet our safety guidelines. Please modify your request to ensure it's appropriate and try again.", + "suggestions": [ + "Remove any potentially harmful, inappropriate, or unsafe content", + "Use more professional and constructive language", + "Focus on legitimate business or educational objectives", + "Ensure your request complies with content policies" + ] } authenticated_user = get_authenticated_user_details(request_headers=request.headers) user_id = authenticated_user["user_principal_id"] @@ -345,7 +355,7 @@ async def create_plan_endpoint(input_task: InputTask, request: Request): description: Error message """ # Perform RAI check on the description - if not await rai_success(input_task.description): + if not await rai_success(input_task.description, False): track_event_if_configured( "RAI failed", { @@ -356,7 +366,18 @@ async def create_plan_endpoint(input_task: InputTask, request: Request): ) raise HTTPException( status_code=400, - detail="Task description failed safety validation. Please revise your request." + detail={ + "error_type": "RAI_VALIDATION_FAILED", + "message": "Content Safety Check Failed", + "description": "Your request contains content that doesn't meet our safety guidelines. Please modify your request to ensure it's appropriate and try again.", + "suggestions": [ + "Remove any potentially harmful, inappropriate, or unsafe content", + "Use more professional and constructive language", + "Focus on legitimate business or educational objectives", + "Ensure your request complies with content policies" + ], + "user_action": "Please revise your request and try again" + } ) # Get authenticated user @@ -420,6 +441,151 @@ async def create_plan_endpoint(input_task: InputTask, request: Request): raise HTTPException(status_code=400, detail=f"Error creating plan: {e}") +@app.post("/api/generate_plan") +async def generate_plan_endpoint(generate_plan_request: GeneratePlanRequest, request: Request): + """ + Generate plan steps for an existing plan using the planner agent. + + --- + tags: + - Plans + parameters: + - name: user_principal_id + in: header + type: string + required: true + description: User ID extracted from the authentication header + - name: body + in: body + required: true + schema: + type: object + properties: + plan_id: + type: string + description: The ID of the existing plan to generate steps for + responses: + 200: + description: Plan generation completed successfully + schema: + type: object + properties: + status: + type: string + description: Success message + plan_id: + type: string + description: The ID of the plan that was generated + steps_created: + type: integer + description: Number of steps created + 400: + description: Invalid request or processing error + schema: + type: object + properties: + detail: + type: string + description: Error message + 404: + description: Plan not found + schema: + type: object + properties: + detail: + type: string + description: Error message + """ + # Get authenticated user + authenticated_user = get_authenticated_user_details(request_headers=request.headers) + user_id = authenticated_user["user_principal_id"] + + if not user_id: + track_event_if_configured( + "UserIdNotFound", {"status_code": 400, "detail": "no user"} + ) + raise HTTPException(status_code=400, detail="no user") + + try: + # Initialize memory store + kernel, memory_store = await initialize_runtime_and_context("", user_id) + + # Get the existing plan + plan = await memory_store.get_plan_by_plan_id(plan_id=generate_plan_request.plan_id) + if not plan: + track_event_if_configured( + "GeneratePlanNotFound", + {"status_code": 404, "detail": "Plan not found", "plan_id": generate_plan_request.plan_id} + ) + raise HTTPException(status_code=404, detail="Plan not found") + + # Create the agents for this session + client = None + try: + client = config.get_ai_project_client() + except Exception as client_exc: + logging.error(f"Error creating AIProjectClient: {client_exc}") + + agents = await AgentFactory.create_all_agents( + session_id=plan.session_id, + user_id=user_id, + memory_store=memory_store, + client=client, + ) + + # Get the group chat manager to process the plan + group_chat_manager = agents[AgentType.GROUP_CHAT_MANAGER.value] + + # Create an InputTask from the plan's initial goal + input_task = InputTask( + session_id=plan.session_id, + description=plan.initial_goal + ) + + # Use the group chat manager to generate the plan steps + await group_chat_manager.handle_input_task(input_task) + + # Get the updated plan with steps + updated_plan = await memory_store.get_plan_by_plan_id(plan_id=generate_plan_request.plan_id) + steps = await memory_store.get_steps_by_plan(plan_id=generate_plan_request.plan_id) + + # Log successful plan generation + track_event_if_configured( + "PlanGenerated", + { + "status": f"Plan generation completed for plan ID: {generate_plan_request.plan_id}", + "plan_id": generate_plan_request.plan_id, + "session_id": plan.session_id, + "steps_created": len(steps), + }, + ) + + if client: + try: + client.close() + except Exception as e: + logging.error(f"Error closing AIProjectClient: {e}") + + return { + "status": "Plan generation completed successfully", + "plan_id": generate_plan_request.plan_id, + "steps_created": len(steps), + } + + except HTTPException: + # Re-raise HTTP exceptions + raise + except Exception as e: + track_event_if_configured( + "GeneratePlanError", + { + "plan_id": generate_plan_request.plan_id, + "error": str(e), + }, + ) + raise HTTPException(status_code=400, detail=f"Error generating plan: {e}") + + @app.post("/api/human_feedback") async def human_feedback_endpoint(human_feedback: HumanFeedback, request: Request): """ @@ -588,12 +754,26 @@ async def human_clarification_endpoint( track_event_if_configured( "RAI failed", { - "status": "Clarification is not received", + "status": "Clarification rejected - RAI validation failed", "description": human_clarification.human_clarification, "session_id": human_clarification.session_id, }, ) - raise HTTPException(status_code=400, detail="Invalida Clarification") + raise HTTPException( + status_code=400, + detail={ + "error_type": "RAI_VALIDATION_FAILED", + "message": "Clarification Safety Check Failed", + "description": "Your clarification contains content that doesn't meet our safety guidelines. Please provide a more appropriate clarification.", + "suggestions": [ + "Use clear and professional language", + "Avoid potentially harmful or inappropriate content", + "Focus on providing constructive feedback or clarification", + "Ensure your message complies with content policies" + ], + "user_action": "Please revise your clarification and try again" + } + ) authenticated_user = get_authenticated_user_details(request_headers=request.headers) user_id = authenticated_user["user_principal_id"] diff --git a/src/backend/models/messages_kernel.py b/src/backend/models/messages_kernel.py index bc8f43667..640c45f28 100644 --- a/src/backend/models/messages_kernel.py +++ b/src/backend/models/messages_kernel.py @@ -310,6 +310,12 @@ class UserLanguage(KernelBaseModel): language: str +class GeneratePlanRequest(KernelBaseModel): + """Message representing a request to generate a plan from an existing plan ID.""" + + plan_id: str + + class ApprovalRequest(KernelBaseModel): """Message sent to HumanAgent to request approval for a step.""" diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 40bce4581..b1cff79d4 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,13 +1,14 @@ import React from 'react'; import './App.css'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; -import { HomePage, PlanPage } from './pages'; +import { HomePage, PlanPage, PlanCreatePage } from './pages'; function App() { return ( } /> + } /> } /> } /> diff --git a/src/frontend/src/components/content/HomeInput.tsx b/src/frontend/src/components/content/HomeInput.tsx index 4234a19e6..c9ba0ece8 100644 --- a/src/frontend/src/components/content/HomeInput.tsx +++ b/src/frontend/src/components/content/HomeInput.tsx @@ -14,6 +14,7 @@ import "./../../styles/HomeInput.css"; import { HomeInputProps, quickTasks, QuickTask } from "../../models/homeInput"; import { TaskService } from "../../services/TaskService"; import { NewTaskService } from "../../services/NewTaskService"; +import { RAIErrorCard, RAIErrorData } from "../errors"; import ChatInput from "@/coral/modules/ChatInput"; import InlineToaster, { useInlineToaster } from "../toast/InlineToaster"; @@ -26,6 +27,7 @@ const HomeInput: React.FC = ({ }) => { const [submitting, setSubmitting] = useState(false); const [input, setInput] = useState(""); + const [raiError, setRAIError] = useState(null); const textareaRef = useRef(null); const navigate = useNavigate(); @@ -40,6 +42,7 @@ const HomeInput: React.FC = ({ const resetTextarea = () => { setInput(""); + setRAIError(null); // Clear any RAI errors if (textareaRef.current) { textareaRef.current.style.height = "auto"; textareaRef.current.focus(); @@ -54,6 +57,7 @@ const HomeInput: React.FC = ({ const handleSubmit = async () => { if (input.trim()) { setSubmitting(true); + setRAIError(null); // Clear any previous RAI errors let id = showToast("Creating a plan", "progress"); try { @@ -67,17 +71,40 @@ const HomeInput: React.FC = ({ if (response.plan_id && response.plan_id !== null) { showToast("Plan created!", "success"); dismissToast(id); - navigate(`/plan/${response.plan_id}`); + navigate(`/plan/${response.plan_id}/create`); } else { showToast("Failed to create plan", "error"); dismissToast(id); } } catch (error: any) { dismissToast(id); - // Show more specific error message if available - const errorMessage = error instanceof Error ? error.message : "Something went wrong"; - showToast(errorMessage, "error"); - showToast(JSON.parse(error?.message)?.detail, "error"); + + // Check if this is an RAI validation error + let errorDetail = null; + try { + // Try to parse the error detail if it's a string + if (typeof error?.response?.data?.detail === 'string') { + errorDetail = JSON.parse(error.response.data.detail); + } else { + errorDetail = error?.response?.data?.detail; + } + } catch (parseError) { + // If parsing fails, use the original error + errorDetail = error?.response?.data?.detail; + } + + // Handle RAI validation errors with better UX + if (errorDetail?.error_type === 'RAI_VALIDATION_FAILED') { + setRAIError(errorDetail); + } else { + // Handle other errors with toast messages + const errorMessage = errorDetail?.description || + errorDetail?.message || + error?.response?.data?.message || + error?.message || + "Something went wrong"; + showToast(errorMessage, "error"); + } } finally { setInput(""); @@ -88,6 +115,7 @@ const HomeInput: React.FC = ({ const handleQuickTaskClick = (task: QuickTask) => { setInput(task.description); + setRAIError(null); // Clear any RAI errors when selecting a quick task if (textareaRef.current) { textareaRef.current.focus(); } @@ -109,6 +137,20 @@ const HomeInput: React.FC = ({ How can I help? + {/* Show RAI error if present */} + {raiError && ( + { + setRAIError(null); + if (textareaRef.current) { + textareaRef.current.focus(); + } + }} + onDismiss={() => setRAIError(null)} + /> + )} + void; + onDismiss?: () => void; + className?: string; +} + +const RAIErrorCard: React.FC = ({ + error, + onRetry, + onDismiss, + className = '' +}) => { + return ( + +
+
+ +
+
+ + {error.message} + + {onDismiss && ( +
+
+ +
+
+ + + {error.description} + +
+ + {error.suggestions && error.suggestions.length > 0 && ( +
+
+ + + Here's how to fix this: + +
+
    + {error.suggestions.map((suggestion, index) => ( +
  • + + {suggestion} + +
  • + ))} +
+
+ )} + +
+ + {error.user_action} + + {onRetry && ( + + )} +
+
+
+ ); +}; + +export default RAIErrorCard; diff --git a/src/frontend/src/components/errors/index.ts b/src/frontend/src/components/errors/index.ts new file mode 100644 index 000000000..65964f03e --- /dev/null +++ b/src/frontend/src/components/errors/index.ts @@ -0,0 +1,2 @@ +export { default as RAIErrorCard } from './RAIErrorCard'; +export type { RAIErrorData } from './RAIErrorCard'; diff --git a/src/frontend/src/hooks/index.ts b/src/frontend/src/hooks/index.ts new file mode 100644 index 000000000..497005355 --- /dev/null +++ b/src/frontend/src/hooks/index.ts @@ -0,0 +1 @@ +export { default as useRAIErrorHandling } from './useRAIErrorHandling'; diff --git a/src/frontend/src/hooks/useRAIErrorHandling.ts b/src/frontend/src/hooks/useRAIErrorHandling.ts new file mode 100644 index 000000000..e7ff43a31 --- /dev/null +++ b/src/frontend/src/hooks/useRAIErrorHandling.ts @@ -0,0 +1,57 @@ +import { useState, useCallback } from 'react'; +import { RAIErrorData } from '../components/errors'; + +export interface UseRAIErrorHandling { + raiError: RAIErrorData | null; + setRAIError: (error: RAIErrorData | null) => void; + handleError: (error: any) => boolean; // Returns true if it was an RAI error + clearRAIError: () => void; +} + +/** + * Custom hook for handling RAI (Responsible AI) validation errors + * Provides standardized error parsing and state management + */ +export const useRAIErrorHandling = (): UseRAIErrorHandling => { + const [raiError, setRAIError] = useState(null); + + const clearRAIError = useCallback(() => { + setRAIError(null); + }, []); + + const handleError = useCallback((error: any): boolean => { + // Clear any previous RAI errors + setRAIError(null); + + // Check if this is an RAI validation error + let errorDetail = null; + try { + // Try to parse the error detail if it's a string + if (typeof error?.response?.data?.detail === 'string') { + errorDetail = JSON.parse(error.response.data.detail); + } else { + errorDetail = error?.response?.data?.detail; + } + } catch (parseError) { + // If parsing fails, use the original error + errorDetail = error?.response?.data?.detail; + } + + // Handle RAI validation errors + if (errorDetail?.error_type === 'RAI_VALIDATION_FAILED') { + setRAIError(errorDetail); + return true; // Indicates this was an RAI error + } + + return false; // Indicates this was not an RAI error + }, []); + + return { + raiError, + setRAIError, + handleError, + clearRAIError + }; +}; + +export default useRAIErrorHandling; diff --git a/src/frontend/src/pages/PlanCreatePage.tsx b/src/frontend/src/pages/PlanCreatePage.tsx new file mode 100644 index 000000000..d95e09656 --- /dev/null +++ b/src/frontend/src/pages/PlanCreatePage.tsx @@ -0,0 +1,311 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { + Text, + ToggleButton, +} from "@fluentui/react-components"; +import "../styles/PlanPage.css"; +import CoralShellColumn from "../coral/components/Layout/CoralShellColumn"; +import CoralShellRow from "../coral/components/Layout/CoralShellRow"; +import Content from "../coral/components/Content/Content"; +import { NewTaskService } from "../services/NewTaskService"; +import { PlanDataService } from "../services/PlanDataService"; +import { Step, ProcessedPlanData } from "@/models"; +import PlanPanelLeft from "@/components/content/PlanPanelLeft"; +import ContentToolbar from "@/coral/components/Content/ContentToolbar"; +import PlanChat from "@/components/content/PlanChat"; +import PlanPanelRight from "@/components/content/PlanPanelRight"; +import InlineToaster, { + useInlineToaster, +} from "../components/toast/InlineToaster"; +import Octo from "../coral/imports/Octopus.png"; // 🐙 Animated PNG loader +import PanelRightToggles from "@/coral/components/Header/PanelRightToggles"; +import { TaskListSquareLtr } from "@/coral/imports/bundleicons"; +import LoadingMessage, { loadingMessages } from "@/coral/components/LoadingMessage"; +import { RAIErrorCard, RAIErrorData } from "../components/errors"; +import { apiClient } from "../api/apiClient"; + +/** + * Page component for creating and viewing a plan being generated + * Accessible via the route /plan/{plan_id}/create + */ +const PlanCreatePage: React.FC = () => { + const { planId } = useParams<{ planId: string }>(); + const navigate = useNavigate(); + const { showToast, dismissToast } = useInlineToaster(); + + const [input, setInput] = useState(""); + const [planData, setPlanData] = useState(null); + const [allPlans, setAllPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [submittingChatDisableInput, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [processingSubtaskId, setProcessingSubtaskId] = useState( + null + ); + const [reloadLeftList, setReloadLeftList] = useState(true); + const [raiError, setRAIError] = useState(null); + const [planGenerated, setPlanGenerated] = useState(false); + + const [loadingMessage, setLoadingMessage] = useState(loadingMessages[0]); + + // 🌀 Cycle loading messages while loading + useEffect(() => { + if (!loading) return; + let index = 0; + const interval = setInterval(() => { + index = (index + 1) % loadingMessages.length; + setLoadingMessage(loadingMessages[index]); + }, 2000); + return () => clearInterval(interval); + }, [loading]); + + useEffect(() => { + const currentPlan = allPlans.find( + (plan) => plan.plan.id === planId + ); + setPlanData(currentPlan || null); + }, [allPlans, planId]); + + const generatePlan = useCallback(async () => { + if (!planId) return; + + try { + setLoading(true); + setError(null); + + let toastId = showToast("Generating plan steps...", "progress"); + + // Call the generate_plan endpoint using apiClient for proper authentication + const result = await apiClient.post('/generate_plan', { + plan_id: planId + }); + + dismissToast(toastId); + showToast("Plan generated successfully!", "success"); + setPlanGenerated(true); + + // Now load the plan data to display it + await loadPlanData(false); + + } catch (err) { + console.error("Failed to generate plan:", err); + setError( + err instanceof Error ? err : new Error("Failed to generate plan") + ); + setLoading(false); + } + }, [planId, showToast, dismissToast]); + + const loadPlanData = useCallback( + async (navigate: boolean = true) => { + if (!planId) return; + + try { + setInput(""); // Clear input on new load + if (navigate) { + setPlanData(null); + setLoading(true); + setError(null); + setProcessingSubtaskId(null); + } + + setError(null); + const data = await PlanDataService.fetchPlanData(planId, navigate); + let plans = [...allPlans]; + const existingIndex = plans.findIndex(p => p.plan.id === data.plan.id); + if (existingIndex !== -1) { + plans[existingIndex] = data; + } else { + plans.push(data); + } + setAllPlans(plans); + + // If plan has steps and we haven't generated yet, mark as generated + if (data.plan.steps && data.plan.steps.length > 0 && !planGenerated) { + setPlanGenerated(true); + } + + } catch (err) { + console.log("Failed to load plan data:", err); + setError( + err instanceof Error ? err : new Error("Failed to load plan data") + ); + } finally { + setLoading(false); + } + }, + [planId, allPlans, planGenerated] + ); + + const handleOnchatSubmit = useCallback( + async (chatInput: string) => { + if (!chatInput.trim()) { + showToast("Please enter a clarification", "error"); + return; + } + setInput(""); + setRAIError(null); // Clear any previous RAI errors + if (!planData?.plan) return; + setSubmitting(true); + let id = showToast("Submitting clarification", "progress"); + try { + await PlanDataService.submitClarification( + planData.plan.id, + planData.plan.session_id, + chatInput + ); + setInput(""); + dismissToast(id); + showToast("Clarification submitted successfully", "success"); + await loadPlanData(false); + } catch (error: any) { + dismissToast(id); + + // Check if this is an RAI validation error + let errorDetail = null; + try { + // Try to parse the error detail if it's a string + if (typeof error?.response?.data?.detail === 'string') { + errorDetail = JSON.parse(error.response.data.detail); + } else { + errorDetail = error?.response?.data?.detail; + } + } catch (parseError) { + // If parsing fails, use the original error + errorDetail = error?.response?.data?.detail; + } + + // Handle RAI validation errors with better UX + if (errorDetail?.error_type === 'RAI_VALIDATION_FAILED') { + setRAIError(errorDetail); + } else { + // Handle other errors with toast messages + showToast("Failed to submit clarification", "error"); + } + } finally { + setInput(""); + setSubmitting(false); + } + }, + [planData, loadPlanData] + ); + + const handleApproveStep = useCallback( + async (step: Step, total: number, completed: number, approve: boolean) => { + setProcessingSubtaskId(step.id); + const toastMessage = approve ? "Approving step" : "Rejecting step"; + let id = showToast(toastMessage, "progress"); + setSubmitting(true); + try { + let approveRejectDetails = await PlanDataService.stepStatus(step, approve); + dismissToast(id); + showToast(`Step ${approve ? "approved" : "rejected"} successfully`, "success"); + if (approveRejectDetails && Object.keys(approveRejectDetails).length > 0) { + await loadPlanData(false); + } + setReloadLeftList(true); + } catch (error) { + dismissToast(id); + showToast(`Failed to ${approve ? "approve" : "reject"} step`, "error"); + } finally { + setProcessingSubtaskId(null); + setSubmitting(false); + } + }, + [loadPlanData] + ); + + useEffect(() => { + const initializePage = async () => { + // Load the basic plan data first + await loadPlanData(true); + }; + + initializePage(); + }, []); + + // Separate effect for plan generation when plan data is loaded + useEffect(() => { + if (planData && (!planData.plan.steps || planData.plan.steps.length === 0) && !planGenerated && !loading) { + generatePlan(); + } + }, [planData, planGenerated, loading]); + + const handleNewTaskButton = () => { + NewTaskService.handleNewTaskFromPlan(navigate); + }; + + if (!planId) { + return ( +
+ Error: No plan ID provided +
+ ); + } + + return ( + + + setReloadLeftList(false)}/> + + + {/* 🐙 Only replaces content body, not page shell */} + {loading ? ( + <> + + + ) : ( + <> + + + } + /> + + + + {/* Show RAI error if present */} + {raiError && ( +
+ { + setRAIError(null); + }} + onDismiss={() => setRAIError(null)} + /> +
+ )} + + + + )} +
+ + +
+
+ ); +}; + +export default PlanCreatePage; diff --git a/src/frontend/src/pages/PlanPage.tsx b/src/frontend/src/pages/PlanPage.tsx index e469ff4bb..2f13c341e 100644 --- a/src/frontend/src/pages/PlanPage.tsx +++ b/src/frontend/src/pages/PlanPage.tsx @@ -22,6 +22,7 @@ import Octo from "../coral/imports/Octopus.png"; // 🐙 Animated PNG loader import PanelRightToggles from "@/coral/components/Header/PanelRightToggles"; import { TaskListSquareLtr } from "@/coral/imports/bundleicons"; import LoadingMessage, { loadingMessages } from "@/coral/components/LoadingMessage"; +import { RAIErrorCard, RAIErrorData } from "../components/errors"; /** * Page component for displaying a specific plan @@ -42,6 +43,7 @@ const PlanPage: React.FC = () => { null ); const [reloadLeftList, setReloadLeftList] = useState(true); + const [raiError, setRAIError] = useState(null); const [loadingMessage, setLoadingMessage] = useState(loadingMessages[0]); @@ -108,6 +110,7 @@ const PlanPage: React.FC = () => { return; } setInput(""); + setRAIError(null); // Clear any previous RAI errors if (!planData?.plan) return; setSubmitting(true); let id = showToast("Submitting clarification", "progress"); @@ -121,9 +124,30 @@ const PlanPage: React.FC = () => { dismissToast(id); showToast("Clarification submitted successfully", "success"); await loadPlanData(false); - } catch (error) { + } catch (error: any) { dismissToast(id); - showToast("Failed to submit clarification", "error"); + + // Check if this is an RAI validation error + let errorDetail = null; + try { + // Try to parse the error detail if it's a string + if (typeof error?.response?.data?.detail === 'string') { + errorDetail = JSON.parse(error.response.data.detail); + } else { + errorDetail = error?.response?.data?.detail; + } + } catch (parseError) { + // If parsing fails, use the original error + errorDetail = error?.response?.data?.detail; + } + + // Handle RAI validation errors with better UX + if (errorDetail?.error_type === 'RAI_VALIDATION_FAILED') { + setRAIError(errorDetail); + } else { + // Handle other errors with toast messages + showToast("Failed to submit clarification", "error"); + } } finally { setInput(""); setSubmitting(false); @@ -201,6 +225,20 @@ const PlanPage: React.FC = () => { /> + + {/* Show RAI error if present */} + {raiError && ( +
+ { + setRAIError(null); + }} + onDismiss={() => setRAIError(null)} + /> +
+ )} + void; + +export class StreamingService { + private static instance: StreamingService; + private handlers: Map = new Map(); + private connections: Map = new Map(); + + static getInstance(): StreamingService { + if (!StreamingService.instance) { + StreamingService.instance = new StreamingService(); + } + return StreamingService.instance; + } + + /** + * Subscribe to streaming updates for a specific plan + * @param planId Plan ID to subscribe to + * @param handler Message handler function + * @returns Cleanup function to unsubscribe + */ + subscribe(planId: string, handler: StreamMessageHandler): () => void { + if (!this.handlers.has(planId)) { + this.handlers.set(planId, []); + } + + this.handlers.get(planId)!.push(handler); + + // TODO: Establish real WebSocket/SSE connection here + // For now, we return a cleanup function + return () => { + const handlers = this.handlers.get(planId); + if (handlers) { + const index = handlers.indexOf(handler); + if (index > -1) { + handlers.splice(index, 1); + } + if (handlers.length === 0) { + this.handlers.delete(planId); + this.disconnect(planId); + } + } + }; + } + + /** + * Connect to real-time stream for plan generation (future implementation) + * @param planId Plan ID to connect to + */ + private async connect(planId: string): Promise { + // TODO: Implement WebSocket or Server-Sent Events connection + // Example WebSocket implementation: + /* + const ws = new WebSocket(`${WEBSOCKET_URL}/plan/${planId}/stream`); + + ws.onmessage = (event) => { + const message: StreamMessage = JSON.parse(event.data); + this.broadcastMessage(planId, message); + }; + + ws.onerror = (error) => { + this.broadcastMessage(planId, { + type: 'error', + content: 'Connection error occurred', + timestamp: new Date() + }); + }; + + this.connections.set(planId, ws); + */ + } + + /** + * Disconnect from real-time stream + * @param planId Plan ID to disconnect from + */ + private disconnect(planId: string): void { + const connection = this.connections.get(planId); + if (connection) { + if (connection instanceof WebSocket) { + connection.close(); + } else if (connection instanceof EventSource) { + connection.close(); + } + this.connections.delete(planId); + } + } + + /** + * Broadcast message to all subscribers of a plan + * @param planId Plan ID + * @param message Stream message + */ + private broadcastMessage(planId: string, message: StreamMessage): void { + const handlers = this.handlers.get(planId); + if (handlers) { + handlers.forEach(handler => handler(message)); + } + } + + /** + * Simulate streaming for development/testing purposes + * @param planId Plan ID + * @param messages Array of messages to simulate + */ + async simulateStreaming(planId: string, messages: Omit[]): Promise { + for (let i = 0; i < messages.length; i++) { + const message: StreamMessage = { + ...messages[i], + timestamp: new Date(), + planId + }; + + // Simulate network delay + await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 2000)); + + this.broadcastMessage(planId, message); + } + } + + /** + * Send a manual message (useful for testing) + * @param planId Plan ID + * @param message Message to send + */ + sendMessage(planId: string, message: Omit): void { + this.broadcastMessage(planId, { + ...message, + timestamp: new Date(), + planId + }); + } + + /** + * Clean up all connections and handlers + */ + cleanup(): void { + this.connections.forEach((connection, planId) => { + this.disconnect(planId); + }); + this.handlers.clear(); + } +} + +// Export singleton instance +export const streamingService = StreamingService.getInstance(); diff --git a/src/frontend/src/services/index.ts b/src/frontend/src/services/index.ts index d498dd380..424d4fe51 100644 --- a/src/frontend/src/services/index.ts +++ b/src/frontend/src/services/index.ts @@ -1 +1,2 @@ export { default as TaskService } from './TaskService'; +export { StreamingService, streamingService } from './StreamingService'; diff --git a/src/frontend/src/styles/PlanCreatePage.css b/src/frontend/src/styles/PlanCreatePage.css new file mode 100644 index 000000000..4cde4a734 --- /dev/null +++ b/src/frontend/src/styles/PlanCreatePage.css @@ -0,0 +1,14 @@ +/* PlanCreatePage.css - Extends PlanPage.css styles */ + +/* Any additional styles specific to plan creation page can go here */ +/* Currently inheriting all styles from PlanPage.css */ + +/* Responsive design for mobile */ +@media (max-width: 768px) { + /* Mobile-specific overrides if needed */ +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + /* Dark mode overrides if needed */ +} From 9fe5a5e0c7d394cc4532e06bff0696d81813915d Mon Sep 17 00:00:00 2001 From: blessing-sanusi Date: Tue, 5 Aug 2025 15:44:44 -0500 Subject: [PATCH 2/2] create plan page --- .../src/services/StreamingService.tsx | 158 ------------------ src/frontend/src/services/index.ts | 2 +- 2 files changed, 1 insertion(+), 159 deletions(-) delete mode 100644 src/frontend/src/services/StreamingService.tsx diff --git a/src/frontend/src/services/StreamingService.tsx b/src/frontend/src/services/StreamingService.tsx deleted file mode 100644 index 9a7d39bc1..000000000 --- a/src/frontend/src/services/StreamingService.tsx +++ /dev/null @@ -1,158 +0,0 @@ -/** - * StreamingService - Handles real-time streaming of plan generation process - * This service can be enhanced to connect to actual WebSocket or Server-Sent Events - */ - -export interface StreamMessage { - type: 'progress' | 'thought' | 'action' | 'complete' | 'error'; - content: string; - timestamp: Date; - planId?: string; - stepId?: string; -} - -export type StreamMessageHandler = (message: StreamMessage) => void; - -export class StreamingService { - private static instance: StreamingService; - private handlers: Map = new Map(); - private connections: Map = new Map(); - - static getInstance(): StreamingService { - if (!StreamingService.instance) { - StreamingService.instance = new StreamingService(); - } - return StreamingService.instance; - } - - /** - * Subscribe to streaming updates for a specific plan - * @param planId Plan ID to subscribe to - * @param handler Message handler function - * @returns Cleanup function to unsubscribe - */ - subscribe(planId: string, handler: StreamMessageHandler): () => void { - if (!this.handlers.has(planId)) { - this.handlers.set(planId, []); - } - - this.handlers.get(planId)!.push(handler); - - // TODO: Establish real WebSocket/SSE connection here - // For now, we return a cleanup function - return () => { - const handlers = this.handlers.get(planId); - if (handlers) { - const index = handlers.indexOf(handler); - if (index > -1) { - handlers.splice(index, 1); - } - if (handlers.length === 0) { - this.handlers.delete(planId); - this.disconnect(planId); - } - } - }; - } - - /** - * Connect to real-time stream for plan generation (future implementation) - * @param planId Plan ID to connect to - */ - private async connect(planId: string): Promise { - // TODO: Implement WebSocket or Server-Sent Events connection - // Example WebSocket implementation: - /* - const ws = new WebSocket(`${WEBSOCKET_URL}/plan/${planId}/stream`); - - ws.onmessage = (event) => { - const message: StreamMessage = JSON.parse(event.data); - this.broadcastMessage(planId, message); - }; - - ws.onerror = (error) => { - this.broadcastMessage(planId, { - type: 'error', - content: 'Connection error occurred', - timestamp: new Date() - }); - }; - - this.connections.set(planId, ws); - */ - } - - /** - * Disconnect from real-time stream - * @param planId Plan ID to disconnect from - */ - private disconnect(planId: string): void { - const connection = this.connections.get(planId); - if (connection) { - if (connection instanceof WebSocket) { - connection.close(); - } else if (connection instanceof EventSource) { - connection.close(); - } - this.connections.delete(planId); - } - } - - /** - * Broadcast message to all subscribers of a plan - * @param planId Plan ID - * @param message Stream message - */ - private broadcastMessage(planId: string, message: StreamMessage): void { - const handlers = this.handlers.get(planId); - if (handlers) { - handlers.forEach(handler => handler(message)); - } - } - - /** - * Simulate streaming for development/testing purposes - * @param planId Plan ID - * @param messages Array of messages to simulate - */ - async simulateStreaming(planId: string, messages: Omit[]): Promise { - for (let i = 0; i < messages.length; i++) { - const message: StreamMessage = { - ...messages[i], - timestamp: new Date(), - planId - }; - - // Simulate network delay - await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 2000)); - - this.broadcastMessage(planId, message); - } - } - - /** - * Send a manual message (useful for testing) - * @param planId Plan ID - * @param message Message to send - */ - sendMessage(planId: string, message: Omit): void { - this.broadcastMessage(planId, { - ...message, - timestamp: new Date(), - planId - }); - } - - /** - * Clean up all connections and handlers - */ - cleanup(): void { - this.connections.forEach((connection, planId) => { - this.disconnect(planId); - }); - this.handlers.clear(); - } -} - -// Export singleton instance -export const streamingService = StreamingService.getInstance(); diff --git a/src/frontend/src/services/index.ts b/src/frontend/src/services/index.ts index 424d4fe51..9619462ad 100644 --- a/src/frontend/src/services/index.ts +++ b/src/frontend/src/services/index.ts @@ -1,2 +1,2 @@ export { default as TaskService } from './TaskService'; -export { StreamingService, streamingService } from './StreamingService'; +