lollms-client 1.1.2__py3-none-any.whl → 1.3.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.
- lollms_client/__init__.py +1 -1
- lollms_client/assets/models_ctx_sizes.json +382 -0
- lollms_client/llm_bindings/lollms/__init__.py +2 -2
- lollms_client/llm_bindings/ollama/__init__.py +56 -0
- lollms_client/llm_bindings/openai/__init__.py +3 -3
- lollms_client/lollms_core.py +285 -131
- lollms_client/lollms_discussion.py +419 -147
- lollms_client/lollms_tti_binding.py +32 -82
- lollms_client/tti_bindings/diffusers/__init__.py +460 -297
- lollms_client/tti_bindings/openai/__init__.py +124 -0
- {lollms_client-1.1.2.dist-info → lollms_client-1.3.0.dist-info}/METADATA +1 -1
- {lollms_client-1.1.2.dist-info → lollms_client-1.3.0.dist-info}/RECORD +15 -14
- lollms_client/tti_bindings/dalle/__init__.py +0 -454
- {lollms_client-1.1.2.dist-info → lollms_client-1.3.0.dist-info}/WHEEL +0 -0
- {lollms_client-1.1.2.dist-info → lollms_client-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {lollms_client-1.1.2.dist-info → lollms_client-1.3.0.dist-info}/top_level.txt +0 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
# ---
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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()
|
|
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',
|
|
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
|
-
#
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
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
|
|
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
|
|
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.
|
|
1994
|
-
self.
|
|
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,
|
|
1989
|
+
def get_discussion_images(self) -> List[Dict[str, Any]]:
|
|
1998
1990
|
"""
|
|
1999
|
-
Returns a list of all images attached to the discussion,
|
|
2000
|
-
|
|
1991
|
+
Returns a list of all images attached to the discussion, ensuring they are
|
|
1992
|
+
in the new dictionary format.
|
|
2001
1993
|
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
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
|
-
#
|
|
2010
|
-
if
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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()
|
|
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}
|
|
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
|
|
2165
|
-
ASCIIColors.success(f"
|
|
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.
|
|
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:
|
|
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
|
|
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
|
|
2185
|
-
self.touch()
|
|
2186
|
-
self.commit()
|
|
2187
|
-
self._rebuild_message_index()
|
|
2188
|
-
self._validate_and_set_active_branch()
|
|
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
|
|
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
|