lollms-client 0.20.6__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.

@@ -1,73 +1,135 @@
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
21
+ sender_type: str
12
22
  content: str
13
23
  id: str = field(default_factory=lambda: str(uuid.uuid4()))
14
24
  parent_id: Optional[str] = None
15
25
  metadata: str = "{}"
16
26
  images: List[Dict[str, str]] = field(default_factory=list)
17
27
 
18
- def to_dict(self):
28
+ def to_dict(self) -> Dict[str, Any]:
29
+ """Serializes the message object to a dictionary."""
19
30
  return {
20
31
  'sender': self.sender,
32
+ 'sender_type': self.sender_type,
21
33
  'content': self.content,
22
34
  'id': self.id,
23
35
  'parent_id': self.parent_id,
24
36
  'metadata': self.metadata,
25
37
  'images': self.images
26
38
  }
27
-
28
39
 
29
40
 
30
- # Enhanced LollmsDiscussion Class with branching support
31
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
+
32
47
  def __init__(self, lollmsClient: 'LollmsClient'):
33
- self.messages: List[LollmsMessage] = []
48
+ """
49
+ Initializes a new LollmsDiscussion instance.
50
+
51
+ Args:
52
+ lollmsClient: An instance of LollmsClient, required for tokenization.
53
+ """
34
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] = []
35
61
  self.active_branch_id: Optional[str] = None
36
62
  self.message_index: Dict[str, LollmsMessage] = {}
37
63
  self.children_index: Dict[Optional[str], List[str]] = defaultdict(list)
38
- self.version: int = 2 # Current version of the format
39
- self.participants: Dict[str, str] = {} # name -> type ("user" or "assistant")
64
+ self.participants: Dict[str, str] = {}
40
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.
41
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 ---
42
95
  def set_system_prompt(self, prompt: str):
96
+ """Sets the main system prompt for the discussion."""
43
97
  self.system_prompt = prompt
44
98
 
