Skip to content

Commit 7cfc554

Browse files
hgohelMelleKoning
authored andcommitted
ChatWithTree addon
1 parent 57891d1 commit 7cfc554

File tree

7 files changed

+1217
-1
lines changed

7 files changed

+1217
-1
lines changed

ChatWithTree/ChatWithTree.gpr.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# ------------------------------------------------------------------------
2+
#
3+
# Register the Gramplet ChatWithTree
4+
#
5+
# ------------------------------------------------------------------------
6+
register(
7+
GRAMPLET,
8+
id="ChatWithTree", # Unique ID for your addon
9+
name=_("Chat With Tree Interactive Addon"), # Display name in Gramps, translatable
10+
description=_("Chat With Tree with the help of AI Large Language Model, needs litellm module"),
11+
version = '0.0.15',
12+
gramps_target_version="6.0", # Specify the Gramps version you are targeting
13+
status=EXPERIMENTAL,
14+
audience = DEVELOPER,
15+
fname="ChatWithTree.py", # The main Python file for your Gramplet
16+
# The 'gramplet' argument points to the class name in your main file
17+
gramplet="ChatWithTreeClass",
18+
gramplet_title=_("Chat With Tree"),
19+
authors = ["Melle Koning"],
20+
authors_email = ["[email protected]"],
21+
height=18,
22+
# addon needs litellm python module
23+
requires_mod=['litellm'],
24+
)

