diff --git a/app/api/ai/workspace/route.ts b/app/api/ai/workspace/route.ts index 9c4f553..6c7c38c 100644 --- a/app/api/ai/workspace/route.ts +++ b/app/api/ai/workspace/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { generateTopicAnswer } from "@/lib/ai/topic-service"; import { generateFollowupAnswer } from "@/lib/ai/followup-service"; +import { resolveTopicExplanation } from "@/lib/ai/resolve-topic-explanation"; import { WorkspaceAction } from "@/types/ai"; import { trackMetric } from "@/lib/ai/metrics"; @@ -44,37 +45,35 @@ export async function POST(request: NextRequest) { // Follow-up question mode → followup-service (live, no cache) if (question) { - const cachedExplanation = body.cachedExplanation?.trim(); - const topic = body.topic?.trim(); - const moduleTitle = body.module?.trim(); - if (!subjectCode) { return NextResponse.json( { success: false, error: "Subject code is required." }, { status: 400 } ); } - if (!cachedExplanation) { + + if (!branch || !semester || !topicId) { return NextResponse.json( { success: false, - error: "Cached explanation is required for follow-up questions.", + error: + "Topic context (branch, semester, topicId) is required for follow-up questions.", }, { status: 400 } ); } - if (!topic) { - return NextResponse.json( - { success: false, error: "Topic is required." }, - { status: 400 } - ); - } - if (!moduleTitle) { - return NextResponse.json( - { success: false, error: "Module is required." }, - { status: 400 } - ); - } + + const resolved = await resolveTopicExplanation({ + branch, + semester, + topicId, + subjectCode, + }); + + const topic = body.topic?.trim() || resolved.topic; + const moduleTitle = body.module?.trim() || resolved.module; + const cachedExplanation = + body.cachedExplanation?.trim() || resolved.explanation; const result = await generateFollowupAnswer({ subjectCode, @@ -151,10 +150,15 @@ export async function POST(request: NextRequest) { message: error instanceof Error ? error.message : "Unknown error", }); + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json( { success: false, - error: "Unable to generate content. Please try again.", + error: + message.includes("API key") || message.includes("Gemini") + ? "AI service is not configured. Please try again later." + : "Unable to generate content. Please try again.", }, { status: 500 } ); diff --git a/components/ai/workspace-chat.tsx b/components/ai/workspace-chat.tsx index 6645790..43f9f44 100644 --- a/components/ai/workspace-chat.tsx +++ b/components/ai/workspace-chat.tsx @@ -43,17 +43,27 @@ export default function WorkspaceChat({ topicTitle, moduleTitle, cachedExplanation, - explanationCached, + explanationCached: initialExplanationCached, initialPrompts = [], welcomeMessage = "Ask me anything about your subject. I can help with explanations, examples, and exam preparation.", inputPlaceholder = "Type your question here...", apiEndpoint = API_ENDPOINTS.AI_WORKSPACE, followupLimit = FOLLOWUP_QUESTION_LIMIT, }: WorkspaceChatProps) { - const isFollowupMode = Boolean( - cachedExplanation && topicTitle && moduleTitle + const isTopicContextReady = Boolean( + topicId && branch && semester && topicTitle && moduleTitle ); + const [topicExplanation, setTopicExplanation] = useState( + cachedExplanation ?? "" + ); + const [explanationCached, setExplanationCached] = useState( + initialExplanationCached + ); + const [explanationLoading, setExplanationLoading] = useState(false); + + const isFollowupMode = isTopicContextReady && Boolean(topicExplanation); + const [messages, setMessages] = useState(() => { if (!cachedExplanation) return []; @@ -74,8 +84,16 @@ export default function WorkspaceChat({ const inputRef = useRef(null); const abortControllerRef = useRef(null); + const activeExplanation = + topicExplanation || + messages.find((m) => m.id === "initial-cached-explanation")?.content || + ""; + const followUpCount = messages.filter((m) => m.role === "user").length; - const limitReached = isFollowupMode && followUpCount >= followupLimit; + const limitReached = + isTopicContextReady && + followUpCount >= followupLimit && + !explanationLoading; const messageCount = messages.length; const scrollToBottom = () => { @@ -90,11 +108,92 @@ export default function WorkspaceChat({ inputRef.current?.focus(); }, []); + const explanationRequestedRef = useRef(false); + + useEffect(() => { + if ( + !isTopicContextReady || + topicExplanation || + cachedExplanation || + explanationRequestedRef.current + ) { + return; + } + + explanationRequestedRef.current = true; + let cancelled = false; + + const loadExplanation = async () => { + setExplanationLoading(true); + setError(null); + + try { + const response = await fetch(apiEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + branch, + semester, + topicId, + subjectCode, + action: "EXPLAIN", + }), + }); + + const data = (await response.json()) as WorkspaceResponse; + + if (cancelled) return; + + if (!response.ok || !data.answer) { + throw new Error(data.error || "Failed to load topic explanation."); + } + + setTopicExplanation(data.answer); + setExplanationCached(data.cached); + setMessages([ + { + id: "initial-cached-explanation", + role: "assistant", + content: data.answer, + timestamp: new Date(), + }, + ]); + } catch (err: unknown) { + if (!cancelled) { + setError( + err instanceof Error + ? err.message + : "Failed to load topic explanation." + ); + } + } finally { + if (!cancelled) { + setExplanationLoading(false); + } + } + }; + + void loadExplanation(); + + return () => { + cancelled = true; + }; + }, [ + apiEndpoint, + branch, + cachedExplanation, + isTopicContextReady, + semester, + subjectCode, + topicExplanation, + topicId, + ]); + const sendMessage = async (content: string) => { if (!content.trim() || limitReached) return; - if (isFollowupMode && !cachedExplanation) { - setError("Topic explanation is not available yet."); + if (isTopicContextReady && !activeExplanation) { + setError("Topic explanation is still loading. Please wait."); return; } @@ -132,7 +231,7 @@ export default function WorkspaceChat({ "Content-Type": "application/json", }, body: JSON.stringify( - isFollowupMode + isTopicContextReady ? { question: content.trim(), subjectCode, @@ -141,7 +240,6 @@ export default function WorkspaceChat({ topicId, topic: topicTitle, module: moduleTitle, - cachedExplanation, messages: conversationHistory, } : { @@ -241,7 +339,7 @@ export default function WorkspaceChat({ - {isFollowupMode && ( + {isTopicContextReady && (

@@ -269,7 +367,7 @@ export default function WorkspaceChat({ )}

- {!isFollowupMode && messages.length === 0 ? ( + {!isTopicContextReady && messages.length === 0 ? (
@@ -306,14 +404,22 @@ export default function WorkspaceChat({
) : ( <> - {isFollowupMode && explanationCached !== undefined && ( + {isTopicContextReady && explanationCached !== undefined && (

- {explanationCached - ? "Topic explanation loaded from cache" - : "Topic explanation freshly generated"} + {explanationLoading + ? "Loading topic explanation..." + : explanationCached + ? "Topic explanation loaded from cache" + : "Topic explanation freshly generated"}

)} + {explanationLoading && messages.length === 0 && ( +
+ +
+ )} + {messages.map((message, index) => ( - {isFollowupMode && !limitReached && initialPrompts.length > 0 && ( -
-

- Suggested follow-ups: -

-
- {initialPrompts.map((item, index) => ( - - ))} + {isFollowupMode && + !limitReached && + !explanationLoading && + initialPrompts.length > 0 && ( +
+

+ Suggested follow-ups: +

+
+ {initialPrompts.map((item, index) => ( + + ))} +
-
- )} + )} )} @@ -382,18 +491,22 @@ export default function WorkspaceChat({
- {limitReached && cachedExplanation ? ( + {limitReached && activeExplanation ? ( - ) : !isFollowupMode ? ( + ) : !isTopicContextReady ? (

Select a topic from the syllabus to start follow-up chat.

+ ) : explanationLoading ? ( +

+ Loading topic explanation... +

) : (
@@ -409,11 +522,16 @@ export default function WorkspaceChat({ placeholder={followupPlaceholder} rows={1} className="w-full resize-none rounded-xl border border-border bg-background px-3 py-3 pr-12 text-sm text-foreground placeholder:text-muted-foreground focus:border-blue-500/50 focus:outline-none focus:ring-2 focus:ring-blue-500/20 disabled:opacity-50 sm:px-4" - disabled={loading || !cachedExplanation} + disabled={loading || explanationLoading || !activeExplanation} />