lollms-client 0.20.7__py3-none-any.whl → 0.20.8__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/lollms_core.py +679 -416
- lollms_client/lollms_discussion.py +251 -344
- lollms_client/lollms_types.py +3 -3
- lollms_client/lollms_utilities.py +97 -0
- lollms_client/mcp_bindings/remote_mcp/__init__.py +2 -0
- {lollms_client-0.20.7.dist-info → lollms_client-0.20.8.dist-info}/METADATA +1 -1
- {lollms_client-0.20.7.dist-info → lollms_client-0.20.8.dist-info}/RECORD +11 -11
- {lollms_client-0.20.7.dist-info → lollms_client-0.20.8.dist-info}/WHEEL +0 -0
- {lollms_client-0.20.7.dist-info → lollms_client-0.20.8.dist-info}/licenses/LICENSE +0 -0
- {lollms_client-0.20.7.dist-info → lollms_client-0.20.8.dist-info}/top_level.txt +0 -0
|
@@ -1,13 +1,22 @@
|
|
|
1
|
+
# lollms_discussion.py
|
|
2
|
+
|
|
1
3
|
import yaml
|
|
2
4
|
from dataclasses import dataclass, field
|
|
3
|
-
from typing import List, Dict, Optional, Union
|
|
5
|
+
from typing import List, Dict, Optional, Union, Any
|
|
4
6
|
import uuid
|
|
5
|
-
import os
|
|
6
7
|
from collections import defaultdict
|
|
7
8
|
|
|
8
|
-
#
|
|
9
|
+
# It's good practice to forward-declare the type for the client to avoid circular imports.
|
|
10
|
+
if False:
|
|
11
|
+
from lollms.client import LollmsClient
|
|
12
|
+
|
|
13
|
+
|
|
9
14
|
@dataclass
|
|
10
15
|
class LollmsMessage:
|
|
16
|
+
"""
|
|
17
|
+
Represents a single message in a LollmsDiscussion, including its content,
|
|
18
|
+
sender, and relationship within the discussion tree.
|
|
19
|
+
"""
|
|
11
20
|
sender: str
|
|
12
21
|
sender_type: str
|
|
13
22
|
content: str
|
|
@@ -16,7 +25,8 @@ class LollmsMessage:
|
|
|
16
25
|
metadata: str = "{}"
|
|
17
26
|
images: List[Dict[str, str]] = field(default_factory=list)
|
|
18
27
|
|
|
19
|
-
def to_dict(self):
|
|
28
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
29
|
+
"""Serializes the message object to a dictionary."""
|
|
20
30
|
return {
|
|
21
31
|
'sender': self.sender,
|
|
22
32
|
'sender_type': self.sender_type,
|
|
@@ -26,52 +36,100 @@ class LollmsMessage:
|
|
|
26
36
|
'metadata': self.metadata,
|
|
27
37
|
'images': self.images
|
|
28
38
|
}
|
|
29
|
-
|
|
30
39
|
|
|
31
40
|
|
|
32
|
-
# Enhanced LollmsDiscussion Class with branching support
|
|
33
41
|
class LollmsDiscussion:
|
|
42
|
+
"""
|
|
43
|
+
Manages a branching conversation tree, including system prompts, participants,
|
|
44
|
+
an internal knowledge scratchpad, and context pruning capabilities.
|
|
45
|
+
"""
|
|
46
|
+
|
|
34
47
|
def __init__(self, lollmsClient: 'LollmsClient'):
|
|
35
|
-
|
|
48
|
+
"""
|
|
49
|
+
Initializes a new LollmsDiscussion instance.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
lollmsClient: An instance of LollmsClient, required for tokenization.
|
|
53
|
+
"""
|
|
36
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] = []
|
|
37
61
|
self.active_branch_id: Optional[str] = None
|
|
38
62
|
self.message_index: Dict[str, LollmsMessage] = {}
|
|
39
63
|
self.children_index: Dict[Optional[str], List[str]] = defaultdict(list)
|
|
40
|
-
self.
|
|
41
|
-
self.participants: Dict[str, str] = {} # name -> type ("user" or "assistant")
|
|
64
|
+
self.participants: Dict[str, str] = {}
|
|
42
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.
|
|
43
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 ---
|
|
44
95
|
def set_system_prompt(self, prompt: str):
|
|
96
|
+
"""Sets the main system prompt for the discussion."""
|
|
45
97
|
self.system_prompt = prompt
|
|
46
98
|
|
|
47
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
|
+
"""
|
|
48
106
|
for name, role in participants.items():
|
|
49
107
|
if role not in ["user", "assistant"]:
|
|
50
108
|
raise ValueError(f"Invalid role '{role}' for participant '{name}'")
|
|
51
109
|
self.participants = participants
|
|
52
110
|
|
|
111
|
+
# --- Core Message Tree Methods ---
|
|
53
112
|
def add_message(
|
|
54
113
|
self,
|
|
55
114
|
sender: str,
|
|
56
115
|
sender_type: str,
|
|
57
116
|
content: str,
|
|
58
|
-
metadata: Dict =
|
|
117
|
+
metadata: Optional[Dict] = None,
|
|
59
118
|
parent_id: Optional[str] = None,
|
|
60
119
|
images: Optional[List[Dict[str, str]]] = None,
|
|
61
120
|
override_id: Optional[str] = None
|
|
62
121
|
) -> str:
|
|
122
|
+
"""
|
|
123
|
+
Adds a new message to the discussion tree.
|
|
124
|
+
"""
|
|
63
125
|
if parent_id is None:
|
|
64
126
|
parent_id = self.active_branch_id
|
|
65
127
|
if parent_id is None:
|
|
66
|
-
parent_id = "
|
|
128
|
+
parent_id = "main_root"
|
|
67
129
|
|
|
68
130
|
message = LollmsMessage(
|
|
69
|
-
sender=sender,
|
|
70
|
-
|
|
71
|
-
content=content,
|
|
72
|
-
parent_id=parent_id,
|
|
73
|
-
metadata=str(metadata),
|
|
74
|
-
images=images or []
|
|
131
|
+
sender=sender, sender_type=sender_type, content=content,
|
|
132
|
+
parent_id=parent_id, metadata=str(metadata or {}), images=images or []
|
|
75
133
|
)
|
|
76
134
|
if override_id:
|
|
77
135
|
message.id = override_id
|
|
@@ -79,427 +137,276 @@ class LollmsDiscussion:
|
|
|
79
137
|
self.messages.append(message)
|
|
80
138
|
self.message_index[message.id] = message
|
|
81
139
|
self.children_index[parent_id].append(message.id)
|
|
82
|
-
|
|
83
140
|
self.active_branch_id = message.id
|
|
84
141
|
return message.id
|
|
85
142
|
|
|
86
|
-
|
|
87
143
|
def get_branch(self, leaf_id: str) -> List[LollmsMessage]:
|
|
88
|
-
"""
|
|
144
|
+
"""Gets the full branch of messages from the root to the specified leaf."""
|
|
89
145
|
branch = []
|
|
90
|
-
current_id = leaf_id
|
|
91
|
-
|
|
92
|
-
while current_id in self.message_index:
|
|
146
|
+
current_id: Optional[str] = leaf_id
|
|
147
|
+
while current_id and current_id in self.message_index:
|
|
93
148
|
msg = self.message_index[current_id]
|
|
94
149
|
branch.append(msg)
|
|
95
150
|
current_id = msg.parent_id
|
|
96
|
-
|
|
97
|
-
# Return from root to leaf
|
|
98
151
|
return list(reversed(branch))
|
|
99
152
|
|
|
100
153
|
def set_active_branch(self, message_id: str):
|
|
154
|
+
"""Sets the active message, effectively switching to a different branch."""
|
|
101
155
|
if message_id not in self.message_index:
|
|
102
|
-
raise ValueError(f"Message ID {message_id} not found")
|
|
156
|
+
raise ValueError(f"Message ID {message_id} not found in discussion.")
|
|
103
157
|
self.active_branch_id = message_id
|
|
104
158
|
|
|
105
|
-
|
|
106
|
-
if message_id not in self.message_index:
|
|
107
|
-
return
|
|
108
|
-
|
|
109
|
-
msg = self.message_index[message_id]
|
|
110
|
-
parent_id = msg.parent_id
|
|
111
|
-
|
|
112
|
-
# Reassign children to parent
|
|
113
|
-
for child_id in self.children_index[message_id]:
|
|
114
|
-
child = self.message_index[child_id]
|
|
115
|
-
child.parent_id = parent_id
|
|
116
|
-
self.children_index[parent_id].append(child_id)
|
|
117
|
-
|
|
118
|
-
# Clean up indexes
|
|
119
|
-
del self.message_index[message_id]
|
|
120
|
-
del self.children_index[message_id]
|
|
121
|
-
|
|
122
|
-
# Remove from parent's children list
|
|
123
|
-
if parent_id in self.children_index and message_id in self.children_index[parent_id]:
|
|
124
|
-
self.children_index[parent_id].remove(message_id)
|
|
125
|
-
|
|
126
|
-
# Remove from main messages list
|
|
127
|
-
self.messages = [m for m in self.messages if m.id != message_id]
|
|
128
|
-
|
|
129
|
-
# Update active branch if needed
|
|
130
|
-
if self.active_branch_id == message_id:
|
|
131
|
-
self.active_branch_id = parent_id
|
|
132
|
-
|
|
159
|
+
# --- Persistence ---
|
|
133
160
|
def save_to_disk(self, file_path: str):
|
|
161
|
+
"""Saves the entire discussion state to a YAML file."""
|
|
134
162
|
data = {
|
|
135
|
-
'version': self.version,
|
|
136
|
-
'
|
|
137
|
-
'
|
|
138
|
-
'participants': self.participants,
|
|
139
|
-
'messages': [m.to_dict() for m in self.messages]
|
|
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]
|
|
140
166
|
}
|
|
141
167
|
with open(file_path, 'w', encoding='utf-8') as file:
|
|
142
|
-
yaml.dump(data, file, allow_unicode=True)
|
|
143
|
-
|
|
168
|
+
yaml.dump(data, file, allow_unicode=True, sort_keys=False)
|
|
144
169
|
|
|
145
170
|
def load_from_disk(self, file_path: str):
|
|
171
|
+
"""Loads a discussion state from a YAML file."""
|
|
146
172
|
with open(file_path, 'r', encoding='utf-8') as file:
|
|
147
173
|
data = yaml.safe_load(file)
|
|
148
174
|
|
|
149
|
-
|
|
150
|
-
self.messages = []
|
|
151
|
-
self.message_index = {}
|
|
152
|
-
self.children_index = defaultdict(list)
|
|
153
|
-
|
|
154
|
-
if isinstance(data, list):
|
|
155
|
-
# Legacy v1 format
|
|
156
|
-
prev_id = None
|
|
157
|
-
for msg_data in data:
|
|
158
|
-
sender = msg_data.get('sender',"unknown")
|
|
159
|
-
msg = LollmsMessage(
|
|
160
|
-
sender=sender,
|
|
161
|
-
sender_type=msg_data.get("sender_type", "user" if sender!="lollms" and sender!="assistant" else "assistant"),
|
|
162
|
-
content=msg_data['content'],
|
|
163
|
-
parent_id=prev_id,
|
|
164
|
-
id=msg_data.get('id', str(uuid.uuid4())),
|
|
165
|
-
metadata=msg_data.get('metadata', '{}')
|
|
166
|
-
)
|
|
167
|
-
self.messages.append(msg)
|
|
168
|
-
self.message_index[msg.id] = msg
|
|
169
|
-
self.children_index[prev_id].append(msg.id)
|
|
170
|
-
prev_id = msg.id
|
|
171
|
-
self.active_branch_id = prev_id if self.messages else None
|
|
172
|
-
self.system_prompt = None
|
|
173
|
-
self.participants = {}
|
|
174
|
-
self.save_to_disk(file_path) # Upgrade
|
|
175
|
-
return
|
|
176
|
-
|
|
177
|
-
# v2 format
|
|
175
|
+
self._reset_state()
|
|
178
176
|
version = data.get("version", 1)
|
|
179
|
-
if version
|
|
180
|
-
raise ValueError(f"
|
|
177
|
+
if version > self.version:
|
|
178
|
+
raise ValueError(f"File version {version} is newer than supported version {self.version}.")
|
|
181
179
|
|
|
182
180
|
self.active_branch_id = data.get('active_branch_id')
|
|
183
181
|
self.system_prompt = data.get('system_prompt', None)
|
|
184
182
|
self.participants = data.get('participants', {})
|
|
183
|
+
self.scratchpad = data.get('scratchpad', None)
|
|
185
184
|
|
|
186
185
|
for msg_data in data.get('messages', []):
|
|
187
|
-
# FIXED: Added `images=msg_data.get('images', [])` to correctly load images from the file.
|
|
188
186
|
msg = LollmsMessage(
|
|
189
|
-
sender=msg_data['sender'],
|
|
190
|
-
content=msg_data['content'],
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
metadata=msg_data.get('metadata', '{}'),
|
|
194
|
-
images=msg_data.get('images', [])
|
|
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', [])
|
|
195
191
|
)
|
|
196
192
|
self.messages.append(msg)
|
|
197
193
|
self.message_index[msg.id] = msg
|
|
198
194
|
self.children_index[msg.parent_id].append(msg.id)
|
|
199
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 ---")
|
|
204
|
+
|
|
205
|
+
if self.system_prompt and self.system_prompt.strip():
|
|
206
|
+
full_sys_prompt_parts.append(self.system_prompt.strip())
|
|
207
|
+
return "\n\n".join(full_sys_prompt_parts) if full_sys_prompt_parts else None
|
|
200
208
|
|
|
201
|
-
def
|
|
202
|
-
|
|
203
|
-
|
|
209
|
+
def summarize_and_prune(self, max_tokens: int, preserve_last_n: int = 4, branch_tip_id: Optional[str] = None) -> Dict[str, Any]:
|
|
210
|
+
"""
|
|
211
|
+
Checks context size and, if exceeded, summarizes the oldest messages
|
|
212
|
+
into the scratchpad and prunes them to free up token space.
|
|
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."}
|
|
204
216
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
current_tokens
|
|
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."}
|
|
208
220
|
|
|
209
|
-
|
|
210
|
-
if
|
|
211
|
-
sys_msg = f"!>system:\n{self.system_prompt.strip()}\n"
|
|
212
|
-
sys_tokens = len(self.lollmsClient.tokenize(sys_msg))
|
|
213
|
-
if max_allowed_tokens and current_tokens + sys_tokens <= max_allowed_tokens:
|
|
214
|
-
formatted_text += sys_msg
|
|
215
|
-
current_tokens += sys_tokens
|
|
221
|
+
branch = self.get_branch(branch_tip_id)
|
|
222
|
+
if len(branch) <= preserve_last_n: return {"pruned": False, "reason": "Not enough messages to prune."}
|
|
216
223
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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])
|
|
227
|
+
|
|
228
|
+
summary_prompt = (
|
|
229
|
+
"You are a summarization expert. Read the following conversation excerpt and create a "
|
|
230
|
+
"concise, factual summary of all key information, decisions, and outcomes. This summary "
|
|
231
|
+
"will be placed in a knowledge scratchpad for future reference. Omit conversational filler.\n\n"
|
|
232
|
+
f"CONVERSATION EXCERPT:\n---\n{text_to_summarize}\n---\n\nCONCISE SUMMARY:"
|
|
233
|
+
)
|
|
234
|
+
try:
|
|
235
|
+
summary = self.lollmsClient.generate(summary_prompt, max_new_tokens=300, temperature=0.1)
|
|
236
|
+
except Exception as e:
|
|
237
|
+
return {"pruned": False, "reason": f"Failed to generate summary: {e}"}
|
|
222
238
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
239
|
+
summary_block = f"--- Summary of earlier conversation (pruned on {uuid.uuid4().hex[:8]}) ---\n{summary.strip()}"
|
|
240
|
+
self.update_scratchpad(summary_block, append=True)
|
|
241
|
+
|
|
242
|
+
ids_to_prune = {msg.id for msg in messages_to_prune}
|
|
243
|
+
new_root_of_branch = messages_to_keep[0]
|
|
244
|
+
original_parent_id = messages_to_prune[0].parent_id
|
|
245
|
+
|
|
246
|
+
self.message_index[new_root_of_branch.id].parent_id = original_parent_id
|
|
247
|
+
if original_parent_id in self.children_index:
|
|
248
|
+
self.children_index[original_parent_id] = [mid for mid in self.children_index[original_parent_id] if mid != messages_to_prune[0].id]
|
|
249
|
+
self.children_index[original_parent_id].append(new_root_of_branch.id)
|
|
250
|
+
|
|
251
|
+
for msg_id in ids_to_prune:
|
|
252
|
+
self.message_index.pop(msg_id, None)
|
|
253
|
+
self.children_index.pop(msg_id, None)
|
|
254
|
+
self.messages = [m for m in self.messages if m.id not in ids_to_prune]
|
|
229
255
|
|
|
230
|
-
|
|
256
|
+
new_prompt_text = self.export("lollms_text", branch_tip_id)
|
|
257
|
+
new_tokens = len(self.lollmsClient.binding.tokenize(new_prompt_text))
|
|
258
|
+
return {"pruned": True, "tokens_saved": current_tokens - new_tokens, "summary_added": True}
|
|
231
259
|
|
|
232
|
-
|
|
233
|
-
def get_branch_as_chatbot_history(self, branch_tip_id: Optional[str] = None) -> List[List[str]]:
|
|
260
|
+
def format_discussion(self, max_allowed_tokens: int, splitter_text: str = "!@>", branch_tip_id: Optional[str] = None) -> str:
|
|
234
261
|
"""
|
|
235
|
-
|
|
236
|
-
|
|
262
|
+
Formats the discussion into a single string for instruct models,
|
|
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.
|
|
237
272
|
"""
|
|
238
273
|
if branch_tip_id is None:
|
|
239
274
|
branch_tip_id = self.active_branch_id
|
|
240
|
-
if not branch_tip_id:
|
|
241
|
-
return []
|
|
242
275
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
for msg in branch:
|
|
246
|
-
# Determine the role from participants, default to 'user'
|
|
247
|
-
role = self.participants.get(msg.sender, "user")
|
|
248
|
-
|
|
249
|
-
if role == "user":
|
|
250
|
-
history.append([msg.content, None])
|
|
251
|
-
else: # assistant
|
|
252
|
-
# If the last user message has no reply yet, append to it
|
|
253
|
-
if history and history[-1][1] is None:
|
|
254
|
-
history[-1][1] = msg.content
|
|
255
|
-
else: # Standalone assistant message (e.g., the first message)
|
|
256
|
-
history.append([None, msg.content])
|
|
257
|
-
return history
|
|
258
|
-
|
|
259
|
-
def render_discussion_tree(self, active_branch_highlight: bool = True) -> str:
|
|
260
|
-
"""
|
|
261
|
-
Renders the entire discussion tree as formatted Markdown for display.
|
|
262
|
-
"""
|
|
263
|
-
if not self.messages:
|
|
264
|
-
return "No messages yet."
|
|
265
|
-
|
|
266
|
-
tree_markdown = "### Discussion Tree\n\n"
|
|
267
|
-
tree_markdown += "Click a message in the dropdown to switch branches.\n\n"
|
|
276
|
+
branch_msgs = self.get_branch(branch_tip_id) if branch_tip_id else []
|
|
277
|
+
full_system_prompt = self._get_full_system_prompt()
|
|
268
278
|
|
|
269
|
-
|
|
270
|
-
|
|
279
|
+
prompt_parts = []
|
|
280
|
+
current_tokens = 0
|
|
271
281
|
|
|
272
|
-
#
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
# Recursively render children
|
|
290
|
-
children_ids = self.children_index.get(node.id, [])
|
|
291
|
-
for child_id in children_ids:
|
|
292
|
-
line += _render_node(child_id, depth + 1)
|
|
293
|
-
|
|
294
|
-
return line
|
|
282
|
+
# Start with the system prompt if defined
|
|
283
|
+
if full_system_prompt:
|
|
284
|
+
sys_msg_text = f"{splitter_text}system:\n{full_system_prompt}\n"
|
|
285
|
+
sys_tokens = len(self.lollmsClient.binding.tokenize(sys_msg_text))
|
|
286
|
+
if sys_tokens <= max_allowed_tokens:
|
|
287
|
+
prompt_parts.append(sys_msg_text)
|
|
288
|
+
current_tokens += sys_tokens
|
|
289
|
+
|
|
290
|
+
# Iterate from newest to oldest to fill the remaining context
|
|
291
|
+
for msg in reversed(branch_msgs):
|
|
292
|
+
sender_str = msg.sender.replace(':', '').replace(splitter_text, '')
|
|
293
|
+
content = msg.content.strip()
|
|
294
|
+
if msg.images:
|
|
295
|
+
content += f"\n({len(msg.images)} image(s) attached)"
|
|
296
|
+
|
|
297
|
+
msg_text = f"{splitter_text}{sender_str}:\n{content}\n"
|
|
298
|
+
msg_tokens = len(self.lollmsClient.binding.tokenize(msg_text))
|
|
295
299
|
|
|
296
|
-
|
|
297
|
-
|
|
300
|
+
if current_tokens + msg_tokens > max_allowed_tokens:
|
|
301
|
+
break # Stop if adding the next message exceeds the limit
|
|
302
|
+
|
|
303
|
+
prompt_parts.insert(1 if full_system_prompt else 0, msg_text) # Prepend after system prompt
|
|
304
|
+
current_tokens += msg_tokens
|
|
298
305
|
|
|
299
|
-
return
|
|
306
|
+
return "".join(prompt_parts).strip()
|
|
307
|
+
|
|
300
308
|
|
|
301
|
-
def get_message_choices(self) -> List[tuple]:
|
|
302
|
-
"""
|
|
303
|
-
Creates a list of (label, id) tuples for a Gradio Dropdown component.
|
|
304
|
-
"""
|
|
305
|
-
choices = [(f"{msg.sender}: {msg.content[:40]}... (ID: ...{msg.id[-4:]})", msg.id) for msg in self.messages]
|
|
306
|
-
# Sort by message creation order (assuming self.messages is ordered)
|
|
307
|
-
return choices
|
|
308
|
-
|
|
309
|
-
|
|
310
309
|
def export(self, format_type: str, branch_tip_id: Optional[str] = None) -> Union[List[Dict], str]:
|
|
311
310
|
"""
|
|
312
|
-
Exports the discussion history in a specific format
|
|
313
|
-
|
|
314
|
-
Args:
|
|
315
|
-
format_type (str): The target format. Supported values are:
|
|
316
|
-
- "openai_chat": For OpenAI, llama.cpp, and other compatible chat APIs.
|
|
317
|
-
- "ollama_chat": For Ollama's chat API.
|
|
318
|
-
- "lollms_text": For the native lollms-webui text/image endpoints.
|
|
319
|
-
- "openai_completion": For legacy text completion APIs.
|
|
320
|
-
branch_tip_id (Optional[str]): The ID of the message to use as the
|
|
321
|
-
tip of the conversation branch. Defaults to the active branch.
|
|
322
|
-
|
|
323
|
-
Returns:
|
|
324
|
-
Union[List[Dict], str]: The formatted conversation history, either as a
|
|
325
|
-
list of dictionaries (for chat formats) or a single string.
|
|
311
|
+
Exports the full, untruncated discussion history in a specific format.
|
|
326
312
|
"""
|
|
327
|
-
if branch_tip_id is None:
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
# Handle case of an empty or uninitialized discussion
|
|
331
|
-
if branch_tip_id is None:
|
|
332
|
-
return "" if format_type in ["lollms_text", "openai_completion"] else []
|
|
313
|
+
if branch_tip_id is None: branch_tip_id = self.active_branch_id
|
|
314
|
+
if branch_tip_id is None and not self._get_full_system_prompt(): return "" if format_type in ["lollms_text", "openai_completion"] else []
|
|
333
315
|
|
|
334
|
-
branch = self.get_branch(branch_tip_id)
|
|
316
|
+
branch = self.get_branch(branch_tip_id) if branch_tip_id else []
|
|
317
|
+
full_system_prompt = self._get_full_system_prompt()
|
|
335
318
|
|
|
336
|
-
# --------------------- OpenAI Chat Format ---------------------
|
|
337
|
-
# Used by: OpenAI API, llama.cpp server, and many other compatible services.
|
|
338
|
-
# Structure: List of dictionaries with 'role' and 'content'.
|
|
339
|
-
# Images are handled via multi-part 'content'.
|
|
340
|
-
# --------------------------------------------------------------
|
|
341
319
|
if format_type == "openai_chat":
|
|
342
320
|
messages = []
|
|
343
|
-
if
|
|
344
|
-
messages.append({"role": "system", "content": self.system_prompt.strip()})
|
|
345
|
-
|
|
321
|
+
if full_system_prompt: messages.append({"role": "system", "content": full_system_prompt})
|
|
346
322
|
def openai_image_block(image: Dict[str, str]) -> Dict:
|
|
347
|
-
"""Creates a dict for an image URL, either from a URL or base64 data."""
|
|
348
323
|
image_url = image['data'] if image['type'] == 'url' else f"data:image/jpeg;base64,{image['data']}"
|
|
349
324
|
return {"type": "image_url", "image_url": {"url": image_url, "detail": "auto"}}
|
|
350
|
-
|
|
351
325
|
for msg in branch:
|
|
352
326
|
role = self.participants.get(msg.sender, "user")
|
|
353
327
|
if msg.images:
|
|
354
|
-
content_parts = []
|
|
355
|
-
if msg.content.strip(): # Add text part only if content exists
|
|
356
|
-
content_parts.append({"type": "text", "text": msg.content.strip()})
|
|
328
|
+
content_parts = [{"type": "text", "text": msg.content.strip()}] if msg.content.strip() else []
|
|
357
329
|
content_parts.extend(openai_image_block(img) for img in msg.images)
|
|
358
330
|
messages.append({"role": role, "content": content_parts})
|
|
359
|
-
else:
|
|
360
|
-
messages.append({"role": role, "content": msg.content.strip()})
|
|
331
|
+
else: messages.append({"role": role, "content": msg.content.strip()})
|
|
361
332
|
return messages
|
|
362
333
|
|
|
363
|
-
# --------------------- Ollama Chat Format ---------------------
|
|
364
|
-
# Used by: Ollama's '/api/chat' endpoint.
|
|
365
|
-
# Structure: List of dictionaries with 'role', 'content', and an optional 'images' key.
|
|
366
|
-
# Images must be a list of base64-encoded strings. URLs are ignored.
|
|
367
|
-
# --------------------------------------------------------------
|
|
368
334
|
elif format_type == "ollama_chat":
|
|
369
335
|
messages = []
|
|
370
|
-
if
|
|
371
|
-
messages.append({"role": "system", "content": self.system_prompt.strip()})
|
|
372
|
-
|
|
336
|
+
if full_system_prompt: messages.append({"role": "system", "content": full_system_prompt})
|
|
373
337
|
for msg in branch:
|
|
374
338
|
role = self.participants.get(msg.sender, "user")
|
|
375
339
|
message_dict = {"role": role, "content": msg.content.strip()}
|
|
376
|
-
|
|
377
|
-
# Filter for and add base64 images, as required by Ollama
|
|
378
340
|
ollama_images = [img['data'] for img in msg.images if img['type'] == 'base64']
|
|
379
|
-
if ollama_images:
|
|
380
|
-
message_dict["images"] = ollama_images
|
|
381
|
-
|
|
341
|
+
if ollama_images: message_dict["images"] = ollama_images
|
|
382
342
|
messages.append(message_dict)
|
|
383
343
|
return messages
|
|
384
344
|
|
|
385
|
-
# --------------------- LoLLMs Native Text Format ---------------------
|
|
386
|
-
# Used by: lollms-webui's '/lollms_generate' and '/lollms_generate_with_images' endpoints.
|
|
387
|
-
# Structure: A single string with messages separated by special tokens like '!@>user:'.
|
|
388
|
-
# Images are not part of the string but are sent separately by the binding.
|
|
389
|
-
# --------------------------------------------------------------------
|
|
390
345
|
elif format_type == "lollms_text":
|
|
391
346
|
full_prompt_parts = []
|
|
392
|
-
if
|
|
393
|
-
full_prompt_parts.append(f"!@>system:\n{self.system_prompt.strip()}")
|
|
394
|
-
|
|
347
|
+
if full_system_prompt: full_prompt_parts.append(f"!@>system:\n{full_system_prompt}")
|
|
395
348
|
for msg in branch:
|
|
396
349
|
sender_str = msg.sender.replace(':', '').replace('!@>', '')
|
|
397
350
|
content = msg.content.strip()
|
|
398
|
-
|
|
399
|
-
if msg.images:
|
|
400
|
-
content += f"\n({len(msg.images)} image(s) attached)"
|
|
351
|
+
if msg.images: content += f"\n({len(msg.images)} image(s) attached)"
|
|
401
352
|
full_prompt_parts.append(f"!@>{sender_str}:\n{content}")
|
|
402
|
-
|
|
403
353
|
return "\n".join(full_prompt_parts)
|
|
404
354
|
|
|
405
|
-
# ------------------ Legacy OpenAI Completion Format ------------------
|
|
406
|
-
# Used by: Older text-completion models.
|
|
407
|
-
# Structure: A single string with human-readable roles (e.g., "User:", "Assistant:").
|
|
408
|
-
# Images are represented by a text placeholder.
|
|
409
|
-
# ----------------------------------------------------------------------
|
|
410
355
|
elif format_type == "openai_completion":
|
|
411
356
|
full_prompt_parts = []
|
|
412
|
-
if
|
|
413
|
-
full_prompt_parts.append(f"System:\n{self.system_prompt.strip()}")
|
|
414
|
-
|
|
357
|
+
if full_system_prompt: full_prompt_parts.append(f"System:\n{full_system_prompt}")
|
|
415
358
|
for msg in branch:
|
|
416
359
|
role_label = self.participants.get(msg.sender, "user").capitalize()
|
|
417
360
|
content = msg.content.strip()
|
|
418
|
-
if msg.images:
|
|
419
|
-
content += f"\n({len(msg.images)} image(s) attached)"
|
|
361
|
+
if msg.images: content += f"\n({len(msg.images)} image(s) attached)"
|
|
420
362
|
full_prompt_parts.append(f"{role_label}:\n{content}")
|
|
421
|
-
|
|
422
363
|
return "\n\n".join(full_prompt_parts)
|
|
423
364
|
|
|
424
|
-
else:
|
|
425
|
-
|
|
426
|
-
|
|
365
|
+
else: raise ValueError(f"Unsupported export format_type: {format_type}")
|
|
366
|
+
|
|
367
|
+
|
|
427
368
|
if __name__ == "__main__":
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
discussion
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
# 🌿 Branch from msg1 with an image
|
|
451
|
-
msg3 = discussion.add_message(
|
|
452
|
-
'Alice',
|
|
453
|
-
'Here is an image of my dog.',
|
|
454
|
-
parent_id=msg1,
|
|
455
|
-
images=[{"type": "url", "data": "https://example.com/alices_dog.jpg"}]
|
|
456
|
-
)
|
|
457
|
-
|
|
458
|
-
# 🖼️ FIXED: Add another message with images using the 'images' parameter directly.
|
|
459
|
-
sample_base64 = base64.b64encode(b'This is a test image of a cat').decode('utf-8')
|
|
460
|
-
msg4 = discussion.add_message(
|
|
461
|
-
'Bob',
|
|
462
|
-
"Nice! Here's my cat.",
|
|
463
|
-
parent_id=msg3,
|
|
464
|
-
images=[
|
|
465
|
-
{"type": "url", "data": "https://example.com/bobs_cat.jpg"},
|
|
466
|
-
{"type": "base64", "data": sample_base64}
|
|
467
|
-
]
|
|
468
|
-
)
|
|
469
|
-
|
|
470
|
-
# 🌿 Switch to the new branch
|
|
471
|
-
discussion.set_active_branch(msg4)
|
|
472
|
-
|
|
473
|
-
# 📁 Save and load discussion
|
|
474
|
-
discussion.save_to_disk("test_discussion.yaml")
|
|
475
|
-
|
|
476
|
-
print("\n💾 Discussion saved to test_discussion.yaml")
|
|
369
|
+
class MockBinding:
|
|
370
|
+
def tokenize(self, text: str) -> List[int]: return text.split()
|
|
371
|
+
class MockLollmsClient:
|
|
372
|
+
def __init__(self): self.binding = MockBinding()
|
|
373
|
+
def generate(self, prompt: str, max_new_tokens: int, temperature: float) -> str: return "This is a generated summary."
|
|
374
|
+
|
|
375
|
+
print("--- Initializing Mock Client and Discussion ---")
|
|
376
|
+
mock_client = MockLollmsClient()
|
|
377
|
+
discussion = LollmsDiscussion(mock_client)
|
|
378
|
+
discussion.set_participants({"User": "user", "Project Lead": "assistant"})
|
|
379
|
+
discussion.set_system_prompt("This is a formal discussion about Project Phoenix.")
|
|
380
|
+
discussion.set_scratchpad("Initial State: Project Phoenix is in the planning phase.")
|
|
381
|
+
|
|
382
|
+
print("\n--- Creating a long discussion history ---")
|
|
383
|
+
parent_id = None
|
|
384
|
+
long_text = "extra text to increase token count"
|
|
385
|
+
for i in range(10):
|
|
386
|
+
user_msg = f"Message #{i*2+1}: Update on task {i+1}? {long_text}"
|
|
387
|
+
user_id = discussion.add_message("User", "user", user_msg, parent_id=parent_id)
|
|
388
|
+
assistant_msg = f"Message #{i*2+2}: Task {i+1} status is blocked. {long_text}"
|
|
389
|
+
assistant_id = discussion.add_message("Project Lead", "assistant", assistant_msg, parent_id=user_id)
|
|
390
|
+
parent_id = assistant_id
|
|
477
391
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
print("
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
#
|
|
498
|
-
|
|
499
|
-
print("\n🤖 Ollama Chat format:\n", yaml.dump(ollama_export, allow_unicode=True, sort_keys=False))
|
|
500
|
-
|
|
501
|
-
# Test that images were loaded correctly
|
|
502
|
-
final_message = new_discussion.message_index[new_discussion.active_branch_id]
|
|
503
|
-
assert len(final_message.images) == 2
|
|
504
|
-
assert final_message.images[1]['type'] == 'base64'
|
|
505
|
-
print("\n✅ Verification successful: Images were loaded correctly from the file.")
|
|
392
|
+
initial_tokens = len(mock_client.binding.tokenize(discussion.export("lollms_text")))
|
|
393
|
+
print(f"Initial message count: {len(discussion.messages)}, Initial tokens: {initial_tokens}")
|
|
394
|
+
|
|
395
|
+
print("\n--- Testing Pruning ---")
|
|
396
|
+
prune_result = discussion.summarize_and_prune(max_tokens=200, preserve_last_n=4)
|
|
397
|
+
if prune_result.get("pruned"):
|
|
398
|
+
print("✅ Pruning was successful!")
|
|
399
|
+
assert "Summary" in discussion.get_scratchpad()
|
|
400
|
+
else: print(f"❌ Pruning failed: {prune_result.get('reason')}")
|
|
401
|
+
|
|
402
|
+
print("\n--- Testing format_discussion (Instruct Model Format) ---")
|
|
403
|
+
truncated_prompt = discussion.format_discussion(max_allowed_tokens=80)
|
|
404
|
+
truncated_tokens = len(mock_client.binding.tokenize(truncated_prompt))
|
|
405
|
+
print(f"Truncated prompt tokens: {truncated_tokens}")
|
|
406
|
+
print("Truncated Prompt:\n" + "="*20 + f"\n{truncated_prompt}\n" + "="*20)
|
|
407
|
+
|
|
408
|
+
# Verification
|
|
409
|
+
assert truncated_tokens <= 80
|
|
410
|
+
# Check that it contains the newest message that fits
|
|
411
|
+
assert "Message #19" in truncated_prompt or "Message #20" in truncated_prompt
|
|
412
|
+
print("✅ format_discussion correctly truncated the prompt.")
|