lollms-client 0.33.0__py3-none-any.whl → 1.0.0__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.

Potentially problematic release.


This version of lollms-client might be problematic. Click here for more details.

Files changed (73) hide show
  1. lollms_client/__init__.py +1 -1
  2. lollms_client/llm_bindings/azure_openai/__init__.py +6 -10
  3. lollms_client/llm_bindings/claude/__init__.py +4 -7
  4. lollms_client/llm_bindings/gemini/__init__.py +3 -7
  5. lollms_client/llm_bindings/grok/__init__.py +3 -7
  6. lollms_client/llm_bindings/groq/__init__.py +4 -6
  7. lollms_client/llm_bindings/hugging_face_inference_api/__init__.py +4 -6
  8. lollms_client/llm_bindings/litellm/__init__.py +15 -6
  9. lollms_client/llm_bindings/llamacpp/__init__.py +27 -9
  10. lollms_client/llm_bindings/lollms/__init__.py +24 -14
  11. lollms_client/llm_bindings/lollms_webui/__init__.py +6 -12
  12. lollms_client/llm_bindings/mistral/__init__.py +3 -5
  13. lollms_client/llm_bindings/ollama/__init__.py +6 -11
  14. lollms_client/llm_bindings/open_router/__init__.py +4 -6
  15. lollms_client/llm_bindings/openai/__init__.py +7 -14
  16. lollms_client/llm_bindings/openllm/__init__.py +12 -12
  17. lollms_client/llm_bindings/pythonllamacpp/__init__.py +1 -1
  18. lollms_client/llm_bindings/tensor_rt/__init__.py +8 -13
  19. lollms_client/llm_bindings/transformers/__init__.py +14 -6
  20. lollms_client/llm_bindings/vllm/__init__.py +16 -12
  21. lollms_client/lollms_core.py +296 -487
  22. lollms_client/lollms_discussion.py +431 -78
  23. lollms_client/lollms_llm_binding.py +191 -380
  24. lollms_client/lollms_mcp_binding.py +33 -2
  25. lollms_client/mcp_bindings/local_mcp/__init__.py +3 -2
  26. lollms_client/mcp_bindings/remote_mcp/__init__.py +6 -5
  27. lollms_client/mcp_bindings/standard_mcp/__init__.py +3 -5
  28. lollms_client/stt_bindings/lollms/__init__.py +6 -8
  29. lollms_client/stt_bindings/whisper/__init__.py +2 -4
  30. lollms_client/stt_bindings/whispercpp/__init__.py +15 -16
  31. lollms_client/tti_bindings/dalle/__init__.py +29 -28
  32. lollms_client/tti_bindings/diffusers/__init__.py +25 -21
  33. lollms_client/tti_bindings/gemini/__init__.py +215 -0
  34. lollms_client/tti_bindings/lollms/__init__.py +8 -9
  35. lollms_client-1.0.0.dist-info/METADATA +1214 -0
  36. lollms_client-1.0.0.dist-info/RECORD +69 -0
  37. {lollms_client-0.33.0.dist-info → lollms_client-1.0.0.dist-info}/top_level.txt +0 -2
  38. examples/article_summary/article_summary.py +0 -58
  39. examples/console_discussion/console_app.py +0 -266
  40. examples/console_discussion.py +0 -448
  41. examples/deep_analyze/deep_analyse.py +0 -30
  42. examples/deep_analyze/deep_analyze_multiple_files.py +0 -32
  43. examples/function_calling_with_local_custom_mcp.py +0 -250
  44. examples/generate_a_benchmark_for_safe_store.py +0 -89
  45. examples/generate_and_speak/generate_and_speak.py +0 -251
  46. examples/generate_game_sfx/generate_game_fx.py +0 -240
  47. examples/generate_text_with_multihop_rag_example.py +0 -210
  48. examples/gradio_chat_app.py +0 -228
  49. examples/gradio_lollms_chat.py +0 -259
  50. examples/internet_search_with_rag.py +0 -226
  51. examples/lollms_chat/calculator.py +0 -59
  52. examples/lollms_chat/derivative.py +0 -48
  53. examples/lollms_chat/test_openai_compatible_with_lollms_chat.py +0 -12
  54. examples/lollms_discussions_test.py +0 -155
  55. examples/mcp_examples/external_mcp.py +0 -267
  56. examples/mcp_examples/local_mcp.py +0 -171
  57. examples/mcp_examples/openai_mcp.py +0 -203
  58. examples/mcp_examples/run_remote_mcp_example_v2.py +0 -290
  59. examples/mcp_examples/run_standard_mcp_example.py +0 -204
  60. examples/simple_text_gen_test.py +0 -173
  61. examples/simple_text_gen_with_image_test.py +0 -178
  62. examples/test_local_models/local_chat.py +0 -9
  63. examples/text_2_audio.py +0 -77
  64. examples/text_2_image.py +0 -144
  65. examples/text_2_image_diffusers.py +0 -274
  66. examples/text_and_image_2_audio.py +0 -59
  67. examples/text_gen.py +0 -30
  68. examples/text_gen_system_prompt.py +0 -29
  69. lollms_client-0.33.0.dist-info/METADATA +0 -854
  70. lollms_client-0.33.0.dist-info/RECORD +0 -101
  71. test/test_lollms_discussion.py +0 -368
  72. {lollms_client-0.33.0.dist-info → lollms_client-1.0.0.dist-info}/WHEEL +0 -0
  73. {lollms_client-0.33.0.dist-info → lollms_client-1.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -111,6 +111,8 @@ def create_dynamic_models(
111
111
  optionally including custom mixin classes for extending functionality and
112
112
  applying encryption to text fields if a key is provided.
113
113
 
114
+ Requires the 'cryptography' library to be installed.
115
+
114
116
  Args:
115
117
  discussion_mixin: An optional class to mix into the Discussion model.
116
118
  message_mixin: An optional class to mix into the Message model.
@@ -312,7 +314,7 @@ class LollmsDataManager:
312
314
  A list of dictionaries representing the matching discussions.
313
315
  """
314
316
  with self.get_session() as session:
315
- query = session.query(self.DiscussionModel)
317
+ query = query = session.query(self.DiscussionModel)
316
318
  for key, value in criteria.items():
317
319
  if hasattr(self.DiscussionModel, key):
318
320
  query = query.filter(getattr(self.DiscussionModel, key).ilike(f"%{value}%"))
@@ -521,6 +523,7 @@ class LollmsDiscussion:
521
523
  # --- END FIX ---
522
524
 
523
525
  self._rebuild_message_index()
526
+ self._validate_and_set_active_branch() # Call for initial load
524
527
 
525
528
  @classmethod
526
529
  def create_new(cls, lollms_client: 'LollmsClient', db_manager: Optional[LollmsDataManager] = None, **kwargs) -> 'LollmsDiscussion':
@@ -639,10 +642,124 @@ class LollmsDiscussion:
639
642
 
640
643
  def _rebuild_message_index(self):
641
644
  """Rebuilds the internal dictionary mapping message IDs to message objects."""
642
- if self._is_db_backed and self._session.is_active and self._db_discussion in self._session:
645
+ if self._is_db_backed and self._session and self._session.is_active and self._db_discussion in self._session:
646
+ # Ensure discussion object's messages collection is loaded/refreshed
643
647
  self._session.refresh(self._db_discussion, ['messages'])
644
648
  self._message_index = {msg.id: msg for msg in self._db_discussion.messages}
645
649
 
650
+ def _find_deepest_leaf(self, start_id: Optional[str]) -> Optional[str]:
651
+ """
652
+ Finds the ID of the most recent leaf message in the branch starting from start_id.
653
+ If start_id is None or not found, it finds the most recent leaf in the entire discussion.
654
+ A leaf message is one that has no children.
655
+ """
656
+ if not self._message_index:
657
+ return None
658
+
659
+ self._rebuild_message_index() # Ensure the internal index is up-to-date
660
+
661
+ # Build an adjacency list (children_of_parent_id -> [child1_id, child2_id])
662
+ children_of = {msg_id: [] for msg_id in self._message_index.keys()}
663
+ for msg_id, msg_obj in self._message_index.items():
664
+ if msg_obj.parent_id in children_of: # Only if parent exists in current index
665
+ children_of[msg_obj.parent_id].append(msg_id)
666
+
667
+ # Helper to find the most recent leaf from a list of messages
668
+ def get_most_recent_leaf_from_list(message_list: List[Any]) -> Optional[str]:
669
+ if not message_list:
670
+ return None
671
+ leaves_in_list = [msg for msg in message_list if not children_of.get(msg.id)]
672
+ if leaves_in_list:
673
+ return max(leaves_in_list, key=lambda msg: msg.created_at).id
674
+ return None
675
+
676
+ if start_id and start_id in self._message_index:
677
+ # Perform BFS to get all descendants including the start_id itself
678
+ queue = [self._message_index[start_id]]
679
+ visited_ids = {start_id}
680
+ descendants_and_self = [self._message_index[start_id]]
681
+
682
+ head = 0
683
+ while head < len(queue):
684
+ current_msg_obj = queue[head]
685
+ head += 1
686
+
687
+ for child_id in children_of.get(current_msg_obj.id, []):
688
+ if child_id not in visited_ids:
689
+ visited_ids.add(child_id)
690
+ child_obj = self._message_index[child_id]
691
+ queue.append(child_obj)
692
+ descendants_and_self.append(child_obj)
693
+
694
+ # Now find the most recent leaf among these descendants and the start_id itself
695
+ result_leaf_id = get_most_recent_leaf_from_list(descendants_and_self)
696
+ if result_leaf_id:
697
+ return result_leaf_id
698
+ else:
699
+ # If no actual leaves were found within the branch rooted at start_id,
700
+ # then start_id itself is the 'leaf' of its known subgraph IF it has no children.
701
+ if not children_of.get(start_id):
702
+ return start_id
703
+ return None # The start_id is not a leaf and has no reachable leaves.
704
+
705
+ else: # No specific starting point, find the most recent leaf in the entire discussion
706
+ all_messages_in_discussion = list(self._message_index.values())
707
+ return get_most_recent_leaf_from_list(all_messages_in_discussion)
708
+
709
+
710
+ def _validate_and_set_active_branch(self):
711
+ """
712
+ Ensures that self.active_branch_id points to an existing message and is a leaf message.
713
+ If it's None, points to a non-existent message, or points to a non-leaf message,
714
+ it attempts to set it to the ID of the most recently created leaf message in the entire discussion.
715
+ If a valid active_branch_id exists but is not a leaf, it will try to find the deepest leaf
716
+ from that point onwards.
717
+ This method directly updates the underlying _db_discussion object to avoid recursion.
718
+ """
719
+ self._rebuild_message_index() # Ensure index is fresh
720
+
721
+ # If the discussion is empty, silently set active_branch_id to None and exit.
722
+ if not self._message_index:
723
+ object.__setattr__(self._db_discussion, 'active_branch_id', None)
724
+ return
725
+
726
+ current_active_id = self._db_discussion.active_branch_id # Access direct attribute
727
+
728
+ # Case 1: Active branch ID is invalid or missing
729
+ if current_active_id is None or current_active_id not in self._message_index:
730
+ ASCIIColors.warning(f"Active branch ID '{current_active_id}' is invalid or missing for discussion {self.id}. Attempting to select a new leaf.")
731
+ new_active_leaf_id = self._find_deepest_leaf(None) # Find most recent leaf in entire discussion
732
+ if new_active_leaf_id:
733
+ object.__setattr__(self._db_discussion, 'active_branch_id', new_active_leaf_id)
734
+ ASCIIColors.success(f"New active branch ID for discussion {self.id} set to: {new_active_leaf_id} (most recent overall leaf).")
735
+ else:
736
+ # This else block should theoretically not be reached if _message_index is not empty,
737
+ # as _find_deepest_leaf(None) would find a leaf. Added for robustness.
738
+ object.__setattr__(self, '_db_discussion.active_branch_id', None) # Use setattr for direct ORM access
739
+ ASCIIColors.yellow(f"Could not find any leaf messages in discussion {self.id}. Active branch ID remains None.")
740
+
741
+ # Case 2: Active branch ID exists, but is it a leaf?
742
+ else:
743
+ # Determine if current_active_id is a leaf
744
+ children_of_current_active = []
745
+ for msg_obj in self._message_index.values():
746
+ if msg_obj.parent_id == current_active_id:
747
+ children_of_current_active.append(msg_obj.id)
748
+
749
+ if children_of_current_active: # If it has children, it's not a leaf
750
+ ASCIIColors.warning(f"Active branch ID '{current_active_id}' is not a leaf message. Finding deepest leaf from this point.")
751
+ new_active_leaf_id = self._find_deepest_leaf(current_active_id)
752
+ if new_active_leaf_id and new_active_leaf_id != current_active_id:
753
+ object.__setattr__(self._db_discussion, 'active_branch_id', new_active_leaf_id)
754
+ ASCIIColors.success(f"Active branch ID for discussion {self.id} updated to: {new_active_leaf_id} (deepest leaf descendant).")
755
+ elif new_active_leaf_id is None: # Should not happen if current_active_id exists
756
+ ASCIIColors.warning(f"Could not find a deeper leaf from '{current_active_id}'. Keeping current ID.")
757
+ else:
758
+ ASCIIColors.info(f"Active branch ID '{current_active_id}' is already the deepest leaf. No change needed.")
759
+ else:
760
+ ASCIIColors.info(f"Active branch ID '{current_active_id}' is already a leaf. No change needed.")
761
+
762
+
646
763
  def touch(self):
647
764
  """Marks the discussion as updated, persists images, and saves if autosave is on."""
648
765
  # Persist in-memory discussion images to the metadata field before saving.
@@ -676,7 +793,8 @@ class LollmsDiscussion:
676
793
 
677
794
  try:
678
795
  self._session.commit()
679
- self._rebuild_message_index()
796
+ self._rebuild_message_index() # Rebuild index after commit to reflect DB state
797
+ # self._validate_and_set_active_branch() # REMOVED: This validation is now handled by direct operations or on load.
680
798
  except Exception as e:
681
799
  self._session.rollback()
682
800
  raise e
@@ -735,8 +853,8 @@ class LollmsDiscussion:
735
853
  new_msg_orm = SimpleNamespace(**message_data)
736
854
  self._db_discussion.messages.append(new_msg_orm)
737
855
 
856
+ self.active_branch_id = msg_id # New message is always a leaf
738
857
  self._message_index[msg_id] = new_msg_orm
739
- self.active_branch_id = msg_id
740
858
  self.touch()
741
859
  return LollmsMessage(self, new_msg_orm)
742
860
 
@@ -754,6 +872,7 @@ class LollmsDiscussion:
754
872
 
755
873
  branch_orms = []
756
874
  current_id = leaf_id
875
+ # Use _message_index for efficient lookup of parents
757
876
  while current_id and current_id in self._message_index:
758
877
  msg_orm = self._message_index[current_id]
759
878
  branch_orms.append(msg_orm)
@@ -774,6 +893,18 @@ class LollmsDiscussion:
774
893
  if db_message:
775
894
  return LollmsMessage(self, db_message)
776
895
  return None
896
+
897
+ def get_all_messages_flat(self) -> List[LollmsMessage]:
898
+ """
899
+ Retrieves all messages stored for this discussion as a flat list.
900
+ Useful for building complex UIs or doing comprehensive data analysis.
901
+
902
+ Returns:
903
+ A list of LollmsMessage objects, representing all messages in the discussion.
904
+ """
905
+ self._rebuild_message_index() # Ensure index is fresh
906
+ return [LollmsMessage(self, msg_obj) for msg_obj in self._message_index.values()]
907
+
777
908
 
778
909
  def get_full_data_zone(self):
779
910
  """Assembles all data zones into a single, formatted string for the prompt."""
@@ -803,6 +934,7 @@ class LollmsDiscussion:
803
934
  max_reasoning_steps: int = 20,
804
935
  images: Optional[List[str]] = None,
805
936
  debug: bool = False,
937
+ remove_thinking_blocks:bool = True,
806
938
  **kwargs
807
939
  ) -> Dict[str, 'LollmsMessage']:
808
940
  """Main interaction method that can invoke the dynamic, multi-modal agent.
@@ -832,6 +964,11 @@ class LollmsDiscussion:
832
964
  images: A list of base64-encoded images provided by the user, which will
833
965
  be passed to the agent or a multi-modal LLM.
834
966
  debug: If True, prints full prompts and raw AI responses to the console.
967
+ remove_thinking_blocks: If True, removes any thinking blocks from the final
968
+ response content, cleaning it up for user display.
969
+ branch_tip_id: If provided, this is the ID of the message to use as the
970
+ starting point for the branch. If None, uses the current
971
+ active branch tip.
835
972
  **kwargs: Additional keyword arguments passed to the underlying generation
836
973
  methods, such as 'streaming_callback'.
837
974
 
@@ -851,7 +988,7 @@ class LollmsDiscussion:
851
988
  if callback:
852
989
  callback("Loading static personality data...", MSG_TYPE.MSG_TYPE_STEP, {"id": "static_data_loading"})
853
990
  if personality.data_source:
854
- self.personality_data_zone = personality.data_source.strip()
991
+ self.personality_data_zone = personality.data_zone.strip()
855
992
 
856
993
  elif callable(personality.data_source):
857
994
  # --- Dynamic Data Source ---
@@ -927,8 +1064,10 @@ class LollmsDiscussion:
927
1064
  **kwargs
928
1065
  )
929
1066
  else: # Regeneration logic
930
- if self.active_branch_id not in self._message_index:
931
- raise ValueError("Regeneration failed: active branch tip not found or is invalid.")
1067
+ # _validate_and_set_active_branch ensures active_branch_id is valid and a leaf.
1068
+ # So, if we are regenerating, active_branch_id must be valid.
1069
+ if self.active_branch_id not in self._message_index: # Redundant check, but safe
1070
+ raise ValueError("Regeneration failed: active branch tip not found or is invalid.")
932
1071
  user_msg_orm = self._message_index[self.active_branch_id]
933
1072
  if user_msg_orm.sender_type != 'user':
934
1073
  raise ValueError(f"Regeneration failed: active branch tip is a '{user_msg_orm.sender_type}' message, not 'user'.")
@@ -975,7 +1114,10 @@ class LollmsDiscussion:
975
1114
  if isinstance(final_raw_response, dict) and final_raw_response.get("status") == "error":
976
1115
  raise Exception(final_raw_response.get("message", "Unknown error from lollmsClient.chat"))
977
1116
  else:
978
- final_content = self.lollmsClient.remove_thinking_blocks(final_raw_response)
1117
+ if remove_thinking_blocks:
1118
+ final_content = self.lollmsClient.remove_thinking_blocks(final_raw_response)
1119
+ else:
1120
+ final_content = final_raw_response
979
1121
  final_scratchpad = None
980
1122
 
981
1123
  end_time = datetime.now()
@@ -998,6 +1140,8 @@ class LollmsDiscussion:
998
1140
  tokens=token_count,
999
1141
  generation_speed=tok_per_sec,
1000
1142
  parent_id=user_msg.id,
1143
+ model_name = self.lollmsClient.llm.model_name,
1144
+ binding_name = self.lollmsClient.llm.binding_name,
1001
1145
  metadata=message_meta
1002
1146
  )
