chat-console 0.3.8__py3-none-any.whl → 0.3.91__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
app/utils.py CHANGED
@@ -201,284 +201,589 @@ async def generate_conversation_title(message: str, model: str, client: Any) ->
201
201
  logger.error(f"Failed to generate title after multiple retries. Last error: {last_error}")
202
202
  return f"Conversation ({datetime.now().strftime('%Y-%m-%d %H:%M')})"
203
203
 
204
- # Worker function for streaming response generation
205
- async def generate_streaming_response(
204
+ # Helper function for OpenAI streaming
205
+ async def _generate_openai_stream(
206
206
  app: 'SimpleChatApp',
207
207
  messages: List[Dict],
208
208
  model: str,
209
209
  style: str,
210
210
  client: Any,
211
- callback: Callable[[str], Awaitable[None]]
211
+ callback: Callable[[str], Awaitable[None]],
212
+ update_lock: asyncio.Lock
212
213
  ) -> Optional[str]:
213
- """
214
- Generate a streaming response from the model (as a Textual worker).
215
- Refactored to be a coroutine, not an async generator.
216
- """
214
+ """Generate streaming response using OpenAI provider."""
217
215
  try:
218
216
  from app.main import debug_log
219
217
  except ImportError:
220
218
  debug_log = lambda msg: None
221
-
222
- logger.info(f"Starting streaming response with model: {model}")
223
- debug_log(f"Starting streaming response with model: '{model}', client type: {type(client).__name__}")
224
-
225
- # Validate messages
226
- if not messages:
227
- debug_log("Error: messages list is empty")
228
- raise ValueError("Messages list cannot be empty")
229
-
230
- # Ensure all messages have required fields
231
- for i, msg in enumerate(messages):
219
+
220
+ debug_log(f"Using OpenAI-specific streaming for model: {model}")
221
+
222
+ # Initialize variables for response tracking
223
+ full_response = ""
224
+ buffer = []
225
+ last_update = time.time()
226
+ update_interval = 0.03 # Responsive updates for OpenAI
227
+
228
+ try:
229
+ # Initialize stream generator
230
+ debug_log("Initializing OpenAI stream generator")
231
+ stream_generator = client.generate_stream(messages, model, style)
232
+
233
+ # Process stream chunks
234
+ debug_log("Beginning to process OpenAI stream chunks")
235
+ async for chunk in stream_generator:
236
+ # Check for task cancellation
237
+ if asyncio.current_task().cancelled():
238
+ debug_log("Task cancellation detected during OpenAI chunk processing")
239
+ if hasattr(client, 'cancel_stream'):
240
+ await client.cancel_stream()
241
+ raise asyncio.CancelledError()
242
+
243
+ # Process chunk content
244
+ if chunk:
245
+ if not isinstance(chunk, str):
246
+ try:
247
+ chunk = str(chunk)
248
+ except Exception:
249
+ continue
250
+
251
+ buffer.append(chunk)
252
+ current_time = time.time()
253
+
254
+ # Update UI with new content
255
+ if (current_time - last_update >= update_interval or
256
+ len(''.join(buffer)) > 5 or
257
+ len(full_response) < 50):
258
+
259
+ new_content = ''.join(buffer)
260
+ full_response += new_content
261
+
262
+ try:
263
+ async with update_lock:
264
+ await callback(full_response)
265
+ if hasattr(app, 'refresh'):
266
+ app.refresh(layout=True)
267
+ except Exception as callback_err:
268
+ logger.error(f"Error in OpenAI UI callback: {str(callback_err)}")
269
+
270
+ buffer = []
271
+ last_update = current_time
272
+ await asyncio.sleep(0.02)
273
+
274
+ # Process any remaining buffer content
275
+ if buffer:
276
+ new_content = ''.join(buffer)
277
+ full_response += new_content
278
+
279
+ try:
280
+ async with update_lock:
281
+ await callback(full_response)
282
+ if hasattr(app, 'refresh'):
283
+ app.refresh(layout=True)
284
+ await asyncio.sleep(0.02)
285
+ try:
286
+ messages_container = app.query_one("#messages-container")
287
+ if messages_container:
288
+ messages_container.scroll_end(animate=False)
289
+ except Exception:
290
+ pass
291
+ except Exception as callback_err:
292
+ logger.error(f"Error in final OpenAI UI callback: {str(callback_err)}")
293
+
294
+ # Final refresh to ensure everything is displayed correctly
232
295
  try:
233
- debug_log(f"Message {i}: role={msg.get('role', 'missing')}, content_len={len(msg.get('content', ''))}")
234
- if 'role' not in msg:
235
- debug_log(f"Adding missing 'role' to message {i}")
236
- msg['role'] = 'user'
237
- if 'content' not in msg:
238
- debug_log(f"Adding missing 'content' to message {i}")
239
- msg['content'] = ''
240
- except Exception as e:
241
- debug_log(f"Error checking message {i}: {str(e)}")
242
- messages[i] = {
243
- 'role': 'user',
244
- 'content': str(msg) if msg else ''
245
- }
246
- debug_log(f"Repaired message {i}")
296
+ await asyncio.sleep(0.05)
297
+ async with update_lock:
298
+ await callback(full_response)
299
+ if hasattr(app, 'refresh'):
300
+ app.refresh(layout=True)
301
+ except Exception:
302
+ pass
303
+
304
+ return full_response
305
+
306
+ except asyncio.CancelledError:
307
+ logger.info(f"OpenAI streaming cancelled. Partial response length: {len(full_response)}")
308
+ if hasattr(client, 'cancel_stream'):
309
+ await client.cancel_stream()
310
+ return full_response
311
+
312
+ except Exception as e:
313
+ logger.error(f"Error during OpenAI streaming: {str(e)}")
314
+ if hasattr(client, 'cancel_stream'):
315
+ await client.cancel_stream()
316
+ raise
247
317
 
318
+ # Helper function for Anthropic streaming
319
+ async def _generate_anthropic_stream(
320
+ app: 'SimpleChatApp',
321
+ messages: List[Dict],
322
+ model: str,
323
+ style: str,
324
+ client: Any,
325
+ callback: Callable[[str], Awaitable[None]],
326
+ update_lock: asyncio.Lock
327
+ ) -> Optional[str]:
328
+ """Generate streaming response using Anthropic provider."""
329
+ try:
330
+ from app.main import debug_log
331
+ except ImportError:
332
+ debug_log = lambda msg: None
333
+
334
+ debug_log(f"Using Anthropic-specific streaming for model: {model}")
335
+
248
336
  # Initialize variables for response tracking
249
337
  full_response = ""
250
338
  buffer = []
251
339
  last_update = time.time()
252
- update_interval = 0.05 # Reduced interval for more frequent updates
253
-
340
+ update_interval = 0.03 # Responsive updates for Anthropic
341
+
254
342
  try:
255
- # Validate client
256
- if client is None:
257
- debug_log("Error: client is None, cannot proceed with streaming")
258
- raise ValueError("Model client is None, cannot proceed with streaming")
259
-
260
- if not hasattr(client, 'generate_stream'):
261
- debug_log(f"Error: client {type(client).__name__} does not have generate_stream method")
262
- raise ValueError(f"Client {type(client).__name__} does not support streaming")
263
-
264
- # Determine client type
265
- is_ollama = 'ollama' in str(type(client)).lower()
266
- is_openai = 'openai' in str(type(client)).lower()
267
- is_anthropic = 'anthropic' in str(type(client)).lower()
343
+ # Initialize stream generator
344
+ debug_log("Initializing Anthropic stream generator")
345
+ stream_generator = client.generate_stream(messages, model, style)
346
+
347
+ # Process stream chunks
348
+ debug_log("Beginning to process Anthropic stream chunks")
349
+ async for chunk in stream_generator:
350
+ # Check for task cancellation
351
+ if asyncio.current_task().cancelled():
352
+ debug_log("Task cancellation detected during Anthropic chunk processing")
353
+ if hasattr(client, 'cancel_stream'):
354
+ await client.cancel_stream()
355
+ raise asyncio.CancelledError()
356
+
357
+ # Process chunk content
358
+ if chunk:
359
+ if not isinstance(chunk, str):
360
+ try:
361
+ chunk = str(chunk)
362
+ except Exception:
363
+ continue
364
+
365
+ buffer.append(chunk)
366
+ current_time = time.time()
367
+
368
+ # Update UI with new content
369
+ if (current_time - last_update >= update_interval or
370
+ len(''.join(buffer)) > 5 or
371
+ len(full_response) < 50):
372
+
373
+ new_content = ''.join(buffer)
374
+ full_response += new_content
375
+
376
+ try:
377
+ async with update_lock:
378
+ await callback(full_response)
379
+ if hasattr(app, 'refresh'):
380
+ app.refresh(layout=True)
381
+ except Exception as callback_err:
382
+ logger.error(f"Error in Anthropic UI callback: {str(callback_err)}")
383
+
384
+ buffer = []
385
+ last_update = current_time
386
+ await asyncio.sleep(0.02)
387
+
388
+ # Process any remaining buffer content
389
+ if buffer:
390
+ new_content = ''.join(buffer)
391
+ full_response += new_content
392
+
393
+ try:
394
+ async with update_lock:
395
+ await callback(full_response)
396
+ if hasattr(app, 'refresh'):
397
+ app.refresh(layout=True)
398
+ await asyncio.sleep(0.02)
399
+ try:
400
+ messages_container = app.query_one("#messages-container")
401
+ if messages_container:
402
+ messages_container.scroll_end(animate=False)
403
+ except Exception:
404
+ pass
405
+ except Exception as callback_err:
406
+ logger.error(f"Error in final Anthropic UI callback: {str(callback_err)}")
407
+
408
+ # Final refresh to ensure everything is displayed correctly
409
+ try:
410
+ await asyncio.sleep(0.05)
411
+ async with update_lock:
412
+ await callback(full_response)
413
+ if hasattr(app, 'refresh'):
414
+ app.refresh(layout=True)
415
+ except Exception:
416
+ pass
417
+
418
+ return full_response
419
+
420
+ except asyncio.CancelledError:
421
+ logger.info(f"Anthropic streaming cancelled. Partial response length: {len(full_response)}")
422
+ if hasattr(client, 'cancel_stream'):
423
+ await client.cancel_stream()
424
+ return full_response
268
425
 
269
- debug_log(f"Client types - Ollama: {is_ollama}, OpenAI: {is_openai}, Anthropic: {is_anthropic}")
426
+ except Exception as e:
427
+ logger.error(f"Error during Anthropic streaming: {str(e)}")
428
+ if hasattr(client, 'cancel_stream'):
429
+ await client.cancel_stream()
430
+ raise
270
431
 
271
- # Only show loading indicator for Ollama (which may need to load models)
272
- # This prevents Ollama-specific UI elements from showing when using other providers
273
- if is_ollama and hasattr(app, 'query_one'):
432
+ # Helper function for Ollama streaming
433
+ async def _generate_ollama_stream(
434
+ app: 'SimpleChatApp',
435
+ messages: List[Dict],
436
+ model: str,
437
+ style: str,
438
+ client: Any,
439
+ callback: Callable[[str], Awaitable[None]],
440
+ update_lock: asyncio.Lock
441
+ ) -> Optional[str]:
442
+ """Generate streaming response using Ollama provider."""
443
+ try:
444
+ from app.main import debug_log
445
+ except ImportError:
446
+ debug_log = lambda msg: None
447
+
448
+ debug_log(f"Using Ollama-specific streaming for model: {model}")
449
+
450
+ # Initialize variables for response tracking
451
+ full_response = ""
452
+ buffer = []
453
+ last_update = time.time()
454
+ update_interval = 0.03 # Responsive updates for Ollama
455
+
456
+ try:
457
+ # Show loading indicator for Ollama (which may need to load models)
458
+ if hasattr(app, 'query_one'):
274
459
  try:
275
460
  debug_log("Showing initial model loading indicator for Ollama")
276
- logger.info("Showing initial model loading indicator for Ollama")
277
461
  loading = app.query_one("#loading-indicator")
278
462
  loading.add_class("model-loading")
279
463
  loading.update("⚙️ Loading Ollama model...")
280
464
  except Exception as e:
281
465
  debug_log(f"Error setting initial Ollama loading state: {str(e)}")
282
- logger.error(f"Error setting initial Ollama loading state: {str(e)}")
283
-
284
- debug_log(f"Starting stream generation with messages length: {len(messages)}")
285
- logger.info(f"Starting stream generation for model: {model}")
286
-
466
+
287
467
  # Initialize stream generator
288
- try:
289
- debug_log("Calling client.generate_stream()")
290
- stream_generator = client.generate_stream(messages, model, style)
291
- debug_log("Successfully obtained stream generator")
292
- except Exception as stream_init_error:
293
- debug_log(f"Error initializing stream generator: {str(stream_init_error)}")
294
- logger.error(f"Error initializing stream generator: {str(stream_init_error)}")
295
- raise
296
-
297
- # Update UI if model is ready (Ollama specific)
298
- # Only check is_loading_model for Ollama clients to prevent errors with other providers
299
- if is_ollama and hasattr(client, 'is_loading_model') and not client.is_loading_model() and hasattr(app, 'query_one'):
468
+ debug_log("Initializing Ollama stream generator")
469
+ stream_generator = client.generate_stream(messages, model, style)
470
+
471
+ # Update UI if model is ready
472
+ if hasattr(client, 'is_loading_model') and not client.is_loading_model() and hasattr(app, 'query_one'):
300
473
  try:
301
474
  debug_log("Ollama model is ready for generation, updating UI")
302
- logger.info("Ollama model is ready for generation, updating UI")
303
475
  loading = app.query_one("#loading-indicator")
304
476
  loading.remove_class("model-loading")
305
477
  loading.update("▪▪▪ Generating response...")
306
478
  except Exception as e:
307
- debug_log(f"Error updating UI after stream init: {str(e)}")
308
- logger.error(f"Error updating UI after stream init: {str(e)}")
309
-
479
+ debug_log(f"Error updating UI after Ollama stream init: {str(e)}")
480
+
310
481
  # Process stream chunks
311
- debug_log("Beginning to process stream chunks")
312
- try:
313
- async for chunk in stream_generator:
314
- # Check for task cancellation
315
- if asyncio.current_task().cancelled():
316
- debug_log("Task cancellation detected during chunk processing")
317
- logger.info("Task cancellation detected during chunk processing")
318
- if hasattr(client, 'cancel_stream'):
319
- debug_log("Calling client.cancel_stream() due to task cancellation")
320
- await client.cancel_stream()
321
- raise asyncio.CancelledError()
322
-
323
- # Handle Ollama model loading state changes - only for Ollama clients
324
- if is_ollama and hasattr(client, 'is_loading_model'):
325
- try:
326
- model_loading = client.is_loading_model()
327
- debug_log(f"Ollama model loading state: {model_loading}")
328
- if hasattr(app, 'query_one'):
329
- try:
330
- loading = app.query_one("#loading-indicator")
331
- if model_loading and hasattr(loading, 'has_class') and not loading.has_class("model-loading"):
332
- debug_log("Ollama model loading started during streaming")
333
- logger.info("Ollama model loading started during streaming")
334
- loading.add_class("model-loading")
335
- loading.update("⚙️ Loading Ollama model...")
336
- elif not model_loading and hasattr(loading, 'has_class') and loading.has_class("model-loading"):
337
- debug_log("Ollama model loading finished during streaming")
338
- logger.info("Ollama model loading finished during streaming")
339
- loading.remove_class("model-loading")
340
- loading.update("▪▪▪ Generating response...")
341
- except Exception as ui_e:
342
- debug_log(f"Error updating UI elements: {str(ui_e)}")
343
- logger.error(f"Error updating UI elements: {str(ui_e)}")
344
- except Exception as e:
345
- debug_log(f"Error checking Ollama model loading state: {str(e)}")
346
- logger.error(f"Error checking Ollama model loading state: {str(e)}")
347
-
348
- # Process chunk content
349
- if chunk:
350
- if not isinstance(chunk, str):
351
- debug_log(f"WARNING: Received non-string chunk of type: {type(chunk).__name__}")
482
+ debug_log("Beginning to process Ollama stream chunks")
483
+ async for chunk in stream_generator:
484
+ # Check for task cancellation
485
+ if asyncio.current_task().cancelled():
486
+ debug_log("Task cancellation detected during Ollama chunk processing")
487
+ if hasattr(client, 'cancel_stream'):
488
+ await client.cancel_stream()
489
+ raise asyncio.CancelledError()
490
+
491
+ # Handle Ollama model loading state changes
492
+ if hasattr(client, 'is_loading_model'):
493
+ try:
494
+ model_loading = client.is_loading_model()
495
+ if hasattr(app, 'query_one'):
352
496
  try:
353
- chunk = str(chunk)
354
- debug_log(f"Successfully converted chunk to string, length: {len(chunk)}")
355
- except Exception as e:
356
- debug_log(f"Error converting chunk to string: {str(e)}")
357
- continue
358
-
359
- debug_log(f"Received chunk of length: {len(chunk)}")
360
- buffer.append(chunk)
361
- current_time = time.time()
362
-
363
- # Update UI with new content
364
- # Always update immediately for the first few chunks for better responsiveness
365
- if (current_time - last_update >= update_interval or
366
- len(''.join(buffer)) > 5 or # Reduced buffer size threshold
367
- len(full_response) < 50): # More aggressive updates for early content
368
-
369
- new_content = ''.join(buffer)
370
- full_response += new_content
371
- debug_log(f"Updating UI with content length: {len(full_response)}")
372
-
373
- # Enhanced debug logging
374
- print(f"STREAM DEBUG: +{len(new_content)} chars, total: {len(full_response)}")
375
- # Print first few characters of content for debugging
376
- if len(full_response) < 100:
377
- print(f"STREAM CONTENT: '{full_response}'")
497
+ loading = app.query_one("#loading-indicator")
498
+ if model_loading and hasattr(loading, 'has_class') and not loading.has_class("model-loading"):
499
+ debug_log("Ollama model loading started during streaming")
500
+ loading.add_class("model-loading")
501
+ loading.update("⚙️ Loading Ollama model...")
502
+ elif not model_loading and hasattr(loading, 'has_class') and loading.has_class("model-loading"):
503
+ debug_log("Ollama model loading finished during streaming")
504
+ loading.remove_class("model-loading")
505
+ loading.update("▪▪▪ Generating response...")
506
+ except Exception:
507
+ pass
508
+ except Exception:
509
+ pass
510
+
511
+ # Process chunk content
512
+ if chunk:
513
+ if not isinstance(chunk, str):
514
+ try:
515
+ chunk = str(chunk)
516
+ except Exception:
517
+ continue
378
518
 
379
- try:
380
- # Call the UI callback with the full response so far
381
- debug_log("Calling UI callback with content")
519
+ buffer.append(chunk)
520
+ current_time = time.time()
521
+
522
+ # Update UI with new content
523
+ if (current_time - last_update >= update_interval or
524
+ len(''.join(buffer)) > 5 or
525
+ len(full_response) < 50):
526
+
527
+ new_content = ''.join(buffer)
528
+ full_response += new_content
529
+
530
+ try:
531
+ async with update_lock:
382
532
  await callback(full_response)
383
- debug_log("UI callback completed successfully")
384
-
385
- # Force app refresh after each update
386
533
  if hasattr(app, 'refresh'):
387
- debug_log("Forcing app refresh")
388
- app.refresh(layout=True) # Force layout refresh
389
- except Exception as callback_err:
390
- debug_log(f"Error in UI callback: {str(callback_err)}")
391
- logger.error(f"Error in UI callback: {str(callback_err)}")
392
- print(f"STREAM ERROR: Error updating UI: {str(callback_err)}")
393
-
394
- buffer = []
395
- last_update = current_time
534
+ app.refresh(layout=True)
535
+ except Exception as callback_err:
536
+ logger.error(f"Error in Ollama UI callback: {str(callback_err)}")
396
537
 
397
- # Shorter sleep between updates for more responsive streaming
398
- await asyncio.sleep(0.02)
399
- except asyncio.CancelledError:
400
- debug_log("CancelledError in stream processing")
401
- raise
402
- except Exception as chunk_error:
403
- debug_log(f"Error processing stream chunks: {str(chunk_error)}")
404
- logger.error(f"Error processing stream chunks: {str(chunk_error)}")
405
- raise
406
-
538
+ buffer = []
539
+ last_update = current_time
540
+ await asyncio.sleep(0.02)
541
+
542
+ # Process any remaining buffer content
407
543
  if buffer:
408
544
  new_content = ''.join(buffer)
409
545
  full_response += new_content
410
- debug_log(f"Sending final content, total length: {len(full_response)}")
546
+
411
547
  try:
412
- await callback(full_response)
413
- debug_log("Final UI callback completed successfully")
414
-
415
- debug_log("Forcing final UI refresh sequence for all models")
416
- try:
548
+ async with update_lock:
549
+ await callback(full_response)
417
550
  if hasattr(app, 'refresh'):
418
- app.refresh(layout=False)
551
+ app.refresh(layout=True)
419
552
  await asyncio.sleep(0.02)
420
553
  try:
421
554
  messages_container = app.query_one("#messages-container")
422
- if messages_container and hasattr(messages_container, 'scroll_end'):
555
+ if messages_container:
423
556
  messages_container.scroll_end(animate=False)
424
557
  except Exception:
425
558
  pass
559
+ except Exception as callback_err:
560
+ logger.error(f"Error in final Ollama UI callback: {str(callback_err)}")
561
+
562
+ # Final refresh to ensure everything is displayed correctly
563
+ try:
564
+ await asyncio.sleep(0.05)
565
+ async with update_lock:
566
+ await callback(full_response)
567
+ if hasattr(app, 'refresh'):
568
+ app.refresh(layout=True)
569
+ except Exception:
570
+ pass
571
+
572
+ return full_response
573
+
574
+ except asyncio.CancelledError:
575
+ logger.info(f"Ollama streaming cancelled. Partial response length: {len(full_response)}")
576
+ if hasattr(client, 'cancel_stream'):
577
+ await client.cancel_stream()
578
+ return full_response
579
+
580
+ except Exception as e:
581
+ logger.error(f"Error during Ollama streaming: {str(e)}")
582
+ if hasattr(client, 'cancel_stream'):
583
+ await client.cancel_stream()
584
+ raise
585
+
586
+ # Generic fallback streaming implementation
587
+ async def _generate_generic_stream(
588
+ app: 'SimpleChatApp',
589
+ messages: List[Dict],
590
+ model: str,
591
+ style: str,
592
+ client: Any,
593
+ callback: Callable[[str], Awaitable[None]],
594
+ update_lock: asyncio.Lock
595
+ ) -> Optional[str]:
596
+ """Generic fallback implementation for streaming responses."""
597
+ try:
598
+ from app.main import debug_log
599
+ except ImportError:
600
+ debug_log = lambda msg: None
601
+
602
+ debug_log(f"Using generic streaming for model: {model}, client type: {type(client).__name__}")
603
+
604
+ # Initialize variables for response tracking
605
+ full_response = ""
606
+ buffer = []
607
+ last_update = time.time()
608
+ update_interval = 0.03 # Responsive updates
609
+
610
+ try:
611
+ # Initialize stream generator
612
+ debug_log("Initializing generic stream generator")
613
+ stream_generator = client.generate_stream(messages, model, style)
614
+
615
+ # Process stream chunks
616
+ debug_log("Beginning to process generic stream chunks")
617
+ async for chunk in stream_generator:
618
+ # Check for task cancellation
619
+ if asyncio.current_task().cancelled():
620
+ debug_log("Task cancellation detected during generic chunk processing")
621
+ if hasattr(client, 'cancel_stream'):
622
+ await client.cancel_stream()
623
+ raise asyncio.CancelledError()
624
+
625
+ # Process chunk content
626
+ if chunk:
627
+ if not isinstance(chunk, str):
628
+ try:
629
+ chunk = str(chunk)
630
+ except Exception:
631
+ continue
632
+
633
+ buffer.append(chunk)
634
+ current_time = time.time()
635
+
636
+ # Update UI with new content
637
+ if (current_time - last_update >= update_interval or
638
+ len(''.join(buffer)) > 5 or
639
+ len(full_response) < 50):
640
+
641
+ new_content = ''.join(buffer)
642
+ full_response += new_content
643
+
644
+ try:
645
+ async with update_lock:
646
+ await callback(full_response)
647
+ if hasattr(app, 'refresh'):
648
+ app.refresh(layout=True)
649
+ except Exception as callback_err:
650
+ logger.error(f"Error in generic UI callback: {str(callback_err)}")
651
+
652
+ buffer = []
653
+ last_update = current_time
654
+ await asyncio.sleep(0.02)
655
+
656
+ # Process any remaining buffer content
657
+ if buffer:
658
+ new_content = ''.join(buffer)
659
+ full_response += new_content
660
+
661
+ try:
662
+ async with update_lock:
663
+ await callback(full_response)
664
+ if hasattr(app, 'refresh'):
426
665
  app.refresh(layout=True)
427
666
  await asyncio.sleep(0.02)
428
667
  try:
429
668
  messages_container = app.query_one("#messages-container")
430
- if messages_container and hasattr(messages_container, 'scroll_end'):
669
+ if messages_container:
431
670
  messages_container.scroll_end(animate=False)
432
671
  except Exception:
433
672
  pass
434
- except Exception as refresh_err:
435
- debug_log(f"Error forcing final UI refresh: {str(refresh_err)}")
436
673
  except Exception as callback_err:
437
- debug_log(f"Error in final UI callback: {str(callback_err)}")
438
- logger.error(f"Error in final UI callback: {str(callback_err)}")
439
-
674
+ logger.error(f"Error in final generic UI callback: {str(callback_err)}")
675
+
676
+ # Final refresh to ensure everything is displayed correctly
440
677
  try:
441
678
  await asyncio.sleep(0.05)
442
- debug_log("Sending one final callback to ensure UI refresh")
443
- await callback(full_response)
444
- if hasattr(app, 'refresh'):
445
- app.refresh(layout=True)
446
- except Exception as final_err:
447
- debug_log(f"Error in final extra callback: {str(final_err)}")
448
-
449
- debug_log(f"Streaming response completed successfully. Response length: {len(full_response)}")
450
- logger.info(f"Streaming response completed successfully. Response length: {len(full_response)}")
679
+ async with update_lock:
680
+ await callback(full_response)
681
+ if hasattr(app, 'refresh'):
682
+ app.refresh(layout=True)
683
+ except Exception:
684
+ pass
685
+
451
686
  return full_response
452
-
687
+
453
688
  except asyncio.CancelledError:
454
- debug_log(f"Streaming response task cancelled. Partial response length: {len(full_response)}")
455
- logger.info(f"Streaming response task cancelled. Partial response length: {len(full_response)}")
689
+ logger.info(f"Generic streaming cancelled. Partial response length: {len(full_response)}")
456
690
  if hasattr(client, 'cancel_stream'):
457
- debug_log("Calling client.cancel_stream() after cancellation")
458
- try:
459
- await client.cancel_stream()
460
- debug_log("Successfully cancelled client stream")
461
- except Exception as cancel_err:
462
- debug_log(f"Error cancelling client stream: {str(cancel_err)}")
691
+ await client.cancel_stream()
463
692
  return full_response
464
-
693
+
465
694
  except Exception as e:
466
- debug_log(f"Error during streaming response: {str(e)}")
467
- logger.error(f"Error during streaming response: {str(e)}")
695
+ logger.error(f"Error during generic streaming: {str(e)}")
468
696
  if hasattr(client, 'cancel_stream'):
469
- debug_log("Attempting to cancel client stream after error")
470
- try:
471
- await client.cancel_stream()
472
- debug_log("Successfully cancelled client stream after error")
473
- except Exception as cancel_err:
474
- debug_log(f"Error cancelling client stream after error: {str(cancel_err)}")
697
+ await client.cancel_stream()
475
698
  raise
476
699
 
477
- finally:
478
- debug_log("generate_streaming_response worker finished or errored.")
479
- if 'full_response' in locals():
480
- return full_response
481
- return None
700
+ # Worker function for streaming response generation
701
+ async def generate_streaming_response(
702
+ app: 'SimpleChatApp',
703
+ messages: List[Dict],
704
+ model: str,
705
+ style: str,
706
+ client: Any,
707
+ callback: Callable[[str], Awaitable[None]]
708
+ ) -> Optional[str]:
709
+ """
710
+ Generate a streaming response from the model (as a Textual worker).
711
+ Refactored to be a coroutine, not an async generator.
712
+ """
713
+ try:
714
+ from app.main import debug_log
715
+ except ImportError:
716
+ debug_log = lambda msg: None
717
+
718
+ logger.info(f"Starting streaming response with model: {model}")
719
+ debug_log(f"Starting streaming response with model: '{model}', client type: {type(client).__name__}")
720
+
721
+ # Validate messages
722
+ if not messages:
723
+ debug_log("Error: messages list is empty")
724
+ raise ValueError("Messages list cannot be empty")
725
+
726
+ # Ensure all messages have required fields
727
+ for i, msg in enumerate(messages):
728
+ try:
729
+ debug_log(f"Message {i}: role={msg.get('role', 'missing')}, content_len={len(msg.get('content', ''))}")
730
+ if 'role' not in msg:
731
+ debug_log(f"Adding missing 'role' to message {i}")
732
+ msg['role'] = 'user'
733
+ if 'content' not in msg:
734
+ debug_log(f"Adding missing 'content' to message {i}")
735
+ msg['content'] = ''
736
+ except Exception as e:
737
+ debug_log(f"Error checking message {i}: {str(e)}")
738
+ messages[i] = {
739
+ 'role': 'user',
740
+ 'content': str(msg) if msg else ''
741
+ }
742
+ debug_log(f"Repaired message {i}")
743
+
744
+ # Create a lock for synchronizing UI updates
745
+ update_lock = asyncio.Lock()
746
+
747
+ # Validate client
748
+ if client is None:
749
+ debug_log("Error: client is None, cannot proceed with streaming")
750
+ raise ValueError("Model client is None, cannot proceed with streaming")
751
+
752
+ if not hasattr(client, 'generate_stream'):
753
+ debug_log(f"Error: client {type(client).__name__} does not have generate_stream method")
754
+ raise ValueError(f"Client {type(client).__name__} does not support streaming")
755
+
756
+ # Explicitly check provider type first
757
+ is_ollama = 'ollama' in str(type(client)).lower()
758
+ is_openai = 'openai' in str(type(client)).lower()
759
+ is_anthropic = 'anthropic' in str(type(client)).lower()
760
+
761
+ debug_log(f"Client types - Ollama: {is_ollama}, OpenAI: {is_openai}, Anthropic: {is_anthropic}")
762
+
763
+ # Use separate implementations for each provider
764
+ try:
765
+ if is_openai:
766
+ debug_log("Using OpenAI-specific streaming implementation")
767
+ return await _generate_openai_stream(app, messages, model, style, client, callback, update_lock)
768
+ elif is_anthropic:
769
+ debug_log("Using Anthropic-specific streaming implementation")
770
+ return await _generate_anthropic_stream(app, messages, model, style, client, callback, update_lock)
771
+ elif is_ollama:
772
+ debug_log("Using Ollama-specific streaming implementation")
773
+ return await _generate_ollama_stream(app, messages, model, style, client, callback, update_lock)
774
+ else:
775
+ # Generic fallback
776
+ debug_log("Using generic streaming implementation")
777
+ return await _generate_generic_stream(app, messages, model, style, client, callback, update_lock)
778
+ except asyncio.CancelledError:
779
+ debug_log("Task cancellation detected in main streaming function")
780
+ if hasattr(client, 'cancel_stream'):
781
+ await client.cancel_stream()
782
+ raise
783
+ except Exception as e:
784
+ debug_log(f"Error in streaming implementation: {str(e)}")
785
+ logger.error(f"Error in streaming implementation: {str(e)}")
786
+ raise
482
787
 
483
788
  async def ensure_ollama_running() -> bool:
484
789
  """
@@ -555,6 +860,39 @@ def resolve_model_id(model_id_or_name: str) -> str:
555
860
  input_lower = model_id_or_name.lower().strip()
556
861
  logger.info(f"Attempting to resolve model identifier: '{input_lower}'")
557
862
 
863
+ # Add special case handling for common OpenAI models
864
+ openai_model_aliases = {
865
+ "04-mini": "gpt-4-mini", # Fix "04-mini" typo to "gpt-4-mini"
866
+ "04": "gpt-4",
867
+ "04-vision": "gpt-4-vision",
868
+ "04-turbo": "gpt-4-turbo",
869
+ "035": "gpt-3.5-turbo",
870
+ "35-turbo": "gpt-3.5-turbo",
871
+ "35": "gpt-3.5-turbo"
872
+ }
873
+
874
+ if input_lower in openai_model_aliases:
875
+ resolved = openai_model_aliases[input_lower]
876
+ logger.info(f"Resolved '{input_lower}' to '{resolved}' via OpenAI model alias")
877
+ return resolved
878
+
879
+ # Special case handling for common typos and model name variations
880
+ typo_corrections = {
881
+ "o4-mini": "04-mini",
882
+ "o1": "01",
883
+ "o1-mini": "01-mini",
884
+ "o1-preview": "01-preview",
885
+ "o4": "04",
886
+ "o4-preview": "04-preview",
887
+ "o4-vision": "04-vision"
888
+ }
889
+
890
+ if input_lower in typo_corrections:
891
+ corrected = typo_corrections[input_lower]
892
+ logger.info(f"Converting '{input_lower}' to '{corrected}' (letter 'o' to zero '0')")
893
+ input_lower = corrected
894
+ model_id_or_name = corrected
895
+
558
896
  # First, check if this is an OpenAI model - if so, return as-is to ensure correct provider
559
897
  if any(name in input_lower for name in ["gpt", "text-", "davinci"]):
560
898
  logger.info(f"Input '{input_lower}' appears to be an OpenAI model, returning as-is")