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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 23 additions & 19 deletions app/api/ai/workspace/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 }
);
Expand Down
194 changes: 156 additions & 38 deletions components/ai/workspace-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Message[]>(() => {
if (!cachedExplanation) return [];

Expand All @@ -74,8 +84,16 @@ export default function WorkspaceChat({
const inputRef = useRef<HTMLTextAreaElement>(null);
const abortControllerRef = useRef<AbortController | null>(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 = () => {
Expand All @@ -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,
]);
Comment on lines +111 to +190

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C4 '<WorkspaceChat\b' --type=tsx --type=ts
rg -nP -C3 'key=' $(fd -tf 'workspace-chat' -e tsx) 2>/dev/null

Repository: imuniqueshiv/HyperLearningTech

Length of output: 2018


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the API route and the component usages relevant to topic switching/remounting.
rg -n --hidden --glob '!node_modules' --glob '!dist' --glob '!build' '/api/ai/workspace|workspace-chat|WorkspaceChat|topicId|branch|semester' components app pages src 2>/dev/null || true

# Inspect the workspace chat component around the effect and render paths.
file="components/ai/workspace-chat.tsx"
if [ -f "$file" ]; then
  nl -ba "$file" | sed -n '1,260p'
fi

# Find the workspace API route file(s).
fd -tf 'workspace' app api pages src 2>/dev/null || true

Repository: imuniqueshiv/HyperLearningTech

Length of output: 15667


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the relevant source slices directly.
for file in \
  "components/ai/workspace-chat.tsx" \
  "components/ai/workspace-chat-loader.tsx" \
  "app/api/ai/workspace/route.ts" \
  "app/rgpv/[branch]/[semester]/[subject]/ai/page.tsx"
do
  if [ -f "$file" ]; then
    echo "===== $file ====="
    sed -n '1,260p' "$file"
    echo
  fi
done

Repository: imuniqueshiv/HyperLearningTech

Length of output: 18876


Fix the workspace payload and reset the explanation request per topic.

  • components/ai/workspace-chat.tsx: explanationRequestedRef never resets, so a topicId change can reuse stale explanation state; if the in-flight request is canceled, explanationLoading can also stay stuck.
  • app/api/ai/workspace/route.ts / components/ai/workspace-chat.tsx: the non-topic path sends question without topic context, but the route treats every question as follow-up and now requires branch, semester, and topicId, so general questions return 400.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/ai/workspace-chat.tsx` around lines 111 - 190, Reset the
explanation request state when the active topic changes, and align the workspace
payload with the route’s expected contract. In `WorkspaceChat`, make sure
`explanationRequestedRef` is cleared on `topicId` changes so a new topic can
fetch its own explanation, and ensure `setExplanationLoading(false)` still runs
after canceled requests. Also update the non-topic request path in
`workspace-chat.tsx` to send the full context expected by the API, or adjust
`workspace/route.ts` so plain `question` requests are not treated as follow-ups
unless `branch`, `semester`, and `topicId` are present.


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;
}

Expand Down Expand Up @@ -132,7 +231,7 @@ export default function WorkspaceChat({
"Content-Type": "application/json",
},
body: JSON.stringify(
isFollowupMode
isTopicContextReady
? {
question: content.trim(),
subjectCode,
Expand All @@ -141,7 +240,6 @@ export default function WorkspaceChat({
topicId,
topic: topicTitle,
module: moduleTitle,
cachedExplanation,
messages: conversationHistory,
}
: {
Expand Down Expand Up @@ -241,7 +339,7 @@ export default function WorkspaceChat({
</div>
</div>

{isFollowupMode && (
{isTopicContextReady && (
<div className="border-b border-border px-4 py-3 sm:px-6">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium text-foreground">
Expand Down Expand Up @@ -269,7 +367,7 @@ export default function WorkspaceChat({
)}

<div className="flex-1 overflow-y-auto p-3 sm:p-4 md:p-6 space-y-4">
{!isFollowupMode && messages.length === 0 ? (
{!isTopicContextReady && messages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<div className="mb-4 rounded-full bg-blue-500/10 p-4">
<Sparkles className="h-8 w-8 text-blue-600 dark:text-blue-400" />
Expand Down Expand Up @@ -306,14 +404,22 @@ export default function WorkspaceChat({
</div>
) : (
<>
{isFollowupMode && explanationCached !== undefined && (
{isTopicContextReady && explanationCached !== undefined && (
<p className="text-center text-[10px] text-muted-foreground">
{explanationCached
? "Topic explanation loaded from cache"
: "Topic explanation freshly generated"}
{explanationLoading
? "Loading topic explanation..."
: explanationCached
? "Topic explanation loaded from cache"
: "Topic explanation freshly generated"}
</p>
)}

{explanationLoading && messages.length === 0 && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-blue-600 dark:text-blue-400" />
</div>
)}

<AnimatePresence initial={false}>
{messages.map((message, index) => (
<motion.div
Expand Down Expand Up @@ -348,26 +454,29 @@ export default function WorkspaceChat({
)}
</AnimatePresence>

{isFollowupMode && !limitReached && initialPrompts.length > 0 && (
<div className="rounded-2xl border border-border bg-muted/20 p-3 sm:p-4">
<p className="text-xs font-medium text-muted-foreground">
Suggested follow-ups:
</p>
<div className="mt-3 flex flex-wrap gap-2">
{initialPrompts.map((item, index) => (
<button
key={index}
type="button"
onClick={() => sendMessage(item.prompt)}
disabled={loading || limitReached}
className="rounded-full border border-border bg-background px-3 py-1.5 text-xs text-foreground transition hover:border-blue-500/30 hover:bg-blue-500/5 disabled:cursor-not-allowed disabled:opacity-50"
>
{item.prompt}
</button>
))}
{isFollowupMode &&
!limitReached &&
!explanationLoading &&
initialPrompts.length > 0 && (
<div className="rounded-2xl border border-border bg-muted/20 p-3 sm:p-4">
<p className="text-xs font-medium text-muted-foreground">
Suggested follow-ups:
</p>
<div className="mt-3 flex flex-wrap gap-2">
{initialPrompts.map((item, index) => (
<button
key={index}
type="button"
onClick={() => sendMessage(item.prompt)}
disabled={loading || limitReached}
className="rounded-full border border-border bg-background px-3 py-1.5 text-xs text-foreground transition hover:border-blue-500/30 hover:bg-blue-500/5 disabled:cursor-not-allowed disabled:opacity-50"
>
{item.prompt}
</button>
))}
</div>
</div>
</div>
)}
)}
</>
)}

Expand All @@ -382,18 +491,22 @@ export default function WorkspaceChat({
</div>

<div className="border-t border-border p-3 sm:p-4 md:p-6">
{limitReached && cachedExplanation ? (
{limitReached && activeExplanation ? (
<ContinueExternalAI
subjectCode={subjectCode}
topic={topicTitle!}
module={moduleTitle!}
cachedExplanation={cachedExplanation}
cachedExplanation={activeExplanation}
messages={conversationForExport}
/>
) : !isFollowupMode ? (
) : !isTopicContextReady ? (
<p className="text-center text-sm text-muted-foreground">
Select a topic from the syllabus to start follow-up chat.
</p>
) : explanationLoading ? (
<p className="text-center text-sm text-muted-foreground">
Loading topic explanation...
</p>
) : (
<form onSubmit={handleSubmit} className="flex gap-2">
<div className="relative flex-1">
Expand All @@ -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}
/>
<button
type="submit"
disabled={!input.trim() || loading || !cachedExplanation}
disabled={
!input.trim() ||
loading ||
explanationLoading ||
!activeExplanation
}
className="absolute bottom-2 right-2 rounded-lg bg-blue-600 p-2 text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Send message"
>
Expand Down
Loading
Loading