1003
1147
 
@@ -1006,55 +1150,76 @@ class LollmsDiscussion:
1006
1150
 
1007
1151
  return {"user_message": user_msg, "ai_message": ai_message_obj}
1008
1152
 
1009
- def regenerate_branch(self, branch_tip_id=None, **kwargs) -> Dict[str, 'LollmsMessage']:
1010
- """Regenerates the last AI response in the active branch.
1153
+ def regenerate_branch(self, branch_tip_id: Optional[str] = None, **kwargs) -> Dict[str, 'LollmsMessage']:
1154
+ """Regenerates the AI response for a given message or the active branch's AI response.
1011
1155
 
1012
- It deletes the previous AI response and calls chat() again with the
1013
- same user prompt.
1156
+ If the target is an AI message, it's deleted and its children are re-parented to its parent
1157
+ (the user message). A new AI response is then generated from that user message.
1158
+ If the target is a user message, all its existing AI children are deleted, and their
1159
+ descendants are re-parented to the user message. A new AI response is then generated.
1014
1160
 
1015
1161
  Args:
1162
+ branch_tip_id (Optional[str]): The ID of the message to regenerate from.
1163
+ If None, the currently active branch tip is used.
1016
1164
  **kwargs: Additional arguments for the chat() method.
1017
1165
 
