Spaces:
Sleeping
Sleeping
Commit
·
f444dc0
1
Parent(s):
8066d07
Implement chunk loading tips, and autoscrolling to conversation regarding a specific chunk
Browse files- backend/app.py +60 -24
- frontend/src/App.css +15 -0
- frontend/src/components/ChunkLoadingTips.jsx +45 -0
- frontend/src/components/ChunkPanel.jsx +87 -46
- frontend/src/components/DocumentProcessor.jsx +38 -25
- frontend/src/components/ProgressBar.jsx +48 -0
- frontend/src/components/SimpleChat.jsx +44 -26
- frontend/src/hooks/useChunkNavigation.js +93 -53
- frontend/src/utils/markdownComponents.jsx +19 -1
backend/app.py
CHANGED
|
@@ -34,14 +34,21 @@ class ChatMessage(BaseModel):
|
|
| 34 |
|
| 35 |
class ChatRequest(BaseModel):
|
| 36 |
messages: List[ChatMessage]
|
| 37 |
-
chunk: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
| 38 |
document: Optional[str] = None
|
| 39 |
|
| 40 |
@app.post("/api/chat")
|
| 41 |
async def chat_endpoint(request: ChatRequest):
|
| 42 |
-
print(f"💬 Received chat with {len(request.messages)} messages")
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
document = request.document or """
|
| 46 |
# Auswertung Versuch F44: Zeeman Effekt
|
| 47 |
Dominic Holst, Moritz Pfau
|
|
@@ -163,25 +170,54 @@ async def chat_endpoint(request: ChatRequest):
|
|
| 163 |
|
| 164 |
*Figure 9: Cadmium rote Linie*
|
| 165 |
"""
|
| 166 |
-
# Create system prompt for research paper tutor
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
-
|
| 171 |
|
| 172 |
-
|
| 173 |
|
| 174 |
-
|
| 175 |
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
|
| 184 |
-
|
| 185 |
|
| 186 |
1. **Greeting and Contextualization:**
|
| 187 |
* Begin with a friendly greeting.
|
|
@@ -226,8 +262,12 @@ async def chat_endpoint(request: ChatRequest):
|
|
| 226 |
for msg in request.messages
|
| 227 |
if msg.role in ["user", "assistant"]
|
| 228 |
]
|
|
|
|
| 229 |
if not any(msg["role"] == "user" for msg in anthropic_messages):
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
print("🤖 Calling Claude for chat response...")
|
| 233 |
response = client.messages.create(
|
|
@@ -235,13 +275,9 @@ async def chat_endpoint(request: ChatRequest):
|
|
| 235 |
max_tokens=10000,
|
| 236 |
system=system_prompt, # system prompt here
|
| 237 |
messages=anthropic_messages,
|
| 238 |
-
thinking={
|
| 239 |
-
"type": "enabled",
|
| 240 |
-
"budget_tokens": 5000
|
| 241 |
-
},
|
| 242 |
)
|
| 243 |
|
| 244 |
-
response_text = response.content[
|
| 245 |
print(f"✅ Received response from Claude: {response_text[:100]}...")
|
| 246 |
return {"role": "assistant", "content": response_text}
|
| 247 |
|
|
|
|
| 34 |
|
| 35 |
class ChatRequest(BaseModel):
|
| 36 |
messages: List[ChatMessage]
|
| 37 |
+
chunk: Optional[str] = None # Legacy support
|
| 38 |
+
currentChunk: Optional[str] = None
|
| 39 |
+
nextChunk: Optional[str] = None
|
| 40 |
+
action: Optional[str] = None # 'skip', 'understood', or None
|
| 41 |
document: Optional[str] = None
|
| 42 |
|
| 43 |
@app.post("/api/chat")
|
| 44 |
async def chat_endpoint(request: ChatRequest):
|
| 45 |
+
print(f"💬 Received chat with {len(request.messages)} messages, action: {request.action}")
|
| 46 |
+
|
| 47 |
+
# Use new format if available, otherwise fall back to legacy
|
| 48 |
+
current_chunk = request.currentChunk or request.chunk or "No specific chunk provided"
|
| 49 |
+
next_chunk = request.nextChunk or ""
|
| 50 |
+
action = request.action
|
| 51 |
+
|
| 52 |
document = request.document or """
|
| 53 |
# Auswertung Versuch F44: Zeeman Effekt
|
| 54 |
Dominic Holst, Moritz Pfau
|
|
|
|
| 170 |
|
| 171 |
*Figure 9: Cadmium rote Linie*
|
| 172 |
"""
|
| 173 |
+
# Create system prompt for research paper tutor with transition support
|
| 174 |
+
is_transition = action in ['skip', 'understood']
|
| 175 |
+
|
| 176 |
+
if is_transition:
|
| 177 |
+
system_prompt = f"""
|
| 178 |
+
You are PaperMentor, an expert academic tutor guiding the user through a continuous learning journey of an academic paper.
|
| 179 |
+
|
| 180 |
+
The user has just {action} the previous section and is transitioning to a new topic. This is part of a continuous conversation where you maintain context and adapt based on the user's actions.
|
| 181 |
+
|
| 182 |
+
User's Action: {action}
|
| 183 |
+
|
| 184 |
+
Previous Section:
|
| 185 |
+
{current_chunk}
|
| 186 |
+
|
| 187 |
+
New Section to Introduce:
|
| 188 |
+
{next_chunk}
|
| 189 |
+
|
| 190 |
+
Full Document for Context:
|
| 191 |
+
{document}
|
| 192 |
+
|
| 193 |
+
Your response should:
|
| 194 |
+
1. **Acknowledge the transition**: Briefly reference their choice to {action} the previous section
|
| 195 |
+
2. **Provide smooth continuity**: Connect the previous section to this new one naturally
|
| 196 |
+
3. **Introduce the new section**: Present the new topic with enthusiasm and context
|
| 197 |
+
4. **Adapt your approach**: If they skipped, perhaps adjust to be more engaging. If they understood, acknowledge their progress
|
| 198 |
+
5. **Begin new exploration**: Start the 3-question sequence for this new section
|
| 199 |
+
|
| 200 |
+
Maintain the same conversational style and focus on phenomenological understanding.
|
| 201 |
+
"""
|
| 202 |
+
else:
|
| 203 |
+
system_prompt = f"""
|
| 204 |
+
You are PaperMentor, an expert academic tutor. Your purpose is to guide a user to a deep, phenomenological understanding of an academic paper.
|
| 205 |
|
| 206 |
+
The user's primary goal is to: "phänomenologisch verstehen, was passiert, was beobachtet wurde und warum das so ist, mit wenig Fokus auf Formeln, sondern Fokus auf intuitivem Verständnis und dem experimentellen Ansatz." (phenomenologically understand what is happening, what was observed, and why, with little focus on formulas but a strong focus on intuitive understanding and the experimental approach).
|
| 207 |
|
| 208 |
+
Your entire interaction must be guided by this goal.
|
| 209 |
|
| 210 |
+
You will be given a specific chunk of the paper to discuss, as well as the full document for context.
|
| 211 |
|
| 212 |
+
---
|
| 213 |
+
Current Chunk:
|
| 214 |
+
{current_chunk}
|
| 215 |
+
---
|
| 216 |
+
Full Document for Context:
|
| 217 |
+
{document}
|
| 218 |
+
---
|
| 219 |
|
| 220 |
+
Your interaction must follow this specific conversational flow:
|
| 221 |
|
| 222 |
1. **Greeting and Contextualization:**
|
| 223 |
* Begin with a friendly greeting.
|
|
|
|
| 262 |
for msg in request.messages
|
| 263 |
if msg.role in ["user", "assistant"]
|
| 264 |
]
|
| 265 |
+
# For transitions, add a dummy user message to trigger Claude response
|
| 266 |
if not any(msg["role"] == "user" for msg in anthropic_messages):
|
| 267 |
+
if is_transition:
|
| 268 |
+
anthropic_messages.append({"role": "user", "content": "Please continue to the next section."})
|
| 269 |
+
else:
|
| 270 |
+
return {"role": "assistant", "content": "I didn't receive your message. Could you please ask again?"}
|
| 271 |
|
| 272 |
print("🤖 Calling Claude for chat response...")
|
| 273 |
response = client.messages.create(
|
|
|
|
| 275 |
max_tokens=10000,
|
| 276 |
system=system_prompt, # system prompt here
|
| 277 |
messages=anthropic_messages,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
)
|
| 279 |
|
| 280 |
+
response_text = response.content[0].text
|
| 281 |
print(f"✅ Received response from Claude: {response_text[:100]}...")
|
| 282 |
return {"role": "assistant", "content": response_text}
|
| 283 |
|
frontend/src/App.css
CHANGED
|
@@ -40,3 +40,18 @@
|
|
| 40 |
.read-the-docs {
|
| 41 |
color: #888;
|
| 42 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
.read-the-docs {
|
| 41 |
color: #888;
|
| 42 |
}
|
| 43 |
+
|
| 44 |
+
@keyframes fade-in {
|
| 45 |
+
from {
|
| 46 |
+
opacity: 0;
|
| 47 |
+
transform: translateY(5px);
|
| 48 |
+
}
|
| 49 |
+
to {
|
| 50 |
+
opacity: 1;
|
| 51 |
+
transform: translateY(0);
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.animate-fade-in {
|
| 56 |
+
animation: fade-in 0.3s ease-out;
|
| 57 |
+
}
|
frontend/src/components/ChunkLoadingTips.jsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
const tips = [
|
| 4 |
+
"You can always skip a chunk if it's not relevant to your learning goals.",
|
| 5 |
+
"Be sure to mark a chunk as understood when you've grasped the concept.",
|
| 6 |
+
"Ask the mentor to confirm your understanding before moving forward.",
|
| 7 |
+
"Don't hesitate to ask follow-up questions about complex topics.",
|
| 8 |
+
"Use the previous chunk button to review earlier concepts.",
|
| 9 |
+
"The mentor adapts explanations based on your questions and responses.",
|
| 10 |
+
"Take your time - there's no rush to complete chunks quickly.",
|
| 11 |
+
"Ask for examples when abstract concepts seem unclear.",
|
| 12 |
+
"Request connections between current and previous chunks when helpful.",
|
| 13 |
+
"The mentor can explain mathematical formulas step by step."
|
| 14 |
+
];
|
| 15 |
+
|
| 16 |
+
const ChunkLoadingTips = ({ message = "Preparing your document..." }) => {
|
| 17 |
+
const [currentTipIndex, setCurrentTipIndex] = useState(0);
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
const interval = setInterval(() => {
|
| 21 |
+
setCurrentTipIndex((prev) => (prev + 1) % tips.length);
|
| 22 |
+
}, 4000); // Change tip every 4 seconds
|
| 23 |
+
|
| 24 |
+
return () => clearInterval(interval);
|
| 25 |
+
}, []);
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
| 29 |
+
{/* Loading spinner */}
|
| 30 |
+
<div className="relative mb-8">
|
| 31 |
+
<div className="w-12 h-12 border-4 border-blue-200 rounded-full animate-spin border-t-blue-600"></div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
{/* Main message */}
|
| 35 |
+
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
| 36 |
+
{message}
|
| 37 |
+
</h3>
|
| 38 |
+
<span key={currentTipIndex} className="inline-block animate-fade-in text-gray-500">
|
| 39 |
+
{tips[currentTipIndex]}
|
| 40 |
+
</span>
|
| 41 |
+
</div>
|
| 42 |
+
);
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
export default ChunkLoadingTips;
|
frontend/src/components/ChunkPanel.jsx
CHANGED
|
@@ -2,32 +2,40 @@ import ReactMarkdown from 'react-markdown';
|
|
| 2 |
import remarkMath from 'remark-math';
|
| 3 |
import rehypeKatex from 'rehype-katex';
|
| 4 |
import rehypeRaw from 'rehype-raw';
|
| 5 |
-
import { getChatMarkdownComponents } from '../utils/markdownComponents.jsx';
|
| 6 |
import SimpleChat from './SimpleChat.jsx';
|
|
|
|
| 7 |
import React, { useState, useEffect } from 'react';
|
| 8 |
|
| 9 |
const ChunkPanel = ({
|
| 10 |
documentData,
|
| 11 |
currentChunkIndex,
|
| 12 |
showChat,
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
setWaitingForFirstResponse,
|
| 16 |
markChunkUnderstood,
|
| 17 |
-
skipChunk
|
|
|
|
| 18 |
}) => {
|
| 19 |
|
| 20 |
const chatMarkdownComponents = getChatMarkdownComponents();
|
|
|
|
| 21 |
const [isLoading, setIsLoading] = useState(false);
|
| 22 |
|
| 23 |
-
// Generate greeting
|
|
|
|
| 24 |
useEffect(() => {
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
if (documentData && showChat && hasNoMessages) {
|
| 28 |
generateGreeting();
|
| 29 |
}
|
| 30 |
-
}, [currentChunkIndex]);
|
| 31 |
|
| 32 |
const generateGreeting = async () => {
|
| 33 |
setIsLoading(true);
|
|
@@ -40,27 +48,29 @@ const ChunkPanel = ({
|
|
| 40 |
headers: { 'Content-Type': 'application/json' },
|
| 41 |
body: JSON.stringify({
|
| 42 |
messages: [],
|
| 43 |
-
|
| 44 |
document: documentData ? JSON.stringify(documentData) : ''
|
| 45 |
})
|
| 46 |
});
|
| 47 |
|
| 48 |
const data = await response.json();
|
| 49 |
|
| 50 |
-
|
| 51 |
{
|
| 52 |
role: 'assistant',
|
| 53 |
-
content: data.content || 'Hi!
|
| 54 |
-
}
|
| 55 |
-
|
|
|
|
| 56 |
} catch (error) {
|
| 57 |
console.error('Error generating greeting:', error);
|
| 58 |
-
|
| 59 |
{
|
| 60 |
role: 'assistant',
|
| 61 |
-
content: 'Hi!
|
| 62 |
-
}
|
| 63 |
-
|
|
|
|
| 64 |
} finally {
|
| 65 |
setIsLoading(false);
|
| 66 |
if (setWaitingForFirstResponse) {
|
|
@@ -70,9 +80,8 @@ const ChunkPanel = ({
|
|
| 70 |
};
|
| 71 |
|
| 72 |
const handleSend = async (text) => {
|
| 73 |
-
const userMessage = { role: 'user', content: text };
|
| 74 |
-
|
| 75 |
-
updateChunkChatHistory(newMessages);
|
| 76 |
setIsLoading(true);
|
| 77 |
|
| 78 |
try {
|
|
@@ -80,23 +89,23 @@ const ChunkPanel = ({
|
|
| 80 |
method: 'POST',
|
| 81 |
headers: { 'Content-Type': 'application/json' },
|
| 82 |
body: JSON.stringify({
|
| 83 |
-
messages:
|
| 84 |
-
|
| 85 |
document: documentData ? JSON.stringify(documentData) : ''
|
| 86 |
})
|
| 87 |
});
|
| 88 |
|
| 89 |
const data = await response.json();
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
} catch (error) {
|
| 95 |
console.error('Error:', error);
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
} finally {
|
| 101 |
setIsLoading(false);
|
| 102 |
}
|
|
@@ -106,24 +115,44 @@ const ChunkPanel = ({
|
|
| 106 |
<>
|
| 107 |
{/* Chunk Header */}
|
| 108 |
<div className="px-6 py-4 flex-shrink-0 bg-white rounded-t-lg border-b border-gray-200 z-10 flex items-center justify-between">
|
| 109 |
-
<div className="
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
}}
|
| 117 |
>
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
</div>
|
| 121 |
|
| 122 |
<div className="flex items-center gap-2">
|
| 123 |
{/* Skip Button */}
|
| 124 |
<button
|
| 125 |
onClick={skipChunk}
|
| 126 |
-
className="p-2 rounded-full bg-
|
| 127 |
title="Skip this chunk"
|
| 128 |
>
|
| 129 |
<svg
|
|
@@ -166,14 +195,26 @@ const ChunkPanel = ({
|
|
| 166 |
|
| 167 |
</div>
|
| 168 |
|
| 169 |
-
{/* Chat Interface - Only shown when showChat is true */}
|
| 170 |
-
{showChat && (
|
| 171 |
<SimpleChat
|
| 172 |
-
messages={
|
|
|
|
|
|
|
| 173 |
onSend={handleSend}
|
| 174 |
-
isLoading={isLoading}
|
| 175 |
/>
|
| 176 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
</>
|
| 178 |
);
|
| 179 |
};
|
|
|
|
| 2 |
import remarkMath from 'remark-math';
|
| 3 |
import rehypeKatex from 'rehype-katex';
|
| 4 |
import rehypeRaw from 'rehype-raw';
|
| 5 |
+
import { getChatMarkdownComponents, getTitleMarkdownComponents } from '../utils/markdownComponents.jsx';
|
| 6 |
import SimpleChat from './SimpleChat.jsx';
|
| 7 |
+
import ChunkLoadingTips from './ChunkLoadingTips.jsx';
|
| 8 |
import React, { useState, useEffect } from 'react';
|
| 9 |
|
| 10 |
const ChunkPanel = ({
|
| 11 |
documentData,
|
| 12 |
currentChunkIndex,
|
| 13 |
showChat,
|
| 14 |
+
isTransitioning,
|
| 15 |
+
updateGlobalChatHistory,
|
| 16 |
+
getGlobalChatHistory,
|
| 17 |
+
addMessageToChunk,
|
| 18 |
+
getCurrentChunkMessages,
|
| 19 |
+
hasChunkMessages,
|
| 20 |
+
isChunkCompleted,
|
| 21 |
+
canEditChunk,
|
| 22 |
setWaitingForFirstResponse,
|
| 23 |
markChunkUnderstood,
|
| 24 |
+
skipChunk,
|
| 25 |
+
goToPrevChunk
|
| 26 |
}) => {
|
| 27 |
|
| 28 |
const chatMarkdownComponents = getChatMarkdownComponents();
|
| 29 |
+
const titleMarkdownComponents = getTitleMarkdownComponents();
|
| 30 |
const [isLoading, setIsLoading] = useState(false);
|
| 31 |
|
| 32 |
+
// Generate greeting for chunks that don't have messages yet
|
| 33 |
+
// Only for initial chunk (0) and when not transitioning
|
| 34 |
useEffect(() => {
|
| 35 |
+
if (documentData && showChat && !hasChunkMessages(currentChunkIndex) && currentChunkIndex === 0 && !isTransitioning) {
|
|
|
|
|
|
|
| 36 |
generateGreeting();
|
| 37 |
}
|
| 38 |
+
}, [currentChunkIndex, documentData, showChat, isTransitioning]);
|
| 39 |
|
| 40 |
const generateGreeting = async () => {
|
| 41 |
setIsLoading(true);
|
|
|
|
| 48 |
headers: { 'Content-Type': 'application/json' },
|
| 49 |
body: JSON.stringify({
|
| 50 |
messages: [],
|
| 51 |
+
currentChunk: documentData?.chunks?.[currentChunkIndex]?.text || '',
|
| 52 |
document: documentData ? JSON.stringify(documentData) : ''
|
| 53 |
})
|
| 54 |
});
|
| 55 |
|
| 56 |
const data = await response.json();
|
| 57 |
|
| 58 |
+
addMessageToChunk(
|
| 59 |
{
|
| 60 |
role: 'assistant',
|
| 61 |
+
content: data.content || 'Hi! Welcome to your learning session. Let\'s explore this document together!'
|
| 62 |
+
},
|
| 63 |
+
currentChunkIndex
|
| 64 |
+
);
|
| 65 |
} catch (error) {
|
| 66 |
console.error('Error generating greeting:', error);
|
| 67 |
+
addMessageToChunk(
|
| 68 |
{
|
| 69 |
role: 'assistant',
|
| 70 |
+
content: 'Hi! Welcome to your learning session. Let\'s explore this document together!'
|
| 71 |
+
},
|
| 72 |
+
currentChunkIndex
|
| 73 |
+
);
|
| 74 |
} finally {
|
| 75 |
setIsLoading(false);
|
| 76 |
if (setWaitingForFirstResponse) {
|
|
|
|
| 80 |
};
|
| 81 |
|
| 82 |
const handleSend = async (text) => {
|
| 83 |
+
const userMessage = { role: 'user', content: text, chunkIndex: currentChunkIndex };
|
| 84 |
+
addMessageToChunk(userMessage, currentChunkIndex);
|
|
|
|
| 85 |
setIsLoading(true);
|
| 86 |
|
| 87 |
try {
|
|
|
|
| 89 |
method: 'POST',
|
| 90 |
headers: { 'Content-Type': 'application/json' },
|
| 91 |
body: JSON.stringify({
|
| 92 |
+
messages: getGlobalChatHistory(),
|
| 93 |
+
currentChunk: documentData?.chunks?.[currentChunkIndex]?.text || '',
|
| 94 |
document: documentData ? JSON.stringify(documentData) : ''
|
| 95 |
})
|
| 96 |
});
|
| 97 |
|
| 98 |
const data = await response.json();
|
| 99 |
+
addMessageToChunk(
|
| 100 |
+
{ role: 'assistant', content: data.content || 'Sorry, no response received.' },
|
| 101 |
+
currentChunkIndex
|
| 102 |
+
);
|
| 103 |
} catch (error) {
|
| 104 |
console.error('Error:', error);
|
| 105 |
+
addMessageToChunk(
|
| 106 |
+
{ role: 'assistant', content: 'Sorry, something went wrong. Please try again.' },
|
| 107 |
+
currentChunkIndex
|
| 108 |
+
);
|
| 109 |
} finally {
|
| 110 |
setIsLoading(false);
|
| 111 |
}
|
|
|
|
| 115 |
<>
|
| 116 |
{/* Chunk Header */}
|
| 117 |
<div className="px-6 py-4 flex-shrink-0 bg-white rounded-t-lg border-b border-gray-200 z-10 flex items-center justify-between">
|
| 118 |
+
<div className="flex items-center flex-1">
|
| 119 |
+
{/* Previous Chunk Button */}
|
| 120 |
+
<button
|
| 121 |
+
onClick={goToPrevChunk}
|
| 122 |
+
disabled={currentChunkIndex === 0}
|
| 123 |
+
className="mr-3 p-2 rounded-full bg-gray-100 hover:bg-gray-200 text-gray-600 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 124 |
+
title="Go to previous chunk"
|
|
|
|
| 125 |
>
|
| 126 |
+
<svg
|
| 127 |
+
className="w-5 h-5"
|
| 128 |
+
fill="currentColor"
|
| 129 |
+
viewBox="0 0 20 20"
|
| 130 |
+
>
|
| 131 |
+
<path
|
| 132 |
+
fillRule="evenodd"
|
| 133 |
+
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
| 134 |
+
clipRule="evenodd"
|
| 135 |
+
/>
|
| 136 |
+
</svg>
|
| 137 |
+
</button>
|
| 138 |
+
|
| 139 |
+
{/* Chunk Title */}
|
| 140 |
+
<div className="font-semibold text-xl text-gray-900 text-left">
|
| 141 |
+
<ReactMarkdown
|
| 142 |
+
remarkPlugins={[remarkMath]}
|
| 143 |
+
rehypePlugins={[rehypeRaw, rehypeKatex]}
|
| 144 |
+
components={titleMarkdownComponents}
|
| 145 |
+
>
|
| 146 |
+
{documentData?.chunks?.[currentChunkIndex]?.topic || "Loading..."}
|
| 147 |
+
</ReactMarkdown>
|
| 148 |
+
</div>
|
| 149 |
</div>
|
| 150 |
|
| 151 |
<div className="flex items-center gap-2">
|
| 152 |
{/* Skip Button */}
|
| 153 |
<button
|
| 154 |
onClick={skipChunk}
|
| 155 |
+
className="p-2 rounded-full bg-gray-100 hover:bg-gray-200 text-gray-600 transition-colors duration-200"
|
| 156 |
title="Skip this chunk"
|
| 157 |
>
|
| 158 |
<svg
|
|
|
|
| 195 |
|
| 196 |
</div>
|
| 197 |
|
| 198 |
+
{/* Chat Interface - Only shown when showChat is true and not transitioning */}
|
| 199 |
+
{showChat && !isLoading && !isTransitioning && (
|
| 200 |
<SimpleChat
|
| 201 |
+
messages={getGlobalChatHistory()}
|
| 202 |
+
currentChunkIndex={currentChunkIndex}
|
| 203 |
+
canEdit={canEditChunk(currentChunkIndex)}
|
| 204 |
onSend={handleSend}
|
| 205 |
+
isLoading={isLoading || isTransitioning}
|
| 206 |
/>
|
| 207 |
)}
|
| 208 |
+
|
| 209 |
+
{/* Loading Tips - Shown when generating greeting */}
|
| 210 |
+
{showChat && isLoading && !hasChunkMessages(currentChunkIndex) && (
|
| 211 |
+
<ChunkLoadingTips message="Preparing your lesson..." />
|
| 212 |
+
)}
|
| 213 |
+
|
| 214 |
+
{/* Transition Loading - Shown when moving between chunks */}
|
| 215 |
+
{showChat && isTransitioning && (
|
| 216 |
+
<ChunkLoadingTips message="Transitioning to next topic..." />
|
| 217 |
+
)}
|
| 218 |
</>
|
| 219 |
);
|
| 220 |
};
|
frontend/src/components/DocumentProcessor.jsx
CHANGED
|
@@ -9,8 +9,8 @@ import { usePanelResize } from '../hooks/usePanelResize';
|
|
| 9 |
// Import components
|
| 10 |
import LoadingAnimation from './LoadingAnimation';
|
| 11 |
import DocumentViewer from './DocumentViewer';
|
| 12 |
-
import ChunkNavigation from './ChunkNavigation';
|
| 13 |
import ChunkPanel from './ChunkPanel';
|
|
|
|
| 14 |
|
| 15 |
function DocumentProcessor() {
|
| 16 |
// State for PDF navigation
|
|
@@ -35,6 +35,7 @@ function DocumentProcessor() {
|
|
| 35 |
currentChunkIndex,
|
| 36 |
chunkExpanded,
|
| 37 |
showChat,
|
|
|
|
| 38 |
goToNextChunk,
|
| 39 |
goToPrevChunk,
|
| 40 |
skipChunk,
|
|
@@ -43,8 +44,13 @@ function DocumentProcessor() {
|
|
| 43 |
setChunkExpanded,
|
| 44 |
setShowChat,
|
| 45 |
setChunkAsInteractive,
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
} = useChunkNavigation(documentData, null);
|
| 49 |
|
| 50 |
const {
|
|
@@ -90,10 +96,6 @@ function DocumentProcessor() {
|
|
| 90 |
);
|
| 91 |
}
|
| 92 |
|
| 93 |
-
if (processing || waitingForFirstResponse) {
|
| 94 |
-
return <LoadingAnimation uploadProgress={uploadProgress} />;
|
| 95 |
-
}
|
| 96 |
-
|
| 97 |
if (!documentData) {
|
| 98 |
return (
|
| 99 |
<div className="h-screen bg-gray-50 flex items-center justify-center">
|
|
@@ -123,12 +125,17 @@ function DocumentProcessor() {
|
|
| 123 |
style={{ cursor: isDragging ? 'col-resize' : 'default' }}
|
| 124 |
>
|
| 125 |
{/* Left Panel - Document */}
|
| 126 |
-
<div style={{ width: `${leftPanelWidth}%`, height: '100%' }}>
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
</div>
|
| 133 |
|
| 134 |
{/* Resizable Divider */}
|
|
@@ -150,27 +157,33 @@ function DocumentProcessor() {
|
|
| 150 |
<div
|
| 151 |
className="flex flex-col"
|
| 152 |
style={{ width: `${100 - leftPanelWidth}%` }}
|
| 153 |
-
>
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
{/* Chunk Panel */}
|
| 164 |
<div className="flex-1 flex flex-col min-h-0 bg-white rounded-lg shadow-sm">
|
| 165 |
<ChunkPanel
|
| 166 |
documentData={documentData}
|
| 167 |
currentChunkIndex={currentChunkIndex}
|
| 168 |
showChat={showChat}
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
setWaitingForFirstResponse={setWaitingForFirstResponse}
|
| 172 |
markChunkUnderstood={markChunkUnderstood}
|
| 173 |
skipChunk={skipChunk}
|
|
|
|
| 174 |
/>
|
| 175 |
</div>
|
| 176 |
</div>
|
|
|
|
| 9 |
// Import components
|
| 10 |
import LoadingAnimation from './LoadingAnimation';
|
| 11 |
import DocumentViewer from './DocumentViewer';
|
|
|
|
| 12 |
import ChunkPanel from './ChunkPanel';
|
| 13 |
+
import ProgressBar from './ProgressBar';
|
| 14 |
|
| 15 |
function DocumentProcessor() {
|
| 16 |
// State for PDF navigation
|
|
|
|
| 35 |
currentChunkIndex,
|
| 36 |
chunkExpanded,
|
| 37 |
showChat,
|
| 38 |
+
isTransitioning,
|
| 39 |
goToNextChunk,
|
| 40 |
goToPrevChunk,
|
| 41 |
skipChunk,
|
|
|
|
| 44 |
setChunkExpanded,
|
| 45 |
setShowChat,
|
| 46 |
setChunkAsInteractive,
|
| 47 |
+
updateGlobalChatHistory,
|
| 48 |
+
getGlobalChatHistory,
|
| 49 |
+
addMessageToChunk,
|
| 50 |
+
getCurrentChunkMessages,
|
| 51 |
+
hasChunkMessages,
|
| 52 |
+
isChunkCompleted,
|
| 53 |
+
canEditChunk
|
| 54 |
} = useChunkNavigation(documentData, null);
|
| 55 |
|
| 56 |
const {
|
|
|
|
| 96 |
);
|
| 97 |
}
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
if (!documentData) {
|
| 100 |
return (
|
| 101 |
<div className="h-screen bg-gray-50 flex items-center justify-center">
|
|
|
|
| 125 |
style={{ cursor: isDragging ? 'col-resize' : 'default' }}
|
| 126 |
>
|
| 127 |
{/* Left Panel - Document */}
|
| 128 |
+
<div style={{ width: `${leftPanelWidth}%`, height: '100%' }} className="flex flex-col">
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
{/* Document Viewer */}
|
| 132 |
+
<div className="flex-1 min-h-0">
|
| 133 |
+
<DocumentViewer
|
| 134 |
+
selectedFile={selectedFile}
|
| 135 |
+
documentData={documentData}
|
| 136 |
+
onPageChange={setPdfNavigation}
|
| 137 |
+
/>
|
| 138 |
+
</div>
|
| 139 |
</div>
|
| 140 |
|
| 141 |
{/* Resizable Divider */}
|
|
|
|
| 157 |
<div
|
| 158 |
className="flex flex-col"
|
| 159 |
style={{ width: `${100 - leftPanelWidth}%` }}
|
| 160 |
+
> {/* Progress Bar */}
|
| 161 |
+
<div className="mb-4 flex-shrink-0">
|
| 162 |
+
<ProgressBar
|
| 163 |
+
currentChunkIndex={currentChunkIndex}
|
| 164 |
+
totalChunks={documentData?.chunks?.length || 0}
|
| 165 |
+
onChunkClick={null} // Start with linear progression only
|
| 166 |
+
/>
|
| 167 |
+
</div>
|
| 168 |
+
|
|
|
|
| 169 |
{/* Chunk Panel */}
|
| 170 |
<div className="flex-1 flex flex-col min-h-0 bg-white rounded-lg shadow-sm">
|
| 171 |
<ChunkPanel
|
| 172 |
documentData={documentData}
|
| 173 |
currentChunkIndex={currentChunkIndex}
|
| 174 |
showChat={showChat}
|
| 175 |
+
isTransitioning={isTransitioning}
|
| 176 |
+
updateGlobalChatHistory={updateGlobalChatHistory}
|
| 177 |
+
getGlobalChatHistory={getGlobalChatHistory}
|
| 178 |
+
addMessageToChunk={addMessageToChunk}
|
| 179 |
+
getCurrentChunkMessages={getCurrentChunkMessages}
|
| 180 |
+
hasChunkMessages={hasChunkMessages}
|
| 181 |
+
isChunkCompleted={isChunkCompleted}
|
| 182 |
+
canEditChunk={canEditChunk}
|
| 183 |
setWaitingForFirstResponse={setWaitingForFirstResponse}
|
| 184 |
markChunkUnderstood={markChunkUnderstood}
|
| 185 |
skipChunk={skipChunk}
|
| 186 |
+
goToPrevChunk={goToPrevChunk}
|
| 187 |
/>
|
| 188 |
</div>
|
| 189 |
</div>
|
frontend/src/components/ProgressBar.jsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
const ProgressBar = ({
|
| 4 |
+
currentChunkIndex,
|
| 5 |
+
totalChunks,
|
| 6 |
+
onChunkClick = null // Optional: allow clicking on progress bar segments
|
| 7 |
+
}) => {
|
| 8 |
+
const progressPercentage = totalChunks > 0 ? ((currentChunkIndex) / totalChunks) * 100 : 0;
|
| 9 |
+
|
| 10 |
+
return (
|
| 11 |
+
<div className="w-full">
|
| 12 |
+
{/* Progress Label */}
|
| 13 |
+
<div className="flex justify-between items-center mb-2">
|
| 14 |
+
<span className="text-sm font-medium text-gray-700">
|
| 15 |
+
Progress: {Math.round(progressPercentage)}%
|
| 16 |
+
</span>
|
| 17 |
+
<span className="text-sm text-gray-500">
|
| 18 |
+
{currentChunkIndex + 1} of {totalChunks} sections
|
| 19 |
+
</span>
|
| 20 |
+
</div>
|
| 21 |
+
|
| 22 |
+
{/* Progress Bar */}
|
| 23 |
+
<div className="w-full bg-gray-200 rounded-lg h-3 overflow-hidden shadow-sm relative">
|
| 24 |
+
{/* Progress fill */}
|
| 25 |
+
<div
|
| 26 |
+
className="h-full bg-green-500 transition-all duration-500 ease-out rounded-lg"
|
| 27 |
+
style={{ width: `${progressPercentage}%` }}
|
| 28 |
+
/>
|
| 29 |
+
|
| 30 |
+
{/* Optional: Clickable segments overlay */}
|
| 31 |
+
{onChunkClick && (
|
| 32 |
+
<div className="absolute inset-0 flex">
|
| 33 |
+
{Array.from({ length: totalChunks }, (_, index) => (
|
| 34 |
+
<button
|
| 35 |
+
key={index}
|
| 36 |
+
onClick={() => onChunkClick(index)}
|
| 37 |
+
className="flex-1 hover:bg-white hover:bg-opacity-20 transition-colors duration-200"
|
| 38 |
+
title={`Go to chunk ${index + 1}`}
|
| 39 |
+
/>
|
| 40 |
+
))}
|
| 41 |
+
</div>
|
| 42 |
+
)}
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
);
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
export default ProgressBar;
|
frontend/src/components/SimpleChat.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useState } from 'react';
|
| 2 |
import ReactMarkdown from 'react-markdown';
|
| 3 |
import remarkMath from 'remark-math';
|
| 4 |
import rehypeKatex from 'rehype-katex';
|
|
@@ -6,42 +6,60 @@ import rehypeRaw from 'rehype-raw';
|
|
| 6 |
import { getChatMarkdownComponents } from '../utils/markdownComponents.jsx';
|
| 7 |
|
| 8 |
|
| 9 |
-
const SimpleChat = ({ messages, onSend, isLoading }) => {
|
| 10 |
const [input, setInput] = useState('');
|
|
|
|
|
|
|
| 11 |
|
| 12 |
const handleSubmit = (e) => {
|
| 13 |
e.preventDefault();
|
| 14 |
-
if (!input.trim() || isLoading) return;
|
| 15 |
onSend(input.trim());
|
| 16 |
setInput('');
|
| 17 |
};
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
return (
|
| 20 |
<div className="flex flex-col h-full">
|
| 21 |
{/* Messages */}
|
| 22 |
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
| 23 |
-
{messages.map((message, idx) =>
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
<div
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
: 'bg-white text-gray-900'
|
| 33 |
-
}`}
|
| 34 |
>
|
| 35 |
-
<
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
</div>
|
| 43 |
-
|
| 44 |
-
)
|
| 45 |
|
| 46 |
{isLoading && (
|
| 47 |
<div className="flex justify-start">
|
|
@@ -69,13 +87,13 @@ const SimpleChat = ({ messages, onSend, isLoading }) => {
|
|
| 69 |
type="text"
|
| 70 |
value={input}
|
| 71 |
onChange={(e) => setInput(e.target.value)}
|
| 72 |
-
placeholder="Type your message..."
|
| 73 |
-
disabled={isLoading}
|
| 74 |
-
className="flex-1 px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
|
| 75 |
/>
|
| 76 |
<button
|
| 77 |
type="submit"
|
| 78 |
-
disabled={!input.trim() || isLoading}
|
| 79 |
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
|
| 80 |
>
|
| 81 |
{isLoading ? '...' : 'Send'}
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef } from 'react';
|
| 2 |
import ReactMarkdown from 'react-markdown';
|
| 3 |
import remarkMath from 'remark-math';
|
| 4 |
import rehypeKatex from 'rehype-katex';
|
|
|
|
| 6 |
import { getChatMarkdownComponents } from '../utils/markdownComponents.jsx';
|
| 7 |
|
| 8 |
|
| 9 |
+
const SimpleChat = ({ messages, currentChunkIndex, canEdit, onSend, isLoading }) => {
|
| 10 |
const [input, setInput] = useState('');
|
| 11 |
+
const messagesEndRef = useRef(null);
|
| 12 |
+
const currentChunkStartRef = useRef(null);
|
| 13 |
|
| 14 |
const handleSubmit = (e) => {
|
| 15 |
e.preventDefault();
|
| 16 |
+
if (!input.trim() || isLoading || !canEdit) return;
|
| 17 |
onSend(input.trim());
|
| 18 |
setInput('');
|
| 19 |
};
|
| 20 |
|
| 21 |
+
// Scroll to current chunk's first message when chunk changes
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
if (currentChunkStartRef.current) {
|
| 24 |
+
currentChunkStartRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 25 |
+
}
|
| 26 |
+
}, [currentChunkIndex]);
|
| 27 |
+
|
| 28 |
+
// Find the first message of the current chunk
|
| 29 |
+
const currentChunkFirstMessageIndex = messages.findIndex(msg => msg.chunkIndex === currentChunkIndex);
|
| 30 |
+
|
| 31 |
return (
|
| 32 |
<div className="flex flex-col h-full">
|
| 33 |
{/* Messages */}
|
| 34 |
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
| 35 |
+
{messages.map((message, idx) => {
|
| 36 |
+
const isCurrentChunk = message.chunkIndex === currentChunkIndex;
|
| 37 |
+
const isFirstOfCurrentChunk = idx === currentChunkFirstMessageIndex;
|
| 38 |
+
|
| 39 |
+
return (
|
| 40 |
<div
|
| 41 |
+
key={idx}
|
| 42 |
+
ref={isFirstOfCurrentChunk ? currentChunkStartRef : null}
|
| 43 |
+
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
|
|
|
|
|
|
| 44 |
>
|
| 45 |
+
<div
|
| 46 |
+
className={`max-w-[90%] p-3 rounded-lg transition-opacity ${
|
| 47 |
+
message.role === 'user'
|
| 48 |
+
? `bg-gray-100 text-white ${isCurrentChunk ? 'opacity-100' : 'opacity-40'}`
|
| 49 |
+
: `bg-white text-gray-900 ${isCurrentChunk ? 'opacity-100' : 'opacity-40'}`
|
| 50 |
+
}`}
|
| 51 |
+
>
|
| 52 |
+
<ReactMarkdown
|
| 53 |
+
remarkPlugins={[remarkMath]}
|
| 54 |
+
rehypePlugins={[rehypeRaw, rehypeKatex]}
|
| 55 |
+
components={getChatMarkdownComponents()}
|
| 56 |
+
>
|
| 57 |
+
{message.content}
|
| 58 |
+
</ReactMarkdown>
|
| 59 |
+
</div>
|
| 60 |
</div>
|
| 61 |
+
);
|
| 62 |
+
})}
|
| 63 |
|
| 64 |
{isLoading && (
|
| 65 |
<div className="flex justify-start">
|
|
|
|
| 87 |
type="text"
|
| 88 |
value={input}
|
| 89 |
onChange={(e) => setInput(e.target.value)}
|
| 90 |
+
placeholder={canEdit ? "Type your message..." : "This chunk is completed - navigation only"}
|
| 91 |
+
disabled={isLoading || !canEdit}
|
| 92 |
+
className="flex-1 px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-500"
|
| 93 |
/>
|
| 94 |
<button
|
| 95 |
type="submit"
|
| 96 |
+
disabled={!input.trim() || isLoading || !canEdit}
|
| 97 |
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
|
| 98 |
>
|
| 99 |
{isLoading ? '...' : 'Send'}
|
frontend/src/hooks/useChunkNavigation.js
CHANGED
|
@@ -4,8 +4,10 @@ export const useChunkNavigation = (documentData, clearTypingAnimation) => {
|
|
| 4 |
const [chunkStates, setChunkStates] = useState({});
|
| 5 |
const [currentChunkIndex, setCurrentChunkIndex] = useState(0);
|
| 6 |
const [chunkExpanded, setChunkExpanded] = useState(true);
|
| 7 |
-
const [
|
| 8 |
const [showChat, setShowChat] = useState(true);
|
|
|
|
|
|
|
| 9 |
|
| 10 |
const goToNextChunk = () => {
|
| 11 |
if (documentData && currentChunkIndex < documentData.chunks.length - 1) {
|
|
@@ -27,50 +29,67 @@ export const useChunkNavigation = (documentData, clearTypingAnimation) => {
|
|
| 27 |
}
|
| 28 |
};
|
| 29 |
|
| 30 |
-
const
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
//
|
| 36 |
-
if (
|
| 37 |
-
|
| 38 |
-
if (clearTypingAnimation) {
|
| 39 |
-
clearTypingAnimation();
|
| 40 |
-
}
|
| 41 |
-
setCurrentChunkIndex(currentChunkIndex + 1);
|
| 42 |
-
setChunkExpanded(true);
|
| 43 |
-
}, 100); // Small delay to allow state update to complete
|
| 44 |
}
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
};
|
| 52 |
|
| 53 |
const markChunkUnderstood = () => {
|
| 54 |
-
|
| 55 |
-
const currentState = prev[currentChunkIndex];
|
| 56 |
-
const newState = currentState === 'understood' ? undefined : 'understood';
|
| 57 |
-
|
| 58 |
-
// Auto-advance to next chunk if setting to understood (not toggling off)
|
| 59 |
-
if (newState === 'understood' && documentData && currentChunkIndex < documentData.chunks.length - 1) {
|
| 60 |
-
setTimeout(() => {
|
| 61 |
-
if (clearTypingAnimation) {
|
| 62 |
-
clearTypingAnimation();
|
| 63 |
-
}
|
| 64 |
-
setCurrentChunkIndex(currentChunkIndex + 1);
|
| 65 |
-
setChunkExpanded(true);
|
| 66 |
-
}, 100); // Small delay to allow state update to complete
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
return {
|
| 70 |
-
...prev,
|
| 71 |
-
[currentChunkIndex]: newState
|
| 72 |
-
};
|
| 73 |
-
});
|
| 74 |
};
|
| 75 |
|
| 76 |
const startInteractiveLesson = (startChunkLessonFn) => {
|
|
@@ -82,21 +101,36 @@ export const useChunkNavigation = (documentData, clearTypingAnimation) => {
|
|
| 82 |
};
|
| 83 |
|
| 84 |
const setChunkAsInteractive = () => {
|
| 85 |
-
|
| 86 |
-
...prev,
|
| 87 |
-
[currentChunkIndex]: 'interactive'
|
| 88 |
-
}));
|
| 89 |
};
|
| 90 |
|
| 91 |
-
const
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
};
|
| 97 |
|
| 98 |
-
const
|
| 99 |
-
return
|
| 100 |
};
|
| 101 |
|
| 102 |
return {
|
|
@@ -104,6 +138,7 @@ export const useChunkNavigation = (documentData, clearTypingAnimation) => {
|
|
| 104 |
currentChunkIndex,
|
| 105 |
chunkExpanded,
|
| 106 |
showChat,
|
|
|
|
| 107 |
goToNextChunk,
|
| 108 |
goToPrevChunk,
|
| 109 |
skipChunk,
|
|
@@ -112,7 +147,12 @@ export const useChunkNavigation = (documentData, clearTypingAnimation) => {
|
|
| 112 |
setChunkExpanded,
|
| 113 |
setShowChat,
|
| 114 |
setChunkAsInteractive,
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
};
|
| 118 |
};
|
|
|
|
| 4 |
const [chunkStates, setChunkStates] = useState({});
|
| 5 |
const [currentChunkIndex, setCurrentChunkIndex] = useState(0);
|
| 6 |
const [chunkExpanded, setChunkExpanded] = useState(true);
|
| 7 |
+
const [globalChatHistory, setGlobalChatHistory] = useState([]);
|
| 8 |
const [showChat, setShowChat] = useState(true);
|
| 9 |
+
const [isTransitioning, setIsTransitioning] = useState(false);
|
| 10 |
+
const [completedChunks, setCompletedChunks] = useState(new Set());
|
| 11 |
|
| 12 |
const goToNextChunk = () => {
|
| 13 |
if (documentData && currentChunkIndex < documentData.chunks.length - 1) {
|
|
|
|
| 29 |
}
|
| 30 |
};
|
| 31 |
|
| 32 |
+
const sendAutomatedMessage = async (action) => {
|
| 33 |
+
if (!documentData || currentChunkIndex >= documentData.chunks.length - 1) return;
|
| 34 |
+
|
| 35 |
+
setIsTransitioning(true);
|
| 36 |
+
const nextChunkIndex = currentChunkIndex + 1;
|
| 37 |
+
const nextChunk = documentData.chunks[nextChunkIndex];
|
| 38 |
+
|
| 39 |
+
// Mark current chunk as completed
|
| 40 |
+
setCompletedChunks(prev => new Set(prev).add(currentChunkIndex));
|
| 41 |
+
|
| 42 |
+
// Update chunk index immediately for UI feedback
|
| 43 |
+
setCurrentChunkIndex(nextChunkIndex);
|
| 44 |
+
setChunkExpanded(true);
|
| 45 |
+
|
| 46 |
+
// Check if we already have messages for this chunk
|
| 47 |
+
if (hasChunkMessages(nextChunkIndex)) {
|
| 48 |
+
// Don't generate new response, just navigate
|
| 49 |
+
setIsTransitioning(false);
|
| 50 |
+
return;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
try {
|
| 54 |
+
const response = await fetch('/api/chat', {
|
| 55 |
+
method: 'POST',
|
| 56 |
+
headers: { 'Content-Type': 'application/json' },
|
| 57 |
+
body: JSON.stringify({
|
| 58 |
+
messages: globalChatHistory,
|
| 59 |
+
currentChunk: documentData.chunks[currentChunkIndex]?.text || '',
|
| 60 |
+
nextChunk: nextChunk.text,
|
| 61 |
+
action: action,
|
| 62 |
+
document: documentData ? JSON.stringify(documentData) : ''
|
| 63 |
+
})
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
const data = await response.json();
|
| 67 |
+
addMessageToChunk(
|
| 68 |
+
{ role: 'assistant', content: data.content || 'Let\'s continue to the next section.' },
|
| 69 |
+
nextChunkIndex
|
| 70 |
+
);
|
| 71 |
|
| 72 |
+
// Clear any animations after successful response
|
| 73 |
+
if (clearTypingAnimation) {
|
| 74 |
+
clearTypingAnimation();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
}
|
| 76 |
+
} catch (error) {
|
| 77 |
+
console.error('Error in automated transition:', error);
|
| 78 |
+
// Clear animations on error too
|
| 79 |
+
if (clearTypingAnimation) {
|
| 80 |
+
clearTypingAnimation();
|
| 81 |
+
}
|
| 82 |
+
} finally {
|
| 83 |
+
setIsTransitioning(false);
|
| 84 |
+
}
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
const skipChunk = () => {
|
| 88 |
+
return sendAutomatedMessage('skip');
|
| 89 |
};
|
| 90 |
|
| 91 |
const markChunkUnderstood = () => {
|
| 92 |
+
return sendAutomatedMessage('understood');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
};
|
| 94 |
|
| 95 |
const startInteractiveLesson = (startChunkLessonFn) => {
|
|
|
|
| 101 |
};
|
| 102 |
|
| 103 |
const setChunkAsInteractive = () => {
|
| 104 |
+
// No longer tracking status - this is just for compatibility
|
|
|
|
|
|
|
|
|
|
| 105 |
};
|
| 106 |
|
| 107 |
+
const updateGlobalChatHistory = (messages) => {
|
| 108 |
+
setGlobalChatHistory(messages);
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
const getGlobalChatHistory = () => {
|
| 112 |
+
return globalChatHistory;
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const addMessageToChunk = (message, chunkIndex) => {
|
| 116 |
+
const messageWithChunk = { ...message, chunkIndex };
|
| 117 |
+
setGlobalChatHistory(prev => [...prev, messageWithChunk]);
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
const getCurrentChunkMessages = () => {
|
| 121 |
+
return globalChatHistory.filter(msg => msg.chunkIndex === currentChunkIndex);
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
const hasChunkMessages = (chunkIndex) => {
|
| 125 |
+
return globalChatHistory.some(msg => msg.chunkIndex === chunkIndex);
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
const isChunkCompleted = (chunkIndex) => {
|
| 129 |
+
return completedChunks.has(chunkIndex);
|
| 130 |
};
|
| 131 |
|
| 132 |
+
const canEditChunk = (chunkIndex) => {
|
| 133 |
+
return chunkIndex === currentChunkIndex && !isChunkCompleted(chunkIndex);
|
| 134 |
};
|
| 135 |
|
| 136 |
return {
|
|
|
|
| 138 |
currentChunkIndex,
|
| 139 |
chunkExpanded,
|
| 140 |
showChat,
|
| 141 |
+
isTransitioning,
|
| 142 |
goToNextChunk,
|
| 143 |
goToPrevChunk,
|
| 144 |
skipChunk,
|
|
|
|
| 147 |
setChunkExpanded,
|
| 148 |
setShowChat,
|
| 149 |
setChunkAsInteractive,
|
| 150 |
+
updateGlobalChatHistory,
|
| 151 |
+
getGlobalChatHistory,
|
| 152 |
+
addMessageToChunk,
|
| 153 |
+
getCurrentChunkMessages,
|
| 154 |
+
hasChunkMessages,
|
| 155 |
+
isChunkCompleted,
|
| 156 |
+
canEditChunk
|
| 157 |
};
|
| 158 |
};
|
frontend/src/utils/markdownComponents.jsx
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
|
|
| 1 |
export const getChatMarkdownComponents = () => ({
|
| 2 |
-
p: ({ children }) => <p className="mb-
|
| 3 |
h1: ({ children }) => <h1 className="text-xl font-bold mb-3 text-gray-900">{children}</h1>,
|
| 4 |
h2: ({ children }) => <h2 className="text-lg font-bold mb-2 text-gray-900">{children}</h2>,
|
| 5 |
h3: ({ children }) => <h3 className="text-base font-bold mb-2 text-gray-900">{children}</h3>,
|
| 6 |
ul: ({ children }) => <ul className="mb-2 ml-4 list-disc">{children}</ul>,
|
| 7 |
ol: ({ children }) => <ol className="mb-2 ml-4 list-decimal">{children}</ol>,
|
| 8 |
li: ({ children }) => <li className="mb-1 text-gray-800">{children}</li>,
|
|
|
|
| 9 |
strong: ({ children }) => <strong className="font-semibold text-gray-900">{children}</strong>,
|
| 10 |
em: ({ children }) => <em className="italic">{children}</em>,
|
| 11 |
code: ({ inline, children }) =>
|
|
@@ -19,4 +21,20 @@ export const getChatMarkdownComponents = () => ({
|
|
| 19 |
{children}
|
| 20 |
</blockquote>
|
| 21 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
});
|
|
|
|
| 1 |
+
// Default markdown components for chat content
|
| 2 |
export const getChatMarkdownComponents = () => ({
|
| 3 |
+
p: ({ children }) => <p className="mb-3 text-gray-800 leading-relaxed">{children}</p>,
|
| 4 |
h1: ({ children }) => <h1 className="text-xl font-bold mb-3 text-gray-900">{children}</h1>,
|
| 5 |
h2: ({ children }) => <h2 className="text-lg font-bold mb-2 text-gray-900">{children}</h2>,
|
| 6 |
h3: ({ children }) => <h3 className="text-base font-bold mb-2 text-gray-900">{children}</h3>,
|
| 7 |
ul: ({ children }) => <ul className="mb-2 ml-4 list-disc">{children}</ul>,
|
| 8 |
ol: ({ children }) => <ol className="mb-2 ml-4 list-decimal">{children}</ol>,
|
| 9 |
li: ({ children }) => <li className="mb-1 text-gray-800">{children}</li>,
|
| 10 |
+
hr: () => <hr className="my-4 border-gray-300" />,
|
| 11 |
strong: ({ children }) => <strong className="font-semibold text-gray-900">{children}</strong>,
|
| 12 |
em: ({ children }) => <em className="italic">{children}</em>,
|
| 13 |
code: ({ inline, children }) =>
|
|
|
|
| 21 |
{children}
|
| 22 |
</blockquote>
|
| 23 |
)
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
// Title-specific markdown components with no bottom margins
|
| 27 |
+
export const getTitleMarkdownComponents = () => ({
|
| 28 |
+
p: ({ children }) => <span className="text-gray-900">{children}</span>,
|
| 29 |
+
h1: ({ children }) => <span className="text-xl font-bold text-gray-900">{children}</span>,
|
| 30 |
+
h2: ({ children }) => <span className="text-lg font-bold text-gray-900">{children}</span>,
|
| 31 |
+
h3: ({ children }) => <span className="text-base font-bold text-gray-900">{children}</span>,
|
| 32 |
+
strong: ({ children }) => <strong className="font-semibold text-gray-900">{children}</strong>,
|
| 33 |
+
em: ({ children }) => <em className="italic">{children}</em>,
|
| 34 |
+
code: ({ children }) => <code className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">{children}</code>,
|
| 35 |
+
// Convert block elements to inline for titles
|
| 36 |
+
ul: ({ children }) => <span>{children}</span>,
|
| 37 |
+
ol: ({ children }) => <span>{children}</span>,
|
| 38 |
+
li: ({ children }) => <span>{children} </span>,
|
| 39 |
+
blockquote: ({ children }) => <span className="italic text-gray-700">{children}</span>
|
| 40 |
});
|