Skip to content

Commit dd42cd3

Browse files
committed
feat(reasoning): add full problem reasoning modal with streaming, correlated steps, regenerate, and KaTeX underscore fix
1 parent aa2d31c commit dd42cd3

4 files changed

Lines changed: 104 additions & 29 deletions

File tree

src/app/problem/[id]/page.tsx

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { SideQuestModal } from "@/components/SideQuestModal";
1111
import PlaygroundViewer from "@/components/PlaygroundViewer";
1212
import { ReasoningPanel } from "@/components/ReasoningPanel";
1313
import { TestResultsPanel } from "@/components/TestResultsPanel";
14-
import { HiBookOpen, HiLightBulb, HiExclamationCircle, HiClock, HiTrash, HiChevronLeft, HiPlay, HiRefresh, HiSave } from "react-icons/hi";
14+
import { HiBookOpen, HiLightBulb, HiExclamationCircle, HiClock, HiTrash, HiChevronLeft, HiPlay, HiRefresh, HiSave, HiSparkles } from "react-icons/hi";
1515
import { executeCode, isAuthenticated, TestResult, getHint, getSubmissions, SubmissionRecord, saveSubmission, deleteSubmission, getSolution } from "@/lib/api";
1616
import { getEditorSettings, getMonacoTheme, EditorSettings, defineMonacoThemes } from "@/lib/settings";
1717

