ltcai 4.3.3 → 4.5.1

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.
Files changed (138) hide show
  1. package/README.md +53 -20
  2. package/docs/CHANGELOG.md +122 -0
  3. package/docs/V4_4_0_EXTRACTION_REPORT.md +239 -0
  4. package/docs/V4_5_0_GEMMA_RUNTIME_COMPATIBILITY_REPORT.md +49 -0
  5. package/docs/V4_5_0_GRAPH_UX_REPORT.md +34 -0
  6. package/docs/V4_5_0_MODEL_RUNTIME_UX_REPORT.md +40 -0
  7. package/docs/V4_5_0_ONBOARDING_REPORT.md +31 -0
  8. package/docs/V4_5_0_PRODUCT_EXPERIENCE_RECOVERY_REPORT.md +49 -0
  9. package/docs/V4_5_0_VALIDATION_REPORT.md +60 -0
  10. package/docs/V4_5_1_GRAPH_EXPERIENCE_REPORT.md +33 -0
  11. package/docs/V4_5_1_MODEL_EXPERIENCE_REPORT.md +37 -0
  12. package/docs/V4_5_1_NAVIGATION_REPORT.md +37 -0
  13. package/docs/V4_5_1_ONBOARDING_REPORT.md +29 -0
  14. package/docs/V4_5_1_PRODUCT_REIMAGINING_REPORT.md +61 -0
  15. package/docs/V4_5_1_RC_ARTIFACTS.md +44 -0
  16. package/docs/V4_5_1_UX_REPORT.md +45 -0
  17. package/docs/V4_5_1_VALIDATION_REPORT.md +54 -0
  18. package/docs/V4_5_1_VISUAL_DESIGN_REPORT.md +30 -0
  19. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +16 -16
  20. package/docs/architecture.md +8 -4
  21. package/frontend/src/App.tsx +152 -91
  22. package/frontend/src/api/client.ts +83 -1
  23. package/frontend/src/components/FirstRunGuide.tsx +99 -0
  24. package/frontend/src/components/primitives.tsx +131 -25
  25. package/frontend/src/components/ui/badge.tsx +2 -2
  26. package/frontend/src/components/ui/button.tsx +7 -7
  27. package/frontend/src/components/ui/card.tsx +5 -5
  28. package/frontend/src/components/ui/input.tsx +1 -1
  29. package/frontend/src/components/ui/textarea.tsx +1 -1
  30. package/frontend/src/pages/Act.tsx +58 -28
  31. package/frontend/src/pages/Ask.tsx +51 -19
  32. package/frontend/src/pages/Brain.tsx +60 -42
  33. package/frontend/src/pages/Capture.tsx +24 -24
  34. package/frontend/src/pages/Library.tsx +222 -32
  35. package/frontend/src/pages/System.tsx +56 -34
  36. package/frontend/src/routes.ts +15 -13
  37. package/frontend/src/store/appStore.ts +8 -1
  38. package/frontend/src/styles.css +666 -36
  39. package/lattice_brain/__init__.py +38 -23
  40. package/lattice_brain/_kg_common.py +11 -1
  41. package/lattice_brain/context.py +212 -2
  42. package/lattice_brain/conversations.py +234 -1
  43. package/lattice_brain/discovery.py +11 -1
  44. package/lattice_brain/documents.py +11 -1
  45. package/lattice_brain/graph/__init__.py +28 -0
  46. package/lattice_brain/graph/_kg_common.py +1123 -0
  47. package/lattice_brain/graph/curator.py +473 -0
  48. package/lattice_brain/graph/discovery.py +1455 -0
  49. package/lattice_brain/graph/documents.py +218 -0
  50. package/lattice_brain/graph/identity.py +175 -0
  51. package/lattice_brain/graph/ingest.py +644 -0
  52. package/lattice_brain/graph/network.py +205 -0
  53. package/lattice_brain/graph/projection.py +571 -0
  54. package/lattice_brain/graph/provenance.py +401 -0
  55. package/lattice_brain/graph/retrieval.py +1341 -0
  56. package/lattice_brain/graph/schema.py +640 -0
  57. package/lattice_brain/graph/store.py +237 -0
  58. package/lattice_brain/graph/write_master.py +225 -0
  59. package/lattice_brain/identity.py +11 -13
  60. package/lattice_brain/ingest.py +11 -1
  61. package/lattice_brain/ingestion.py +318 -0
  62. package/lattice_brain/memory.py +100 -1
  63. package/lattice_brain/network.py +11 -1
  64. package/lattice_brain/portability.py +431 -0
  65. package/lattice_brain/projection.py +11 -1
  66. package/lattice_brain/provenance.py +11 -1
  67. package/lattice_brain/retrieval.py +11 -1
  68. package/lattice_brain/runtime/__init__.py +32 -0
  69. package/lattice_brain/runtime/agent_runtime.py +569 -0
  70. package/lattice_brain/runtime/hooks.py +754 -0
  71. package/lattice_brain/runtime/multi_agent.py +795 -0
  72. package/lattice_brain/schema.py +11 -1
  73. package/lattice_brain/store.py +10 -2
  74. package/lattice_brain/workflow.py +461 -0
  75. package/lattice_brain/write_master.py +11 -1
  76. package/latticeai/__init__.py +1 -1
  77. package/latticeai/api/agents.py +2 -2
  78. package/latticeai/api/browser.py +1 -1
  79. package/latticeai/api/chat.py +1 -1
  80. package/latticeai/api/computer_use.py +1 -1
  81. package/latticeai/api/hooks.py +2 -2
  82. package/latticeai/api/mcp.py +1 -1
  83. package/latticeai/api/models.py +107 -18
  84. package/latticeai/api/tools.py +1 -1
  85. package/latticeai/api/workflow_designer.py +2 -2
  86. package/latticeai/app_factory.py +4 -4
  87. package/latticeai/brain/__init__.py +24 -6
  88. package/latticeai/brain/_kg_common.py +11 -1117
  89. package/latticeai/brain/context.py +12 -208
  90. package/latticeai/brain/conversations.py +12 -231
  91. package/latticeai/brain/discovery.py +13 -1451
  92. package/latticeai/brain/documents.py +13 -214
  93. package/latticeai/brain/identity.py +11 -169
  94. package/latticeai/brain/ingest.py +13 -640
  95. package/latticeai/brain/memory.py +12 -97
  96. package/latticeai/brain/network.py +12 -200
  97. package/latticeai/brain/projection.py +13 -567
  98. package/latticeai/brain/provenance.py +13 -397
  99. package/latticeai/brain/retrieval.py +13 -1337
  100. package/latticeai/brain/schema.py +12 -635
  101. package/latticeai/brain/store.py +13 -233
  102. package/latticeai/brain/write_master.py +13 -221
  103. package/latticeai/core/agent.py +1 -1
  104. package/latticeai/core/agent_registry.py +2 -2
  105. package/latticeai/core/builtin_hooks.py +2 -2
  106. package/latticeai/core/graph_curator.py +6 -468
  107. package/latticeai/core/hooks.py +6 -749
  108. package/latticeai/core/marketplace.py +1 -1
  109. package/latticeai/core/model_compat.py +250 -0
  110. package/latticeai/core/multi_agent.py +6 -790
  111. package/latticeai/core/workflow_engine.py +6 -456
  112. package/latticeai/core/workspace_os.py +1 -1
  113. package/latticeai/models/router.py +136 -32
  114. package/latticeai/services/agent_runtime.py +6 -564
  115. package/latticeai/services/ingestion.py +6 -313
  116. package/latticeai/services/kg_portability.py +6 -426
  117. package/latticeai/services/model_catalog.py +2 -2
  118. package/latticeai/services/model_recommendation.py +8 -1
  119. package/latticeai/services/model_runtime.py +18 -3
  120. package/latticeai/services/platform_runtime.py +3 -3
  121. package/latticeai/services/run_executor.py +1 -1
  122. package/latticeai/services/upload_service.py +1 -1
  123. package/p_reinforce.py +1 -1
  124. package/package.json +1 -1
  125. package/scripts/build_frontend_assets.mjs +12 -1
  126. package/scripts/bump_version.py +1 -1
  127. package/scripts/wheel_smoke.py +7 -0
  128. package/src-tauri/Cargo.lock +1 -1
  129. package/src-tauri/Cargo.toml +1 -1
  130. package/src-tauri/tauri.conf.json +1 -1
  131. package/static/app/asset-manifest.json +5 -5
  132. package/static/app/assets/index-3G8qcrIS.js +336 -0
  133. package/static/app/assets/index-3G8qcrIS.js.map +1 -0
  134. package/static/app/assets/index-C0wYZp7k.css +2 -0
  135. package/static/app/index.html +2 -2
  136. package/static/app/assets/index-CHHal8Zl.css +0 -2
  137. package/static/app/assets/index-pdzil9ac.js +0 -333
  138. package/static/app/assets/index-pdzil9ac.js.map +0 -1