1018
1166
  Returns:
1019
1167
  A dictionary with the user and the newly generated AI message.
1020
1168
  """
1021
- if not branch_tip_id:
1022
- branch_tip_id = self.active_branch_id
1023
- if not self.active_branch_id or self.active_branch_id not in self._message_index:
1024
- if len(self._message_index)>0:
1025
- ASCIIColors.warning("No active message to regenerate from.\n")
1026
- ASCIIColors.warning(f"Using last available message:{list(self._message_index.keys())[-1]}\n")
1027
- # Fix for when branch_tip_id is not provided
1028
- branch_tip_id = list(self._message_index.keys())[-1]
1029
- else:
1030
- raise ValueError("No active message to regenerate from.")
1169
+ self._rebuild_message_index() # Ensure index is fresh before operations
1170
+
1171
+ target_id = branch_tip_id if branch_tip_id is not None else self.active_branch_id
1172
+
1173
+ if not target_id or target_id not in self._message_index:
1174
+ raise ValueError("Regeneration failed: Target message ID not found or discussion is empty.")
1175
+
1176
+ target_message_orm = self._message_index[target_id]
1031
1177
 
1032
- last_message_orm = self._message_index[self.active_branch_id]
1178
+ # Determine the user message ID that will be the parent for the new AI generation
1179
+ if target_message_orm.sender_type == 'assistant':
1180
+ user_parent_id = target_message_orm.parent_id
1181
+ if user_parent_id is None or user_parent_id not in self._message_index:
1182
+ raise ValueError("Regeneration failed: Assistant message has no valid user parent to regenerate from.")
1183
+ user_msg_to_regenerate_from = self._message_index[user_parent_id]
1184
+ elif target_message_orm.sender_type == 'user':
1185
+ user_msg_to_regenerate_from = target_message_orm
1186
+ user_parent_id = user_msg_to_regenerate_from.id
1187
+ else:
1188
+ raise ValueError(f"Regeneration failed: Target message '{target_id}' is of an unexpected sender type '{target_message_orm.sender_type}'.")
1189
+
1190
+ ai_messages_to_overwrite_ids = set()
1191
+ if target_message_orm.sender_type == 'assistant':
1192
+ # If target is an AI, we only remove this specific AI message.
1193
+ ai_messages_to_overwrite_ids.add(target_message_orm.id)
1194
+ elif target_message_orm.sender_type == 'user':
1195
+ # If target is a user, we remove ALL AI children of this user message.
1196
+ for msg_obj in self._db_discussion.messages:
1197
+ if msg_obj.parent_id == user_msg_to_regenerate_from.id and msg_obj.sender_type == 'assistant':
1198
+ ai_messages_to_overwrite_ids.add(msg_obj.id)
1033
1199
 
1034
- if last_message_orm.sender_type == 'assistant':
1035
- parent_id = last_message_orm.parent_id
1036
- if not parent_id:
1037
- raise ValueError("Cannot regenerate from an assistant message with no parent.")
1038
-
1039
- last_message_id = last_message_orm.id
1040
- self._db_discussion.messages.remove(last_message_orm)
1041
- del self._message_index[last_message_id]
1042
- if self._is_db_backed:
1043
- self._messages_to_delete_from_db.add(last_message_id)
1044
-
1045
- self.active_branch_id = parent_id
1046
- # We now pass the parent ID as the tip, because that's what we want to generate from
1047
- return self.chat(user_message="", add_user_message=False, branch_tip_id=parent_id, **kwargs)
1200
+ if not ai_messages_to_overwrite_ids:
1201
+ ASCIIColors.warning(f"No AI messages found to regenerate from '{target_id}'. This might be unintended.")
1202
+ # If no AI messages to overwrite, just proceed with generation from user message.
1203
+ # No changes to existing messages needed, so skip the cleanup phase.
1204
+ self.active_branch_id = user_msg_to_regenerate_from.id # Ensure active branch is correct for chat
1205
+ return self.chat(user_message="", add_user_message=False, branch_tip_id=user_msg_to_regenerate_from.id, **kwargs)
1206
+
1207
+ # --- Phase 1: Generate new AI response ---
1208
+ # The user message for the new generation is user_msg_to_regenerate_from
1209
+ self.active_branch_id = user_msg_to_regenerate_from.id
1210
+
1211
+ # Call chat with add_user_message=False as the user message already exists (or was just found)
1212
+ # The chat method's add_message will set the new AI message as the active_branch_id.
1213
+ return self.chat(user_message="", add_user_message=False, branch_tip_id=user_msg_to_regenerate_from.id, **kwargs)
1048
1214
 
1049
- # If the last message is a user message, we can just call chat on it
1050
- return self.chat(user_message="", add_user_message=False, branch_tip_id=branch_tip_id, **kwargs)
1051
-
1052
1215
  def delete_branch(self, message_id: str):
1053
1216
  """Deletes a message and its entire descendant branch.
