milwright commited on
Commit
3ca518f
·
1 Parent(s): 129f98d

fix: improve chat interface and error handling

Browse files

- Remove console warnings for cleaner development experience
- Unify chat interface to use dropdown on all devices
- Fix word context initialization in preprocessed passages
- Add consistent 1-second loading transitions
- Improve spacing in chat interface
- Enhance error handling for batch API responses

README.md CHANGED
@@ -37,4 +37,25 @@ An interactive cloze reading practice application with AI-powered assistance. Pr
37
 
38
  ## Technology
39
 
40
- Built with vanilla JavaScript, powered by AI for intelligent word selection and contextual assistance.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  ## Technology
39
 
40
+ Built with vanilla JavaScript, powered by AI for intelligent word selection and contextual assistance.
41
+
42
+ ## Running Locally with Docker
43
+
44
+ To run the Cloze Reader application locally using Docker:
45
+
46
+ 1. **Build the Docker image**:
47
+ ```bash
48
+ docker build -t cloze-reader .
49
+ ```
50
+
51
+ 2. **Run the container**:
52
+ ```bash
53
+ docker run -p 7860:7860 cloze-reader
54
+ ```
55
+
56
+ 3. **Access the application**:
57
+ Open your browser and navigate to `http://localhost:7860`
58
+
59
+ ### Prerequisites
60
+ - Docker installed on your system
61
+ - Port 7860 available on your machine
src/aiService.js CHANGED
@@ -12,7 +12,7 @@ class OpenRouterService {
12
  if (typeof window !== 'undefined' && window.OPENROUTER_API_KEY) {
13
  return window.OPENROUTER_API_KEY;
14
  }
15
- console.warn('No API key found in getApiKey()');
16
  return '';
17
  }
18
 
