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

@@ -31,7 +31,8 @@ if False:
31
31
 
32
32
  from lollms_client.lollms_utilities import build_image_dicts, robust_json_parser
33
33
  from ascii_colors import ASCIIColors, trace_exception
34
- from .lollms_types import MSG_TYPE
34
+ # Assuming lollms_types is a local module providing MSG_TYPE enum/constants
35
+ # from .lollms_types import MSG_TYPE
35
36
 
36
37
  class EncryptedString(TypeDecorator):
37
38
  """A SQLAlchemy TypeDecorator for field-level database encryption.
@@ -461,7 +462,7 @@ class LollmsDiscussion:
461
462
  """Initializes a discussion instance.
462
463
 
463
464
  Args:
464
- lollmsClient: The LollmsClient instance used for generation and token counting.
465
+ lollmsClient: The LollmsClient instance for generation and token counting.
465
466
  db_manager: An optional LollmsDataManager for database persistence.
466
467
  discussion_id: The ID of the discussion to load (if db_manager is provided).
467
468
  db_discussion_obj: A pre-loaded ORM object to wrap.
@@ -500,30 +501,20 @@ class LollmsDiscussion:
500
501
 
501
502
  object.__setattr__(self, '_system_prompt', getattr(self._db_discussion, 'system_prompt', None))
502
503
 
503
- # --- THE FIX IS HERE: Load discussion-level images from metadata ---
504
+ # --- REVISED IMAGE HANDLING ---
505
+ # Load raw image data from metadata. This might be in the old (list of strings)
506
+ # or new (list of dicts) format. The get_discussion_images() method will
507
+ # handle the migration and act as the single source of truth.
504
508
  metadata = getattr(self._db_discussion, 'discussion_metadata', {}) or {}
505
- image_data = metadata.get("discussion_images", {})
506
-
507
- images_list = []
508
- active_list = []
509
-
510
- if isinstance(image_data, dict) and 'data' in image_data:
511
- # New format: {'data': [...], 'active': [...]}
512
- images_list = image_data.get('data', [])
513
- active_list = image_data.get('active', [])
514
- else:
515
- # Covers case where image_data is None, empty dict, or a legacy list
516
- # We will rely on migration logic in `get_user_discussion` to fix the DB format
517
- # For now, we just don't crash and load empty lists.
518
- images_list = []
519
- active_list = []
520
-
521
- object.__setattr__(self, 'images', images_list)
522
- object.__setattr__(self, 'active_images', active_list)
523
- # --- END FIX ---
509
+ images_data = metadata.get("discussion_images", [])
510
+ object.__setattr__(self, 'images', images_data)
511
+ # The separate `active_images` list is deprecated and removed to avoid inconsistency.
524
512
 
525
513
  self._rebuild_message_index()
526
- self._validate_and_set_active_branch() # Call for initial load
514
+ self._validate_and_set_active_branch()
515
+ # Trigger potential migration on load to ensure data is consistent from the start.
516
+ self.get_discussion_images()
517
+
527
518
 
528
519
  @classmethod
529
520
  def create_new(cls, lollms_client: 'LollmsClient', db_manager: Optional[LollmsDataManager] = None, **kwargs) -> 'LollmsDiscussion':
@@ -597,7 +588,7 @@ class LollmsDiscussion:
597
588
  # and should not be proxied to the underlying data object.
598
589
  internal_attrs = [
599
590
  'lollmsClient', 'db_manager', 'autosave', 'max_context_size', 'scratchpad',
600
- 'images', 'active_images',
591
+ 'images',
601
592
  '_session', '_db_discussion', '_message_index', '_messages_to_delete_from_db',
602
593
  '_is_db_backed', '_system_prompt'
603
594
  ]
@@ -763,13 +754,12 @@ class LollmsDiscussion:
763
754
  def touch(self):
764
755
  """Marks the discussion as updated, persists images, and saves if autosave is on."""
765
756
  # Persist in-memory discussion images to the metadata field before saving.
766
- # This works for both DB-backed and in-memory discussions.
757
+ # self.images is the single source of truth and is guaranteed to be in the
758
+ # correct dictionary format by the get_discussion_images() lazy migration.
767
759
  metadata = (getattr(self._db_discussion, 'discussion_metadata', {}) or {}).copy()
768
- if self.images or "discussion_images" in metadata: # Only update if needed
769
- metadata["discussion_images"] = {
770
- "data": self.images,
771
- "active": self.active_images
772
- }
760
+
761
+ if self.images or "discussion_images" in metadata:
762
+ metadata["discussion_images"] = self.images
773
763
  setattr(self._db_discussion, 'discussion_metadata', metadata)