1054
1217
 
1055
1218
  This method removes the specified message and any messages that have it
1056
1219
  as a parent or an ancestor. After deletion, the active branch is moved
1057
- to the parent of the deleted message.
1220
+ to the parent of the deleted message. If the parent doesn't exist or is also
1221
+ deleted, it finds the most recent remaining message. Crucially, it re-parented
1222
+ children of the deleted message to the parent of the deleted message.
1058
1223
 
1059
1224
  This operation is only supported for database-backed discussions.
1060
1225
 
@@ -1068,51 +1233,77 @@ class LollmsDiscussion:
1068
1233
  if not self._is_db_backed:
1069
1234
  raise NotImplementedError("Branch deletion is only supported for database-backed discussions.")
1070
1235
 
1236
+ # Ensure message index is up-to-date with current DB state
1237
+ self._rebuild_message_index()
1238
+
1071
1239
  if message_id not in self._message_index:
1072
1240
  raise ValueError(f"Message with ID '{message_id}' not found in the discussion.")
1073
1241
 
1074
- # --- 1. Identify all messages to delete ---
1075
- # We start with the target message and find all of its descendants.
1076
- messages_to_delete_ids = set()
1077
- queue = [message_id] # A queue for breadth-first search of descendants
1242
+ original_message_obj = self._message_index[message_id]
1243
+ new_parent_id_for_children = original_message_obj.parent_id
1244
+
1245
+ # Identify direct children of the message being deleted (before removal from _db_discussion.messages)
1246
+ children_of_deleted_message_ids = [
1247
+ msg_obj.id for msg_obj in self._db_discussion.messages
1248
+ if msg_obj.parent_id == message_id and msg_obj.id != message_id
1249
+ ]
1078
1250
 