@@ -6,6 +6,7 @@ import asyncio
6
6
  import base64
7
7
  import gc
8
8
  import io
9
+ import json
9
10
  import os
10
11
  import re
11
12
  import time
@@ -29,15 +30,28 @@ executor = ThreadPoolExecutor(max_workers=1)
29
30
 
30
31
  try:
31
32
  import mlx.core as mx
33
+ except Exception as e:
34
+ mx = None
35
+ print(f"⚠️ MLX core unavailable: {e}")
36
+
37
+ try:
32
38
  from mlx_vlm import load as vlm_load
33
39
  VLM_AVAILABLE = True
34
40
  print("✅ MLX-VLM is ready for multimodal models.")
35
41
  except Exception as e:
36
- mx = None
37
42
  vlm_load = None
38
43
  VLM_AVAILABLE = False
39
44
  print(f"⚠️ MLX-VLM unavailable: {e}")
40
45
 
46
+ try:
47
+ from mlx_lm import load as lm_load
48
+ LM_AVAILABLE = True
49
+ print("✅ MLX-LM is ready for text fallback models.")
50
+ except Exception as e:
51
+ lm_load = None
52
+ LM_AVAILABLE = False
53
+ print(f"⚠️ MLX-LM unavailable: {e}")
54
+
41
55
  BRAND_NAME = "Lattice AI"