ChatWithTree/ChatWithTree.py

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
#
2+
# Gramps - a GTK+/GNOME based genealogy program
3+
#
4+
# Copyright (C) 2025 Melle Koning
5+
#
6+
# This program is free software; you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation; either version 2 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with this program; if not, write to the Free Software
18+
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
19+
#
20+
# ChatWithTree.py
21+
import logging
22+
LOG = logging.getLogger(".")
23+
LOG.debug("loading chatwithtree")
24+
# ==============================================================================
25+
# Standard Python libraries
26+
# ==============================================================================
27+
import gi
28+
gi.require_version("Gtk", "3.0")
29+
from gi.repository import Gtk, Gdk
30+
from gi.repository import GLib
31+
32+
# ==============================================================================
33+
# GRAMPS API
34+
# ==============================================================================
35+
from gramps.gen.plug import Gramplet
36+
from gramps.gen.const import GRAMPS_LOCALE as glocale
37+
_ = glocale.get_addon_translator(__file__).gettext
38+
39+
from chatwithllm import IChatLogic, ChatWithLLM, YieldType
40+
41+
try:
42+
from ChatWithTreeBot import ChatBot
43+
except ImportError as e:
44+
LOG.warning(e)
45+
raise ImportError("Failed to import ChatBot from chatbot module: " + str(e))
46+
47+
LOG.debug("ChatWithTree file header loaded successfully.")
48+
49+
ONE_SECOND = 1000 # milliseconds
50+
51+
# ==============================================================================
52+
# Gramplet Class Definition
53+
# ==============================================================================
54+
class ChatWithTreeClass(Gramplet):
55+
"""
56+
A simple interactive Gramplet that takes user input and provides a reply.
57+
58+
This version uses a Gtk.ListBox to create a dynamic, chat-like interface
59+
with styled message "balloons" for user input and system replies.
60+
"""
61+
62+
def __init__(self, parent=None, **kwargs):
63+
"""
64+
The constructor for the Gramplet.
65+
We call the base class constructor here. The GUI is built in the
66+
init() method.
67+
"""
68+
# Call the base class constructor. This is a mandatory step.
69+
Gramplet.__init__(self, parent, **kwargs)
70+
71+
def init(self):
72+
"""
73+
This method is called by the Gramps framework after the Gramplet
74+
has been fully initialized. We build our GUI here.
75+
"""
76+
# Build our custom GUI widgets.
77+
self.vbox = self._build_gui()
78+
79+
# The Gramplet's container widget is found via `self.gui`.
80+
# We first remove the default textview...
81+
self.gui.get_container_widget().remove(self.gui.textview)
82+
# ... and then we add our new vertical box.
83+
self.gui.get_container_widget().add(self.vbox)
84+
85+
# Show all widgets.
86+
self.vbox.show()
87+
# db change signal
88+
self.dbstate.connect('database-changed', self.change_db)
89+
90+
# Instantiate the chat logic class. This decouples the logic from the UI.
91+
# Choose ChatWIthLLM for simple reverse chat
92+
# self.chat_logic = ChatWithLLM()
93+
# Choose Chatbot for chat with Tree
94+
#self.chat_logic = ChatBot(self)
95+
96+
def change_db(self, db):
97+
"""
98+
This method is called when the database is opened or closed.
99+
The 'dbstate' parameter is the current database state object.
100+
"""
101+
# Add the initial message to the list box.
102+
self._add_message_row(_("Database change detected"), YieldType.PARTIAL)
103+
104+
if self.dbstate.db:
105+
LOG.debug("Database handle is now available. Initializing chatbot.")
106+
# The database is open, so it is now safe to instantiate the chatbot
107+
# and pass the Gramplet instance with a valid db handle.
108+
self.chat_logic = ChatBot(self)
109+
else:
110+
LOG.debug("Database is closed. Chatbot logic is reset.")
111+
self.chat_logic = None
112+
113+
def _build_gui(self):
114+
"""
115+
Creates all the GTK widgets for the Gramplet's user interface.
116+
Returns the top-level container widget.
117+
"""
118+
# Create the main vertical box to hold all our widgets.
119+
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
120+
121+
# -------------------
122+
# 1. Chat History Section
123+
# -------------------
124+
# We use a Gtk.ListBox to hold our chat "balloons".
125+
self.chat_listbox = Gtk.ListBox()
126+
# Set a name for CSS styling.
127+
self.chat_listbox.set_name("chat-listbox")
128+
# Ensure the listbox is a single-column list.
129+
self.chat_listbox.set_selection_mode(Gtk.SelectionMode.NONE)
130+
131+
# We need a reference to the scrolled window to control its scrolling.
132+
self.scrolled_window = Gtk.ScrolledWindow()
133+
self.scrolled_window.set_hexpand(True)
134+
self.scrolled_window.set_vexpand(True)
135+
self.scrolled_window.add(self.chat_listbox)
136+
vbox.pack_start(self.scrolled_window, True, True, 0)
137+
138+
# Apply CSS styling for the chat balloons.
139+
self._apply_css_styles()
140+
141+
# -------------------
142+
# 2. Input Section
143+
# -------------------
144+
input_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
145+
146+
self.input_entry = Gtk.Entry()
147+
self.input_entry.set_placeholder_text(_("Type a message..."))
148+
self.input_entry.connect("activate", self.on_process_button_clicked)
149+
input_hbox.pack_start(self.input_entry, True, True, 0)
150+
151+
self.process_button = Gtk.Button(label=_("Send"))
152+
self.process_button.connect("clicked", self.on_process_button_clicked)
153+
input_hbox.pack_start(self.process_button, False, False, 0)
154+
155+
vbox.pack_start(input_hbox, False, False, 0)
156+
157+
# Add the initial message to the list box.
158+
self._add_message_row(_("Chat with Tree initialized. Type /help for help."), YieldType.PARTIAL)
159+
160+
return vbox
161+
162+
def _apply_css_styles(self):
163+
"""
164+
Defines and applies CSS styles to the Gramplet's widgets.
165+
"""
166+
css_provider = Gtk.CssProvider()
167+
css = """
168+
#chat-listbox {
169+
background-color: white;
170+
}
171+
.message-box {
172+
background-color: #f0f0f0; /* Default background */
173+
padding: 10px;
174+
margin: 5px;
175+
border-radius: 15px;
176+
}
177+
.user-message-box {
178+
background-color: #dcf8c6; /* Light green for user messages */
179+
}
180+
.tree-reply-box {
181+
background-color: #d1e2f4; /* Light blue for replies */
182+
}
183+
.tree-toolcall-box {
184+
background-color: #fce8b2; /* Light yellow for tool calls */
185+
}
186+
"""
187+
css_provider.load_from_data(css.encode('utf-8'))
188+
screen = Gdk.Screen.get_default()
189+
context = Gtk.StyleContext()
190+
context.add_provider_for_screen(screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
191+
192+
# We need to set up a style context on the chat listbox
193+
style_context = self.chat_listbox.get_style_context()
194+
style_context.add_class("message-box") # This won't work on the listbox itself, but it's good practice.
195+
196+
def _add_message_row(self, text:str, reply_type: YieldType):
197+
"""
198+
Creates a new message "balloon" widget and adds it to the listbox.
199+
"""
200+
# Create a horizontal box to act as the message container.
201+
hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
202+
hbox.set_spacing(6)
203+
204+
# Create the message "balloon" box.
205+
message_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
206+
message_box.get_style_context().add_class("message-box")
207+
208+
# Create the label for the text.
209+
message_label = Gtk.Label(label=text)
210+
message_label.set_halign(Gtk.Align.START)
211+
message_label.set_line_wrap(True)
212+
message_label.set_max_width_chars(80) # Limit width to prevent it from spanning the entire window.
213+
message_box.pack_start(message_label, True, True, 0)
214+
215+
if reply_type == YieldType.USER:
216+
message_box.get_style_context().add_class("user-message-box")
217+
# Align the message balloon to the right.
218+
hbox.set_halign(Gtk.Align.END)
219+
elif reply_type in (YieldType.PARTIAL, YieldType.TOOL_CALL):
220+
message_box.get_style_context().add_class("tree-toolcall-box")
221+
# Align the message balloon to the left.
222+
hbox.set_halign(Gtk.Align.CENTER)
223+
224+
elif reply_type == YieldType.FINAL:
225+
message_box.get_style_context().add_class("tree-reply-box")
226+
# Align the message balloon to the left.
227+
hbox.set_halign(Gtk.Align.START)
228+
229+
# Add the message balloon to the main horizontal container.
230+
hbox.add(message_box)
231+
232+
# Add the whole row to the listbox.
233+
self.chat_listbox.add(hbox)
234+
self.chat_listbox.show_all()
235+
236+
# The goal is to scroll down after adding a row to the box
237+
# after one full second
238+
# so that Gtk has time to redraw the listbox in that time
239+
GLib.timeout_add(ONE_SECOND, self.scroll_to_bottom)
240+
241+
return message_label
242+
243+
def scroll_to_bottom(self):
244+
"""
245+
Helper function to scroll the listbox to the end.
246+
This runs on the main GTK thread after a redraw.
247+
"""
248+
adj = self.scrolled_window.get_vadjustment()
249+
adj.set_value(adj.get_upper())
250+
251+
# Return False to run the callback only once
252+
return GLib.SOURCE_REMOVE
253+
254+
def _get_reply_on_idle(self):
255+
"""
256+
This is a separate method and to be called via GLib.idle_add
257+
Goal: gets the reply from chatbot and updates the UI.
258+
It runs when the main loop is idle, therefore we return
259+
either GLib.SOURCE_CONTINUE in case there are more replies,
260+
or GLib.SOURCE_REMOVE when the iteration is done
261+
"""
262+
try:
263+
264+
# Using a sentinel object to check for exhaustion
265+
SENTINEL = object()
266+
# use the assigned self.reply_iterator iterator to get the next reply
267+
result = next(self.reply_iterator, SENTINEL)
268+
if result is SENTINEL:
269+
# end of iteration, no replies from iterator
270+
return GLib.SOURCE_REMOVE
271+
# unpack the result tuple
272+
reply_type, content = result
273+
if reply_type == YieldType.PARTIAL:
274+
# sometimes there is no content in the partial yield
275+
# if there is, it is usually an explained strategy what the
276+
# model will do to achieve the final result
277+
self._add_message_row(content, reply_type)
278+
if reply_type == YieldType.TOOL_CALL:
279+
if self.current_tool_call_label is None:
280+
self.current_tool_call_label = self._add_message_row(content, reply_type)
281+
else:
282+
# This is a subsequent tool call. Update the existing label.
283+
# We append the new content to the old content for a streaming effect.
284+
existing_text = self.current_tool_call_label.get_text()
285+
self.current_tool_call_label.set_text(existing_text + " " + content)
286+
elif reply_type == YieldType.FINAL:
287+
# Final reply from the chatbot
288+
# We let the iterator SENTINEL take care of returning Glib.SOURCE_REMOVE
289+
self._add_message_row(content, reply_type)
290+
291+
return GLib.SOURCE_CONTINUE
292+
293+
except Exception as e:
294+
# Handle potential errors from the get_reply function
295+
error_message = f"Error: {type(e).__name__} - {e}"
296+
self._add_message_row(f"Type 'help' for help. \n{error_message}", YieldType.PARTIAL)
297+
298+
return GLib.SOURCE_REMOVE # Stop the process on error
299+
300+
# This function must return False to be removed from the idle handler list.
301+
# If it returns True, it will be called again on the next idle loop.
302+
return False
303+
304+
def on_process_button_clicked(self, widget):
305+
"""
306+
Callback function when the 'Send' button is clicked or 'Enter' is pressed.
307+
"""
308+
user_input = self.input_entry.get_text()
309+
self.input_entry.set_text("")
310+
if user_input.strip():
311+
# Add the user's message to the chat.
312+
self._add_message_row(f"{user_input}", YieldType.USER)
313+
314+
# Now, schedule the reply-getting logic to run when the main loop is idle.
315+
self.reply_iterator = self.chat_logic.get_reply(user_input)
316+
self.current_tool_call_label = None
317+
318+
GLib.idle_add(self._get_reply_on_idle)
319+
320+
321+
def main(self):
322+
"""
323+
This method is called when the Gramplet needs to update its content.
324+
"""
325+
pass
326+
327+
def destroy(self):
328+
"""
329+
Clean up resources when the Gramplet is closed.
330+
"""
331+
Gramplet.destroy(self)

0 commit comments

Comments
 (0)