774
764
 
775
765
  setattr(self._db_discussion, 'updated_at', datetime.utcnow())
@@ -794,7 +784,6 @@ class LollmsDiscussion:
794
784
  try:
795
785
  self._session.commit()
796
786
  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.
798
787
  except Exception as e:
799
788
  self._session.rollback()
800
789
  raise e
@@ -983,6 +972,9 @@ class LollmsDiscussion:
983
972
 
984
973
  # --- New Data Source Handling Logic ---
985
974
  if hasattr(personality, 'data_source') and personality.data_source is not None:
975
+ # Placeholder for MSG_TYPE if not imported
976
+ MSG_TYPE = SimpleNamespace(MSG_TYPE_STEP="step", MSG_TYPE_STEP_START="step_start", MSG_TYPE_STEP_END="step_end", MSG_TYPE_EXCEPTION="exception")
977
+
986
978
  if isinstance(personality.data_source, str):
987
979
  # --- Static Data Source ---
988
980
  if callback:
@@ -1413,11 +1405,9 @@ class LollmsDiscussion:
1413
1405
 
1414
1406
  # --- OPENAI & OLLAMA CHAT FORMATS ---
1415
1407
  messages = []
1416
- # Get active discussion-level images
1417
- active_discussion_b64 = [
1418
- img for i, img in enumerate(self.images or [])
1419
- if i < len(self.active_images or []) and self.active_images[i]
1420
- ]
1408
+ # Get active discussion-level images using the corrected method
1409
+ active_discussion_b64 = self.get_active_images(branch_tip_id=None) # Get all active discussion images
1410
+
1421
1411
  # Handle system message, which can now contain text and/or discussion-level images
1422
1412
  if full_system_prompt or (active_discussion_b64 and format_type in ["openai_chat", "ollama_chat", "markdown"]):
1423
1413
  discussion_level_images = build_image_dicts(active_discussion_b64)
@@ -1795,7 +1785,7 @@ class LollmsDiscussion:
1795
1785
  content += f"\n({len(active_images)} image(s) attached)"
1796
1786
  # Count image tokens
1797
1787
  for i, image_b64 in enumerate(active_images):
1798
- tokens = self.lollmsClient.count_image_tokens(image_b64) # Use self.lollmsClient.count_image_tokens
1788
+ tokens = self.lollmsClient.count_image_tokens(image_b64)
1799
1789
  if tokens > 0:
1800
1790
  total_image_tokens += tokens
1801
1791
  image_details_list.append({"message_id": msg.id, "index": i, "tokens": tokens})
@@ -1820,11 +1810,9 @@ class LollmsDiscussion:
1820
1810
  }
1821
1811
 
1822
1812
  # Calculate discussion-level image tokens separately and add them to the total.
1823
- active_discussion_b64 = [
1824
- img for i, img in enumerate(self.images or [])
1825
- if i < len(self.active_images or []) and self.active_images[i]
1826
- ]
1827
- discussion_image_tokens = sum(self.lollmsClient.count_image_tokens(img) for img in active_discussion_b64) # Use self.lollmsClient.count_image_tokens
1813
+ active_discussion_images = self.get_discussion_images()
1814
+ active_discussion_b64 = [img['data'] for img in active_discussion_images if img.get('active', True)]
1815
+ discussion_image_tokens = sum(self.lollmsClient.count_image_tokens(img) for img in active_discussion_b64)
1828
1816
 
1829
1817
  # Add a new zone for discussion images for clarity
1830
1818
  if discussion_image_tokens > 0:
@@ -1892,10 +1880,11 @@ class LollmsDiscussion:
1892
1880
  Returns:
1893
1881
  A flat list of base64-encoded strings for all active images.
1894
1882
  """
1895
- # Start with active discussion-level images
1883
+ # Start with active discussion-level images. get_discussion_images() ensures
1884
+ # the format is correct (list of dicts) before we filter.
1885
+ discussion_images = self.get_discussion_images()
1896
1886
  active_discussion_images = [
1897
- img for i, img in enumerate(self.images or [])
1898
- if i < len(self.active_images or []) and self.active_images[i]
1887
+ img['data'] for img in discussion_images if img.get('active', True)
1899
1888
  ]
1900
1889
 
1901
1890
  branch = self.get_branch(branch_tip_id or self.active_branch_id)
@@ -1927,10 +1916,6 @@ class LollmsDiscussion:
1927
1916
  else:
1928
1917
  # Fallback: If no deeper leaf is found (e.g., branch_id is already a leaf or has no valid descendants)
1929
1918
  # 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
1919
  if branch_id in self._message_index:
1935
1920
  self.active_branch_id = branch_id
1936
1921
  self.touch()
@@ -1978,109 +1963,100 @@ class LollmsDiscussion:
1978
1963
  self.metadata = new_metadata
1979
1964
  self.commit()
1980
1965
 
1981
- def add_discussion_image(self, image_b64: str):
1966
+ def add_discussion_image(self, image_b64: str, source: str = "user", active: bool = True):
1982
1967
  """