45
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
+ """
46
106
  for name, role in participants.items():
47
107
  if role not in ["user", "assistant"]:
48
108
  raise ValueError(f"Invalid role '{role}' for participant '{name}'")
49
109
  self.participants = participants
50
110
 
111
+ # --- Core Message Tree Methods ---
51
112
  def add_message(
52
113
  self,
53
114
  sender: str,
115
+ sender_type: str,
54
116
  content: str,
55
- metadata: Dict = {},
117
+ metadata: Optional[Dict] = None,
56
118
  parent_id: Optional[str] = None,
57
119
  images: Optional[List[Dict[str, str]]] = None,
58
120
  override_id: Optional[str] = None
59
121
  ) -> str:
122
+ """
123
+ Adds a new message to the discussion tree.
124
+ """
60
125
  if parent_id is None:
61
126
  parent_id = self.active_branch_id
62
127
  if parent_id is None:
63
- parent_id = "main"
128
+ parent_id = "main_root"
64
129
 
65
130
  message = LollmsMessage(
66
- sender=sender,
67
- content=content,
68
- parent_id=parent_id,
69
- metadata=str(metadata),
70
- images=images or []
131
+ sender=sender, sender_type=sender_type, content=content,
132
+ parent_id=parent_id, metadata=str(metadata or {}), images=images or []
71
133
  )
72
134
  if override_id:
73
135
  message.id = override_id
@@ -75,425 +137,276 @@ class LollmsDiscussion:
75
137
  self.messages.append(message)
76
138
  self.message_index[message.id] = message
77
139
  self.children_index[parent_id].append(message.id)
78
-
79
140
  self.active_branch_id = message.id
80
141
  return message.id
81
142
 
82
-
83
143
  def get_branch(self, leaf_id: str) -> List[LollmsMessage]:
84
- """Get full branch from root to specified leaf"""
144
+ """Gets the full branch of messages from the root to the specified leaf."""
85
145
  branch = []
86
- current_id = leaf_id
87
-
88
- 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:
89
148
  msg = self.message_index[current_id]
90
149
  branch.append(msg)
91
150
  current_id = msg.parent_id
92
-
93
- # Return from root to leaf
94
151
  return list(reversed(branch))
95
152
 
96
153
  def set_active_branch(self, message_id: str):
154
+ """Sets the active message, effectively switching to a different branch."""
97
155
  if message_id not in self.message_index:
98
- raise ValueError(f"Message ID {message_id} not found")
156
+ raise ValueError(f"Message ID {message_id} not found in discussion.")
99
157
  self.active_branch_id = message_id
100
158
 
101
- def remove_message(self, message_id: str):
102
- if message_id not in self.message_index:
103
- return
104
-
105
- msg = self.message_index[message_id]
106
- parent_id = msg.parent_id
107
-
108
- # Reassign children to parent
109
- for child_id in self.children_index[message_id]:
110
- child = self.message_index[child_id]
111
- child.parent_id = parent_id
112
- self.children_index[parent_id].append(child_id)
113
-
114
- # Clean up indexes
115
- del self.message_index[message_id]
116
- del self.children_index[message_id]
117
-
118
- # Remove from parent's children list
119
- if parent_id in self.children_index and message_id in self.children_index[parent_id]:
120
- self.children_index[parent_id].remove(message_id)
121
-
122
- # Remove from main messages list
123
- self.messages = [m for m in self.messages if m.id != message_id]
124
-
125
- # Update active branch if needed
126
- if self.active_branch_id == message_id:
127
- self.active_branch_id = parent_id
128
-
159
+ # --- Persistence ---
129
160
  def save_to_disk(self, file_path: str):
161
+ """Saves the entire discussion state to a YAML file."""
130
162
  data = {
131
- 'version': self.version,
132
- 'active_branch_id': self.active_branch_id,
133
- 'system_prompt': self.system_prompt,
134
- 'participants': self.participants,
135
- '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]
136
166
  }
137
167
  with open(file_path, 'w', encoding='utf-8') as file:
138
- yaml.dump(data, file, allow_unicode=True)
139
-
168
+ yaml.dump(data, file, allow_unicode=True, sort_keys=False)
140
169
 
141
170
  def load_from_disk(self, file_path: str):
171
+ """Loads a discussion state from a YAML file."""
142
172
  with open(file_path, 'r', encoding='utf-8') as file:
143
173
  data = yaml.safe_load(file)
144
174
 
145
- # Reset
146
- self.messages = []
147
- self.message_index = {}
148
- self.children_index = defaultdict(list)
149
-
150
- if isinstance(data, list):
151
- # Legacy v1 format
152
- prev_id = None
153
- for msg_data in data:
154
- msg = LollmsMessage(
155
- sender=msg_data['sender'],
156
- content=msg_data['content'],
157
- parent_id=prev_id,
158
- id=msg_data.get('id', str(uuid.uuid4())),
159
- metadata=msg_data.get('metadata', '{}')
160
- )
161
- self.messages.append(msg)
162
- self.message_index[msg.id] = msg
163
- self.children_index[prev_id].append(msg.id)
164
- prev_id = msg.id
165
- self.active_branch_id = prev_id if self.messages else None
166
- self.system_prompt = None
167
- self.participants = {}
168
- self.save_to_disk(file_path) # Upgrade
169
- return
170
-
171
- # v2 format
175
+ self._reset_state()
172
176
  version = data.get("version", 1)
173
- if version != self.version:
174
- 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}.")
175
179
 
176
180
  self.active_branch_id = data.get('active_branch_id')
177
181
  self.system_prompt = data.get('system_prompt', None)
178
182
  self.participants = data.get('participants', {})
183
+ self.scratchpad = data.get('scratchpad', None)
179
184
 
180
185
  for msg_data in data.get('messages', []):
181
- # FIXED: Added `images=msg_data.get('images', [])` to correctly load images from the file.
182
186
  msg = LollmsMessage(
183
- sender=msg_data['sender'],
184
- content=msg_data['content'],
185
- parent_id=msg_data.get('parent_id'),
186
- id=msg_data.get('id'),
187
- metadata=msg_data.get('metadata', '{}'),
188
- 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', [])
189
191
  )
190
192
  self.messages.append(msg)
191
193
  self.message_index[msg.id] = msg
192
194
  self.children_index[msg.parent_id].append(msg.id)
193
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
194
208
 
195
- def format_discussion(self, max_allowed_tokens: int, splitter_text: str = "!@>", branch_tip_id: Optional[str] = None) -> str:
196
- if branch_tip_id is None:
197
- 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."}
198
216
 
199
- branch_msgs = self.get_branch(branch_tip_id) if branch_tip_id else []
200
- formatted_text = ""
201
- 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."}
202
220
 
203
- # Start with system prompt if defined
204
- if self.system_prompt:
205
- sys_msg = f"!>system:\n{self.system_prompt.strip()}\n"
206
- sys_tokens = len(self.lollmsClient.tokenize(sys_msg))
207
- if max_allowed_tokens and current_tokens + sys_tokens <= max_allowed_tokens:
208
- formatted_text += sys_msg
209
- 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."}
210
223
 
211
- for msg in reversed(branch_msgs):
212
- content = msg.content.strip()
213
- # FIXED: Add a placeholder for images to represent them in text-only formats.
214
- if msg.images:
215
- 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}"}
216
238
 
217
- msg_text = f"{splitter_text}{msg.sender.replace(':', '').replace('!@>', '')}:\n{content}\n"
218
- msg_tokens = len(self.lollmsClient.tokenize(msg_text))
219
- if current_tokens + msg_tokens > max_allowed_tokens:
220
- break
221
- formatted_text = msg_text + formatted_text
222
- 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]
223
255
 
224
- 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}
225
259
 
226
- # gradio helpers -------------------------
227
- 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:
228
261
  """
