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/__init__.py +1 -1
- app/api/anthropic.py +180 -195
- app/api/base.py +5 -0
- app/api/ollama.py +52 -0
- app/api/openai.py +31 -0
- app/config.py +29 -26
- app/main.py +20 -4
- app/ui/chat_interface.py +55 -48
- app/utils.py +548 -210
- {chat_console-0.3.8.dist-info → chat_console-0.3.91.dist-info}/METADATA +1 -1
- chat_console-0.3.91.dist-info/RECORD +24 -0
- chat_console-0.3.8.dist-info/RECORD +0 -24
- {chat_console-0.3.8.dist-info → chat_console-0.3.91.dist-info}/WHEEL +0 -0
- {chat_console-0.3.8.dist-info → chat_console-0.3.91.dist-info}/entry_points.txt +0 -0
- {chat_console-0.3.8.dist-info → chat_console-0.3.91.dist-info}/licenses/LICENSE +0 -0
- {chat_console-0.3.8.dist-info → chat_console-0.3.91.dist-info}/top_level.txt +0 -0
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
|
-
#
|
205
|
-
async def
|
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
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
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
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
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.
|
253
|
-
|
340
|
+
update_interval = 0.03 # Responsive updates for Anthropic
|
341
|
+
|
254
342
|
try:
|
255
|
-
#
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
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
|
-
|
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
|
-
|
272
|
-
|
273
|
-
|
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
|
-
|
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
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
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
|
-
|
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
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
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
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
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
|
-
|
380
|
-
|
381
|
-
|
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
|
-
|
388
|
-
|
389
|
-
|
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
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
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
|
-
|
546
|
+
|
411
547
|
try:
|
412
|
-
|
413
|
-
|
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=
|
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
|
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
|
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
|
-
|
438
|
-
|
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
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
except Exception
|
447
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
478
|
-
|
479
|
-
|
480
|
-
|
481
|
-
|
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")
|