1079
- while queue:
1080
- current_id = queue.pop(0)
1081
- if current_id in messages_to_delete_ids:
1082
- continue # Already processed
1251
+ # Identify all messages to delete (including the one specified and its descendants)
1252
+ messages_to_delete_ids = set()
1253
+ queue = [message_id]
1254
+ processed_queue_idx = 0
1255
+ while processed_queue_idx < len(queue):
1256
+ current_msg_id = queue[processed_queue_idx]
1257
+ processed_queue_idx += 1
1083
1258
 
1084
- messages_to_delete_ids.add(current_id)
1085
-
1086
- # Find all direct children of the current message
1087
- children = [msg.id for msg in self._db_discussion.messages if msg.parent_id == current_id]
1088
- queue.extend(children)
1259
+ if current_msg_id in messages_to_delete_ids:
1260
+ continue
1261
+
1262
+ messages_to_delete_ids.add(current_msg_id)
1263
+
1264
+ # Find children of current_msg_id from the current _message_index
1265
+ for msg_in_index_id, msg_in_index_obj in self._message_index.items():
1266
+ if msg_in_index_obj.parent_id == current_msg_id and msg_in_index_id not in messages_to_delete_ids:
1267
+ queue.append(msg_in_index_id)
1268
+
1269
+ # Re-parent children of the deleted message to its parent BEFORE removing messages from _db_discussion.messages
1270
+ reparented_children_count = 0
1271
+ for msg_obj in self._db_discussion.messages:
1272
+ if msg_obj.id in children_of_deleted_message_ids:
1273
+ msg_obj.parent_id = new_parent_id_for_children
1274
+ reparented_children_count += 1
1089
1275
 
