lollms-client 0.20.10__py3-none-any.whl → 0.22.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.
- examples/console_discussion.py +448 -0
- examples/gradio_lollms_chat.py +259 -0
- examples/lollms_discussions_test.py +155 -0
- lollms_client/__init__.py +5 -2
- lollms_client/llm_bindings/ollama/__init__.py +1 -1
- lollms_client/lollms_core.py +86 -2
- lollms_client/lollms_discussion.py +638 -386
- lollms_client/lollms_personality.py +182 -0
- lollms_client/lollms_types.py +19 -16
- lollms_client/lollms_utilities.py +71 -57
- lollms_client/mcp_bindings/remote_mcp/__init__.py +2 -1
- {lollms_client-0.20.10.dist-info → lollms_client-0.22.0.dist-info}/METADATA +1 -1
- {lollms_client-0.20.10.dist-info → lollms_client-0.22.0.dist-info}/RECORD +17 -15
- {lollms_client-0.20.10.dist-info → lollms_client-0.22.0.dist-info}/top_level.txt +1 -0
- personalities/parrot.py +10 -0
- examples/personality_test/chat_test.py +0 -37
- examples/personality_test/chat_with_aristotle.py +0 -42
- examples/personality_test/tesks_test.py +0 -62
- {lollms_client-0.20.10.dist-info → lollms_client-0.22.0.dist-info}/WHEEL +0 -0
- {lollms_client-0.20.10.dist-info → lollms_client-0.22.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,412 +1,664 @@
|
|
|
1
|
-
# lollms_discussion.py
|
|
2
|
-
|
|
3
1
|
import yaml
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import json
|
|
3
|
+
import base64
|
|
4
|
+
import os
|
|
6
5
|
import uuid
|
|
6
|
+
import shutil
|
|
7
|
+
import re
|
|
7
8
|
from collections import defaultdict
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import List, Dict, Optional, Union, Any, Type, Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import SimpleNamespace
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import (create_engine, Column, String, Text, Integer, DateTime,
|
|
15
|
+
ForeignKey, JSON, Boolean, LargeBinary, Index, Float)
|
|
16
|
+
from sqlalchemy.orm import sessionmaker, relationship, Session, declarative_base, declared_attr
|
|
17
|
+
from sqlalchemy.types import TypeDecorator
|
|
18
|
+
from sqlalchemy.orm.exc import NoResultFound
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
22
|
+
from cryptography.hazmat.primitives import hashes
|
|
23
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
24
|
+
from cryptography.hazmat.backends import default_backend
|
|
25
|
+
ENCRYPTION_AVAILABLE = True
|
|
26
|
+
except ImportError:
|
|
27
|
+
ENCRYPTION_AVAILABLE = False
|
|
28
|
+
|
|
29
|
+
# Type hint placeholders for classes defined externally
|
|
30
|
+
if False:
|
|
31
|
+
from lollms_client import LollmsClient
|
|
32
|
+
from lollms_client.lollms_types import MSG_TYPE
|
|
33
|
+
from lollms_personality import LollmsPersonality
|
|
34
|
+
|
|
35
|
+
class EncryptedString(TypeDecorator):
|
|
36
|
+
"""A SQLAlchemy TypeDecorator for field-level database encryption."""
|
|
37
|
+
impl = LargeBinary
|
|
38
|
+
cache_ok = True
|
|
39
|
+
|
|
40
|
+
def __init__(self, key: str, *args, **kwargs):
|
|
41
|
+
super().__init__(*args, **kwargs)
|
|
42
|
+
if not ENCRYPTION_AVAILABLE:
|
|
43
|
+
raise ImportError("'cryptography' is required for DB encryption.")
|
|
44
|
+
self.salt = b'lollms-fixed-salt-for-db-encryption'
|
|
45
|
+
kdf = PBKDF2HMAC(
|
|
46
|
+
algorithm=hashes.SHA256(), length=32, salt=self.salt,
|
|
47
|
+
iterations=480000, backend=default_backend()
|
|
48
|
+
)
|
|
49
|
+
derived_key = base64.urlsafe_b64encode(kdf.derive(key.encode()))
|
|
50
|
+
self.fernet = Fernet(derived_key)
|
|
8
51
|
|
|
9
|
-
|
|
10
|
-
if
|
|
11
|
-
|
|
52
|
+
def process_bind_param(self, value: Optional[str], dialect) -> Optional[bytes]:
|
|
53
|
+
if value is None:
|
|
54
|
+
return None
|
|
55
|
+
return self.fernet.encrypt(value.encode('utf-8'))
|
|
12
56
|
|
|
57
|
+
def process_result_value(self, value: Optional[bytes], dialect) -> Optional[str]:
|
|
58
|
+
if value is None:
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
return self.fernet.decrypt(value).decode('utf-8')
|
|
62
|
+
except InvalidToken:
|
|
63
|
+
return "<DECRYPTION_FAILED: Invalid Key or Corrupt Data>"
|
|
64
|
+
|
|
65
|
+
def create_dynamic_models(discussion_mixin: Optional[Type] = None, message_mixin: Optional[Type] = None, encryption_key: Optional[str] = None):
|
|
66
|
+
"""Factory to dynamically create SQLAlchemy ORM models with custom mixins."""
|
|
67
|
+
Base = declarative_base()
|
|
68
|
+
EncryptedText = EncryptedString(encryption_key) if encryption_key else Text
|
|
69
|
+
|
|
70
|
+
class DiscussionBase:
|
|
71
|
+
__abstract__ = True
|
|
72
|
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
73
|
+
system_prompt = Column(EncryptedText, nullable=True)
|
|
74
|
+
participants = Column(JSON, nullable=True, default=dict)
|
|
75
|
+
active_branch_id = Column(String, nullable=True)
|
|
76
|
+
discussion_metadata = Column(JSON, nullable=True, default=dict)
|
|
77
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
78
|
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
79
|
+
|
|
80
|
+
@declared_attr
|
|
81
|
+
def messages(cls):
|
|
82
|
+
return relationship("Message", back_populates="discussion", cascade="all, delete-orphan", lazy="joined")
|
|
83
|
+
|
|
84
|
+
class MessageBase:
|
|
85
|
+
__abstract__ = True
|
|
86
|
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
87
|
+
discussion_id = Column(String, ForeignKey('discussions.id'), nullable=False, index=True)
|
|
88
|
+
parent_id = Column(String, ForeignKey('messages.id'), nullable=True, index=True)
|
|
89
|
+
sender = Column(String, nullable=False)
|
|
90
|
+
sender_type = Column(String, nullable=False)
|
|
91
|
+
|
|
92
|
+
raw_content = Column(EncryptedText, nullable=True)
|
|
93
|
+
thoughts = Column(EncryptedText, nullable=True)
|
|
94
|
+
content = Column(EncryptedText, nullable=False)
|
|
95
|
+
scratchpad = Column(EncryptedText, nullable=True)
|
|
96
|
+
|
|
97
|
+
tokens = Column(Integer, nullable=True)
|
|
98
|
+
binding_name = Column(String, nullable=True)
|
|
99
|
+
model_name = Column(String, nullable=True)
|
|
100
|
+
generation_speed = Column(Float, nullable=True)
|
|
101
|
+
|
|
102
|
+
message_metadata = Column(JSON, nullable=True, default=dict)
|
|
103
|
+
images = Column(JSON, nullable=True, default=list)
|
|
104
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
105
|
+
|
|
106
|
+
@declared_attr
|
|
107
|
+
def discussion(cls):
|
|
108
|
+
return relationship("Discussion", back_populates="messages")
|
|
109
|
+
|
|
110
|
+
discussion_bases = (discussion_mixin, DiscussionBase, Base) if discussion_mixin else (DiscussionBase, Base)
|
|
111
|
+
DynamicDiscussion = type('Discussion', discussion_bases, {'__tablename__': 'discussions'})
|
|
112
|
+
|
|
113
|
+
message_bases = (message_mixin, MessageBase, Base) if message_mixin else (MessageBase, Base)
|
|
114
|
+
DynamicMessage = type('Message', message_bases, {'__tablename__': 'messages'})
|
|
115
|
+
|
|
116
|
+
return Base, DynamicDiscussion, DynamicMessage
|
|
117
|
+
|
|
118
|
+
class LollmsDataManager:
|
|
119
|
+
"""Manages database connection, session, and table creation."""
|
|
120
|
+
def __init__(self, db_path: str, discussion_mixin: Optional[Type] = None, message_mixin: Optional[Type] = None, encryption_key: Optional[str] = None):
|
|
121
|
+
if not db_path:
|
|
122
|
+
raise ValueError("Database path cannot be empty.")
|
|
123
|
+
self.Base, self.DiscussionModel, self.MessageModel = create_dynamic_models(
|
|
124
|
+
discussion_mixin, message_mixin, encryption_key
|
|
125
|
+
)
|
|
126
|
+
self.engine = create_engine(db_path)
|
|
127
|
+
self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
|
|
128
|
+
self.create_tables()
|
|
129
|
+
|
|
130
|
+
def create_tables(self):
|
|
131
|
+
self.Base.metadata.create_all(bind=self.engine)
|
|
132
|
+
|
|
133
|
+
def get_session(self) -> Session:
|
|
134
|
+
return self.SessionLocal()
|
|
135
|
+
|
|
136
|
+
def list_discussions(self) -> List[Dict]:
|
|
137
|
+
with self.get_session() as session:
|
|
138
|
+
discussions = session.query(self.DiscussionModel).all()
|
|
139
|
+
return [{c.name: getattr(disc, c.name) for c in disc.__table__.columns} for disc in discussions]
|
|
140
|
+
|
|
141
|
+
def get_discussion(self, lollms_client: 'LollmsClient', discussion_id: str, **kwargs) -> Optional['LollmsDiscussion']:
|
|
142
|
+
with self.get_session() as session:
|
|
143
|
+
try:
|
|
144
|
+
db_disc = session.query(self.DiscussionModel).filter_by(id=discussion_id).one()
|
|
145
|
+
session.expunge(db_disc)
|
|
146
|
+
return LollmsDiscussion(lollmsClient=lollms_client, db_manager=self, db_discussion_obj=db_disc, **kwargs)
|
|
147
|
+
except NoResultFound:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def search_discussions(self, **criteria) -> List[Dict]:
|
|
151
|
+
with self.get_session() as session:
|
|
152
|
+
query = session.query(self.DiscussionModel)
|
|
153
|
+
for key, value in criteria.items():
|
|
154
|
+
if hasattr(self.DiscussionModel, key):
|
|
155
|
+
query = query.filter(getattr(self.DiscussionModel, key).ilike(f"%{value}%"))
|
|
156
|
+
discussions = query.all()
|
|
157
|
+
return [{c.name: getattr(disc, c.name) for c in disc.__table__.columns} for disc in discussions]
|
|
158
|
+
|
|
159
|
+
def delete_discussion(self, discussion_id: str):
|
|
160
|
+
with self.get_session() as session:
|
|
161
|
+
db_disc = session.query(self.DiscussionModel).filter_by(id=discussion_id).first()
|
|
162
|
+
if db_disc:
|
|
163
|
+
session.delete(db_disc)
|
|
164
|
+
session.commit()
|
|
13
165
|
|
|
14
|
-
@dataclass
|
|
15
166
|
class LollmsMessage:
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
'sender_type': self.sender_type,
|
|
33
|
-
'content': self.content,
|
|
34
|
-
'id': self.id,
|
|
35
|
-
'parent_id': self.parent_id,
|
|
36
|
-
'metadata': self.metadata,
|
|
37
|
-
'images': self.images
|
|
38
|
-
}
|
|
167
|
+
"""A wrapper for a message ORM object, providing direct attribute access."""
|
|
168
|
+
def __init__(self, discussion: 'LollmsDiscussion', db_message: Any):
|
|
169
|
+
object.__setattr__(self, '_discussion', discussion)
|
|
170
|
+
object.__setattr__(self, '_db_message', db_message)
|
|
171
|
+
|
|
172
|
+
def __getattr__(self, name: str) -> Any:
|
|
173
|
+
if name == 'metadata':
|
|
174
|
+
return getattr(self._db_message, 'message_metadata', None)
|
|
175
|
+
return getattr(self._db_message, name)
|
|
176
|
+
|
|
177
|
+
def __setattr__(self, name: str, value: Any):
|
|
178
|
+
if name == 'metadata':
|
|
179
|
+
setattr(self._db_message, 'message_metadata', value)
|
|
180
|
+
else:
|
|
181
|
+
setattr(self._db_message, name, value)
|
|
182
|
+
self._discussion.touch()
|
|
39
183
|
|
|
184
|
+
def __repr__(self) -> str:
|
|
185
|
+
return f"<LollmsMessage id={self.id} sender='{self.sender}'>"
|
|
40
186
|
|
|
41
187
|
class LollmsDiscussion:
|
|
42
|
-
"""
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def __init__(self, lollmsClient: 'LollmsClient'):
|
|
48
|
-
"""
|
|
49
|
-
Initializes a new LollmsDiscussion instance.
|
|
50
|
-
|
|
51
|
-
Args:
|
|
52
|
-
lollmsClient: An instance of LollmsClient, required for tokenization.
|
|
53
|
-
"""
|
|
54
|
-
self.lollmsClient = lollmsClient
|
|
55
|
-
self.version: int = 3 # Current version of the format with scratchpad support
|
|
56
|
-
self._reset_state()
|
|
57
|
-
|
|
58
|
-
def _reset_state(self):
|
|
59
|
-
"""Helper to reset all discussion attributes to their defaults."""
|
|
60
|
-
self.messages: List[LollmsMessage] = []
|
|
61
|
-
self.active_branch_id: Optional[str] = None
|
|
62
|
-
self.message_index: Dict[str, LollmsMessage] = {}
|
|
63
|
-
self.children_index: Dict[Optional[str], List[str]] = defaultdict(list)
|
|
64
|
-
self.participants: Dict[str, str] = {}
|
|
65
|
-
self.system_prompt: Optional[str] = None
|
|
66
|
-
self.scratchpad: Optional[str] = None
|
|
67
|
-
|
|
68
|
-
# --- Scratchpad Management Methods ---
|
|
69
|
-
def set_scratchpad(self, content: str):
|
|
70
|
-
"""Sets or replaces the entire content of the internal scratchpad."""
|
|
71
|
-
self.scratchpad = content
|
|
72
|
-
|
|
73
|
-
def update_scratchpad(self, new_content: str, append: bool = True):
|
|
74
|
-
"""
|
|
75
|
-
Updates the scratchpad. By default, it appends with a newline separator.
|
|
76
|
-
|
|
77
|
-
Args:
|
|
78
|
-
new_content: The new text to add to the scratchpad.
|
|
79
|
-
append: If True, appends to existing content. If False, replaces it.
|
|
80
|
-
"""
|
|
81
|
-
if append and self.scratchpad:
|
|
82
|
-
self.scratchpad += f"\n{new_content}"
|
|
83
|
-
else:
|
|
84
|
-
self.scratchpad = new_content
|
|
85
|
-
|
|
86
|
-
def get_scratchpad(self) -> Optional[str]:
|
|
87
|
-
"""Returns the current content of the scratchpad."""
|
|
88
|
-
return self.scratchpad
|
|
89
|
-
|
|
90
|
-
def clear_scratchpad(self):
|
|
91
|
-
"""Clears the scratchpad content."""
|
|
92
|
-
self.scratchpad = None
|
|
93
|
-
|
|
94
|
-
# --- Configuration Methods ---
|
|
95
|
-
def set_system_prompt(self, prompt: str):
|
|
96
|
-
"""Sets the main system prompt for the discussion."""
|
|
97
|
-
self.system_prompt = prompt
|
|
98
|
-
|
|
99
|
-
def set_participants(self, participants: Dict[str, str]):
|
|
100
|
-
"""
|
|
101
|
-
Defines the participants and their roles ('user' or 'assistant').
|
|
102
|
-
|
|
103
|
-
Args:
|
|
104
|
-
participants: A dictionary mapping sender names to roles.
|
|
105
|
-
"""
|
|
106
|
-
for name, role in participants.items():
|
|
107
|
-
if role not in ["user", "assistant"]:
|
|
108
|
-
raise ValueError(f"Invalid role '{role}' for participant '{name}'")
|
|
109
|
-
self.participants = participants
|
|
110
|
-
|
|
111
|
-
# --- Core Message Tree Methods ---
|
|
112
|
-
def add_message(
|
|
113
|
-
self,
|
|
114
|
-
sender: str,
|
|
115
|
-
sender_type: str,
|
|
116
|
-
content: str,
|
|
117
|
-
metadata: Optional[Dict] = None,
|
|
118
|
-
parent_id: Optional[str] = None,
|
|
119
|
-
images: Optional[List[Dict[str, str]]] = None,
|
|
120
|
-
override_id: Optional[str] = None
|
|
121
|
-
) -> str:
|
|
122
|
-
"""
|
|
123
|
-
Adds a new message to the discussion tree.
|
|
124
|
-
"""
|
|
125
|
-
if parent_id is None:
|
|
126
|
-
parent_id = self.active_branch_id
|
|
127
|
-
if parent_id is None:
|
|
128
|
-
parent_id = "main_root"
|
|
129
|
-
|
|
130
|
-
message = LollmsMessage(
|
|
131
|
-
sender=sender, sender_type=sender_type, content=content,
|
|
132
|
-
parent_id=parent_id, metadata=str(metadata or {}), images=images or []
|
|
133
|
-
)
|
|
134
|
-
if override_id:
|
|
135
|
-
message.id = override_id
|
|
136
|
-
|
|
137
|
-
self.messages.append(message)
|
|
138
|
-
self.message_index[message.id] = message
|
|
139
|
-
self.children_index[parent_id].append(message.id)
|
|
140
|
-
self.active_branch_id = message.id
|
|
141
|
-
return message.id
|
|
142
|
-
|
|
143
|
-
def get_branch(self, leaf_id: str) -> List[LollmsMessage]:
|
|
144
|
-
"""Gets the full branch of messages from the root to the specified leaf."""
|
|
145
|
-
branch = []
|
|
146
|
-
current_id: Optional[str] = leaf_id
|
|
147
|
-
while current_id and current_id in self.message_index:
|
|
148
|
-
msg = self.message_index[current_id]
|
|
149
|
-
branch.append(msg)
|
|
150
|
-
current_id = msg.parent_id
|
|
151
|
-
return list(reversed(branch))
|
|
152
|
-
|
|
153
|
-
def set_active_branch(self, message_id: str):
|
|
154
|
-
"""Sets the active message, effectively switching to a different branch."""
|
|
155
|
-
if message_id not in self.message_index:
|
|
156
|
-
raise ValueError(f"Message ID {message_id} not found in discussion.")
|
|
157
|
-
self.active_branch_id = message_id
|
|
158
|
-
|
|
159
|
-
# --- Persistence ---
|
|
160
|
-
def save_to_disk(self, file_path: str):
|
|
161
|
-
"""Saves the entire discussion state to a YAML file."""
|
|
162
|
-
data = {
|
|
163
|
-
'version': self.version, 'active_branch_id': self.active_branch_id,
|
|
164
|
-
'system_prompt': self.system_prompt, 'participants': self.participants,
|
|
165
|
-
'scratchpad': self.scratchpad, 'messages': [m.to_dict() for m in self.messages]
|
|
166
|
-
}
|
|
167
|
-
with open(file_path, 'w', encoding='utf-8') as file:
|
|
168
|
-
yaml.dump(data, file, allow_unicode=True, sort_keys=False)
|
|
169
|
-
|
|
170
|
-
def load_from_disk(self, file_path: str):
|
|
171
|
-
"""Loads a discussion state from a YAML file."""
|
|
172
|
-
with open(file_path, 'r', encoding='utf-8') as file:
|
|
173
|
-
data = yaml.safe_load(file)
|
|
174
|
-
|
|
175
|
-
self._reset_state()
|
|
176
|
-
version = data.get("version", 1)
|
|
177
|
-
if version > self.version:
|
|
178
|
-
raise ValueError(f"File version {version} is newer than supported version {self.version}.")
|
|
179
|
-
|
|
180
|
-
self.active_branch_id = data.get('active_branch_id')
|
|
181
|
-
self.system_prompt = data.get('system_prompt', None)
|
|
182
|
-
self.participants = data.get('participants', {})
|
|
183
|
-
self.scratchpad = data.get('scratchpad', None)
|
|
184
|
-
|
|
185
|
-
for msg_data in data.get('messages', []):
|
|
186
|
-
msg = LollmsMessage(
|
|
187
|
-
sender=msg_data['sender'], sender_type=msg_data.get('sender_type', 'user'),
|
|
188
|
-
content=msg_data['content'], parent_id=msg_data.get('parent_id'),
|
|
189
|
-
id=msg_data.get('id', str(uuid.uuid4())), metadata=msg_data.get('metadata', '{}'),
|
|
190
|
-
images=msg_data.get('images', [])
|
|
191
|
-
)
|
|
192
|
-
self.messages.append(msg)
|
|
193
|
-
self.message_index[msg.id] = msg
|
|
194
|
-
self.children_index[msg.parent_id].append(msg.id)
|
|
195
|
-
|
|
196
|
-
# --- Context Management and Formatting ---
|
|
197
|
-
def _get_full_system_prompt(self) -> Optional[str]:
|
|
198
|
-
"""Combines the scratchpad and system prompt into a single string for the LLM."""
|
|
199
|
-
full_sys_prompt_parts = []
|
|
200
|
-
if self.scratchpad and self.scratchpad.strip():
|
|
201
|
-
full_sys_prompt_parts.append("--- KNOWLEDGE SCRATCHPAD ---")
|
|
202
|
-
full_sys_prompt_parts.append(self.scratchpad.strip())
|
|
203
|
-
full_sys_prompt_parts.append("--- END SCRATCHPAD ---")
|
|
188
|
+
"""Represents and manages a single discussion, acting as a high-level interface."""
|
|
189
|
+
def __init__(self, lollmsClient: 'LollmsClient', db_manager: Optional[LollmsDataManager] = None,
|
|
190
|
+
discussion_id: Optional[str] = None, db_discussion_obj: Optional[Any] = None,
|
|
191
|
+
autosave: bool = False, max_context_size: Optional[int] = None):
|
|
204
192
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
"""
|
|
214
|
-
if branch_tip_id is None: branch_tip_id = self.active_branch_id
|
|
215
|
-
if not branch_tip_id: return {"pruned": False, "reason": "No active branch."}
|
|
216
|
-
|
|
217
|
-
full_prompt_text = self.export("lollms_text", branch_tip_id)
|
|
218
|
-
current_tokens = len(self.lollmsClient.binding.tokenize(full_prompt_text))
|
|
219
|
-
if current_tokens <= max_tokens: return {"pruned": False, "reason": "Token count within limit."}
|
|
220
|
-
|
|
221
|
-
branch = self.get_branch(branch_tip_id)
|
|
222
|
-
if len(branch) <= preserve_last_n: return {"pruned": False, "reason": "Not enough messages to prune."}
|
|
223
|
-
|
|
224
|
-
messages_to_prune = branch[:-preserve_last_n]
|
|
225
|
-
messages_to_keep = branch[-preserve_last_n:]
|
|
226
|
-
text_to_summarize = "\n\n".join([f"{self.participants.get(m.sender, 'user').capitalize()}: {m.content}" for m in messages_to_prune])
|
|
193
|
+
object.__setattr__(self, 'lollmsClient', lollmsClient)
|
|
194
|
+
object.__setattr__(self, 'db_manager', db_manager)
|
|
195
|
+
object.__setattr__(self, 'autosave', autosave)
|
|
196
|
+
object.__setattr__(self, 'max_context_size', max_context_size)
|
|
197
|
+
object.__setattr__(self, 'scratchpad', "")
|
|
198
|
+
object.__setattr__(self, 'show_thoughts', False)
|
|
199
|
+
object.__setattr__(self, 'include_thoughts_in_context', False)
|
|
200
|
+
object.__setattr__(self, 'thought_placeholder', "<thought process hidden>")
|
|
227
201
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
202
|
+
object.__setattr__(self, '_session', None)
|
|
203
|
+
object.__setattr__(self, '_db_discussion', None)
|
|
204
|
+
object.__setattr__(self, '_message_index', None)
|
|
205
|
+
object.__setattr__(self, '_messages_to_delete_from_db', set())
|
|
206
|
+
object.__setattr__(self, '_is_db_backed', db_manager is not None)
|
|
207
|
+
|
|
208
|
+
if self._is_db_backed:
|
|
209
|
+
if not db_discussion_obj and not discussion_id:
|
|
210
|
+
raise ValueError("Either discussion_id or db_discussion_obj must be provided for DB-backed discussions.")
|
|
211
|
+
|
|
212
|
+
self._session = db_manager.get_session()
|
|
213
|
+
if db_discussion_obj:
|
|
214
|
+
self._db_discussion = self._session.merge(db_discussion_obj)
|
|
215
|
+
else:
|
|
216
|
+
try:
|
|
217
|
+
self._db_discussion = self._session.query(db_manager.DiscussionModel).filter_by(id=discussion_id).one()
|
|
218
|
+
except NoResultFound:
|
|
219
|
+
self._session.close()
|
|
220
|
+
raise ValueError(f"No discussion found with ID: {discussion_id}")
|
|
221
|
+
else:
|
|
222
|
+
self._create_in_memory_proxy(id=discussion_id)
|
|
223
|
+
self._rebuild_message_index()
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def remaining_tokens(self) -> Optional[int]:
|
|
227
|
+
"""Calculates the remaining tokens available in the context window."""
|
|
228
|
+
binding = self.lollmsClient.binding
|
|
229
|
+
if not binding or not hasattr(binding, 'ctx_size') or not binding.ctx_size:
|
|
230
|
+
return None
|
|
231
|
+
max_ctx = binding.ctx_size
|
|
232
|
+
current_prompt = self.format_discussion(max_ctx)
|
|
233
|
+
current_tokens = self.lollmsClient.count_tokens(current_prompt)
|
|
234
|
+
return max_ctx - current_tokens
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def create_new(cls, lollms_client: 'LollmsClient', db_manager: Optional[LollmsDataManager] = None, **kwargs) -> 'LollmsDiscussion':
|
|
238
|
+
init_args = {
|
|
239
|
+
'autosave': kwargs.pop('autosave', False),
|
|
240
|
+
'max_context_size': kwargs.pop('max_context_size', None)
|
|
241
|
+
}
|
|
242
|
+
if db_manager:
|
|
243
|
+
with db_manager.get_session() as session:
|
|
244
|
+
valid_keys = db_manager.DiscussionModel.__table__.columns.keys()
|
|
245
|
+
db_creation_args = {k: v for k, v in kwargs.items() if k in valid_keys}
|
|
246
|
+
db_discussion_orm = db_manager.DiscussionModel(**db_creation_args)
|
|
247
|
+
session.add(db_discussion_orm)
|
|
248
|
+
session.commit()
|
|
249
|
+
session.expunge(db_discussion_orm)
|
|
250
|
+
return cls(lollmsClient=lollms_client, db_manager=db_manager, db_discussion_obj=db_discussion_orm, **init_args)
|
|
251
|
+
else:
|
|
252
|
+
return cls(lollmsClient=lollms_client, discussion_id=kwargs.get('id'), **init_args)
|
|
253
|
+
|
|
254
|
+
def __getattr__(self, name: str) -> Any:
|
|
255
|
+
if name == 'metadata':
|
|
256
|
+
return getattr(self._db_discussion, 'discussion_metadata', None)
|
|
257
|
+
if name == 'messages':
|
|
258
|
+
return [LollmsMessage(self, msg) for msg in self._db_discussion.messages]
|
|
259
|
+
return getattr(self._db_discussion, name)
|
|
260
|
+
|
|
261
|
+
def __setattr__(self, name: str, value: Any):
|
|
262
|
+
internal_attrs = [
|
|
263
|
+
'lollmsClient','db_manager','autosave','max_context_size','scratchpad',
|
|
264
|
+
'show_thoughts', 'include_thoughts_in_context', 'thought_placeholder',
|
|
265
|
+
'_session','_db_discussion','_message_index','_messages_to_delete_from_db', '_is_db_backed'
|
|
266
|
+
]
|
|
267
|
+
if name in internal_attrs:
|
|
268
|
+
object.__setattr__(self, name, value)
|
|
269
|
+
else:
|
|
270
|
+
if name == 'metadata':
|
|
271
|
+
setattr(self._db_discussion, 'discussion_metadata', value)
|
|
272
|
+
else:
|
|
273
|
+
setattr(self._db_discussion, name, value)
|
|
274
|
+
self.touch()
|
|
275
|
+
|
|
276
|
+
def _create_in_memory_proxy(self, id: Optional[str] = None):
|
|
277
|
+
proxy = SimpleNamespace()
|
|
278
|
+
proxy.id, proxy.system_prompt, proxy.participants = id or str(uuid.uuid4()), None, {}
|
|
279
|
+
proxy.active_branch_id, proxy.discussion_metadata = None, {}
|
|
280
|
+
proxy.created_at, proxy.updated_at = datetime.utcnow(), datetime.utcnow()
|
|
281
|
+
proxy.messages = []
|
|
282
|
+
object.__setattr__(self, '_db_discussion', proxy)
|
|
283
|
+
|
|
284
|
+
def _rebuild_message_index(self):
|
|
285
|
+
if self._is_db_backed and self._session.is_active and self._db_discussion in self._session:
|
|
286
|
+
self._session.refresh(self._db_discussion, ['messages'])
|
|
287
|
+
self._message_index = {msg.id: msg for msg in self._db_discussion.messages}
|
|
288
|
+
|
|
289
|
+
def touch(self):
|
|
290
|
+
setattr(self._db_discussion, 'updated_at', datetime.utcnow())
|
|
291
|
+
if self._is_db_backed and self.autosave:
|
|
292
|
+
self.commit()
|
|
293
|
+
|
|
294
|
+
def commit(self):
|
|
295
|
+
if not self._is_db_backed or not self._session:
|
|
296
|
+
return
|
|
297
|
+
if self._messages_to_delete_from_db:
|
|
298
|
+
for msg_id in self._messages_to_delete_from_db:
|
|
299
|
+
msg_to_del = self._session.get(self.db_manager.MessageModel, msg_id)
|
|
300
|
+
if msg_to_del:
|
|
301
|
+
self._session.delete(msg_to_del)
|
|
302
|
+
self._messages_to_delete_from_db.clear()
|
|
234
303
|
try:
|
|
235
|
-
|
|
304
|
+
self._session.commit()
|
|
305
|
+
self._rebuild_message_index()
|
|
236
306
|
except Exception as e:
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
self.
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
truncating from the start to respect the token limit.
|
|
264
|
-
|
|
265
|
-
Args:
|
|
266
|
-
max_allowed_tokens: The maximum token limit for the final prompt.
|
|
267
|
-
splitter_text: The separator token to use (e.g., '!@>').
|
|
268
|
-
branch_tip_id: The ID of the branch to format. Defaults to active.
|
|
269
|
-
|
|
270
|
-
Returns:
|
|
271
|
-
A single, truncated prompt string.
|
|
272
|
-
"""
|
|
273
|
-
if branch_tip_id is None:
|
|
274
|
-
branch_tip_id = self.active_branch_id
|
|
307
|
+
self._session.rollback()
|
|
308
|
+
raise e
|
|
309
|
+
|
|
310
|
+
def close(self):
|
|
311
|
+
if self._session:
|
|
312
|
+
self.commit()
|
|
313
|
+
self._session.close()
|
|
314
|
+
|
|
315
|
+
def add_message(self, **kwargs) -> LollmsMessage:
|
|
316
|
+
msg_id, parent_id = kwargs.get('id', str(uuid.uuid4())), kwargs.get('parent_id', self.active_branch_id)
|
|
317
|
+
message_data = {'id': msg_id, 'parent_id': parent_id, 'discussion_id': self.id, 'created_at': datetime.utcnow(), **kwargs}
|
|
318
|
+
if 'metadata' in message_data:
|
|
319
|
+
message_data['message_metadata'] = message_data.pop('metadata')
|
|
320
|
+
if self._is_db_backed:
|
|
321
|
+
valid_keys = {c.name for c in self.db_manager.MessageModel.__table__.columns}
|
|
322
|
+
filtered_data = {k: v for k, v in message_data.items() if k in valid_keys}
|
|
323
|
+
new_msg_orm = self.db_manager.MessageModel(**filtered_data)
|
|
324
|
+
self._db_discussion.messages.append(new_msg_orm)
|
|
325
|
+
if new_msg_orm not in self._session:
|
|
326
|
+
self._session.add(new_msg_orm)
|
|
327
|
+
else:
|
|
328
|
+
new_msg_orm = SimpleNamespace(**message_data)
|
|
329
|
+
self._db_discussion.messages.append(new_msg_orm)
|
|
330
|
+
self._message_index[msg_id], self.active_branch_id = new_msg_orm, msg_id
|
|
331
|
+
self.touch()
|
|
332
|
+
return LollmsMessage(self, new_msg_orm)
|
|
275
333
|
|
|
276
|
-
|
|
277
|
-
|
|
334
|
+
def get_branch(self, leaf_id: Optional[str]) -> List[LollmsMessage]:
|
|
335
|
+
if not leaf_id:
|
|
336
|
+
return []
|
|
337
|
+
branch_orms, current_id = [], leaf_id
|
|
338
|
+
while current_id and current_id in self._message_index:
|
|
339
|
+
msg_orm = self._message_index[current_id]
|
|
340
|
+
branch_orms.append(msg_orm)
|
|
341
|
+
current_id = msg_orm.parent_id
|
|
342
|
+
return [LollmsMessage(self, orm) for orm in reversed(branch_orms)]
|
|
343
|
+
|
|
344
|
+
def chat(self, user_message: str, personality: Optional['LollmsPersonality'] = None, **kwargs) -> LollmsMessage:
|
|
345
|
+
if self.max_context_size is not None:
|
|
346
|
+
self.summarize_and_prune(self.max_context_size)
|
|
347
|
+
|
|
348
|
+
if user_message:
|
|
349
|
+
self.add_message(sender="user", sender_type="user", content=user_message)
|
|
350
|
+
|
|
351
|
+
rag_context = None
|
|
352
|
+
original_system_prompt = self.system_prompt
|
|
353
|
+
if personality:
|
|
354
|
+
self.system_prompt = personality.system_prompt
|
|
355
|
+
if user_message:
|
|
356
|
+
rag_context = personality.get_rag_context(user_message)
|
|
278
357
|
|
|
279
|
-
|
|
280
|
-
|
|
358
|
+
if rag_context:
|
|
359
|
+
self.system_prompt = f"{original_system_prompt or ''}\n\n--- Relevant Information ---\n{rag_context}\n---"
|
|
360
|
+
|
|
361
|
+
from lollms_client.lollms_types import MSG_TYPE
|
|
362
|
+
is_streaming = "streaming_callback" in kwargs and kwargs.get("streaming_callback") is not None
|
|
281
363
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
364
|
+
final_raw_response = ""
|
|
365
|
+
start_time = datetime.now()
|
|
366
|
+
|
|
367
|
+
if personality and personality.script_module and hasattr(personality.script_module, 'run'):
|
|
368
|
+
try:
|
|
369
|
+
print(f"[{personality.name}] Running custom script...")
|
|
370
|
+
final_raw_response = personality.script_module.run(self, kwargs.get("streaming_callback"))
|
|
371
|
+
except Exception as e:
|
|
372
|
+
print(f"[{personality.name}] Error in custom script: {e}")
|
|
373
|
+
final_raw_response = f"Error executing personality script: {e}"
|
|
374
|
+
else:
|
|
375
|
+
raw_response_accumulator = []
|
|
376
|
+
if is_streaming:
|
|
377
|
+
full_response_parts, token_buffer, in_thought_block = [], "", False
|
|
378
|
+
original_callback = kwargs.get("streaming_callback")
|
|
379
|
+
def accumulating_callback(token: str, msg_type: MSG_TYPE = MSG_TYPE.MSG_TYPE_CHUNK):
|
|
380
|
+
nonlocal token_buffer, in_thought_block
|
|
381
|
+
raw_response_accumulator.append(token)
|
|
382
|
+
continue_streaming = True
|
|
383
|
+
if token: token_buffer += token
|
|
384
|
+
while True:
|
|
385
|
+
if in_thought_block:
|
|
386
|
+
end_tag_pos = token_buffer.find("</think>")
|
|
387
|
+
if end_tag_pos != -1:
|
|
388
|
+
thought_chunk = token_buffer[:end_tag_pos]
|
|
389
|
+
if self.show_thoughts and original_callback and thought_chunk:
|
|
390
|
+
if not original_callback(thought_chunk, MSG_TYPE.MSG_TYPE_THOUGHT_CHUNK): continue_streaming = False
|
|
391
|
+
in_thought_block, token_buffer = False, token_buffer[end_tag_pos + len("</think>"):]
|
|
392
|
+
else:
|
|
393
|
+
if self.show_thoughts and original_callback and token_buffer:
|
|
394
|
+
if not original_callback(token_buffer, MSG_TYPE.MSG_TYPE_THOUGHT_CHUNK): continue_streaming = False
|
|
395
|
+
token_buffer = ""; break
|
|
396
|
+
else:
|
|
397
|
+
start_tag_pos = token_buffer.find("<think>")
|
|
398
|
+
if start_tag_pos != -1:
|
|
399
|
+
response_chunk = token_buffer[:start_tag_pos]
|
|
400
|
+
if response_chunk:
|
|
401
|
+
full_response_parts.append(response_chunk)
|
|
402
|
+
if original_callback:
|
|
403
|
+
if not original_callback(response_chunk, MSG_TYPE.MSG_TYPE_CHUNK): continue_streaming = False
|
|
404
|
+
in_thought_block, token_buffer = True, token_buffer[start_tag_pos + len("<think>"):]
|
|
405
|
+
else:
|
|
406
|
+
if token_buffer:
|
|
407
|
+
full_response_parts.append(token_buffer)
|
|
408
|
+
if original_callback:
|
|
409
|
+
if not original_callback(token_buffer, MSG_TYPE.MSG_TYPE_CHUNK): continue_streaming = False
|
|
410
|
+
token_buffer = ""; break
|
|
411
|
+
return continue_streaming
|
|
412
|
+
kwargs["streaming_callback"], kwargs["stream"] = accumulating_callback, True
|
|
413
|
+
self.lollmsClient.chat(self, **kwargs)
|
|
414
|
+
final_raw_response = "".join(raw_response_accumulator)
|
|
415
|
+
else:
|
|
416
|
+
kwargs["stream"] = False
|
|
417
|
+
final_raw_response = self.lollmsClient.chat(self, **kwargs) or ""
|
|
418
|
+
|
|
419
|
+
end_time = datetime.now()
|
|
420
|
+
if rag_context:
|
|
421
|
+
self.system_prompt = original_system_prompt
|
|
422
|
+
|
|
423
|
+
duration = (end_time - start_time).total_seconds()
|
|
424
|
+
thoughts_match = re.search(r"<think>(.*?)</think>", final_raw_response, re.DOTALL)
|
|
425
|
+
thoughts_text = thoughts_match.group(1).strip() if thoughts_match else None
|
|
426
|
+
final_content = self.lollmsClient.remove_thinking_blocks(final_raw_response)
|
|
427
|
+
token_count = self.lollmsClient.count_tokens(final_content)
|
|
428
|
+
tok_per_sec = (token_count / duration) if duration > 0 else 0
|
|
289
429
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
430
|
+
ai_message_obj = self.add_message(
|
|
431
|
+
sender="assistant", sender_type="assistant", content=final_content,
|
|
432
|
+
raw_content=final_raw_response, thoughts=thoughts_text, tokens=token_count,
|
|
433
|
+
binding_name=self.lollmsClient.binding.binding_name, model_name=self.lollmsClient.binding.model_name,
|
|
434
|
+
generation_speed=tok_per_sec
|
|
435
|
+
)
|
|
296
436
|
|
|
297
|
-
|
|
298
|
-
|
|
437
|
+
if self._is_db_backed and not self.autosave:
|
|
438
|
+
self.commit()
|
|
439
|
+
return ai_message_obj
|
|
440
|
+
|
|
441
|
+
def process_and_summarize(self, large_text: str, user_prompt: str, chunk_size: int = 4096, **kwargs) -> LollmsMessage:
|
|
442
|
+
user_msg = self.add_message(sender="user", sender_type="user", content=user_prompt)
|
|
443
|
+
chunks = [large_text[i:i + chunk_size] for i in range(0, len(large_text), chunk_size)]
|
|
444
|
+
current_summary, total_chunks = "", len(chunks)
|
|
445
|
+
for i, chunk in enumerate(chunks):
|
|
446
|
+
print(f"\nProcessing chunk {i+1}/{total_chunks}...")
|
|
447
|
+
if i == 0:
|
|
448
|
+
prompt = f"""The user wants to know: "{user_prompt}"\nHere is the first part of the document (chunk 1 of {total_chunks}). \nRead it and create a detailed summary of all information relevant to the user's prompt.\n\nDOCUMENT CHUNK:\n---\n{chunk}\n---\nSUMMARY:"""
|
|
449
|
+
else:
|
|
450
|
+
prompt = f"""The user wants to know: "{user_prompt}"\nYou are processing a large document sequentially. Here is the summary of the previous chunks and the content of the next chunk ({i+1} of {total_chunks}).\nUpdate your summary by integrating new relevant information from the new chunk. Do not repeat information you already have. Output ONLY the new, updated, complete summary.\n\nPREVIOUS SUMMARY:\n---\n{current_summary}\n---\n\nNEW DOCUMENT CHUNK:\n---\n{chunk}\n---\nUPDATED SUMMARY:"""
|
|
451
|
+
current_summary = self.lollmsClient.generate_text(prompt, **kwargs).strip()
|
|
452
|
+
final_prompt = f"""Based on the following comprehensive summary of a document, provide a final answer to the user's original prompt.\nUser's prompt: "{user_prompt}"\n\nCOMPREHENSIVE SUMMARY:\n---\n{current_summary}\n---\nFINAL ANSWER:"""
|
|
453
|
+
final_answer = self.lollmsClient.generate_text(final_prompt, **kwargs).strip()
|
|
454
|
+
ai_message_obj = self.add_message(
|
|
455
|
+
sender="assistant", sender_type="assistant", content=final_answer,
|
|
456
|
+
scratchpad=current_summary, parent_id=user_msg.id
|
|
457
|
+
)
|
|
458
|
+
if self._is_db_backed and not self.autosave:
|
|
459
|
+
self.commit()
|
|
460
|
+
return ai_message_obj
|
|
461
|
+
|
|
462
|
+
def regenerate_branch(self, **kwargs) -> LollmsMessage:
|
|
463
|
+
if not self.active_branch_id or self.active_branch_id not in self._message_index:
|
|
464
|
+
raise ValueError("No active message to regenerate from.")
|
|
465
|
+
last_message_orm = self._message_index[self.active_branch_id]
|
|
466
|
+
if last_message_orm.sender_type != 'assistant':
|
|
467
|
+
raise ValueError("Can only regenerate from an assistant's message.")
|
|
468
|
+
parent_id, last_message_id = last_message_orm.parent_id, last_message_orm.id
|
|
469
|
+
self._db_discussion.messages.remove(last_message_orm)
|
|
470
|
+
del self._message_index[last_message_id]
|
|
471
|
+
if self._is_db_backed:
|
|
472
|
+
self._messages_to_delete_from_db.add(last_message_id)
|
|
473
|
+
self.active_branch_id = parent_id
|
|
474
|
+
self.touch()
|
|
475
|
+
return self.chat("", **kwargs)
|
|
476
|
+
|
|
477
|
+
def delete_branch(self, message_id: str):
|
|
478
|
+
if not self._is_db_backed:
|
|
479
|
+
raise NotImplementedError("Branch deletion is only supported for database-backed discussions.")
|
|
480
|
+
if message_id not in self._message_index:
|
|
481
|
+
raise ValueError("Message not found.")
|
|
482
|
+
msg_to_delete = self._session.query(self.db_manager.MessageModel).filter_by(id=message_id).first()
|
|
483
|
+
if msg_to_delete:
|
|
484
|
+
self.active_branch_id = msg_to_delete.parent_id
|
|
485
|
+
self._session.delete(msg_to_delete)
|
|
486
|
+
self.commit()
|
|
487
|
+
|
|
488
|
+
def switch_to_branch(self, message_id: str):
|
|
489
|
+
if message_id not in self._message_index:
|
|
490
|
+
raise ValueError(f"Message ID '{message_id}' not found in the current discussion.")
|
|
491
|
+
self.active_branch_id = message_id
|
|
492
|
+
self.touch()
|
|
299
493
|
|
|
300
|
-
|
|
301
|
-
|
|
494
|
+
def format_discussion(self, max_allowed_tokens: int, branch_tip_id: Optional[str] = None) -> str:
|
|
495
|
+
return self.export("lollms_text", branch_tip_id, max_allowed_tokens)
|
|
302
496
|
|
|
303
|
-
|
|
304
|
-
|
|
497
|
+
def _get_full_system_prompt(self) -> Optional[str]:
|
|
498
|
+
parts = []
|
|
499
|
+
if self.scratchpad:
|
|
500
|
+
parts.extend(["--- KNOWLEDGE SCRATCHPAD ---", self.scratchpad.strip(), "--- END SCRATCHPAD ---"])
|
|
501
|
+
if self.system_prompt and self.system_prompt.strip():
|
|
502
|
+
parts.append(self.system_prompt.strip())
|
|
503
|
+
return "\n\n".join(parts) if parts else None
|
|
504
|
+
|
|
505
|
+
def export(self, format_type: str, branch_tip_id: Optional[str] = None, max_allowed_tokens: Optional[int] = None) -> Union[List[Dict], str]:
|
|
506
|
+
branch_tip_id = branch_tip_id or self.active_branch_id
|
|
507
|
+
if not branch_tip_id and format_type in ["lollms_text", "openai_chat", "ollama_chat"]:
|
|
508
|
+
return "" if format_type == "lollms_text" else []
|
|
509
|
+
branch, full_system_prompt, participants = self.get_branch(branch_tip_id), self._get_full_system_prompt(), self.participants or {}
|
|
510
|
+
|
|
511
|
+
def get_full_content(msg: LollmsMessage) -> str:
|
|
512
|
+
content_to_use = msg.content
|
|
513
|
+
if self.include_thoughts_in_context and msg.sender_type == 'assistant' and msg.raw_content:
|
|
514
|
+
if self.thought_placeholder:
|
|
515
|
+
content_to_use = re.sub(r"<think>.*?</think>", f"<think>{self.thought_placeholder}</think>", msg.raw_content, flags=re.DOTALL)
|
|
516
|
+
else:
|
|
517
|
+
content_to_use = msg.raw_content
|
|
305
518
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
""
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if full_system_prompt: messages.append({"role": "system", "content": full_system_prompt})
|
|
322
|
-
def openai_image_block(image: Dict[str, str]) -> Dict:
|
|
323
|
-
image_url = image['data'] if image['type'] == 'url' else f"data:image/jpeg;base64,{image['data']}"
|
|
324
|
-
return {"type": "image_url", "image_url": {"url": image_url, "detail": "auto"}}
|
|
325
|
-
for msg in branch:
|
|
326
|
-
role = self.participants.get(msg.sender, "user")
|
|
519
|
+
parts = [f"--- Internal Scratchpad ---\n{msg.scratchpad.strip()}\n---"] if msg.scratchpad and msg.scratchpad.strip() else []
|
|
520
|
+
parts.append(content_to_use.strip())
|
|
521
|
+
return "\n".join(parts)
|
|
522
|
+
|
|
523
|
+
if format_type == "lollms_text":
|
|
524
|
+
prompt_parts, current_tokens = [], 0
|
|
525
|
+
if full_system_prompt:
|
|
526
|
+
sys_msg_text = f"!@>system:\n{full_system_prompt}\n"
|
|
527
|
+
sys_tokens = self.lollmsClient.count_tokens(sys_msg_text)
|
|
528
|
+
if max_allowed_tokens is None or sys_tokens <= max_allowed_tokens:
|
|
529
|
+
prompt_parts.append(sys_msg_text)
|
|
530
|
+
current_tokens += sys_tokens
|
|
531
|
+
for msg in reversed(branch):
|
|
532
|
+
sender_str = msg.sender.replace(':', '').replace('!@>', '')
|
|
533
|
+
content = get_full_content(msg)
|
|
327
534
|
if msg.images:
|
|
328
|
-
|
|
329
|
-
|
|
535
|
+
content += f"\n({len(msg.images)} image(s) attached)"
|
|
536
|
+
msg_text = f"!@>{sender_str}:\n{content}\n"
|
|
537
|
+
msg_tokens = self.lollmsClient.count_tokens(msg_text)
|
|
538
|
+
if max_allowed_tokens is not None and current_tokens + msg_tokens > max_allowed_tokens:
|
|
539
|
+
break
|
|
540
|
+
prompt_parts.insert(1 if full_system_prompt else 0, msg_text)
|
|
541
|
+
current_tokens += msg_tokens
|
|
542
|
+
return "".join(prompt_parts).strip()
|
|
543
|
+
|
|
544
|
+
messages = []
|
|
545
|
+
if full_system_prompt:
|
|
546
|
+
messages.append({"role": "system", "content": full_system_prompt})
|
|
547
|
+
for msg in branch:
|
|
548
|
+
role, content, images = participants.get(msg.sender, "user"), get_full_content(msg), msg.images or []
|
|
549
|
+
if format_type == "openai_chat":
|
|
550
|
+
if images:
|
|
551
|
+
content_parts = [{"type": "text", "text": content}] if content else []
|
|
552
|
+
for img in images:
|
|
553
|
+
content_parts.append({"type": "image_url", "image_url": {"url": img['data'] if img['type'] == 'url' else f"data:image/jpeg;base64,{img['data']}", "detail": "auto"}})
|
|
330
554
|
messages.append({"role": role, "content": content_parts})
|
|
331
|
-
else:
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
role = self.participants.get(msg.sender, "user")
|
|
339
|
-
message_dict = {"role": role, "content": msg.content.strip()}
|
|
340
|
-
ollama_images = [img['data'] for img in msg.images if img['type'] == 'base64']
|
|
341
|
-
if ollama_images: message_dict["images"] = ollama_images
|
|
555
|
+
else:
|
|
556
|
+
messages.append({"role": role, "content": content})
|
|
557
|
+
elif format_type == "ollama_chat":
|
|
558
|
+
message_dict = {"role": role, "content": content}
|
|
559
|
+
base64_images = [img['data'] for img in images if img['type'] == 'base64']
|
|
560
|
+
if base64_images:
|
|
561
|
+
message_dict["images"] = base64_images
|
|
342
562
|
messages.append(message_dict)
|
|
343
|
-
|
|
563
|
+
else:
|
|
564
|
+
raise ValueError(f"Unsupported export format_type: {format_type}")
|
|
565
|
+
return messages
|
|
566
|
+
|
|
567
|
+
def summarize_and_prune(self, max_tokens: int, preserve_last_n: int = 4):
|
|
568
|
+
branch_tip_id = self.active_branch_id
|
|
569
|
+
if not branch_tip_id:
|
|
570
|
+
return
|
|
571
|
+
current_tokens = self.lollmsClient.count_tokens(self.format_discussion(999999, branch_tip_id))
|
|
572
|
+
if current_tokens <= max_tokens:
|
|
573
|
+
return
|
|
574
|
+
branch = self.get_branch(branch_tip_id)
|
|
575
|
+
if len(branch) <= preserve_last_n:
|
|
576
|
+
return
|
|
577
|
+
messages_to_prune = branch[:-preserve_last_n]
|
|
578
|
+
text_to_summarize = "\n\n".join([f"{m.sender}: {m.content}" for m in messages_to_prune])
|
|
579
|
+
summary_prompt = f"Concisely summarize this conversation excerpt:\n---\n{text_to_summarize}\n---\nSUMMARY:"
|
|
580
|
+
try:
|
|
581
|
+
summary = self.lollmsClient.generate_text(summary_prompt, n_predict=300, temperature=0.1)
|
|
582
|
+
except Exception as e:
|
|
583
|
+
print(f"\n[WARNING] Pruning failed, couldn't generate summary: {e}")
|
|
584
|
+
return
|
|
585
|
+
self.scratchpad = f"{self.scratchpad}\n\n--- Summary of earlier conversation ---\n{summary.strip()}".strip()
|
|
586
|
+
pruned_ids = {msg.id for msg in messages_to_prune}
|
|
587
|
+
if self._is_db_backed:
|
|
588
|
+
self._messages_to_delete_from_db.update(pruned_ids)
|
|
589
|
+
self._db_discussion.messages = [m for m in self._db_discussion.messages if m.id not in pruned_ids]
|
|
590
|
+
else:
|
|
591
|
+
self._db_discussion.messages = [m for m in self._db_discussion.messages if m.id not in pruned_ids]
|
|
592
|
+
self._rebuild_message_index()
|
|
593
|
+
self.touch()
|
|
594
|
+
print(f"\n[INFO] Discussion auto-pruned. {len(messages_to_prune)} messages summarized.")
|
|
344
595
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
596
|
+
def to_dict(self):
|
|
597
|
+
return {
|
|
598
|
+
"id": self.id, "system_prompt": self.system_prompt, "participants": self.participants,
|
|
599
|
+
"active_branch_id": self.active_branch_id, "metadata": self.metadata, "scratchpad": self.scratchpad,
|
|
600
|
+
"messages": [{ 'id': m.id, 'parent_id': m.parent_id, 'discussion_id': m.discussion_id, 'sender': m.sender,
|
|
601
|
+
'sender_type': m.sender_type, 'content': m.content, 'scratchpad': m.scratchpad, 'images': m.images,
|
|
602
|
+
'created_at': m.created_at.isoformat(), 'metadata': m.metadata } for m in self.messages],
|
|
603
|
+
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
604
|
+
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
def load_from_dict(self, data: Dict):
|
|
608
|
+
self._create_in_memory_proxy(id=data.get("id"))
|
|
609
|
+
self.system_prompt, self.participants = data.get("system_prompt"), data.get("participants", {})
|
|
610
|
+
self.active_branch_id, self.metadata = data.get("active_branch_id"), data.get("metadata", {})
|
|
611
|
+
self.scratchpad = data.get("scratchpad", "")
|
|
612
|
+
for msg_data in data.get("messages", []):
|
|
613
|
+
if 'created_at' in msg_data and isinstance(msg_data['created_at'], str):
|
|
614
|
+
try:
|
|
615
|
+
msg_data['created_at'] = datetime.fromisoformat(msg_data['created_at'])
|
|
616
|
+
except ValueError:
|
|
617
|
+
msg_data['created_at'] = datetime.utcnow()
|
|
618
|
+
self.add_message(**msg_data)
|
|
619
|
+
self.created_at = datetime.fromisoformat(data['created_at']) if data.get('created_at') else datetime.utcnow()
|
|
620
|
+
self.updated_at = datetime.fromisoformat(data['updated_at']) if data.get('updated_at') else self.created_at
|
|
621
|
+
|
|
622
|
+
@staticmethod
|
|
623
|
+
def migrate(lollms_client: 'LollmsClient', db_manager: LollmsDataManager, folder_path: Union[str, Path]):
|
|
624
|
+
folder = Path(folder_path)
|
|
625
|
+
if not folder.is_dir():
|
|
626
|
+
print(f"Error: Path '{folder}' is not a valid directory.")
|
|
627
|
+
return
|
|
628
|
+
print(f"\n--- Starting Migration from '{folder}' ---")
|
|
629
|
+
files = list(folder.glob("*.json")) + list(folder.glob("*.yaml"))
|
|
630
|
+
with db_manager.get_session() as session:
|
|
631
|
+
valid_disc_keys = {c.name for c in db_manager.DiscussionModel.__table__.columns}
|
|
632
|
+
valid_msg_keys = {c.name for c in db_manager.MessageModel.__table__.columns}
|
|
633
|
+
for i, file_path in enumerate(files):
|
|
634
|
+
print(f"Migrating file {i+1}/{len(files)}: {file_path.name} ... ", end="")
|
|
635
|
+
try:
|
|
636
|
+
data = yaml.safe_load(file_path.read_text(encoding='utf-8'))
|
|
637
|
+
discussion_id = data.get("id", str(uuid.uuid4()))
|
|
638
|
+
if session.query(db_manager.DiscussionModel).filter_by(id=discussion_id).first():
|
|
639
|
+
print("SKIPPED (already exists)")
|
|
640
|
+
continue
|
|
641
|
+
discussion_data = data.copy()
|
|
642
|
+
if 'metadata' in discussion_data:
|
|
643
|
+
discussion_data['discussion_metadata'] = discussion_data.pop('metadata')
|
|
644
|
+
for key in ['created_at', 'updated_at']:
|
|
645
|
+
if key in discussion_data and isinstance(discussion_data[key], str):
|
|
646
|
+
discussion_data[key] = datetime.fromisoformat(discussion_data[key])
|
|
647
|
+
db_discussion = db_manager.DiscussionModel(**{k: v for k, v in discussion_data.items() if k in valid_disc_keys})
|
|
648
|
+
session.add(db_discussion)
|
|
649
|
+
for msg_data in data.get("messages", []):
|
|
650
|
+
msg_data['discussion_id'] = db_discussion.id
|
|
651
|
+
if 'metadata' in msg_data:
|
|
652
|
+
msg_data['message_metadata'] = msg_data.pop('metadata')
|
|
653
|
+
if 'created_at' in msg_data and isinstance(msg_data['created_at'], str):
|
|
654
|
+
msg_data['created_at'] = datetime.fromisoformat(msg_data['created_at'])
|
|
655
|
+
msg_orm = db_manager.MessageModel(**{k: v for k, v in msg_data.items() if k in valid_msg_keys})
|
|
656
|
+
session.add(msg_orm)
|
|
657
|
+
session.flush()
|
|
658
|
+
print("OK")
|
|
659
|
+
except Exception as e:
|
|
660
|
+
print(f"FAILED. Error: {e}")
|
|
661
|
+
session.rollback()
|
|
662
|
+
continue
|
|
663
|
+
session.commit()
|
|
664
|
+
print("--- Migration Finished ---")
|