1983
- Adds an image at the discussion level and marks it as active.
1984
-
1985
- This image is not tied to a specific message but is considered part of the
1986
- overall discussion context. It will be included with the system prompt
1987
- when exporting the conversation for multi-modal models. The change is
1988
- persisted to the database on the next commit.
1989
-
1968
+ Adds an image at the discussion level and marks it with a source.
1969
+
1990
1970
  Args:
1991
1971
  image_b64: A base64-encoded string of the image to add.
1972
+ source: The origin of the image ('user', 'generation', 'artefact:<name> v<version>').
1973
+ active: Whether the image should be active by default.
1992
1974
  """
1993
- self.images.append(image_b64)
1994
- self.active_images.append(True)
1975
+ # Ensures self.images is in the correct format before appending.
1976
+ current_images = self.get_discussion_images()
1977
+
1978
+ new_image_data = {
1979
+ "data": image_b64,
1980
+ "source": source,
1981
+ "active": active,
1982
+ "created_at": datetime.utcnow().isoformat()
1983
+ }
1984
+
1985
+ current_images.append(new_image_data)
1986
+ self.images = current_images
1995
1987
  self.touch()
1996
1988
 
1997
- def get_discussion_images(self) -> List[Dict[str, Union[str, bool]]]:
1989
+ def get_discussion_images(self) -> List[Dict[str, Any]]:
1998
1990
  """
1999
- Returns a list of all images attached to the discussion, including their
2000
- activation status.
1991
+ Returns a list of all images attached to the discussion, ensuring they are
1992
+ in the new dictionary format.
2001
1993
 
2002
- Returns:
2003
- A list of dictionaries, where each dictionary represents an image.
2004
- Example: [{"data": "base64_string", "active": True}]
1994
+ - This method performs a lazy migration: if it detects the old format
1995
+ (a list of base64 strings), it converts it to the new format (a list
1996
+ of dictionaries) and marks the discussion for saving.
2005
1997
  """
2006
1998
  if not self.images:
2007
1999
  return []
2008
2000
 
2009
- # Ensure active_images list is in sync, default to True if not
2010
- if len(self.active_images) != len(self.images):
2011
- active_flags = [True] * len(self.images)
2012
- else:
2013
- active_flags = self.active_images
2001
+ # Check if migration is needed (if the first element is a string).
2002
+ if isinstance(self.images[0], str):
2003
+ ASCIIColors.yellow(f"Discussion {self.id}: Upgrading legacy discussion image format.")
2004
+ upgraded_images = []
2005
+ for i, img_data_str in enumerate(self.images):
2006
+ upgraded_images.append({
2007
+ "data": img_data_str,
2008
+ "source": "user", # Assume 'user' for old format
2009
+ "active": True, # Assume active for old format
2010
+ "created_at": datetime.utcnow().isoformat()
2011
+ })
2012
+ self.images = upgraded_images
2013
+ self.touch() # Mark for saving the upgraded format.
2014
+
2015
+ return self.images
2014
2016
 
2015
- return [
2016
- {"data": img_data, "active": active_flags[i]}
2017
- for i, img_data in enumerate(self.images)
2018
- ]
2019
2017
 
2020
2018
  def toggle_discussion_image_activation(self, index: int, active: Optional[bool] = None):
2021
2019
  """
2022
2020
  Toggles or sets the activation status of a discussion-level image at a given index.