@@ -238,13 +238,103 @@ Return as JSON: {"passage1": {...}, "passage2": {...}}`
238
  }
239
 
240
  const data = await response.json();
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  const content = data.choices[0].message.content.trim();
242
 
243
  try {
244
- return JSON.parse(content);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  } catch (e) {
246
  console.error('Failed to parse batch response:', e);
247
- throw new Error('Invalid API response format');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  }
249
  } catch (error) {
250
  console.error('Error processing passages:', error);
 
12
  if (typeof window !== 'undefined' && window.OPENROUTER_API_KEY) {
13
  return window.OPENROUTER_API_KEY;
14
  }
15
+ // console.warn('No API key found in getApiKey()');
16
  return '';
17
  }
18
 
 
238
  }
239
 
240
  const data = await response.json();
241
+
242
+ // Check for error response
243
+ if (data.error) {
244
+ console.error('OpenRouter API error for batch processing:', data.error);
245
+ throw new Error(`OpenRouter API error: ${data.error.message || JSON.stringify(data.error)}`);
246
+ }
247
+
248
+ // Check if response has expected structure
249
+ if (!data.choices || !data.choices[0] || !data.choices[0].message || !data.choices[0].message.content) {
250
+ console.error('Invalid batch API response structure:', data);
251
+ throw new Error('API response missing expected content');
252
+ }
253
+
254
  const content = data.choices[0].message.content.trim();
255
 
256
  try {
257
+ // Try to extract JSON from the response
258
+ // Sometimes the model returns JSON wrapped in markdown code blocks
259
+ const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/) || content.match(/```\s*([\s\S]*?)\s*```/);
260
+ let jsonString = jsonMatch ? jsonMatch[1] : content;
261
+
262
+ // Clean up the JSON string
263
+ jsonString = jsonString
264
+ .replace(/^\s*```json\s*/, '')
265
+ .replace(/\s*```\s*$/, '')
266
+ .trim();
267
+
268
+ // Try to fix common JSON issues
269
+ // Check if JSON is truncated (missing closing braces)
270
+ const openBraces = (jsonString.match(/{/g) || []).length;
271
+ const closeBraces = (jsonString.match(/}/g) || []).length;
272
+
273
+ if (openBraces > closeBraces) {
274
+ // Add missing closing braces
275
+ jsonString += '}'.repeat(openBraces - closeBraces);
276
+ }
277
+
278
+ // Remove any trailing garbage after the last closing brace
279
+ const lastBrace = jsonString.lastIndexOf('}');
280
+ if (lastBrace !== -1 && lastBrace < jsonString.length - 1) {
281
+ jsonString = jsonString.substring(0, lastBrace + 1);
282
+ }
283
+
284
+ const parsed = JSON.parse(jsonString);
285
+
286
+ // Validate the structure
287
+ if (!parsed.passage1 || !parsed.passage2) {
288
+ console.error('Parsed response missing expected structure:', parsed);
289
+ throw new Error('Response missing passage1 or passage2');
290
+ }
291
+
292
+ // Ensure words arrays exist and are arrays
293
+ if (!Array.isArray(parsed.passage1.words)) {
294
+ parsed.passage1.words = [];
295
+ }
296
+ if (!Array.isArray(parsed.passage2.words)) {
297
+ parsed.passage2.words = [];
298
+ }
299
+
300
+ return parsed;
301
  } catch (e) {
302
  console.error('Failed to parse batch response:', e);
303
+ console.error('Raw content:', content);
304
+
305
+ // Try to extract any usable data from the partial response
306
+ try {
307
+ // Extract passage contexts using regex
308
+ const context1Match = content.match(/"context":\s*"([^"]+)"/);
309
+ const context2Match = content.match(/"passage2"[\s\S]*?"context":\s*"([^"]+)"/);
310
+
311
+ // Extract words arrays using regex
312
+ const words1Match = content.match(/"words":\s*\[([^\]]+)\]/);
313
+ const words2Match = content.match(/"passage2"[\s\S]*?"words":\s*\[([^\]]+)\]/);
314
+
315
+ const extractWords = (match) => {
316
+ if (!match) return [];
317
+ try {
318
+ return JSON.parse(`[${match[1]}]`);
319
+ } catch {
320
+ return match[1].split(',').map(w => w.trim().replace(/['"]/g, ''));
321
+ }
322
+ };
323
+
324
+ return {
325
+ passage1: {
326
+ words: extractWords(words1Match),
327
+ context: context1Match ? context1Match[1] : `From "${book1.title}" by ${book1.author}`
328
+ },
329
+ passage2: {
330
+ words: extractWords(words2Match),
331
+ context: context2Match ? context2Match[1] : `From "${book2.title}" by ${book2.author}`
332
+ }
333
+ };
334
+ } catch (extractError) {
335
+ console.error('Failed to extract partial data:', extractError);
336
+ throw new Error('Invalid API response format');
337
+ }
338
  }
339
  } catch (error) {
340
  console.error('Error processing passages:', error);
src/app.js CHANGED
@@ -204,11 +204,14 @@ class App {
204
  async handleNext() {
205
  try {
206
  // Show loading immediately with specific message
207
- this.showLoading(true, 'Loading next passage...');
208
 
209
  // Clear chat history when starting new passage/round
210
  this.chatUI.clearChatHistory();
211
 
 
 
 
212
  // Check if we should load next passage or next round
213
  let roundData;
214
  if (this.game.currentPassageIndex === 0) {
@@ -219,6 +222,12 @@ class App {
219
  roundData = await this.game.nextRound();
220
  }
221
 
 
 
 
 
 
 
222
  this.displayRound(roundData);
223
  this.resetUI();
224
  this.showLoading(false);
 
204
  async handleNext() {
205
  try {
206
  // Show loading immediately with specific message
207
+ this.showLoading(true, 'Loading passage...');
208
 
209
  // Clear chat history when starting new passage/round
210
  this.chatUI.clearChatHistory();
211
 
212
+ // Always show loading for at least 1 second for smooth UX
213
+ const startTime = Date.now();
214
+
215
  // Check if we should load next passage or next round
216
  let roundData;
217
  if (this.game.currentPassageIndex === 0) {
 
222
  roundData = await this.game.nextRound();
223
  }
224
 
225
+ // Ensure loading is shown for at least 1 second
226
+ const elapsedTime = Date.now() - startTime;
227
+ if (elapsedTime < 1000) {
228
+ await new Promise(resolve => setTimeout(resolve, 1000 - elapsedTime));
229
+ }
230
+
231
  this.displayRound(roundData);
232
  this.resetUI();
233
  this.showLoading(false);
src/chatInterface.js CHANGED
@@ -41,17 +41,12 @@ class ChatUI {
41
  </div>
42
  </div>
43
 
44
- <!-- Question buttons area -->
45
  <div class="p-4 border-t">
46
- <div class="text-sm text-gray-600 mb-3">Choose a question:</div>
47
- <!-- Dropdown for mobile -->
48
- <select id="question-dropdown" class="w-full p-2 border rounded md:hidden mb-2">
49
  <option value="">Select a question...</option>
50
  </select>
51
- <!-- Button grid for desktop -->
52
- <div id="question-buttons" class="hidden md:grid grid-cols-2 gap-2">
53
- <!-- Question buttons will be inserted here -->
54
- </div>
55
  </div>
56
  </div>
57
  </div>
@@ -110,9 +105,8 @@ class ChatUI {
110
  clearMessages() {
111
  const messagesContainer = document.getElementById('chat-messages');
112
  messagesContainer.innerHTML = `
113
- <div class="text-center text-gray-500 text-sm">
114
  Choose a question below to get help with this word.
115
- <br>
116
  </div>
117
  `;
118
  }
@@ -162,54 +156,29 @@ class ChatUI {
162
  this.messageHistory.clear();
163
  }
164
 
165
- // Load question buttons with disabled state for used questions
166
  loadQuestionButtons() {
167
- const buttonsContainer = document.getElementById('question-buttons');
168
  const dropdown = document.getElementById('question-dropdown');
169
  const questions = this.game.getSuggestedQuestionsForBlank(this.activeChatBlank);
170
 
171
  // Clear existing content
172
- buttonsContainer.innerHTML = '';
173
  dropdown.innerHTML = '<option value="">Select a question...</option>';
174
 
175
- // Build button grid for desktop and dropdown options
176
- let buttonHtml = '';
177
  questions.forEach(question => {
178
  const isDisabled = question.used;
179
- const buttonClass = isDisabled
180
- ? 'question-btn px-2 py-1 bg-gray-100 text-gray-500 rounded cursor-not-allowed text-xs'
181
- : 'question-btn px-2 py-1 bg-blue-50 text-blue-700 rounded hover:bg-blue-100 text-xs border border-blue-200';
182
-
183
- // Desktop buttons
184
- buttonHtml += `
185
- <button class="${buttonClass}"
186
- data-type="${question.type}"
187
- ${isDisabled ? 'disabled' : ''}>
188
- ${question.text}${isDisabled ? ' ✓' : ''}
189
- </button>
190
- `;
191
 
192
- // Mobile dropdown options
193
- if (!isDisabled) {
194
- dropdown.innerHTML += `<option value="${question.type}">${question.text}</option>`;
195
- }
196
- });
197
-
198
- buttonsContainer.innerHTML = buttonHtml;
199
-
200
- // Add click listeners to desktop buttons
201
- buttonsContainer.querySelectorAll('.question-btn').forEach(btn => {
202
- btn.addEventListener('click', (e) => {
203
- if (!btn.disabled) {
204
- e.preventDefault();
205
- e.stopPropagation();
206
- const questionType = btn.dataset.type;
207
- this.askQuestion(questionType);
208
- }
209
- });
210
  });
211
 
212
- // Add change listener to mobile dropdown
213
  dropdown.addEventListener('change', (e) => {
214
  if (e.target.value) {
215
  this.askQuestion(e.target.value);
 
41
  </div>
42
  </div>
43
 
44
+ <!-- Question dropdown area -->
45
  <div class="p-4 border-t">
46
+ <!-- Dropdown for all devices -->
47
+ <select id="question-dropdown" class="w-full p-2 border rounded mb-2">
 
48
  <option value="">Select a question...</option>
49
  </select>
 
 
 
 
50
  </div>
51
  </div>
52
  </div>
 
105
  clearMessages() {
106
  const messagesContainer = document.getElementById('chat-messages');
107
  messagesContainer.innerHTML = `
108
+ <div class="text-center text-gray-500 text-sm mb-4">
109
  Choose a question below to get help with this word.
 
110
  </div>
111
  `;
112
  }
 
156
  this.messageHistory.clear();
157
  }
158
 
159
+ // Load question dropdown with disabled state for used questions
160
  loadQuestionButtons() {
 
161
  const dropdown = document.getElementById('question-dropdown');
162
  const questions = this.game.getSuggestedQuestionsForBlank(this.activeChatBlank);
163
 
164
  // Clear existing content
 
165
  dropdown.innerHTML = '<option value="">Select a question...</option>';
166
 
167
+ // Build dropdown options
 
168
  questions.forEach(question => {
169
  const isDisabled = question.used;
170
+ const optionText = isDisabled ? `${question.text} ✓` : question.text;
 
 
 
 
 
 
 
 
 
 
 
171
 
172
+ // Add all options but mark used ones as disabled
173
+ const option = document.createElement('option');
174
+ option.value = isDisabled ? '' : question.type;
175
+ option.textContent = optionText;
176
+ option.disabled = isDisabled;
177
+ option.style.color = isDisabled ? '#9CA3AF' : '#111827';
178
+ dropdown.appendChild(option);
 
 
 
 
 
 
 
 
 
 
 
179
  });
180
 
181
+ // Add change listener to dropdown
182
  dropdown.addEventListener('change', (e) => {
183
  if (e.target.value) {
184
  this.askQuestion(e.target.value);
src/clozeGameEngine.js CHANGED
@@ -52,7 +52,15 @@ class ClozeGame {
52
  this.currentPassageIndex = 0;
53
 
54
  // Calculate blanks per passage based on level
55
- const blanksPerPassage = Math.min(this.currentLevel + 2, 5);
 
 
 
 
 
 
 
 
56
 
57
  // Process both passages in a single API call
58
  try {
@@ -210,6 +218,20 @@ class ClozeGame {
210
  wordIndex: wordIndex
211
  });
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  // Generate structural hint
214
  const hint = this.currentLevel <= 2
215
  ? `${cleanWord.length} letters, starts with "${cleanWord[0]}", ends with "${cleanWord[cleanWord.length - 1]}"`
 
52
  this.currentPassageIndex = 0;
53
 
54
  // Calculate blanks per passage based on level
55
+ // Progressive difficulty: levels 1-2 = 1 blank, levels 3-4 = 2 blanks, level 5+ = 3 blanks
56
+ let blanksPerPassage;
57
+ if (this.currentLevel <= 2) {
58
+ blanksPerPassage = 1;
59
+ } else if (this.currentLevel <= 4) {
60
+ blanksPerPassage = 2;
61
+ } else {
62
+ blanksPerPassage = 3;
63
+ }
64
 
65
  // Process both passages in a single API call
66
  try {
 
218
  wordIndex: wordIndex
219
  });
220
 
221
+ // Initialize chat context for this word
222
+ const wordContext = {
223
+ originalWord: cleanWord,
224
+ sentence: this.originalText,
225
+ passage: this.originalText,
226
+ bookTitle: this.currentBook.title,
227
+ author: this.currentBook.author,
228
+ year: this.currentBook.year,
229
+ wordPosition: wordIndex,
230
+ difficulty: this.calculateWordDifficulty(cleanWord, wordIndex, wordsOnly)
231
+ };
232
+
233
+ this.chatService.initializeWordContext(`blank_${blankIndex}`, wordContext);
234
+
235
  // Generate structural hint
236
  const hint = this.currentLevel <= 2
237
  ? `${cleanWord.length} letters, starts with "${cleanWord[0]}", ends with "${cleanWord[cleanWord.length - 1]}"`
src/welcomeOverlay.js CHANGED
@@ -6,13 +6,11 @@ class WelcomeOverlay {
6
  }
7
 
8
  show() {
9
- console.log('WelcomeOverlay.show() called, hasBeenShown:', this.hasBeenShown);
10
  // Always show overlay regardless of previous views
11
 
12
  this.isVisible = true;
13
  const overlay = this.createOverlay();
14
  document.body.appendChild(overlay);
15
- console.log('Welcome overlay added to DOM');
16
 
17
  // Animate in
18
  requestAnimationFrame(() => {
 
6
  }
7
 
8
  show() {
 
9
  // Always show overlay regardless of previous views
10
 
11
  this.isVisible = true;
12
  const overlay = this.createOverlay();
13
  document.body.appendChild(overlay);
 
14
 
15
  // Animate in
16
  requestAnimationFrame(() => {