229
- Converts a discussion branch into Gradio's chatbot list format.
230
- [[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.
231
272
  """
232
273
  if branch_tip_id is None:
233
274
  branch_tip_id = self.active_branch_id
234
- if not branch_tip_id:
235
- return []
236
275
 
237
- branch = self.get_branch(branch_tip_id)
238
- history = []
239
- for msg in branch:
240
- # Determine the role from participants, default to 'user'
241
- role = self.participants.get(msg.sender, "user")
242
-
243
- if role == "user":
244
- history.append([msg.content, None])
245
- else: # assistant
246
- # If the last user message has no reply yet, append to it
247
- if history and history[-1][1] is None:
248
- history[-1][1] = msg.content
249
- else: # Standalone assistant message (e.g., the first message)
250
- history.append([None, msg.content])
251
- return history
252
-
253
- def render_discussion_tree(self, active_branch_highlight: bool = True) -> str:
254
- """
255
- Renders the entire discussion tree as formatted Markdown for display.
256
- """
257
- if not self.messages:
258
- return "No messages yet."
259
-
260
- tree_markdown = "### Discussion Tree\n\n"
261
- 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()
262
278
 
263
- # Find root nodes (messages with no parent)
264
- root_ids = [msg.id for msg in self.messages if msg.parent_id is None]
279
+ prompt_parts = []
280
+ current_tokens = 0
265
281
 
266
- # Recursive function to render a node and its children
267
- def _render_node(node_id: str, depth: int) -> str:
268
- node = self.message_index.get(node_id)
269
- if not node:
270
- return ""
271
-
272
- indent = " " * depth
273
- # Highlight the active message
274
- is_active = ""
275
- if active_branch_highlight and node.id == self.active_branch_id:
276
- is_active = " <span class='activ'>[ACTIVE]</span>"
277
-
278
- # Format the message line
279
- prefix = f"{indent}- **{node.sender}**: "
280
- content_preview = node.content.replace('\n', ' ').strip()[:80]
281
- line = f"{prefix} _{content_preview}..._{is_active}\n"
282
-
283
- # Recursively render children
284
- children_ids = self.children_index.get(node.id, [])
285
- for child_id in children_ids:
286
- line += _render_node(child_id, depth + 1)
287
-
288
- 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))
289
299
 
290
- for root_id in root_ids:
291
- 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
292
305
 
293
- return tree_markdown
306
+ return "".join(prompt_parts).strip()
307
+
294
308
 
295
- def get_message_choices(self) -> List[tuple]:
296
- """
297
- Creates a list of (label, id) tuples for a Gradio Dropdown component.
298
- """
299
- choices = [(f"{msg.sender}: {msg.content[:40]}... (ID: ...{msg.id[-4:]})", msg.id) for msg in self.messages]
300
- # Sort by message creation order (assuming self.messages is ordered)
301
- return choices
302
-
303
-
304
309
  def export(self, format_type: str, branch_tip_id: Optional[str] = None) -> Union[List[Dict], str]:
305
310
  """
306
- Exports the discussion history in a specific format suitable for different model APIs.
307
-
308
- Args:
309
- format_type (str): The target format. Supported values are:
310
- - "openai_chat": For OpenAI, llama.cpp, and other compatible chat APIs.
311
- - "ollama_chat": For Ollama's chat API.
312
- - "lollms_text": For the native lollms-webui text/image endpoints.
313
- - "openai_completion": For legacy text completion APIs.
314
- branch_tip_id (Optional[str]): The ID of the message to use as the
315
- tip of the conversation branch. Defaults to the active branch.
316
-
317
- Returns:
318
- Union[List[Dict], str]: The formatted conversation history, either as a
319
- list of dictionaries (for chat formats) or a single string.
311
+ Exports the full, untruncated discussion history in a specific format.
320
312
  """
321
- if branch_tip_id is None:
322
- branch_tip_id = self.active_branch_id
323
-
324
- # Handle case of an empty or uninitialized discussion
325
- if branch_tip_id is None:
326
- 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 []
327
315
 
328
- 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()
329
318
 
330
- # --------------------- OpenAI Chat Format ---------------------
331
- # Used by: OpenAI API, llama.cpp server, and many other compatible services.
332
- # Structure: List of dictionaries with 'role' and 'content'.
333
- # Images are handled via multi-part 'content'.
334
- # --------------------------------------------------------------
335
319
  if format_type == "openai_chat":
336
320
  messages = []
337
- if self.system_prompt:
338
- messages.append({"role": "system", "content": self.system_prompt.strip()})
339
-
321
+ if full_system_prompt: messages.append({"role": "system", "content": full_system_prompt})
340
322
  def openai_image_block(image: Dict[str, str]) -> Dict:
341
- """Creates a dict for an image URL, either from a URL or base64 data."""
342
323
  image_url = image['data'] if image['type'] == 'url' else f"data:image/jpeg;base64,{image['data']}"
343
324
  return {"type": "image_url", "image_url": {"url": image_url, "detail": "auto"}}
344
-
345
325
  for msg in branch:
346
326
  role = self.participants.get(msg.sender, "user")
347
327
  if msg.images:
348
- content_parts = []
349
- if msg.content.strip(): # Add text part only if content exists
350
- content_parts.append({"type": "text", "text": msg.content.strip()})
328
+ content_parts = [{"type": "text", "text": msg.content.strip()}] if msg.content.strip() else []
351
329
  content_parts.extend(openai_image_block(img) for img in msg.images)
352
330
  messages.append({"role": role, "content": content_parts})
353
- else:
354
- messages.append({"role": role, "content": msg.content.strip()})
331
+ else: messages.append({"role": role, "content": msg.content.strip()})
355
332
  return messages
356
333
 
357
- # --------------------- Ollama Chat Format ---------------------
358
- # Used by: Ollama's '/api/chat' endpoint.
359
- # Structure: List of dictionaries with 'role', 'content', and an optional 'images' key.
360
- # Images must be a list of base64-encoded strings. URLs are ignored.
361
- # --------------------------------------------------------------
362
334
  elif format_type == "ollama_chat":
363
335
  messages = []
364
- if self.system_prompt:
365
- messages.append({"role": "system", "content": self.system_prompt.strip()})
366
-
336
+ if full_system_prompt: messages.append({"role": "system", "content": full_system_prompt})
367
337
  for msg in branch:
368
338
  role = self.participants.get(msg.sender, "user")
369
339
  message_dict = {"role": role, "content": msg.content.strip()}
370
-
371
- # Filter for and add base64 images, as required by Ollama
372
340
  ollama_images = [img['data'] for img in msg.images if img['type'] == 'base64']
373
- if ollama_images:
374
- message_dict["images"] = ollama_images
375
-
341
+ if ollama_images: message_dict["images"] = ollama_images
376
342
  messages.append(message_dict)
377
343
  return messages
378
344
 
379
- # --------------------- LoLLMs Native Text Format ---------------------
380
- # Used by: lollms-webui's '/lollms_generate' and '/lollms_generate_with_images' endpoints.
381
- # Structure: A single string with messages separated by special tokens like '!@>user:'.
382
- # Images are not part of the string but are sent separately by the binding.
383
- # --------------------------------------------------------------------
384
345
  elif format_type == "lollms_text":
385
346
  full_prompt_parts = []
386
- if self.system_prompt:
387
- full_prompt_parts.append(f"!@>system:\n{self.system_prompt.strip()}")
388
-
347
+ if full_system_prompt: full_prompt_parts.append(f"!@>system:\n{full_system_prompt}")
389
348
  for msg in branch:
390
349
  sender_str = msg.sender.replace(':', '').replace('!@>', '')
391
350
  content = msg.content.strip()
392
- # Images are handled separately by the binding, but a placeholder can be useful for context
393
- if msg.images:
394
- content += f"\n({len(msg.images)} image(s) attached)"
351
+ if msg.images: content += f"\n({len(msg.images)} image(s) attached)"
395
352
  full_prompt_parts.append(f"!@>{sender_str}:\n{content}")
396
-
397
353
  return "\n".join(full_prompt_parts)
398
354
 
399
- # ------------------ Legacy OpenAI Completion Format ------------------
400
- # Used by: Older text-completion models.
401
- # Structure: A single string with human-readable roles (e.g., "User:", "Assistant:").
402
- # Images are represented by a text placeholder.
403
- # ----------------------------------------------------------------------
404
355
  elif format_type == "openai_completion":
405
356
  full_prompt_parts = []
406
- if self.system_prompt:
407
- full_prompt_parts.append(f"System:\n{self.system_prompt.strip()}")
408
-
357
+ if full_system_prompt: full_prompt_parts.append(f"System:\n{full_system_prompt}")
409
358
  for msg in branch:
410
359
  role_label = self.participants.get(msg.sender, "user").capitalize()
411
360
  content = msg.content.strip()
412
- if msg.images:
413
- content += f"\n({len(msg.images)} image(s) attached)"
361
+ if msg.images: content += f"\n({len(msg.images)} image(s) attached)"
414
362
  full_prompt_parts.append(f"{role_label}:\n{content}")
415
-
416
363
  return "\n\n".join(full_prompt_parts)
417
364
 
418
- else:
419
- raise ValueError(f"Unsupported export format_type: {format_type}")
420
- # Example usage
365
+ else: raise ValueError(f"Unsupported export format_type: {format_type}")
366
+
367
+
421
368
  if __name__ == "__main__":
422
- import base64
423
-
424
- # 🔧 Mock client for token counting
425
- from lollms_client import LollmsClient
426
- client = LollmsClient(binding_name="ollama",model_name="mistral:latest")
427
- discussion = LollmsDiscussion(client)
428
-
429
- # 👥 Set participants
430
- discussion.set_participants({
431
- "Alice": "user",
432
- "Bob": "assistant"
433
- })
434
-
435
- # 📝 Set a system prompt
436
- discussion.set_system_prompt("You are a helpful and friendly assistant.")
437
-
438
- # 📩 Add root message
439
- msg1 = discussion.add_message('Alice', 'Hello!')
440
-
441
- # 📩 Add reply
442
- msg2 = discussion.add_message('Bob', 'Hi there!')
443
-
444
- # 🌿 Branch from msg1 with an image
445
- msg3 = discussion.add_message(
446
- 'Alice',
447
- 'Here is an image of my dog.',
448
- parent_id=msg1,
449
- images=[{"type": "url", "data": "https://example.com/alices_dog.jpg"}]
450
- )
451
-
452
- # 🖼️ FIXED: Add another message with images using the 'images' parameter directly.
453
- sample_base64 = base64.b64encode(b'This is a test image of a cat').decode('utf-8')
454
- msg4 = discussion.add_message(
455
- 'Bob',
456
- "Nice! Here's my cat.",
457
- parent_id=msg3,
458
- images=[
459
- {"type": "url", "data": "https://example.com/bobs_cat.jpg"},
460
- {"type": "base64", "data": sample_base64}
461
- ]
462
- )
463
-
464
- # 🌿 Switch to the new branch
465
- discussion.set_active_branch(msg4)
466
-
467
- # 📁 Save and load discussion
468
- discussion.save_to_disk("test_discussion.yaml")
469
-
470
- 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
471
391
 
472
- new_discussion = LollmsDiscussion(client)
473
- new_discussion.load_from_disk("test_discussion.yaml")
474
- # Participants must be set again as they are part of the runtime configuration
475
- # but the loader now correctly loads them from the file.
476
- print("📂 Discussion loaded from test_discussion.yaml")
477
-
478
-
479
- # 🧾 Format the discussion
480
- formatted = new_discussion.format_discussion(1000)
481
- print("\n📜 Formatted discussion (text-only with placeholders):\n", formatted)
482
-
483
- # 🔁 Export to OpenAI Chat format
484
- openai_chat = new_discussion.export("openai_chat")
485
- print("\n📦 OpenAI Chat format:\n", yaml.dump(openai_chat, allow_unicode=True, sort_keys=False))
486
-
487
- # 🔁 Export to OpenAI Completion format
488
- openai_completion = new_discussion.export("openai_completion")
489
- print("\n📜 OpenAI Completion format:\n", openai_completion)
490
-
491
- # 🔁 Export to Ollama Chat format
492
- ollama_export = new_discussion.export("ollama_chat")
493
- print("\n🤖 Ollama Chat format:\n", yaml.dump(ollama_export, allow_unicode=True, sort_keys=False))
494
-
495
- # Test that images were loaded correctly
496
- final_message = new_discussion.message_index[new_discussion.active_branch_id]
497
- assert len(final_message.images) == 2
498
- assert final_message.images[1]['type'] == 'base64'
499
- 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.")