2023
- The change is persisted to the database on the next commit.
2024
-
2025
- Args:
2026
- index: The index of the image in the discussion's 'images' list.
2027
- active: If provided, sets the status to this boolean. If None, toggles the current status.
2028
2021
  """
2029
- if not self.images or index >= len(self.images):
2022
+ current_images = self.get_discussion_images() # Ensures format is upgraded.
2023
+ if index >= len(current_images):
2030
2024
  raise IndexError("Discussion image index out of range.")
2031
2025
 
2032
- # Ensure active_images list is in sync before modification
2033
- if len(self.active_images) != len(self.images):
2034
- self.active_images = [True] * len(self.images)
2035
-
2036
2026
  if active is None:
2037
- self.active_images[index] = not self.active_images[index]
2027
+ # Toggle the current state, defaulting to True if key is missing.
2028
+ current_images[index]["active"] = not current_images[index].get("active", True)
2038
2029
  else:
2039
- self.active_images[index] = bool(active)
2030
+ current_images[index]["active"] = bool(active)
2040
2031
 
2032
+ self.images = current_images
2041
2033
  self.touch()
2042
2034
 
2043
- def remove_discussion_image(self, index: int):
2035
+ def remove_discussion_image(self, index: int, commit: bool = True):
2044
2036
  """
2045
2037
  Removes a discussion-level image at a given index.
2046
- The change is persisted to the database on the next commit.
2047
-
2048
- Args:
2049
- index: The index of the image in the discussion's 'images' list.
2050
2038
  """
2051
- if not self.images or index >= len(self.images):
2039
+ current_images = self.get_discussion_images() # Ensures format is upgraded.
2040
+ if index >= len(current_images):
2052
2041
  raise IndexError("Discussion image index out of range.")
2053
-
2054
- # Ensure active_images list is in sync before modification
2055
- if len(self.active_images) != len(self.images):
2056
- self.active_images = [True] * len(self.images)
2057
-
2058
- del self.images[index]
2059
- del self.active_images[index]
2060
- self.touch()
2061
2042
 
2043
+ del current_images[index]
2044
+ self.images = current_images
2045
+
2046
+ self.touch()
2047
+ if commit:
2048
+ self.commit()
2049
+
2062
2050
  def fix_orphan_messages(self):
2063
2051
  """
2064
2052
  Detects and re-chains orphan messages or branches in the discussion.
2065
2053
 
2066
2054
  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.