1090
- # --- 2. Get the parent of the starting message to reset the active branch ---
1091
- original_message_orm = self._message_index[message_id]
1092
- new_active_branch_id = original_message_orm.parent_id
1276
+ if reparented_children_count > 0:
1277
+ ASCIIColors.info(f"Re-parented {reparented_children_count} children from deleted message '{message_id}' to '{new_parent_id_for_children}'.")
1093
1278
 
1094
- # --- 3. Perform the deletion ---
1095
- # Remove from the ORM object's list
1279
+ # Update the ORM's in-memory list of messages and mark for actual DB deletion
1096
1280
  self._db_discussion.messages = [
1097
- msg for msg in self._db_discussion.messages if msg.id not in messages_to_delete_ids
1281
+ msg_obj for msg_obj in self._db_discussion.messages if msg_obj.id not in messages_to_delete_ids
1098
1282
  ]
1099
-
1100
- # Remove from the quick-access index
1101
- for mid in messages_to_delete_ids:
1102
- if mid in self._message_index:
1103
- del self._message_index[mid]
1104
-
1105
- # Add to the set of messages to be deleted from the DB on next commit
1106
1283
  self._messages_to_delete_from_db.update(messages_to_delete_ids)
1107
1284
 
1108
- # --- 4. Update the active branch ---
1109
- # If we deleted the branch that was active, move to its parent.
1110
- if self.active_branch_id in messages_to_delete_ids:
1111
- self.active_branch_id = new_active_branch_id
1285
+ # Update the internal message index to reflect in-memory changes immediately
1286
+ temp_message_index = {}
1287
+ for mid, mobj in self._message_index.items():
1288
+ if mid not in messages_to_delete_ids:
1289
+ temp_message_index[mid] = mobj
1290
+ object.__setattr__(self, '_message_index', temp_message_index)
1291
+
1292
+
1293
+ # Determine the new active_branch_id by finding the most recent leaf
1294
+ # We first try to find a leaf descendant from the `new_parent_id_for_children` path.
1295
+ # If that path does not exist or has no leaves, then we search the entire discussion for the most recent leaf.
1296
+ new_active_id = None
1297
+ if new_parent_id_for_children and new_parent_id_for_children in self._message_index:
1298
+ new_active_id = self._find_deepest_leaf(new_parent_id_for_children)
1112
1299
 
1113
- self.touch() # Mark discussion as updated and save if autosave is on
1300
+ if new_active_id is None: # Fallback if direct re-parenting path doesn't yield a leaf
1301
+ new_active_id = self._find_deepest_leaf(None) # Find most recent leaf in the entire remaining discussion
1114
1302
 
1115
- print(f"Marked branch starting at {message_id} ({len(messages_to_delete_ids)} messages) for deletion.")
1303
+ self.active_branch_id = new_active_id
1304
+
1305
+ self.touch() # Mark for update and auto-save if configured
1306
+ print(f"Branch starting at {message_id} ({len(messages_to_delete_ids)} messages) removed. New active branch: {self.active_branch_id}")
1116
1307
 
1117
1308
  def export(self, format_type: str, branch_tip_id: Optional[str] = None, max_allowed_tokens: Optional[int] = None) -> Union[List[Dict], str]:
