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

@@ -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
- # LollmsMessage Class with parent_id support
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
- self.messages: List[LollmsMessage] = []
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.version: int = 2 # Current version of the format
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 = "main"
128
+ parent_id = "main_root"
67
129
 
68
130
  message = LollmsMessage(
69
- sender=sender,
70
- sender_type=sender_type,
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
- """Get full branch from root to specified leaf"""
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
- def remove_message(self, message_id: str):
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
- 'active_branch_id': self.active_branch_id,
137
- 'system_prompt': self.system_prompt,
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
- # Reset
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 != self.version:
180
- raise ValueError(f"Unsupported version: {version}")
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
- parent_id=msg_data.get('parent_id'),
192
- id=msg_data.get('id'),
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 format_discussion(self, max_allowed_tokens: int, splitter_text: str = "!@>", branch_tip_id: Optional[str] = None) -> str:
202
- if branch_tip_id is None:
203
- branch_tip_id = self.active_branch_id
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
- branch_msgs = self.get_branch(branch_tip_id) if branch_tip_id else []
206
- formatted_text = ""
207
- current_tokens = 0
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
- # Start with system prompt if defined
210
- if self.system_prompt:
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
- for msg in reversed(branch_msgs):
218
- content = msg.content.strip()
219
- # FIXED: Add a placeholder for images to represent them in text-only formats.
220
- if msg.images:
221
- content += f"\n({len(msg.images)} image(s) attached)"
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
- msg_text = f"{splitter_text}{msg.sender.replace(':', '').replace('!@>', '')}:\n{content}\n"
224
- msg_tokens = len(self.lollmsClient.tokenize(msg_text))
225
- if current_tokens + msg_tokens > max_allowed_tokens:
226
- break
227
- formatted_text = msg_text + formatted_text
228
- current_tokens += msg_tokens
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
- return formatted_text.strip()
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
- # gradio helpers -------------------------
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
- Converts a discussion branch into Gradio's chatbot list format.
236
- [[user_msg, ai_reply], [user_msg, ai_reply], ...]
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
- branch = self.get_branch(branch_tip_id)
244
- history = []
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
- # Find root nodes (messages with no parent)
270
- root_ids = [msg.id for msg in self.messages if msg.parent_id is None]
279
+ prompt_parts = []
280
+ current_tokens = 0
271
281
 
272
- # Recursive function to render a node and its children
273
- def _render_node(node_id: str, depth: int) -> str:
274
- node = self.message_index.get(node_id)
275
- if not node:
276
- return ""
277
-
278
- indent = " " * depth
279
- # Highlight the active message
280
- is_active = ""
281
- if active_branch_highlight and node.id == self.active_branch_id:
282
- is_active = " <span class='activ'>[ACTIVE]</span>"
283
-
284
- # Format the message line
285
- prefix = f"{indent}- **{node.sender}**: "
286
- content_preview = node.content.replace('\n', ' ').strip()[:80]
287
- line = f"{prefix} _{content_preview}..._{is_active}\n"
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
- for root_id in root_ids:
297
- tree_markdown += _render_node(root_id, 0)
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 tree_markdown
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 suitable for different model APIs.
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
- branch_tip_id = self.active_branch_id
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 self.system_prompt:
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 self.system_prompt:
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 self.system_prompt:
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
- # Images are handled separately by the binding, but a placeholder can be useful for context
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 self.system_prompt:
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
- raise ValueError(f"Unsupported export format_type: {format_type}")
426
- # Example usage
365
+ else: raise ValueError(f"Unsupported export format_type: {format_type}")
366
+
367
+
427
368
  if __name__ == "__main__":
428
- import base64
429
-
430
- # 🔧 Mock client for token counting
431
- from lollms_client import LollmsClient
432
- client = LollmsClient(binding_name="ollama",model_name="mistral:latest")
433
- discussion = LollmsDiscussion(client)
434
-
435
- # 👥 Set participants
436
- discussion.set_participants({
437
- "Alice": "user",
438
- "Bob": "assistant"
439
- })
440
-
441
- # 📝 Set a system prompt
442
- discussion.set_system_prompt("You are a helpful and friendly assistant.")
443
-
444
- # 📩 Add root message
445
- msg1 = discussion.add_message('Alice', 'Hello!')
446
-
447
- # 📩 Add reply
448
- msg2 = discussion.add_message('Bob', 'Hi there!')
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
- new_discussion = LollmsDiscussion(client)
479
- new_discussion.load_from_disk("test_discussion.yaml")
480
- # Participants must be set again as they are part of the runtime configuration
481
- # but the loader now correctly loads them from the file.
482
- print("📂 Discussion loaded from test_discussion.yaml")
483
-
484
-
485
- # 🧾 Format the discussion
486
- formatted = new_discussion.format_discussion(1000)
487
- print("\n📜 Formatted discussion (text-only with placeholders):\n", formatted)
488
-
489
- # 🔁 Export to OpenAI Chat format
490
- openai_chat = new_discussion.export("openai_chat")
491
- print("\n📦 OpenAI Chat format:\n", yaml.dump(openai_chat, allow_unicode=True, sort_keys=False))
492
-
493
- # 🔁 Export to OpenAI Completion format
494
- openai_completion = new_discussion.export("openai_completion")
495
- print("\n📜 OpenAI Completion format:\n", openai_completion)
496
-
497
- # 🔁 Export to Ollama Chat format
498
- ollama_export = new_discussion.export("ollama_chat")
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.")