42
56
  LEGACY_BRAND_PATTERNS = [
43
57
  (re.compile(r"\bconnect\s+ai\b", re.IGNORECASE), BRAND_NAME),
@@ -236,20 +250,63 @@ def _resolve_local_hf_model(model_id: str) -> str:
236
250
  return str(local_dir)
237
251
  return model_id
238
252
 
253
+ def _is_gemma4_model_id(model_id: str) -> bool:
254
+ raw = str(model_id or "").lower()
255
+ return bool(re.search(r"gemma[-_/ ]?4|gemma4", raw))
256
+
257
+
258
+ def _local_model_type(path_or_model_id: str) -> Optional[str]:
259
+ raw = str(path_or_model_id or "").strip()
260
+ candidates = []
261
+ explicit = Path(raw).expanduser()
262
+ if raw and explicit.exists():
263
+ candidates.append(explicit / "config.json")
264
+ candidates.append(hf_model_dir(raw) / "config.json")
265
+ for config_path in candidates:
266
+ try:
267
+ if config_path.exists():
268
+ data = json.loads(config_path.read_text(encoding="utf-8"))
269
+ model_type = str(data.get("model_type") or "").strip().lower()
270
+ if model_type:
271
+ return model_type
272
+ except Exception as e:
273
+ print(f"⚠️ Model config read skipped for {config_path}: {e}")
274
+ return None
275
+
276
+
239
277
  def ensure_mlx_runtime() -> None:
240
- global mx, vlm_load, VLM_AVAILABLE
241
- if mx is not None and vlm_load is not None:
278
+ global mx, vlm_load, lm_load, VLM_AVAILABLE, LM_AVAILABLE
279
+ if mx is not None and (vlm_load is not None or lm_load is not None):
242
280
  return
281
+ errors = []
243
282
  try:
244
283
  import mlx.core as mlx_core
245
- from mlx_vlm import load as mlx_vlm_load
246
-
247
284
  mx = mlx_core
285
+ mx.set_default_device(mx.gpu)
286
+ except Exception as e:
287
+ errors.append(f"mlx: {e}")
288
+ mx = None
289
+
290
+ try:
291
+ from mlx_vlm import load as mlx_vlm_load
248
292
  vlm_load = mlx_vlm_load
249
293
  VLM_AVAILABLE = True
250
- mx.set_default_device(mx.gpu)
251
294
  except Exception as e:
252
- raise RuntimeError(f"MLX-VLM runtime is not available after install: {e}") from e
295
+ vlm_load = None
296
+ VLM_AVAILABLE = False
297
+ errors.append(f"mlx-vlm: {e}")
298
+
299
+ try:
300
+ from mlx_lm import load as mlx_lm_load
301
+ lm_load = mlx_lm_load
302
+ LM_AVAILABLE = True
303
+ except Exception as e:
304
+ lm_load = None
305
+ LM_AVAILABLE = False
306
+ errors.append(f"mlx-lm: {e}")
307
+
308
+ if mx is None or (vlm_load is None and lm_load is None):
309
+ raise RuntimeError(f"MLX runtime is not available after install: {'; '.join(errors)}")
253
310
 
254
311
  def _mlx_sampler(temperature: float):
255
312
  """Build an MLX sampler callable for the given temperature.
@@ -353,8 +410,8 @@ class LLMRouter:
353
410
  return self._load_cloud_model(provider, provider_model, api_key_override=api_key_override, owner=owner)
354
411
 
355
412
  ensure_mlx_runtime()
356
- if mx is None or vlm_load is None:
357
- raise RuntimeError("MLX-VLM is not available in this process. Run on Apple Silicon with Metal access.")
413
+ if mx is None or (vlm_load is None and lm_load is None):
414
+ raise RuntimeError("MLX is not available in this process. Run on Apple Silicon with Metal access.")
358
415
 
359
416
  cache_key = f"{model_id}_{draft_model_id}" if draft_model_id else model_id
360
417
  if cache_key in self._cache:
@@ -370,25 +427,43 @@ class LLMRouter:
370
427
 
371
428
  def _load():
372
429
  mx.set_default_device(mx.gpu)
373
- print(f"🔄 Loading Target (VLM Mode): {target_model_id}...")
374
- model, tokenizer = vlm_load(target_model_id)
430
+ is_gemma4 = _is_gemma4_model_id(model_id)
431
+ model_type = _local_model_type(target_model_id) or _local_model_type(model_id)
432
+ loader_kind = "mlx_vlm"
433
+
434
+ try:
435
+ if vlm_load is None:
436
+ raise RuntimeError("MLX-VLM is not installed.")
437
+ print(f"🔄 Loading Target (VLM Mode): {target_model_id}...")
438
+ model, tokenizer = vlm_load(target_model_id)
439
+ except Exception as vlm_error:
440
+ if not (is_gemma4 and model_type != "gemma4_unified" and lm_load is not None):
441
+ raise
442
+ print(f"⚠️ Gemma 4 MLX-VLM load failed; retrying MLX-LM text path: {vlm_error}")
443
+ print(f"🔄 Loading Target (LM Mode): {target_model_id}...")
444
+ model, tokenizer = lm_load(target_model_id)
445
+ loader_kind = "mlx_lm"
375
446
 
376
447
  draft_model = None
377
448
  if target_draft_model_id:
378
- print(f"🔄 Loading Assistant (VLM Mode): {target_draft_model_id}...")
379
- draft_model, _ = vlm_load(target_draft_model_id)
449
+ if loader_kind == "mlx_vlm":
450
+ print(f"🔄 Loading Assistant (VLM Mode): {target_draft_model_id}...")
451
+ draft_model, _ = vlm_load(target_draft_model_id)
452
+ elif lm_load is not None:
453
+ print(f"🔄 Loading Assistant (LM Mode): {target_draft_model_id}...")
454
+ draft_model, _ = lm_load(target_draft_model_id)
380
455
  print("✅ Assistant Ready.")
381
456
 
382
- return model, tokenizer, draft_model
457
+ return model, tokenizer, draft_model, loader_kind
383
458
 
384
459
  try:
385
460
  # Use the dedicated single-thread executor to ensure MLX GPU streams match during inference
386
- model, tokenizer, draft_model = await loop.run_in_executor(executor, _load)
387
- self._cache[cache_key] = (model, tokenizer, draft_model)
461
+ model, tokenizer, draft_model, loader_kind = await loop.run_in_executor(executor, _load)
462
+ self._cache[cache_key] = (model, tokenizer, draft_model, loader_kind)
388
463
  self._current = cache_key
389
464
  self._touch(cache_key)
390
- print(f"✅ Fully Loaded: {cache_key}")
391
- return f"Success: {cache_key}"
465
+ print(f"✅ Fully Loaded: {cache_key} ({loader_kind})")
466
+ return f"Success: {cache_key} ({loader_kind})"
392
467
  except Exception as e:
393
468
  print(f"❌ Load Error: {e}")
394
469
  raise e
@@ -510,6 +585,11 @@ class LLMRouter:
510
585
  print(f"⚠️ VLM chat template fallback: {e}")
511
586
  return self._build_prompt(message, context, processor)
512
587
 
588
+ def _unpack_local_cache(self, cached: Tuple) -> Tuple[object, object, object, str]:
589
+ model, tokenizer, draft_model = cached[:3]
590
+ loader_kind = str(cached[3]) if len(cached) > 3 else "mlx_vlm"
591
+ return model, tokenizer, draft_model, loader_kind
592
+
513
593
  async def generate_as(self, model_id: str | None, message: str, context: Optional[str] = None, max_tokens: int = 4096, temperature: float = 0.2) -> str:
514
594
  """Generate using a specific model, temporarily switching if needed. Falls back to current model if model_id is None or not loaded."""
515
595
  if not model_id or model_id == self._current:
@@ -531,16 +611,24 @@ class LLMRouter:
531
611
  if isinstance(cached, CloudModel):
532
612
  return await self._cloud_generate(cached, message, context, max_tokens, temperature)
533
613
 
534
- model, tokenizer, draft_model = self._cache[self._current]
535
- prompt = self._build_vlm_prompt(model, tokenizer, message, context, 1 if image_data else 0)
614
+ model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(self._cache[self._current])
615
+ use_vlm = loader_kind == "mlx_vlm"
616
+ prompt = (
617
+ self._build_vlm_prompt(model, tokenizer, message, context, 1 if image_data else 0)
618
+ if use_vlm
619
+ else self._build_prompt(message, context, tokenizer)
620
+ )
536
621
 
537
622
  loop = asyncio.get_event_loop()
538
623
 
539
624
  def _gen():
540
625
  import mlx.core as mx
541
626
  mx.set_default_device(mx.gpu)
542
- from mlx_vlm import generate as vlm_gen
543
- return vlm_gen(model, tokenizer, prompt=prompt, image=self._prep_image(image_data) if image_data else None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
627
+ if use_vlm:
628
+ from mlx_vlm import generate as vlm_gen
629
+ return vlm_gen(model, tokenizer, prompt=prompt, image=self._prep_image(image_data) if image_data else None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
630
+ from mlx_lm import generate as lm_gen
631
+ return lm_gen(model, tokenizer, prompt=prompt, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model)
544
632
  result = await loop.run_in_executor(executor, _gen)
545
633
  # mlx-vlm might return a GenerationResult object; extract the text
546
634
  if hasattr(result, "text"):
@@ -577,8 +665,13 @@ class LLMRouter:
577
665
  yield chunk
578
666
  return
579
667
 
580
- model, tokenizer, draft_model = self._cache[self._current]
581
- prompt = self._build_vlm_prompt(model, tokenizer, message, context, 1 if image_data else 0)
668
+ model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(self._cache[self._current])
669
+ use_vlm = loader_kind == "mlx_vlm"
670
+ prompt = (
671
+ self._build_vlm_prompt(model, tokenizer, message, context, 1 if image_data else 0)
672
+ if use_vlm
673
+ else self._build_prompt(message, context, tokenizer)
674
+ )
582
675
  loop = asyncio.get_event_loop()
583
676
  queue = asyncio.Queue()
584
677
 
@@ -586,8 +679,12 @@ class LLMRouter:
586
679
  import mlx.core as mx
587
680
  mx.set_default_device(mx.gpu)
588
681
  try:
589
- from mlx_vlm import stream_generate as vlm_stream
590
- gen = vlm_stream(model, tokenizer, prompt=prompt, image=self._prep_image(image_data) if image_data else None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
682
+ if use_vlm:
683
+ from mlx_vlm import stream_generate as vlm_stream
684
+ gen = vlm_stream(model, tokenizer, prompt=prompt, image=self._prep_image(image_data) if image_data else None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
685
+ else:
686
+ from mlx_lm import stream_generate as lm_stream
687
+ gen = lm_stream(model, tokenizer, prompt=prompt, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model)
591
688
 
592
689
  for chunk in gen:
593
690
  text = chunk.text if hasattr(chunk, "text") else (chunk[0] if isinstance(chunk, tuple) else str(chunk))
@@ -660,7 +757,7 @@ class LLMRouter:
660
757
  if isinstance(cached, CloudModel):
661
758
  return await self._cloud_generate_document(cached, message, system_prompt, max_tokens, temperature)
662
759
 
663
- model, tokenizer, draft_model = cached
760
+ model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(cached)
664
761
  if hasattr(tokenizer, "apply_chat_template"):
665
762
  try:
666
763
  msgs = [
@@ -677,8 +774,11 @@ class LLMRouter:
677
774
  def _gen():
678
775
  import mlx.core as mx
679
776
  mx.set_default_device(mx.gpu)
680
- from mlx_vlm import generate as vlm_gen
681
- return vlm_gen(model, tokenizer, prompt=prompt, image=None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
777
+ if loader_kind == "mlx_vlm":
778
+ from mlx_vlm import generate as vlm_gen
779
+ return vlm_gen(model, tokenizer, prompt=prompt, image=None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
780
+ from mlx_lm import generate as lm_gen
781
+ return lm_gen(model, tokenizer, prompt=prompt, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model)
682
782
  result = await loop.run_in_executor(executor, _gen)
683
783
  if hasattr(result, "text"):
684
784
  return normalize_branding(result.text)
@@ -719,7 +819,7 @@ class LLMRouter:
719
819
  yield chunk
720
820
  return
721
821
 
722
- model, tokenizer, draft_model = cached
822
+ model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(cached)
723
823
  if hasattr(tokenizer, "apply_chat_template"):
724
824
  try:
725
825
  msgs = [
@@ -739,8 +839,12 @@ class LLMRouter:
739
839
  import mlx.core as mx
740
840
  mx.set_default_device(mx.gpu)
741
841
  try:
742
- from mlx_vlm import stream_generate as vlm_stream
743
- gen = vlm_stream(model, tokenizer, prompt=prompt, image=None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
842
+ if loader_kind == "mlx_vlm":
843
+ from mlx_vlm import stream_generate as vlm_stream
844
+ gen = vlm_stream(model, tokenizer, prompt=prompt, image=None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
845
+ else:
846
+ from mlx_lm import stream_generate as lm_stream
847
+ gen = lm_stream(model, tokenizer, prompt=prompt, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model)
744
848
  for chunk in gen:
745
849
  text = chunk.text if hasattr(chunk, "text") else (chunk[0] if isinstance(chunk, tuple) else str(chunk))
746
850
  loop.call_soon_threadsafe(queue.put_nowait, text)