1118
1309
  """Exports the discussion history into a specified format.
@@ -1604,7 +1795,7 @@ class LollmsDiscussion:
1604
1795
  content += f"\n({len(active_images)} image(s) attached)"
1605
1796
  # Count image tokens
1606
1797
  for i, image_b64 in enumerate(active_images):
1607
- tokens = tokenizer_images(image_b64)
1798
+ tokens = self.lollmsClient.count_image_tokens(image_b64) # Use self.lollmsClient.count_image_tokens
1608
1799
  if tokens > 0:
1609
1800
  total_image_tokens += tokens
1610
1801
  image_details_list.append({"message_id": msg.id, "index": i, "tokens": tokens})
@@ -1633,7 +1824,7 @@ class LollmsDiscussion:
1633
1824
  img for i, img in enumerate(self.images or [])
1634
1825
  if i < len(self.active_images or []) and self.active_images[i]
1635
1826
  ]
1636
- discussion_image_tokens = sum(tokenizer_images(img) for img in active_discussion_b64)
1827
+ discussion_image_tokens = sum(self.lollmsClient.count_image_tokens(img) for img in active_discussion_b64) # Use self.lollmsClient.count_image_tokens
1637
1828
 
1638
1829
  # Add a new zone for discussion images for clarity
1639
1830
  if discussion_image_tokens > 0:
@@ -1716,8 +1907,39 @@ class LollmsDiscussion:
1716
1907
 
1717
1908
  return active_discussion_images
1718
1909
 
1719
- def switch_to_branch(self, branch_id):
1720
- self.active_branch_id = branch_id
1910
+ def switch_to_branch(self, branch_id: str):
1911
+ """
1912
+ Switches the active discussion branch to the specified message ID.
1913
+ It then finds the deepest leaf descendant of that message and sets it as the active branch.
1914
+ """
1915
+ if branch_id not in self._message_index:
1916
+ ASCIIColors.warning(f"Attempted to switch to non-existent branch ID: {branch_id}. No action taken.")
1917
+ return
1918
+
1919
+ # Find the deepest leaf in the branch starting from the provided branch_id
1920
+ # This ensures the active_branch_id is always a leaf
1921
+ new_active_leaf_id = self._find_deepest_leaf(branch_id)
1922
+
1923
+ if new_active_leaf_id:
1924
+ self.active_branch_id = new_active_leaf_id
1925
+ self.touch() # Mark for saving if autosave is on
1926
+ ASCIIColors.info(f"Switched active branch to leaf: {self.active_branch_id}.")
1927
+ else:
1928
+ # Fallback: If no deeper leaf is found (e.g., branch_id is already a leaf or has no valid descendants)
1929
+ # then set active_branch_id to the provided branch_id.
1930
+ # _find_deepest_leaf handles returning start_id if it's a leaf.
1931
+ # So, if new_active_leaf_id is None, it means the provided branch_id was either not found (already checked)
1932
+ # or it's a non-leaf with no valid leaf descendants. In that edge case, we'll still try to use it.
1933
+ # Re-confirming it is present in the index:
1934
+ if branch_id in self._message_index:
1935
+ self.active_branch_id = branch_id
1936
+ self.touch()
1937
+ ASCIIColors.warning(f"Could not find a deeper leaf from branch ID {branch_id}. Active branch set to {branch_id}.")
1938
+ else:
1939
+ self.active_branch_id = None
1940
+ self.touch()
1941
+ ASCIIColors.error(f"Failed to set active branch: provided ID {branch_id} is invalid and no leaf could be found.")
1942
+
1721
1943
 
1722
1944
  def auto_title(self):
1723
1945
  try:
@@ -1837,7 +2059,138 @@ class LollmsDiscussion:
1837
2059
  del self.active_images[index]
1838
2060
  self.touch()
1839
2061
 
2062
+ def fix_orphan_messages(self):
2063
+ """
2064
+ Detects and re-chains orphan messages or branches in the discussion.
2065
+
2066
+ An "orphan message" is one whose parent_id points to a message that
2067
+ does not exist in the discussion, or whose lineage cannot be traced
2068
+ back to a root message (parent_id is None) within the current discussion.
2069
+
2070
+ This method attempts to reconnect such messages by:
2071
+ 1. Identifying all messages and their parent-child relationships.
2072
+ 2. Finding the true root message(s) of the discussion.
2073
+ 3. Identifying all reachable messages from the true root(s).
2074
+ 4. Any message not reachable is considered an orphan.
2075
+ 5. For each orphan, traces up to find its "orphan branch top"
2076
+ (the highest message in its disconnected chain).
2077
+ 6. Re-parents these orphan branch tops to the main discussion's
2078
+ primary root (the oldest root message). If no primary root exists,
2079
+ the oldest orphan branch top becomes the new primary root.
2080
+ """
2081
+ ASCIIColors.info(f"Checking discussion {self.id} for orphan messages...")
2082
+
2083
+ self._rebuild_message_index() # Ensure the index is fresh
2084
+
2085
+ all_messages_orms = list(self._message_index.values())
2086
+ if not all_messages_orms:
2087
+ ASCIIColors.yellow("No messages in discussion. Nothing to fix.")
2088
+ return
2089
+
2090
+ message_map = {msg_orm.id: msg_orm for msg_orm in all_messages_orms}
2091
+
2092
+ # 1. Identify all true root messages (parent_id is None)
2093
+ # And also build a children map for efficient traversal
2094
+ root_messages = []
2095
+ children_map = {msg_id: [] for msg_id in message_map.keys()}
2096
+
2097
+ for msg_orm in all_messages_orms:
2098
+ if msg_orm.parent_id is None:
2099
+ root_messages.append(msg_orm)
2100
+ elif msg_orm.parent_id in message_map:
2101
+ # Only add to children map if the parent actually exists in this discussion
2102
+ children_map[msg_orm.parent_id].append(msg_orm.id)
2103
+
2104
+ # Sort roots by creation time to find the 'primary' root
2105
+ root_messages.sort(key=lambda msg: msg.created_at)
2106
+ primary_root = root_messages[0] if root_messages else None
2107
+
2108
+ if primary_root:
2109
+ ASCIIColors.info(f"Primary discussion root identified: {primary_root.id} (created at {primary_root.created_at})")
2110
+ else:
2111
+ ASCIIColors.warning("No root message found in discussion initially.")
2112
+
2113
+ # 2. Find all messages reachable from the primary root (or any identified root)
2114
+ reachable_messages = set()
2115
+ queue = []
2116
+
2117
+ # Add any current roots as starting points for reachability check.
2118
+ for root in root_messages:
2119
+ if root.id not in reachable_messages: # Avoid re-processing if multiple paths lead to same root
2120
+ queue.append(root.id)
2121
+ reachable_messages.add(root.id)
2122
+
2123
+ head = 0
2124
+ while head < len(queue):
2125
+ current_msg_id = queue[head]
2126
+ head += 1
2127
+
2128
+ for child_id in children_map.get(current_msg_id, []):
2129
+ if child_id not in reachable_messages:
2130
+ reachable_messages.add(child_id)
2131
+ queue.append(child_id)
2132
+
2133
+ # 3. Identify orphan messages (those not reachable from any current root)
2134
+ orphan_messages_ids = set(message_map.keys()) - reachable_messages
2135
+
2136
+ if not orphan_messages_ids:
2137
+ ASCIIColors.success("No orphan messages found. Discussion chain is healthy.")
2138
+ return
2139
+
2140
+ ASCIIColors.warning(f"Found {len(orphan_messages_ids)} orphan message(s). Attempting to fix...")
2141
+
2142
+ # 4. Find the "top" message of each orphan branch
2143
+ orphan_branch_tops = set()
2144
+ for orphan_id in orphan_messages_ids:
2145
+ current_id = orphan_id
2146
+ # Trace upwards until parent is None or parent is NOT an orphan
2147
+ # We must use message_map for lookup as parent might be deleted from _message_index
2148
+ while message_map[current_id].parent_id is not None and message_map[current_id].parent_id in orphan_messages_ids:
2149
+ current_id = message_map[current_id].parent_id
2150
+ orphan_branch_tops.add(current_id)
2151
+
2152
+ # Sort orphan branch tops by creation time, oldest first
2153
+ sorted_orphan_tops_orms = sorted(
2154
+ [message_map[top_id] for top_id in orphan_branch_tops],
2155
+ key=lambda msg: msg.created_at
2156
+ )
2157
+
2158
+ reparented_count = 0
2159
+
2160
+ if not primary_root:
2161
+ # If there was no primary root, make the oldest orphan top the new primary root
2162
+ if sorted_orphan_tops_orms:
2163
+ new_primary_root_orm = sorted_orphan_tops_orms[0]
2164
+ new_primary_root_orm.parent_id = None # Make it a root
2165
+ ASCIIColors.success(f"Discussion had no root. Set oldest orphan top '{new_primary_root_orm.id}' as new primary root.")
2166
+ primary_root = new_primary_root_orm
2167
+ reparented_count += 1
2168
+ # Remove this one from the list of tops to be reparented
2169
+ sorted_orphan_tops_orms = sorted_orphan_tops_orms[1:]
2170
+ else:
2171
+ ASCIIColors.warning("No orphan branch tops found to create a new root. Discussion remains empty or unrooted.")
2172
+ return
2173
+
2174
+ if primary_root:
2175
+ # Re-parent all remaining orphan branch tops to the primary root
2176
+ for orphan_top_orm in sorted_orphan_tops_orms:
2177
+ if orphan_top_orm.id != primary_root.id: # Ensure not reparenting the primary root to itself
2178
+ old_parent = orphan_top_orm.parent_id # Store old parent for logging
2179
+ orphan_top_orm.parent_id = primary_root.id
2180
+ ASCIIColors.info(f"Re-parented orphan branch top '{orphan_top_orm.id}' (was child of '{old_parent}') to primary root '{primary_root.id}'.")
2181
+ reparented_count += 1
2182
+
2183
+ if reparented_count > 0:
2184
+ ASCIIColors.success(f"Successfully re-parented {reparented_count} orphan message(s)/branches.")
2185
+ self.touch() # Mark discussion as updated
2186
+ self.commit() # Commit changes to DB
2187
+ self._rebuild_message_index() # Rebuild index after changes
2188
+ self._validate_and_set_active_branch() # Re-validate active branch after fixing orphans
2189
+ else:
2190
+ ASCIIColors.yellow("No new messages were re-parented (they might have already been roots or discussion was already healthy).")
2191
+
2192
+
1840
2193
  @property
1841
2194
  def system_prompt(self) -> str:
1842
2195
  """Returns the system prompt for this discussion."""
1843
- return self._system_prompt
2196
+ return self._system_prompt