2055
+ does not exist, or whose lineage cannot be traced back to a root.
2080
2056
  """
2081
2057
  ASCIIColors.info(f"Checking discussion {self.id} for orphan messages...")
2082
2058
 
2083
- self._rebuild_message_index() # Ensure the index is fresh
2059
+ self._rebuild_message_index()
2084
2060
 
2085
2061
  all_messages_orms = list(self._message_index.values())
2086
2062
  if not all_messages_orms:
@@ -2089,8 +2065,6 @@ class LollmsDiscussion:
2089
2065
 
2090
2066
  message_map = {msg_orm.id: msg_orm for msg_orm in all_messages_orms}
2091
2067
 
2092
- # 1. Identify all true root messages (parent_id is None)
2093
- # And also build a children map for efficient traversal
2094
2068
  root_messages = []
2095
2069
  children_map = {msg_id: [] for msg_id in message_map.keys()}
2096
2070
 
@@ -2098,27 +2072,19 @@ class LollmsDiscussion:
2098
2072
  if msg_orm.parent_id is None:
2099
2073
  root_messages.append(msg_orm)
2100
2074
  elif msg_orm.parent_id in message_map:
2101
- # Only add to children map if the parent actually exists in this discussion
2102
2075
  children_map[msg_orm.parent_id].append(msg_orm.id)
2103
2076
 
2104
- # Sort roots by creation time to find the 'primary' root
2105
2077
  root_messages.sort(key=lambda msg: msg.created_at)
2106
2078
  primary_root = root_messages[0] if root_messages else None
2107
2079
 
2108
2080
  if primary_root:
2109
- ASCIIColors.info(f"Primary discussion root identified: {primary_root.id} (created at {primary_root.created_at})")
2081
+ ASCIIColors.info(f"Primary discussion root identified: {primary_root.id}")
2110
2082
  else:
2111
2083
  ASCIIColors.warning("No root message found in discussion initially.")
2112
2084
 
2113
- # 2. Find all messages reachable from the primary root (or any identified root)
2114
2085
  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)
2086
+ queue = [r.id for r in root_messages]
2087
+ reachable_messages.update(queue)
2122
2088
 
2123
2089
  head = 0
2124
2090
  while head < len(queue):
@@ -2130,7 +2096,6 @@ class LollmsDiscussion:
2130
2096
  reachable_messages.add(child_id)
2131
2097
  queue.append(child_id)
2132
2098
 
2133
- # 3. Identify orphan messages (those not reachable from any current root)
2134
2099
  orphan_messages_ids = set(message_map.keys()) - reachable_messages
2135
2100
 
2136
2101
  if not orphan_messages_ids:
@@ -2139,17 +2104,13 @@ class LollmsDiscussion:
2139
2104
 
2140
2105
  ASCIIColors.warning(f"Found {len(orphan_messages_ids)} orphan message(s). Attempting to fix...")
2141
2106
 
2142
- # 4. Find the "top" message of each orphan branch
2143
2107
  orphan_branch_tops = set()
2144
2108
  for orphan_id in orphan_messages_ids:
2145
2109
  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
2110
  while message_map[current_id].parent_id is not None and message_map[current_id].parent_id in orphan_messages_ids:
2149
2111
  current_id = message_map[current_id].parent_id
2150
2112
  orphan_branch_tops.add(current_id)
2151
2113
 
2152
- # Sort orphan branch tops by creation time, oldest first
2153
2114
  sorted_orphan_tops_orms = sorted(
2154
2115
  [message_map[top_id] for top_id in orphan_branch_tops],
2155
2116
  key=lambda msg: msg.created_at
@@ -2158,39 +2119,350 @@ class LollmsDiscussion:
2158
2119
  reparented_count = 0
2159
2120
 
2160
2121
  if not primary_root:
2161
- # If there was no primary root, make the oldest orphan top the new primary root
2162
2122
  if sorted_orphan_tops_orms:
2163
2123
  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.")
2124
+ new_primary_root_orm.parent_id = None
2125
+ ASCIIColors.success(f"Set oldest orphan '{new_primary_root_orm.id}' as new primary root.")
2166
2126
  primary_root = new_primary_root_orm
2167
2127
  reparented_count += 1
2168
- # Remove this one from the list of tops to be reparented
2169
2128
  sorted_orphan_tops_orms = sorted_orphan_tops_orms[1:]
2170
2129
  else:
2171
- ASCIIColors.warning("No orphan branch tops found to create a new root. Discussion remains empty or unrooted.")
2130
+ ASCIIColors.error("Could not create a new root. Discussion remains unrooted.")
2172
2131
  return
2173
2132
 
2174
2133
  if primary_root:
2175
- # Re-parent all remaining orphan branch tops to the primary root
2176
2134
  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
2135
+ if orphan_top_orm.id != primary_root.id:
2179
2136
  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}'.")
2137
+ ASCIIColors.info(f"Re-parented orphan '{orphan_top_orm.id}' to primary root '{primary_root.id}'.")
2181
2138
  reparented_count += 1
2182
2139
 
2183
2140
  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
2141
+ ASCIIColors.success(f"Successfully re-parented {reparented_count} orphan(s).")
2142
+ self.touch()
2143
+ self.commit()
2144
+ self._rebuild_message_index()
2145
+ self._validate_and_set_active_branch()
2189
2146
  else:
2190
- ASCIIColors.yellow("No new messages were re-parented (they might have already been roots or discussion was already healthy).")
2147
+ ASCIIColors.yellow("No messages were re-parented.")
2191
2148
 
2192
2149
 
2193
2150
  @property
2194
2151
  def system_prompt(self) -> str:
2195
2152
  """Returns the system prompt for this discussion."""
2196
2153
  return self._system_prompt
2154
+
2155
+ # Artefacts management system
2156
+
2157
+ def list_artefacts(self) -> List[Dict[str, Any]]:
2158
+ """
2159
+ Lists all artefacts stored in the discussion's metadata.
2160
+
2161
+ - Upgrades artefacts with missing fields to the new schema on-the-fly.
2162
+ - Computes the `is_loaded` status for each artefact.
2163
+ """
2164
+ metadata = self.metadata or {}
2165
+ artefacts = metadata.get("_artefacts", [])
2166
+ now = datetime.utcnow().isoformat()
2167
+
2168
+ upgraded = []
2169
+ dirty = False
2170
+ for artefact in artefacts:
2171
+ fixed = artefact.copy()
2172
+ # Schema upgrade checks
2173
+ if "title" not in fixed: fixed["title"] = "untitled"; dirty = True
2174
+ if "content" not in fixed: fixed["content"] = ""; dirty = True
2175
+ if "images" not in fixed: fixed["images"] = []; dirty = True
2176
+ if "audios" not in fixed: fixed["audios"] = []; dirty = True
2177
+ if "videos" not in fixed: fixed["videos"] = []; dirty = True
2178
+ if "zip" not in fixed: fixed["zip"] = None; dirty = True
2179
+ if "version" not in fixed: fixed["version"] = 1; dirty = True
2180
+ if "created_at" not in fixed: fixed["created_at"] = now; dirty = True
2181
+ if "updated_at" not in fixed: fixed["updated_at"] = now; dirty = True
2182
+
2183
+ # Reconstruct `is_loaded` from discussion context
2184
+ section_start = f"--- Document: {fixed['title']} v{fixed['version']} ---"
2185
+ is_content_loaded = section_start in (self.discussion_data_zone or "")
2186
+
2187
+ artefact_source_id = f"artefact:{fixed['title']} v{fixed['version']}"
2188
+ is_image_loaded = any(
2189
+ img.get("source") == artefact_source_id for img in self.get_discussion_images()
2190
+ )
2191
+
2192
+ fixed["is_loaded"] = is_content_loaded or is_image_loaded
2193
+ upgraded.append(fixed)
2194
+
2195
+ if dirty:
2196
+ metadata["_artefacts"] = upgraded
2197
+ self.metadata = metadata
2198
+ self.commit()
2199
+
2200
+ return upgraded
2201
+
2202
+ def add_artefact(self, title: str, content: str = "", images: List[str] = None, audios: List[str] = None, videos: List[str] = None, zip_content: Optional[str] = None, version: int = 1, **extra_data) -> Dict[str, Any]:
2203
+ """
2204
+ Adds or overwrites an artefact in the discussion.
2205
+ """
2206
+ new_metadata = (self.metadata or {}).copy()
2207
+ artefacts = new_metadata.get("_artefacts", [])
2208
+
2209
+ artefacts = [a for a in artefacts if not (a.get('title') == title and a.get('version') == version)]
2210
+
2211
+ new_artefact = {
2212
+ "title": title, "version": version, "content": content,
2213
+ "images": images or [], "audios": audios or [], "videos": videos or [],
2214
+ "zip": zip_content, "created_at": datetime.utcnow().isoformat(),
2215
+ "updated_at": datetime.utcnow().isoformat(), **extra_data
2216
+ }
2217
+ artefacts.append(new_artefact)
2218
+
2219
+ new_metadata["_artefacts"] = artefacts
2220
+ self.metadata = new_metadata
2221
+ self.commit()
2222
+ return new_artefact
2223
+
2224
+ def get_artefact(self, title: str, version: Optional[int] = None) -> Optional[Dict[str, Any]]:
2225
+ """
2226
+ Retrieves an artefact by title. Returns the latest version if `version` is None.
2227
+ """
2228
+ artefacts = self.list_artefacts()
2229
+ candidates = [a for a in artefacts if a.get('title') == title]
2230
+ if not candidates:
2231
+ return None
2232
+
2233
+ if version is not None:
2234
+ return next((a for a in candidates if a.get('version') == version), None)
2235
+ else:
2236
+ return max(candidates, key=lambda a: a.get('version', 0))
2237
+
2238
+ def update_artefact(self, title: str, new_content: str, new_images: List[str] = None, **extra_data) -> Dict[str, Any]:
2239
+ """
2240
+ Creates a new, incremented version of an existing artefact.
2241
+ """
2242
+ latest_artefact = self.get_artefact(title)
2243
+ if latest_artefact is None:
2244
+ raise ValueError(f"Cannot update non-existent artefact '{title}'.")
2245
+
2246
+ latest_version = latest_artefact.get("version", 0)
2247
+
2248
+ return self.add_artefact(
2249
+ title, content=new_content, images=new_images,
2250
+ audios=latest_artefact.get("audios", []), videos=latest_artefact.get("videos", []),
2251
+ zip_content=latest_artefact.get("zip"), version=latest_version + 1, **extra_data
2252
+ )
2253
+
2254
+ def load_artefact_into_data_zone(self, title: str, version: Optional[int] = None):
2255
+ """
2256
+ Loads an artefact's content and images into the active discussion context.
2257
+ """
2258
+ artefact = self.get_artefact(title, version)
2259
+ if not artefact:
2260
+ raise ValueError(f"Artefact '{title}' not found.")
2261
+
2262
+ # Load text content
2263
+ if artefact.get('content'):
2264
+ section = (
2265
+ f"--- Document: {artefact['title']} v{artefact['version']} ---\n"
2266
+ f"{artefact['content']}\n"
2267
+ f"--- End Document: {artefact['title']} ---\n\n"
2268
+ )
2269
+ if section not in (self.discussion_data_zone or ""):
2270
+ current_zone = self.discussion_data_zone or ""
2271
+ self.discussion_data_zone = current_zone.rstrip() + "\n\n" + section
2272
+
2273
+ # Load images
2274
+ artefact_source_id = f"artefact:{artefact['title']} v{artefact['version']}"
2275
+ if artefact.get('images'):
2276
+ current_images_data = [img['data'] for img in self.get_discussion_images() if img.get('source') == artefact_source_id]
2277
+ for img_b64 in artefact['images']:
2278
+ if img_b64 not in current_images_data:
2279
+ self.add_discussion_image(img_b64, source=artefact_source_id)
2280
+
2281
+ self.touch()
2282
+ self.commit()
2283
+ print(f"Loaded artefact '{title}' v{artefact['version']} into context.")
2284
+
2285
+ def unload_artefact_from_data_zone(self, title: str, version: Optional[int] = None):
2286
+ """
2287
+ Removes an artefact's content and images from the discussion context.
2288
+ """
2289
+ artefact = self.get_artefact(title, version)
2290
+ if not artefact:
2291
+ raise ValueError(f"Artefact '{title}' not found.")
2292
+
2293
+ # Unload text content
2294
+ if self.discussion_data_zone and artefact.get('content'):
2295
+ section_start = f"--- Document: {artefact['title']} v{artefact['version']} ---"
2296
+ pattern = rf"\n*\s*{re.escape(section_start)}.*?--- End Document: {re.escape(artefact['title'])} ---\s*\n*"
2297
+ self.discussion_data_zone = re.sub(pattern, "", self.discussion_data_zone, flags=re.DOTALL).strip()
2298
+
2299
+ # Unload images
2300
+ artefact_source_id = f"artefact:{artefact['title']} v{artefact['version']}"
2301
+ all_images = self.get_discussion_images()
2302
+
2303
+ indices_to_remove = [i for i, img in enumerate(all_images) if img.get("source") == artefact_source_id]
2304
+
2305
+ if indices_to_remove:
2306
+ for index in sorted(indices_to_remove, reverse=True):
2307
+ self.remove_discussion_image(index, commit=False)
2308
+
2309
+ self.touch()
2310
+ self.commit()
2311
+ print(f"Unloaded artefact '{title}' v{artefact['version']} from context.")
2312
+
2313
+
2314
+ def is_artefact_loaded(self, title: str, version: Optional[int] = None) -> bool:
2315
+ """
2316
+ Checks if any part of an artefact is currently loaded in the context.
2317
+ """
2318
+ artefact = self.get_artefact(title, version)
2319
+ if not artefact:
2320
+ return False
2321
+
2322
+ section_start = f"--- Document: {artefact['title']} v{artefact['version']} ---"
2323
+ if section_start in (self.discussion_data_zone or ""):
2324
+ return True
2325
+
2326
+ artefact_source_id = f"artefact:{artefact['title']} v{artefact['version']}"
2327
+ if any(img.get("source") == artefact_source_id for img in self.get_discussion_images()):
2328
+ return True
2329
+
2330
+ return False
2331
+
2332
+ def export_as_artefact(self, title: str, version: int = 1, **extra_data) -> Dict[str, Any]:
2333
+ """
2334
+ Exports the discussion_data_zone content as a new artefact.
2335
+ """
2336
+ content = (self.discussion_data_zone or "").strip()
2337
+ if not content:
2338
+ raise ValueError("Discussion data zone is empty. Nothing to export.")
2339
+
2340
+ return self.add_artefact(
2341
+ title=title, content=content, version=version, **extra_data
2342
+ )
2343
+
2344
+ def clone_without_messages(self) -> 'LollmsDiscussion':
2345
+ """
2346
+ Creates a new discussion with the same context but no message history.
2347
+ """
2348
+ discussion_data = {
2349
+ "system_prompt": self.system_prompt,
2350
+ "user_data_zone": self.user_data_zone,
2351
+ "discussion_data_zone": self.discussion_data_zone,
2352
+ "personality_data_zone": self.personality_data_zone,
2353
+ "memory": self.memory,
2354
+ "participants": self.participants,
2355
+ "discussion_metadata": self.metadata,
2356
+ "images": [img.copy() for img in self.get_discussion_images()],
2357
+ }
2358
+
2359
+ new_discussion = LollmsDiscussion.create_new(
2360
+ lollms_client=self.lollmsClient,
2361
+ db_manager=self.db_manager,
2362
+ **discussion_data
2363
+ )
2364
+ return new_discussion
2365
+
2366
+ def export_to_json_str(self) -> str:
2367
+ """
2368
+ Serializes the entire discussion state to a JSON string.
2369
+ """
2370
+ export_data = {
2371
+ "id": self.id,
2372
+ "system_prompt": self.system_prompt,
2373
+ "user_data_zone": self.user_data_zone,
2374
+ "discussion_data_zone": self.discussion_data_zone,
2375
+ "personality_data_zone": self.personality_data_zone,
2376
+ "memory": self.memory,
2377
+ "participants": self.participants,
2378
+ "active_branch_id": self.active_branch_id,
2379
+ "discussion_metadata": self.metadata,
2380
+ "created_at": self.created_at.isoformat() if self.created_at else None,
2381
+ "updated_at": self.updated_at.isoformat() if self.updated_at else None,
2382
+ "pruning_summary": self.pruning_summary,
2383
+ "pruning_point_id": self.pruning_point_id,
2384
+ "images": self.get_discussion_images(), # Ensures new format is exported
2385
+ "messages": []
2386
+ }
2387
+
2388
+ for msg in self.get_all_messages_flat():
2389
+ msg_data = {
2390
+ "id": msg.id, "discussion_id": msg.discussion_id, "parent_id": msg.parent_id,
2391
+ "sender": msg.sender, "sender_type": msg.sender_type,
2392
+ "raw_content": msg.raw_content, "thoughts": msg.thoughts, "content": msg.content,
2393
+ "scratchpad": msg.scratchpad, "tokens": msg.tokens,
2394
+ "binding_name": msg.binding_name, "model_name": msg.model_name,
2395
+ "generation_speed": msg.generation_speed, "message_metadata": msg.metadata,
2396
+ "images": msg.images, "active_images": msg.active_images,
2397
+ "created_at": msg.created_at.isoformat() if msg.created_at else None,
2398
+ }
2399
+ export_data["messages"].append(msg_data)
2400
+
2401
+ return json.dumps(export_data, indent=2)
2402
+
2403
+ @classmethod
2404
+ def import_from_json_str(
2405
+ cls,
2406
+ json_str: str,
2407
+ lollms_client: 'LollmsClient',
2408
+ db_manager: Optional[LollmsDataManager] = None
2409
+ ) -> 'LollmsDiscussion':
2410
+ """
2411
+ Creates a new LollmsDiscussion instance from a JSON string.
2412
+ """
2413
+ data = json.loads(json_str)
2414
+
2415
+ message_data_list = data.pop("messages", [])
2416
+
2417
+ # Clean up deprecated fields before creation if they exist in the JSON.
2418
+ data.pop("active_images", None)
2419
+
2420
+ new_discussion = cls.create_new(
2421
+ lollms_client=lollms_client,
2422
+ db_manager=db_manager,
2423
+ **data
2424
+ )
2425
+
2426
+ for msg_data in message_data_list:
2427
+ if 'created_at' in msg_data and msg_data['created_at']:
2428
+ msg_data['created_at'] = datetime.fromisoformat(msg_data['created_at'])
2429
+
2430
+ new_discussion.add_message(**msg_data)
2431
+
2432
+ new_discussion.active_branch_id = data.get('active_branch_id')
2433
+ if db_manager:
2434
+ new_discussion.commit()
2435
+
2436
+ return new_discussion
2437
+
2438
+ def remove_artefact(self, title: str, version: Optional[int] = None) -> int:
2439
+ """
2440
+ Removes artefacts by title. Removes all versions if `version` is None.
2441
+
2442
+ Returns:
2443
+ The number of artefact entries removed.
2444
+ """
2445
+ new_metadata = (self.metadata or {}).copy()
2446
+ artefacts = new_metadata.get("_artefacts", [])
2447
+ if not artefacts:
2448
+ return 0
2449
+
2450
+ initial_count = len(artefacts)
2451
+
2452
+ if version is None:
2453
+ # Remove all versions with the matching title
2454
+ kept_artefacts = [a for a in artefacts if a.get('title') != title]
2455
+ else:
2456
+ # Remove only the specific title and version
2457
+ kept_artefacts = [a for a in artefacts if not (a.get('title') == title and a.get('version') == version)]
2458
+
2459
+ if len(kept_artefacts) < initial_count:
2460
+ new_metadata["_artefacts"] = kept_artefacts
2461
+ self.metadata = new_metadata
2462
+ self.commit()
2463
+
2464
+ removed_count = initial_count - len(kept_artefacts)
2465
+ if removed_count > 0:
2466
+ print(f"Removed {removed_count} artefact(s) titled '{title}'.")
2467
+
2468
+ return removed_count