@@ -29,7 +29,7 @@ export default function ProblemPage({ params }: { params: Promise<{ id: string }
2929
const [running, setRunning] = useState(false);
3030
const [sideQuestsOpen, setSideQuestsOpen] = useState(false);
3131
const [activeStep, setActiveStep] = useState(1);
32-
const [activeTab, setActiveTab] = useState<"problem" | "solution" | "playground" | "reasoning">("problem");
32+
const [activeTab, setActiveTab] = useState<"problem" | "solution" | "playground">("problem");
3333
const [showQuestModal, setShowQuestModal] = useState(false);
3434
const [learnOpen, setLearnOpen] = useState(false);
3535
const [hint, setHint] = useState<string | null>(null);
@@ -44,6 +44,7 @@ export default function ProblemPage({ params }: { params: Promise<{ id: string }
4444
const [solution, setSolution] = useState<string | null>(null);
4545
const [loadingSolution, setLoadingSolution] = useState(false);
4646
const [showPlaygroundModal, setShowPlaygroundModal] = useState(false);
47+
const [showReasoningModal, setShowReasoningModal] = useState(false);
4748

4849
// Load editor settings
4950
useEffect(() => {
@@ -358,13 +359,11 @@ export default function ProblemPage({ params }: { params: Promise<{ id: string }
358359
)}
359360
{quest && (
360361
<button
361-
onClick={() => setActiveTab("reasoning")}
362-
className={`px-4 py-3 text-sm font-bold flex items-center gap-1 transition-colors ${activeTab === "reasoning"
363-
? "text-purple-400 border-b-2 border-purple-400 -mb-[2px]"
364-
: "text-purple-400/60 hover:text-purple-400"
365-
}`}
362+
onClick={() => setShowReasoningModal(true)}
363+
className="px-4 py-3 text-sm font-bold flex items-center gap-1 transition-colors text-purple-400 hover:text-purple-300 hover:bg-purple-400/10"
366364
>
367-
[Reasoning] 🧠
365+
<HiSparkles className="w-4 h-4" />
366+
[Reasoning]
368367
</button>
369368
)}
370369
</div>
@@ -527,12 +526,6 @@ export default function ProblemPage({ params }: { params: Promise<{ id: string }
527526
)
528527
)}
529528

530-
{activeTab === "reasoning" && quest && (
531-
<ReasoningPanel
532-
problemId={problemId}
533-
totalSteps={quest.sub_quests?.length || 0}
534-
/>
535-
)}
536529
</div>
537530
</div>
538531

@@ -747,6 +740,36 @@ export default function ProblemPage({ params }: { params: Promise<{ id: string }
747740
</div>
748741
</div>
749742
)}
743+
744+
{/* Fullscreen Reasoning Modal */}
745+
{showReasoningModal && quest && (
746+
<div className="fixed inset-0 z-50 bg-black/90 flex flex-col">
747+
{/* Modal Header */}
748+
<div className="flex items-center justify-between px-6 py-4 border-b-2 border-gray-700 bg-[#0d0d14]">
749+
<div className="flex items-center gap-3">
750+
<HiSparkles className="text-purple-400 text-xl" />
751+
<div>
752+
<h2 className="text-lg font-bold text-purple-400">[Reasoning] {problem?.title}</h2>
753+
<p className="text-sm text-gray-500">// Step-by-step mathematical solution path</p>
754+
</div>
755+
</div>
756+
<button
757+
onClick={() => setShowReasoningModal(false)}
758+
className="px-4 py-2 text-sm font-bold text-gray-400 hover:text-white border-2 border-gray-700 hover:border-purple-400 transition-colors"
759+
>
760+
[ESC] Close
761+
</button>
762+
</div>
763+
764+
{/* Modal Content */}
765+
<div className="flex-1 overflow-auto p-6">
766+
<ReasoningPanel
767+
problemId={problemId}
768+
totalSteps={quest.sub_quests?.length || 0}
769+
/>
770+
</div>
771+
</div>
772+
)}
750773
</div>
751774
);
752775
}

src/components/MathRenderer.tsx

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,24 @@ export function MathRenderer({ content, className = "", inline = false }: MathRe
1919
// Process the content to render LaTeX
2020
let processedContent = content;
2121

22+
// Helper function to escape underscores in \text{} blocks for KaTeX
23+
const escapeTextUnderscores = (latex: string): string => {
24+
// Replace underscores in \text{...} with \_
25+
return latex.replace(/\\text\{([^}]*)\}/g, (match, text) => {
26+
// Replace underscores with escaped underscores
27+
const escapedText = text.replace(/_/g, '\\_');
28+
return `\\text{${escapedText}}`;
29+
}).replace(/\\texttt\{([^}]*)\}/g, (match, text) => {
30+
const escapedText = text.replace(/_/g, '\\_');
31+
return `\\texttt{${escapedText}}`;
32+
});
33+
};
34+
2235
// Render display math ($$...$$ or \[...\])
2336
processedContent = processedContent.replace(/\$\$([\s\S]+?)\$\$/g, (_, latex) => {
2437
try {
25-
return `<span class="block my-4 overflow-x-auto">${katex.renderToString(latex.trim(), {
38+
const escapedLatex = escapeTextUnderscores(latex.trim());
39+
return `<span class="block my-4 overflow-x-auto">${katex.renderToString(escapedLatex, {
2640
displayMode: true,
2741
throwOnError: false,
2842
trust: true,
@@ -35,7 +49,8 @@ export function MathRenderer({ content, className = "", inline = false }: MathRe
3549
// Render display math with \[...\]
3650
processedContent = processedContent.replace(/\\\[([\s\S]+?)\\\]/g, (_, latex) => {
3751
try {
38-
return `<span class="block my-4 overflow-x-auto">${katex.renderToString(latex.trim(), {
52+
const escapedLatex = escapeTextUnderscores(latex.trim());
53+
return `<span class="block my-4 overflow-x-auto">${katex.renderToString(escapedLatex, {
3954
displayMode: true,
4055
throwOnError: false,
4156
trust: true,
@@ -48,7 +63,8 @@ export function MathRenderer({ content, className = "", inline = false }: MathRe
4863
// Render inline math ($...$)
4964
processedContent = processedContent.replace(/\$([^$\n]+?)\$/g, (_, latex) => {
5065
try {
51-
return katex.renderToString(latex.trim(), {
66+
const escapedLatex = escapeTextUnderscores(latex.trim());
67+
return katex.renderToString(escapedLatex, {
5268
displayMode: false,
5369
throwOnError: false,
5470
trust: true,
@@ -61,7 +77,8 @@ export function MathRenderer({ content, className = "", inline = false }: MathRe
6177
// Render inline math with \(...\) - use lazy match up to \)
6278
processedContent = processedContent.replace(/\\\(([\s\S]+?)\\\)/g, (_, latex) => {
6379
try {
64-
return katex.renderToString(latex.trim(), {
80+
const escapedLatex = escapeTextUnderscores(latex.trim());
81+
return katex.renderToString(escapedLatex, {
6582
displayMode: false,
6683
throwOnError: false,
6784
trust: true,
@@ -71,13 +88,27 @@ export function MathRenderer({ content, className = "", inline = false }: MathRe
7188
}
7289
});
7390

74-
// Convert markdown-style headers
75-
processedContent = processedContent.replace(/^### (.+)$/gm, '<span class="block text-lg font-semibold mt-6 mb-2 text-white">$1</span>');
91+
// Convert code blocks (```python ... ``` or ``` ... ```)
92+
processedContent = processedContent.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
93+
const langClass = lang ? `language-${lang}` : '';
94+
return `<pre class="bg-[#0a0a0f] border border-gray-700 p-3 my-3 overflow-x-auto text-sm ${langClass}"><code class="text-cyan-300">${code.trim().replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code></pre>`;
95+
});
96+
97+
// Convert inline code (`...`)
98+
processedContent = processedContent.replace(/`([^`]+)`/g, '<code class="bg-gray-800 text-cyan-400 px-1 py-0.5 text-sm">$1</code>');
99+
100+
// Convert markdown-style headers (order matters - more # first)
101+
processedContent = processedContent.replace(/^#### (.+)$/gm, '<span class="block text-base font-semibold mt-4 mb-2 text-purple-400">$1</span>');
102+
processedContent = processedContent.replace(/^### (.+)$/gm, '<span class="block text-lg font-semibold mt-5 mb-2 text-cyan-400">$1</span>');
76103
processedContent = processedContent.replace(/^## (.+)$/gm, '<span class="block text-xl font-bold mt-6 mb-3 text-white">$1</span>');
77104

78105
// Convert **bold** to <strong>
79106
processedContent = processedContent.replace(/\*\*([^*]+)\*\*/g, '<strong class="text-white">$1</strong>');
80107

108+
// Convert bullet points
109+
processedContent = processedContent.replace(/^- (.+)$/gm, '<span class="block ml-4">• $1</span>');
110+
processedContent = processedContent.replace(/^\d+\. (.+)$/gm, '<span class="block ml-4 text-gray-300">$&</span>');
111+
81112
// Convert newlines to <br> for better readability
82113
processedContent = processedContent.replace(/\n\n/g, '<br/><br/>');
83114
processedContent = processedContent.replace(/\n/g, '<br/>');

src/components/ReasoningPanel.tsx

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useState, useEffect } from "react";
44
import { MathRenderer } from "@/components/MathRenderer";
5-
import { HiSparkles, HiCheck, HiRefresh } from "react-icons/hi";
5+
import { HiSparkles, HiCheck, HiRefresh, HiExclamationCircle } from "react-icons/hi";
66
import { streamFullReasoning, getCachedFullReasoning, FullReasoningStep, isAuthenticated } from "@/lib/api";
77

88
interface ReasoningPanelProps {
@@ -38,7 +38,7 @@ export function ReasoningPanel({ problemId, totalSteps }: ReasoningPanelProps) {
3838
checkCached();
3939
}, [problemId]);
4040

41-
const handleGenerate = async () => {
41+
const handleGenerate = async (force: boolean = false) => {
4242
if (!isAuthenticated()) {
4343
setError("Please login to generate reasoning");
4444
return;
@@ -49,9 +49,10 @@ export function ReasoningPanel({ problemId, totalSteps }: ReasoningPanelProps) {
4949
setSteps([]);
5050
setSummary("");
5151
setCurrentStep(0);
52+
setIsCached(false);
5253

5354
try {
54-
for await (const event of streamFullReasoning(problemId)) {
55+
for await (const event of streamFullReasoning(problemId, force)) {
5556
if (event.type === 'step') {
5657
setSteps(prev => [...prev, event.data]);
5758
setCurrentStep(event.data.step);
@@ -71,6 +72,10 @@ export function ReasoningPanel({ problemId, totalSteps }: ReasoningPanelProps) {
7172
}
7273
};
7374

75+
const handleRegenerate = () => {
76+
handleGenerate(true); // Force regenerate
77+
};
78+
7479
return (
7580
<div className="space-y-4">
7681
{/* Header */}
@@ -86,10 +91,24 @@ export function ReasoningPanel({ problemId, totalSteps }: ReasoningPanelProps) {
8691
)}
8792
</div>
8893

94+
{/* Description */}
95+
{!hasGenerated && !loading && (
96+
<div className="border-l-2 border-purple-400/50 pl-4 py-2 bg-purple-400/5">
97+
<p className="text-sm text-gray-400">
98+
Generate a step-by-step mathematical reasoning path that explains how each quest step
99+
contributes to solving the overall problem. The AI will analyze each step sequentially
100+
and provide a comprehensive explanation with formulas.
101+
</p>
102+
<p className="text-xs text-gray-500 mt-2">
103+
// Reasoning is generated once and cached for future visits
104+
</p>
105+
</div>
106+
)}
107+
89108
{/* Generate Button */}
90109
{!hasGenerated && !loading && (
91110
<button
92-
onClick={handleGenerate}
111+
onClick={() => handleGenerate(false)}
93112
className="w-full py-4 border-2 border-purple-400 bg-purple-400/10 hover:bg-purple-400 hover:text-black font-bold text-purple-400 transition-all flex items-center justify-center gap-2"
94113
>
95114
<HiSparkles className="w-5 h-5" />
@@ -100,7 +119,7 @@ export function ReasoningPanel({ problemId, totalSteps }: ReasoningPanelProps) {
100119
{/* Regenerate Button */}
101120
{hasGenerated && !loading && (
102121
<button
103-
onClick={handleGenerate}
122+
onClick={handleRegenerate}
104123
className="flex items-center gap-2 px-3 py-1 text-xs border border-gray-600 text-gray-400 hover:border-purple-400 hover:text-purple-400 transition-colors"
105124
>
106125
<HiRefresh className="w-3 h-3" />
@@ -110,8 +129,9 @@ export function ReasoningPanel({ problemId, totalSteps }: ReasoningPanelProps) {
110129

111130
{/* Error */}
112131
{error && (
113-
<div className="border-2 border-red-400/30 bg-red-400/5 p-3 text-red-400 text-sm">
114-
{error}
132+
<div className="border-2 border-red-400/30 bg-red-400/5 p-3 text-red-400 text-sm flex items-center gap-2">
133+
<HiExclamationCircle className="w-4 h-4 flex-shrink-0" />
134+
{error}
115135
</div>
116136
)}
117137

src/lib/api.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,13 +295,14 @@ export type ReasoningStreamEvent =
295295
| { type: 'done'; cached: boolean }
296296
| { type: 'error'; message: string };
297297

298-
export async function* streamFullReasoning(problemId: number): AsyncGenerator<ReasoningStreamEvent> {
298+
export async function* streamFullReasoning(problemId: number, force: boolean = false): AsyncGenerator<ReasoningStreamEvent> {
299299
const token = getAuthToken();
300300
const API_BASE = typeof window !== 'undefined'
301301
? `http://${window.location.hostname}:8000`
302302
: 'http://localhost:8000';
303303

304-
const response = await fetch(`${API_BASE}/api/quest/full-reasoning/${problemId}/stream`, {
304+
const url = `${API_BASE}/api/quest/full-reasoning/${problemId}/stream${force ? '?force=true' : ''}`;
305+
const response = await fetch(url, {
305306
headers: {
306307
'Authorization': `Bearer ${token}`,
307308
},

0 commit comments

Comments
 (0)