/
githubmirror
/
AgentPilot
Обзор
Документация
Войти
/
githubmirror
/
AgentPilot
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
src/members/workflow.py
2 964 строки
118 KB
jb
0.5.1
13 май 2025, 22:07
13 май 2025, 22:07
577d300
Код
Авторство
О чём код?
import asyncio import json import sqlite3 import uuid from functools import partial from typing import Optional, Dict, Tuple, List, Any from src.members.base import Member from src.members.agent import Agent from src.members.block import TextBlock from src.members.node import Node from src.members.notification import Notif, NotifSettings from src.members.user import User, UserSettings from src.system.plugins import get_plugin_model_settings from src.utils import sql from src.utils.messages import MessageHistory from PySide6.QtCore import QPointF, QRectF, QPoint, Signal, QTimer from PySide6.QtGui import Qt, QPen, QColor, QBrush, QPainter, QPainterPath, QCursor, QRadialGradient, \ QPainterPathStroker, QPolygonF, QLinearGradient, QAction, QFont from PySide6.QtWidgets import QWidget, QGraphicsScene, QGraphicsEllipseItem, QGraphicsItem, QGraphicsView, \ QMessageBox, QGraphicsPathItem, QStackedLayout, QMenu, QInputDialog, QGraphicsWidget, \ QSizePolicy, QApplication, QFrame, QTreeWidgetItem, QSplitter, QGraphicsProxyWidget from src.gui.config import ConfigWidget, CVBoxLayout, CHBoxLayout, ConfigFields, IconButtonCollection, \ ConfigJsonTree, ConfigJoined from src.gui.widgets import IconButton, ToggleIconButton, TreeDialog, BaseTreeWidget, find_main_widget from src.utils.helpers import path_to_pixmap, display_message_box, get_avatar_paths_from_config, \ merge_config_into_workflow_config, get_member_name_from_config, block_signals, display_message, apply_alpha_to_hex loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) class Workflow(Member): def __init__(self, **kwargs): super().__init__(**kwargs) from src.system.base import manager self._parent_workflow = kwargs.get('workflow', None) self.system = manager self.member_type: str = 'workflow' self.config: Dict[str, Any] = kwargs.get('config', {}) self.params: Dict[str, Any] = kwargs.get('params', {}) or {} # optional, usually only used for tool / block workflows self.tool_uuid: Optional[str] = kwargs.get('tool_uuid', None) # only used for tool workflows self.chat_page = kwargs.get('chat_page', None) # Load base workflow if not self._parent_workflow: self.context_id: int = kwargs.get('context_id', None) self._chat_name: str = '' self._chat_title: str = kwargs.get('chat_title', '') self._leaf_id: int = self.context_id self._message_history = MessageHistory(self) kind = kwargs.get('kind', 'CHAT') get_latest = kwargs.get('get_latest', False) if get_latest and self.context_id is not None: print("Warning: get_latest and context_id are both set, get_latest will be ignored.") # todo warnings if get_latest and self.context_id is None: # Load latest context self.context_id = sql.get_scalar("SELECT id FROM contexts WHERE parent_id IS NULL AND kind = ? ORDER BY id DESC LIMIT 1", (kind,)) if self.context_id is not None: if self.config: print("Warning: config is set, but will be ignored because an existing workflow is being loaded.") # todo warnings config_str = sql.get_scalar("SELECT config FROM contexts WHERE id = ?", (self.context_id,)) self.config = json.loads(config_str) or {} else: # # Create new context kind_init_members = { 'CHAT': 'agent', } if not self.config: init_member_config = {'_TYPE': kind_init_members.get(kind, 'block')} self.config = merge_config_into_workflow_config(init_member_config) sql.execute("INSERT INTO contexts (kind, config, name) VALUES (?, ?, ?)", (kind, json.dumps(self.config), self.chat_title)) self.context_id = sql.get_scalar("SELECT id FROM contexts WHERE kind = ? ORDER BY id DESC LIMIT 1", (kind,)) self.loop = asyncio.get_event_loop() self.responding = False self.stop_requested = False self.members: Dict[str, Member] = {} # id: member self.boxes: List[set] = [] self.autorun = True self.behaviour = None self.load() self.receivable_function = self.behaviour.receive @property def context_id(self) -> int: return self.get_from_root('_context_id') @property def chat_name(self) -> str: return self.get_from_root('_chat_name') @property def chat_title(self) -> str: return self.get_from_root('_chat_title') @property def leaf_id(self) -> int: return self.get_from_root('_leaf_id') @property def message_history(self) -> MessageHistory: return self.get_from_root('_message_history') @context_id.setter def context_id(self, value): self._context_id = value @chat_name.setter def chat_name(self, value): self._chat_name = value @chat_title.setter def chat_title(self, value): self._chat_title = value @leaf_id.setter def leaf_id(self, value): self._leaf_id = value @message_history.setter def message_history(self, value): self._message_history = value def get_from_root(self, attr_name) -> Any: if hasattr(self, attr_name): return getattr(self, attr_name, None) return self._parent_workflow.get_from_root(attr_name) def load_config(self, json_config=None): if json_config is None: if self._parent_workflow: member_config = self._parent_workflow.get_member_config(self.member_id) self.config = member_config else: config_str = sql.get_scalar("SELECT config FROM contexts WHERE id = ?", (self.context_id,)) self.config = json.loads(config_str) or {} else: if isinstance(json_config, str): json_config = json.loads(json_config) self.config = json_config def load(self): workflow_config = self.config.get('config', {}) self.autorun = workflow_config.get('autorun', True) self.load_members() if self._parent_workflow is None: # Load base workflow self.message_history.load() self.chat_title = sql.get_scalar("SELECT name FROM contexts WHERE id = ?", (self.context_id,)) def load_members(self): from src.system.plugins import get_plugin_class # Get members and inputs from the loaded json config if self.config.get('_TYPE', 'agent') == 'workflow': members = self.config['members'] else: # is a single entity, this allows single entity to be in workflow config for simplicity wf_config = merge_config_into_workflow_config(self.config) members = wf_config.get('members', []) inputs = self.config.get('inputs', []) last_member_id = None last_loc_x = -100 current_box_member_ids = set() members = sorted(members, key=lambda x: x['loc_x']) self.members = {} #!looper!# self.boxes = [] iterable = iter(members) while len(members) > 0: try: member_dict = next(iterable) except StopIteration: # todo temp make nicer iterable = iter(members) continue member_id = str(member_dict['id']) if self._parent_workflow: pass entity_id = member_dict.get('agent_id', None) member_config = member_dict['config'] loc_x = member_dict.get('loc_x', 50) loc_y = member_dict.get('loc_y', 0) member_input_ids = [ input_info['source_member_id'] for input_info in inputs if input_info['target_member_id'] == member_id and not input_info['config'].get('looper', False) ] # Order based on the inputs if len(member_input_ids) > 0: if not all((inp_id in self.members) for inp_id in member_input_ids): continue # Instantiate the member member_type = member_dict.get('config', {}).get('_TYPE', 'agent') kwargs = dict(main=self.main, workflow=self, member_id=member_id, config=member_config, agent_id=entity_id, loc_x=loc_x, loc_y=loc_y, inputs=member_input_ids) if member_type == 'agent': #!membermod!# use_plugin = member_config.get('info.use_plugin', None) member_class = get_plugin_class(plugin_type='Agent', plugin_name=use_plugin, default_class=Agent) member = member_class(**kwargs) elif member_type == 'block': use_plugin = member_config.get('block_type', None) member_class = get_plugin_class(plugin_type='Block', plugin_name=use_plugin, default_class=TextBlock) member = member_class(**kwargs) elif member_type == 'model': use_plugin = member_config.get('model_type', None) member_class = get_plugin_class(plugin_type='ModelTypes', plugin_name=use_plugin, default_class=Agent) member = member_class(**kwargs) elif member_type == 'workflow': member = Workflow(**kwargs) elif member_type == 'user': member = User(**kwargs) elif member_type == 'node': member = Node(**kwargs) elif member_type == 'notif': member = Notif(**kwargs) else: raise NotImplementedError(f"Member type '{member_type}' not implemented") member.load() if member_type in ('workflow', 'agent', 'block'): if abs(loc_x - last_loc_x) < 10: # 10px threshold if last_member_id is not None: current_box_member_ids |= {last_member_id} current_box_member_ids |= {member_id} else: if current_box_member_ids: self.boxes.append(current_box_member_ids) current_box_member_ids = set() last_loc_x = loc_x last_member_id = member_id self.members[member_id] = member members.remove(member_dict) iterable = iter(members) if current_box_member_ids: self.boxes.append(current_box_member_ids) del_boxes = [] for box in self.boxes: for member_id in box: fnd = self.walk_inputs_recursive(member_id, box) if fnd: del_boxes.append(box) break for box in del_boxes: self.boxes.remove(box) counted_members = self.count_members() if counted_members == 1: all_members = self.get_members() # excl_types=('user',)) first_member = next((m for m in all_members if m.config.get('_TYPE', 'agent') != 'user'), None) if not first_member: first_member = next(iter(all_members)) config = first_member.config self.chat_name = get_member_name_from_config(config) else: self.chat_name = f'{counted_members} members' self.update_behaviour() def walk_inputs_recursive(self, member_id: str, search_list: set) -> bool: #!asyncrecdupe!# member = self.members[member_id] #!params!# found = False for inp in member.inputs: # is_looper = self..inputs[inp].config.get('looper', False) if inp in search_list: return True found = found or self.walk_inputs_recursive(inp, search_list) return found def get_members(self, incl_types: Any = 'all', excl_types=None) -> List[Member]: if incl_types == 'all': #!membermod!# incl_types = ('agent', 'workflow', 'user', 'tool', 'block', 'node', 'notif', 'model') excl_types = excl_types or [] excl_types = [e for e in excl_types] excl_types.append('node') incl_types = tuple(t for t in incl_types if t not in excl_types) matched_members = [m for m in self.members.values() if m.config.get('_TYPE', 'agent') in incl_types] if self._parent_workflow is not None: # todo !userbypass if matched_members[0].config.get('_TYPE', 'agent') == 'user': matched_members = matched_members[1:] return matched_members def count_members(self, incl_types='all', excl_initial_user=True) -> int: extra_user_count = max(len(self.get_members(incl_types=('user',))) - 1, 0) excl_types = ('user',) if excl_initial_user else () matched_members = self.get_members(incl_types=incl_types, excl_types=excl_types) return len(matched_members) + (extra_user_count if excl_initial_user else 0) def next_expected_member(self) -> Optional[Member]: """Returns the next member where turn output is None""" next_member = next((member for member in self.get_members() if member.turn_output is None), None) return next_member def next_expected_is_last_member(self) -> bool: """Returns True if the next expected member is the last member""" only_one_empty = len([member for member in self.get_members() if member.turn_output is None]) == 1 return only_one_empty #!99!# #!looper!# def get_member_async_group(self, member_id) -> Optional[List[str]]: for box in self.boxes: if member_id in box: return [b for b in box if self.last_output is None] return None # [member_id] def get_member_config(self, member_id) -> Dict[str, Any]: member = self.members.get(member_id) return member.config if member else {} def reset_last_outputs(self): """Reset the last_output and turn_output of all members.""" for member in self.members.values(): member.last_output = None member.turn_output = None if isinstance(member, Workflow): member.reset_last_outputs() def set_last_outputs(self, map_dict): # {full_member_id: output} for k, v in map_dict.items(): member = self.get_member_by_full_member_id(k) if member: member.last_output = v def set_turn_outputs(self, map_dict): # {full_member_id: output} for k, v in map_dict.items(): member = self.get_member_by_full_member_id(k) if member: member.turn_output = v def get_member_by_full_member_id(self, full_member_id: str) -> Optional[Member]: """Returns the member object based on the full member id (e.g. '1.2.3')""" full_split = full_member_id.split('.') workflow = self member = None for local_id in full_split: member = workflow.members.get(local_id) if member is None: return None workflow = member return member def save_message( self, role: str, content: str, member_id: str = None, # '1', log_obj=None ): """Saves a message to the database and returns the message_id""" if role == 'output': content = 'The code executed without any output' if content.strip() == '' else content if content == '': return None return self.message_history.add(role, content, member_id=member_id, log_obj=log_obj) def deactivate_all_branches_with_msg(self, msg_id): sql.execute(""" UPDATE contexts SET active = 0 WHERE branch_msg_id = ( SELECT branch_msg_id FROM contexts WHERE id = ( SELECT context_id FROM contexts_messages WHERE id = ? ) );""", (msg_id,)) # def get_active_states(self): # todo temp helper # return sql.get_results(""" # WITH RECURSIVE context_tree AS ( # -- Base case: start with the root context # SELECT id, parent_id, active # FROM contexts # WHERE id = ? # # UNION ALL # # -- Recursive case: get all children # SELECT c.id, c.parent_id, c.active # FROM contexts c # JOIN context_tree ct ON c.parent_id = ct.id # ) # SELECT id, active # FROM context_tree # ORDER BY id;""", (self.context_id,), return_type='dict') def activate_branch_with_msg(self, msg_id): sql.execute(""" UPDATE contexts SET active = 1 WHERE id = ( SELECT context_id FROM contexts_messages WHERE id = ? );""", (msg_id,)) def get_common_group_key(self): """Get all distinct group_keys and if there's only one, return it, otherwise return empty key""" group_keys = set(getattr(member, 'group_key', '') for member in self.members.values()) if len(group_keys) == 1: return next(iter(group_keys)) return '' def update_behaviour(self): """Update the behaviour of the context based on the common key""" from src.system.plugins import ALL_PLUGINS common_group_key = self.get_common_group_key() behaviour = ALL_PLUGINS['Workflow'].get(common_group_key, None) self.behaviour = behaviour(self) if behaviour else WorkflowBehaviour(self) def get_final_message(self, filter_role='all'): """Returns the final output of the workflow""" # todo check matched_msgs = [m for m in self.message_history.get(base_member_id=self.full_member_id()) if m['role'] == filter_role or filter_role == 'all'] return None if not matched_msgs else matched_msgs[-1] class WorkflowBehaviour: def __init__(self, workflow): self.workflow: Workflow = workflow # self.tasks = [] async def start(self, from_member_id: int = None, feed_back: bool = False): async for key, chunk in self.receive(from_member_id, feed_back): pass async def receive(self, from_member_id: int = None, feed_back: bool = False): processed_members = set() def create_async_group_task(member_ids): """ Helper function to create and return a coroutine that runs all members in the member_async_group """ async def run_group(): group_tasks = [] for member_id in member_ids: if member_id not in processed_members: m = self.workflow.members[member_id] sub_task = asyncio.create_task(run_member_task(m)) group_tasks.append(sub_task) processed_members.add(member_id) try: await asyncio.gather(*group_tasks) except StopIteration: return return run_group async def run_member_task(member): # todo dirty async for _ in member.run_member(): pass if len(self.workflow.members) == 0: return # first_member = next(iter(self.workflow.members.values())) # if first_member.config.get('_TYPE', 'agent') == 'user': #!33!# # from_member_id = first_member.member_id filter_role = self.workflow.config.get('config', {}).get('filter_role', 'All').lower() self.workflow.responding = True try: found_source = True if from_member_id is None else False for member in self.workflow.members.values(): if not found_source and member.member_id == from_member_id: found_source = True if not found_source: continue # todo clean mechanism ignore_turn_output = feed_back and member.member_id == from_member_id if (member.turn_output is not None and not ignore_turn_output) or member.member_id in processed_members: continue if self.workflow.chat_page: self.workflow.chat_page.workflow_settings.refresh_member_highlights() async_group_member_ids = self.workflow.get_member_async_group(member.member_id) if async_group_member_ids: self.workflow.gen_members = async_group_member_ids # Create a single coroutine to handle the entire member async group run_method = create_async_group_task(async_group_member_ids) result = await run_method() if result is True: return else: nem = self.workflow.next_expected_member() is_final_message = self.workflow.next_expected_is_last_member() and member == nem # # Run individual member try: async for key, chunk in member.run_member(): if key == 'SYS' and chunk == 'BREAK': # break is_base_workflow = self.workflow._parent_workflow is None if is_base_workflow: return break if is_final_message and (key == filter_role or filter_role == 'all'): yield key, chunk except StopIteration: # todo still needed? return if not self.workflow.autorun: return if self.workflow._parent_workflow is not None: # todo # last_member = list(self.workflow.members.values())[-1] final_message = self.workflow.get_final_message(filter_role=filter_role) if final_message: full_member_id = self.workflow.full_member_id() log_obj = sql.get_scalar("SELECT log FROM contexts_messages WHERE id = ?", (final_message['id'],)) self.workflow.save_message(final_message['role'], final_message['content'], full_member_id, json.loads(log_obj)) except asyncio.CancelledError: pass # task was cancelled, so we ignore the exception except Exception as e: raise e finally: self.workflow.responding = False def stop(self): self.workflow.stop_requested = True class WorkflowSettings(ConfigWidget): def __init__(self, parent, **kwargs): super().__init__(parent=parent) self.compact_mode: bool = kwargs.get('compact_mode', False) # For use in agent page self.compact_mode_editing: bool = False self.setSizePolicy(QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)) self.members_in_view: Dict[str, DraggableMember] = {} self.inputs_in_view: Dict[Tuple[str, str], ConnectionLine] = {} # (source_member_id, target_member_id): line self.boxes_in_view: List[List[RoundedRectWidget]] = [] self.new_lines: Optional[List[InsertableLine]] = None self.new_agents: Optional[List[Tuple[QPointF, InsertableMember]]] = None self.adding_line: Optional[ConnectionLine] = None self.autorun: bool = True self.layout = CVBoxLayout(self) self.workflow_buttons = self.WorkflowButtons(parent=self) self.scene = QGraphicsScene(self) self.scene.setSceneRect(0, 0, 2000, 2000) self.view = CustomGraphicsView(self.scene, self) self.compact_mode_back_button = self.CompactModeBackButton(parent=self) self.member_config_widget = DynamicMemberConfigWidget(parent=self) self.member_config_widget.hide() # 32 h_layout = CHBoxLayout() h_layout.addWidget(self.view) enable_member_list = self.linked_workflow() is not None if enable_member_list: self.member_list = self.MemberList(parent=self) h_layout.addWidget(self.member_list) self.member_list.hide() self.workflow_config = self.WorkflowConfig(parent=self) self.workflow_config.build_schema() self.workflow_params = self.WorkflowParams(parent=self) self.workflow_params.build_schema() self.workflow_extras = None # not added here self.scene.selectionChanged.connect(self.on_selection_changed) self.workflow_panel = QWidget() self.workflow_panel_layout = CVBoxLayout(self.workflow_panel) self.workflow_panel_layout.addWidget(self.compact_mode_back_button) self.workflow_panel_layout.addWidget(self.workflow_params) self.workflow_panel_layout.addWidget(self.workflow_config) self.workflow_panel_layout.addWidget(self.workflow_buttons) self.workflow_panel_layout.addLayout(h_layout) self.splitter = QSplitter(Qt.Vertical) self.splitter.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.splitter.setChildrenCollapsible(False) self.splitter.addWidget(self.workflow_panel) self.splitter.addWidget(self.member_config_widget) self.layout.addWidget(self.splitter) def load_config(self, json_config=None): if json_config is None: json_config = {} if isinstance(json_config, str): json_config = json.loads(json_config) if json_config.get('_TYPE', 'agent') != 'workflow': json_config = merge_config_into_workflow_config(json_config) json_wf_config = json_config.get('config', {}) json_wf_params = json_config.get('params', []) json_wf_extras = json_config.get('extras', {}) # todo rename? self.workflow_config.load_config(json_wf_config) self.workflow_params.load_config({'data': json_wf_params}) # !55! # if self.workflow_extras: self.workflow_extras.load_config(json_wf_extras) super().load_config(json_config) def get_config(self): workflow_config = self.workflow_config.get_config() workflow_config['autorun'] = self.workflow_buttons.autorun workflow_config['show_hidden_bubbles'] = self.workflow_buttons.show_hidden_bubbles workflow_config['show_nested_bubbles'] = self.workflow_buttons.show_nested_bubbles workflow_params = self.workflow_params.get_config() config = { '_TYPE': 'workflow', 'members': [], 'inputs': [], 'config': workflow_config, 'params': workflow_params.get('data', []), } if self.workflow_extras: config['extras'] = self.workflow_extras.get_config() for member_id, member in self.members_in_view.items(): # # add _TYPE to member_config member.member_config['_TYPE'] = member.member_type config['members'].append({ 'id': member_id, 'agent_id': None, # member.agent_id, todo 'loc_x': int(member.x()), 'loc_y': int(member.y()), 'config': member.member_config, }) for line_key, line in self.inputs_in_view.items(): source_member_id, target_member_id = line_key config['inputs'].append({ 'source_member_id': source_member_id, 'target_member_id': target_member_id, 'config': line.config, }) return config def update_config(self): super().update_config() self.load_async_groups() for m in self.members_in_view.values(): m.refresh_avatar() self.refresh_member_highlights() if hasattr(self, 'member_list'): self.member_list.load() def load(self): self.setUpdatesEnabled(False) sel_member_ids = [x.id for x in self.scene.selectedItems() if isinstance(x, DraggableMember)] self.load_members() self.load_inputs() self.load_async_groups() self.member_config_widget.load() self.workflow_params.load() self.workflow_config.load() if self.workflow_extras: self.workflow_extras.load() self.workflow_buttons.load() if hasattr(self, 'member_list'): self.member_list.load() if self.can_simplify_view(): self.toggle_view(False) # Select the member so that it's config is shown, then hide the workflow panel until more members are added other_member_ids = [k for k, m in self.members_in_view.items() if not m.member_config.get('_TYPE', 'agent') == 'user'] if other_member_ids: self.select_ids([other_member_ids[0]]) else: # Show the workflow panel in case it was hidden self.toggle_view(True) # .view.show() # # Select the members that were selected before, patch for deselecting members todo if not self.compact_mode: self.select_ids(sel_member_ids) self.reposition_view() self.refresh_member_highlights() self.setUpdatesEnabled(True) def load_members(self): # Clear any existing members from the scene for m_id, member in self.members_in_view.items(): self.scene.removeItem(member) self.members_in_view = {} members_data = self.config.get('members', []) # Iterate over the parsed 'members' data and add them to the scene for member_info in members_data: _id = member_info['id'] # agent_id = member_info.get('agent_id') member_config = member_info.get('config') loc_x = member_info.get('loc_x') loc_y = member_info.get('loc_y') member = DraggableMember(self, _id, loc_x, loc_y, member_config) self.scene.addItem(member) self.members_in_view[_id] = member # self.test_widget = ConfigFields(parent=self, schema=[ # # { # # 'text': 'Color', # # 'type': 'ColorPickerWidget', # # 'default': '#438BB9', # # }, # { # 'text': 'Message', # 'type': str, # 'default': '', # 'num_lines': 4, # 'stretch_x': True, # 'stretch_y': True, # 'label_position': 'top', # }, # ]) # self.test_widget.build_schema() # # self.test_proxy = QGraphicsProxyWidget() # self.test_proxy.setFlag(QGraphicsItem.ItemIsMovable) # self.test_proxy.setFlag(QGraphicsItem.ItemIsSelectable) # self.test_proxy.setWidget(self.test_widget) # # self.test_proxy.setScale(0.5) # self.scene.addItem(self.test_proxy) # self.test_proxy.setPos(0, 0) def load_async_groups(self): # Clear any existing members from the scene for box in self.boxes_in_view: self.scene.removeItem(box) self.boxes_in_view = [] last_member_id = None last_member_pos = None last_loc_x = -100 current_box_member_positions = [] current_box_member_ids = [] members = self.members_in_view.values() members = sorted(members, key=lambda m: m.x()) for member in members: loc_x = member.x() loc_y = member.y() pos = QPointF(loc_x, loc_y) member_type = member.member_config.get('_TYPE', 'agent') if member_type in ('workflow', 'agent', 'block'): if abs(loc_x - last_loc_x) < 10: current_box_member_positions += [last_member_pos, pos] current_box_member_ids += [last_member_id, member.id] else: if current_box_member_positions: box = RoundedRectWidget(self, points=current_box_member_positions, member_ids=current_box_member_ids) self.scene.addItem(box) self.boxes_in_view.append(box) current_box_member_positions = [] current_box_member_ids = [] last_loc_x = loc_x last_member_pos = pos last_member_id = member.id # Handle the last group after finishing the loop if current_box_member_positions: box = RoundedRectWidget(self, points=current_box_member_positions, member_ids=current_box_member_ids) self.scene.addItem(box) self.boxes_in_view.append(box) del_boxes = [] for box in self.boxes_in_view: for member_id in box.member_ids: fnd = self.walk_inputs_recursive(member_id, box.member_ids) if fnd: del_boxes.append(box) break for box in del_boxes: self.scene.removeItem(box) self.boxes_in_view.remove(box) def load_inputs(self): for _, line in self.inputs_in_view.items(): self.scene.removeItem(line) self.inputs_in_view = {} inputs_data = self.config.get('inputs', []) for input_dict in inputs_data: source_member_id = input_dict['source_member_id'] target_member_id = input_dict['target_member_id'] input_config = input_dict.get('config', {}) source_member = self.members_in_view.get(source_member_id) target_member = self.members_in_view.get(target_member_id) if source_member is None or target_member is None: return line = ConnectionLine(self, source_member, target_member, input_config) self.scene.addItem(line) self.inputs_in_view[(source_member_id, target_member_id)] = line def walk_inputs_recursive(self, member_id, search_list) -> bool: #!asyncrecdupe!# todo dupe found = False member_inputs = [k[0] for k, v in self.inputs_in_view.items() if k[1] == member_id and v.config.get('looper', False) is False] for inp in member_inputs: if inp in search_list: return True found = found or self.walk_inputs_recursive(inp, search_list) return found def update_member(self, update_list, save=False): for member_id, attribute, value in update_list: member = self.members_in_view.get(member_id) if not member: return setattr(member, attribute, value) if save: self.update_config() def linked_workflow(self): return getattr(self.parent, 'workflow', None) def count_other_members(self, exclude_initial_user=True): # count members but minus one for the user member member_count = len(self.members_in_view) if exclude_initial_user and any(m.member_type == 'user' for m in self.members_in_view.values()): member_count -= 1 return member_count def can_simplify_view(self): # !wfdiff! # member_count = len(self.members_in_view) input_count = len(self.inputs_in_view) if input_count > 0: return False if member_count == 1: member_config = next(iter(self.members_in_view.values())).member_config types_to_simplify = ['block'] if member_config.get('_TYPE', 'agent') in types_to_simplify: return True elif member_count == 2: members = list(self.members_in_view.values()) members.sort(key=lambda x: x.x()) first_member = members[0] second_member = members[1] if first_member.member_type == 'user' and second_member.member_type == 'agent': return True return False def toggle_view(self, visible): self.view.setVisible(visible) QTimer.singleShot(10, lambda: self.splitter.setSizes([300 if visible else 22, 0 if visible else 1000])) self.splitter.setHandleWidth(0 if not visible else 3) self.reposition_view() def reposition_view(self): self.view.horizontalScrollBar().setValue(0) self.view.verticalScrollBar().setValue(0) def set_edit_mode(self, state): if not self.compact_mode: return self.view.temp_block_move_flag = True # deselect all members first, to avoid layout issue - only if multiple other members if not self.can_simplify_view() and state is False: self.select_ids([]) # deselecting id's will trigger on_selection_changed, which will hide the member_config_widget # and below, when we set the tree to visible, the window resizes to fit the tree # so after the member_config_widget is hidden, we need to update geometry self.updateGeometry() # todo check if still needed on all os self.compact_mode_editing = state if hasattr(self.parent, 'view'): self.parent.toggle_view(not state) else: parent = self.parent while not hasattr(parent, 'tree_container'): parent = parent.parent if hasattr(parent, 'tree_container'): parent.tree_container.setVisible(not state) self.compact_mode_back_button.setVisible(state) def select_ids(self, ids, send_signal=True): with block_signals(self.scene): for item in self.scene.selectedItems(): item.setSelected(False) for _id in ids: # todo clean if _id in self.members_in_view: self.members_in_view[_id].setSelected(True) if send_signal: self.on_selection_changed() def on_selection_changed(self): selected_objects = self.scene.selectedItems() selected_agents = [x for x in selected_objects if isinstance(x, DraggableMember)] selected_lines = [x for x in selected_objects if isinstance(x, ConnectionLine)] can_simplify = self.can_simplify_view() if self.compact_mode and not can_simplify and len(selected_objects) > 0 and not self.compact_mode_editing: self.set_edit_mode(True) if len(selected_objects) == 1: if len(selected_agents) == 1: member = selected_agents[0] self.member_config_widget.display_config_for_member(member) self.member_config_widget.show() if self.member_config_widget.workflow_settings: self.member_config_widget.workflow_settings.reposition_view() elif len(selected_lines) == 1: line = selected_lines[0] self.member_config_widget.display_config_for_input(line) self.member_config_widget.show() else: self.member_config_widget.hide() # 32 self.workflow_buttons.load() if hasattr(self, 'member_list'): self.member_list.refresh_selected() def add_insertable_entity(self, item): if self.compact_mode: self.set_edit_mode(True) if self.new_agents: return all_items = [] # list of tuple(pos, config) if isinstance(item, QTreeWidgetItem): item_config = json.loads(item.data(0, Qt.UserRole).get('config', '{}')) all_items = [(QPointF(0, 0), item_config)] elif isinstance(item, dict): all_items = [(QPointF(0, 0), item)] elif isinstance(item, list): all_items = item self.toggle_view(True) mouse_point = self.view.mapToScene(self.view.mapFromGlobal(QCursor.pos())) self.new_agents = [ ( pos, InsertableMember( self, config, mouse_point + pos ), ) for pos, config in all_items ] for pos, entity in self.new_agents: self.scene.addItem(entity) self.view.setFocus() def add_insertable_input(self, item, member_bundle): if self.compact_mode: self.set_edit_mode(True) if self.new_lines: return if not isinstance(item, list): return all_inputs = item # list of tuple() self.toggle_view(True) self.new_lines = [] if len(all_inputs) > 0 else None for inp in all_inputs: source_member_index, member_index, config = inp self.new_lines.append( InsertableLine( self, member_bundle=member_bundle, source_member_index=source_member_index, member_index=member_index, config=config, ) ) for line in self.new_lines: self.scene.addItem(line) self.view.setFocus() def add_entity(self): member_in_view_int_keys = [int(k) for k in self.members_in_view.keys()] start_member_id = max(member_in_view_int_keys) + 1 if len(self.members_in_view) else 1 member_index_id_map = {} for i, enitity_tup in enumerate(self.new_agents): entity_id = str(start_member_id + i) pos, entity = enitity_tup entity_config = entity.config loc_x, loc_y = entity.x(), entity.y() member = DraggableMember(self, entity_id, loc_x, loc_y, entity_config) self.scene.addItem(member) self.members_in_view[entity_id] = member member_index_id_map[i] = entity_id for new_line in self.new_lines or []: source_member_id = member_index_id_map[new_line.source_member_index] target_member_id = member_index_id_map[new_line.target_member_index] source_member = self.members_in_view[source_member_id] target_member = self.members_in_view[target_member_id] line = ConnectionLine(self, source_member, target_member, new_line.config) self.scene.addItem(line) self.inputs_in_view[(source_member_id, target_member_id)] = line self.view.cancel_new_line() self.view.cancel_new_entity() self.update_config() if hasattr(self.parent, 'top_bar'): self.parent.load() def add_input(self, target_member_id): if not self.adding_line: return source_member_id = self.adding_line.source_member_id if target_member_id == source_member_id: return if (source_member_id, target_member_id) in self.inputs_in_view: return cr_check = self.check_for_circular_references(target_member_id, [source_member_id]) is_looper = self.adding_line.config.get('looper', False) if cr_check and not is_looper: display_message(self, message='Circular reference detected', icon=QMessageBox.Warning, ) return source_member = self.members_in_view[source_member_id] target_member = self.members_in_view[target_member_id] config = {'looper': is_looper} allows_messages = [ # todo 'user', 'agent' ] if target_member.member_config.get('_TYPE', 'agent') == 'workflow': first_member = next(iter(sorted(target_member.member_config['members'], key=lambda x: x['loc_x'])), None) if first_member: first_member_is_user = first_member['config'].get('_TYPE', 'agent') == 'user' if first_member_is_user: allows_messages.append('workflow') if target_member.member_config.get('_TYPE', 'agent') in allows_messages: config['mappings.data'] = [{'source': 'Output', 'target': 'Message'}] line = ConnectionLine(self, source_member, target_member, config) self.scene.addItem(line) self.inputs_in_view[(source_member_id, target_member_id)] = line self.scene.removeItem(self.adding_line) self.adding_line = None self.update_config() def check_for_circular_references(self, target_member_id, input_member_ids): """ Recursive function to check for circular references""" connected_input_members = [line_key[0] for line_key, line in self.inputs_in_view.items() if line_key[1] in input_member_ids and line.config.get('looper', False) is False] if target_member_id in connected_input_members: return True if len(connected_input_members) == 0: return False return self.check_for_circular_references(target_member_id, connected_input_members) def refresh_member_highlights(self): if self.compact_mode or not self.linked_workflow(): return for member in self.members_in_view.values(): member.highlight_background.hide() workflow = self.linked_workflow() next_expected_member = workflow.next_expected_member() if not next_expected_member: return if next_expected_member: self.members_in_view[next_expected_member.member_id].highlight_background.show() def goto_member(self, full_member_id): member_ids = full_member_id.split('.') # deselect all members and lines self.scene.clearSelection() # click each member in the path widget = self for member_id in member_ids: member = widget.members_in_view.get(member_id) if member is None: return member.setSelected(True) widget = widget.member_config_widget.workflow_settings if not widget: return class CompactModeBackButton(QWidget): def __init__(self, parent): super().__init__(parent) self.parent = parent self.layout = CHBoxLayout(self) self.btn_back = IconButton( parent=self, icon_path=':/resources/icon-cross.png', tooltip='Back', size=22, text='Close edit mode', ) self.btn_back.clicked.connect(partial(self.parent.set_edit_mode, False)) self.layout.addWidget(self.btn_back) self.layout.addStretch(1) self.hide() class WorkflowButtons(IconButtonCollection): def __init__(self, parent): super().__init__(parent=parent) self.layout.addSpacing(15) self.autorun = True self.show_hidden_bubbles = False self.show_nested_bubbles = False self.btn_add = IconButton( parent=self, icon_path=':/resources/icon-new.png', target=self.show_add_context_menu, tooltip='Add', size=self.icon_size, ) self.btn_save_as = IconButton( parent=self, icon_path=':/resources/icon-save.png', target=self.show_save_context_menu, tooltip='Save As', size=self.icon_size, ) self.btn_clear_chat = IconButton( parent=self, icon_path=':/resources/icon-clear.png', target=self.clear_chat, tooltip='Clear Chat', size=self.icon_size, ) self.btn_copy = IconButton( parent=self, icon_path=':/resources/icon-copy.png', target=self.copy_selected_items, tooltip='Copy', size=self.icon_size, ) self.btn_paste = IconButton( parent=self, icon_path=':/resources/icon-paste.png', target=self.paste_items, tooltip='Paste', size=self.icon_size, ) self.btn_delete = IconButton( parent=self, icon_path=':/resources/close.png', target=self.delete_selected_items, icon_size_percent=0.6, tooltip='Delete', size=self.icon_size, ) self.layout.addWidget(self.btn_add) self.layout.addWidget(self.btn_save_as) self.layout.addWidget(self.btn_clear_chat) # separator self.layout.addSpacing(5) self.layout.addWidget(self.btn_copy) self.layout.addWidget(self.btn_paste) self.layout.addWidget(self.btn_delete) self.layout.addStretch(1) self.btn_disable_autorun = ToggleIconButton( parent=self, icon_path=':/resources/icon-run-solid.png', icon_path_checked=':/resources/icon-run.png', target=partial(self.toggle_attribute, 'autorun'), tooltip='Disable autorun', tooltip_checked='Enable autorun', size=self.icon_size, ) self.btn_member_list = ToggleIconButton( parent=self, icon_path=':/resources/icon-agent-solid.png', target=self.toggle_member_list, tooltip='View member list', icon_size_percent=0.9, size=self.icon_size, ) self.btn_view = ToggleIconButton( parent=self, icon_path=':/resources/icon-eye.png', target=self.btn_view_clicked, size=self.icon_size, ) self.btn_workflow_params = ToggleIconButton( parent=self, icon_path=':/resources/icon-parameter.png', target=self.toggle_workflow_params, tooltip='Workflow params', size=self.icon_size, ) self.btn_workflow_config = ToggleIconButton( parent=self, icon_path=':/resources/icon-settings-solid.png', target=self.toggle_workflow_config, tooltip='Workflow config', size=self.icon_size, ) self.layout.addWidget(self.btn_disable_autorun) self.layout.addWidget(self.btn_member_list) self.layout.addWidget(self.btn_view) self.layout.addWidget(self.btn_workflow_params) self.layout.addWidget(self.btn_workflow_config) self.workflow_is_linked = self.parent.linked_workflow() is not None self.btn_clear_chat.setVisible(self.workflow_is_linked) self.btn_view.setVisible(self.workflow_is_linked) self.btn_disable_autorun.setVisible(self.workflow_is_linked) self.btn_member_list.setVisible(self.workflow_is_linked) def load(self): workflow_settings = self.parent workflow_config = workflow_settings.config.get('config', {}) self.autorun = workflow_config.get('autorun', True) self.show_hidden_bubbles = workflow_config.get('show_hidden_bubbles', False) self.show_nested_bubbles = workflow_config.get('show_nested_bubbles', False) is_multi_member = workflow_settings.count_other_members() > 1 contains_workflow_member = any(m.member_type == 'workflow' for m in workflow_settings.members_in_view.values()) self.btn_member_list.setVisible(is_multi_member and self.workflow_is_linked) self.btn_disable_autorun.setChecked(not self.autorun) self.btn_disable_autorun.setVisible(is_multi_member and self.workflow_is_linked) self.btn_view.setChecked(self.show_hidden_bubbles or self.show_nested_bubbles) self.btn_view.setVisible((is_multi_member or contains_workflow_member) and self.workflow_is_linked) any_is_agent = any(m.member_type == 'agent' for m in workflow_settings.members_in_view.values()) is_chat_workflow = workflow_settings.__class__.__name__ == 'ChatWorkflowSettings' param_list = workflow_settings.workflow_params.config.get('data', []) has_params = len(param_list) > 0 self.btn_save_as.setVisible(is_multi_member or is_chat_workflow) self.btn_workflow_params.setChecked(has_params) self.btn_workflow_params.setVisible(is_multi_member or not any_is_agent or has_params) self.btn_workflow_config.setVisible(is_multi_member or not any_is_agent) compact_mode_editing = workflow_settings.compact_mode_editing show_edit_group = is_multi_member and (is_chat_workflow or compact_mode_editing) self.btn_copy.setVisible(show_edit_group) self.btn_paste.setVisible(show_edit_group) self.btn_delete.setVisible(show_edit_group) if show_edit_group: selected_count = len(workflow_settings.scene.selectedItems()) self.btn_copy.setEnabled(selected_count > 0) self.btn_paste.setEnabled(self.has_copied_items()) self.btn_delete.setEnabled(selected_count > 0) self.toggle_workflow_params() def on_secondary_window_closed(self): page_chat = self.parent.main.page_chat page_chat.workspace_window = None # Reset the reference when the secondary window is closed def add_contxt_menu_header(self, menu, title): section = QAction(title, self) section.setEnabled(False) font = QFont() font.setPointSize(8) section.setFont(font) menu.addAction(section) def show_add_context_menu(self): from src.gui.style import TEXT_COLOR menu = QMenu(self) # style sheet for disabled menu items menu.setStyleSheet(f""" QMenu::item {{ color: {TEXT_COLOR}; }} QMenu::item:disabled {{ color: {apply_alpha_to_hex(TEXT_COLOR, 0.5)}; padding-left: 10px; }} """) self.add_contxt_menu_header(menu, 'Conversation') add_agent = menu.addAction('Agent') #!membermod!# add_user = menu.addAction('User') menu.addSeparator() self.add_contxt_menu_header(menu, 'Blocks') add_text = menu.addAction('Text') add_code = menu.addAction('Code') add_prompt = menu.addAction('Prompt') menu.addSeparator() self.add_contxt_menu_header(menu, 'Models') add_model = menu.addAction('Image') add_model = menu.addAction('Voice') menu.addSeparator() self.add_contxt_menu_header(menu, 'Flow') add_node = menu.addAction('Node') menu.addSeparator() self.add_contxt_menu_header(menu, 'System') add_notif = menu.addAction('Notification') # add_tool = menu.addAction('Tool') add_agent.triggered.connect(partial(self.choose_member, "AGENT")) add_user.triggered.connect(partial( self.parent.add_insertable_entity, {"_TYPE": "user"} )) add_model.triggered.connect(partial( self.parent.add_insertable_entity, {"_TYPE": "model"} )) add_node.triggered.connect(partial( self.parent.add_insertable_entity, {"_TYPE": "node"} )) add_notif.triggered.connect(partial( self.parent.add_insertable_entity, {"_TYPE": "notif"} )) add_text.triggered.connect(partial(self.choose_member, "TEXT")) add_code.triggered.connect(partial(self.choose_member, "CODE")) add_prompt.triggered.connect(partial(self.choose_member, "PROMPT")) menu.exec_(QCursor.pos()) def choose_member(self, list_type): self.parent.set_edit_mode(True) list_dialog = TreeDialog( parent=self, title="Add Member", list_type=list_type, callback=self.parent.add_insertable_entity, show_blank=True, ) list_dialog.open() def show_save_context_menu(self): menu = QMenu(self) save_agent = menu.addAction('Save as Agent') save_agent.triggered.connect(partial(self.save_as, 'AGENT')) save_block = menu.addAction('Save as Block') save_block.triggered.connect(partial(self.save_as, 'BLOCK')) save_tool = menu.addAction('Save as Tool') save_tool.triggered.connect(partial(self.save_as, 'TOOL')) menu.exec_(QCursor.pos()) def save_as(self, save_type): new_name, ok = QInputDialog.getText(self, f"New {save_type.capitalize()}", f"Enter the name for the new {save_type.lower()}:") if not ok: return workflow_config = json.dumps(self.parent.get_config()) try: if save_type == 'AGENT': sql.execute(""" INSERT INTO entities (name, kind, config) VALUES (?, ?, ?) """, (new_name, 'AGENT', workflow_config,)) elif save_type == 'BLOCK': sql.execute(""" INSERT INTO blocks (name, config) VALUES (?, ?) """, (new_name, workflow_config,)) elif save_type == 'TOOL': sql.execute(""" INSERT INTO tools (uuid, name, config) VALUES (?, ?, ?) """, (str(uuid.uuid4()), new_name, workflow_config,)) display_message(self, message='Entity saved', icon=QMessageBox.Information, ) except sqlite3.IntegrityError as e: display_message(self, message='Name already exists', icon=QMessageBox.Warning, ) def clear_chat(self): retval = display_message_box( icon=QMessageBox.Warning, text="Are you sure you want to permanently clear the chat messages?\nThis should only be used when testing a workflow.\nTo keep your data start a new chat.", title="Clear Chat", buttons=QMessageBox.Ok | QMessageBox.Cancel, ) if retval != QMessageBox.Ok: return workflow = self.parent.linked_workflow() if not workflow: return sql.execute(""" WITH RECURSIVE delete_contexts(id) AS ( SELECT id FROM contexts WHERE id = ? UNION ALL SELECT contexts.id FROM contexts JOIN delete_contexts ON contexts.parent_id = delete_contexts.id ) DELETE FROM contexts_messages WHERE context_id IN delete_contexts; """, (workflow.context_id,)) sql.execute(""" DELETE FROM contexts_messages WHERE context_id = ?""", (workflow.context_id,)) sql.execute(""" WITH RECURSIVE delete_contexts(id) AS ( SELECT id FROM contexts WHERE id = ? UNION ALL SELECT contexts.id FROM contexts JOIN delete_contexts ON contexts.parent_id = delete_contexts.id ) DELETE FROM contexts WHERE id IN delete_contexts AND id != ?; """, (workflow.context_id, workflow.context_id,)) if hasattr(self.parent.parent, 'main'): self.parent.parent.main.page_chat.load() def copy_selected_items(self): scene = self.parent.view.scene() member_configs = [] # list of tuple(pos, config_dict) member_inputs = [] # list of tuple(member_index, input_member_index, config_dict) member_id_indexes = {} # dict of member_id: index for selected_member in scene.selectedItems(): if isinstance(selected_member, DraggableMember): item_position = selected_member.pos() member_configs.append( ( item_position, selected_member.member_config ) ) member_id_indexes[selected_member.id] = len(member_configs) - 1 for selected_line in scene.selectedItems(): if isinstance(selected_line, ConnectionLine): if selected_line.target_member_id not in member_id_indexes or selected_line.source_member_id not in member_id_indexes: continue member_inputs.append( ( member_id_indexes[selected_line.source_member_id], member_id_indexes[selected_line.target_member_id], selected_line.config, ) ) if len(member_configs) == 0: return center_x = sum([pos.x() for pos, _ in member_configs]) / len(member_configs) center_y = sum([pos.y() for pos, _ in member_configs]) / len(member_configs) center = QPointF(center_x, center_y) member_configs = [(pos - center, config) for pos, config in member_configs] relative_members = [(f'{pos.x()},{pos.y()}', config) for pos, config in member_configs] member_bundle = (relative_members, member_inputs) # add to clipboard clipboard = QApplication.clipboard() copied_data = 'WORKFLOW_MEMBERS:' + json.dumps(member_bundle) clipboard.setText(copied_data) self.load() def paste_items(self): try: clipboard = QApplication.clipboard() copied_data = clipboard.text() start_text = 'WORKFLOW_MEMBERS:' if copied_data.startswith(start_text): copied_data = copied_data[len(start_text):] member_bundle = json.loads(copied_data) member_configs = member_bundle[0] member_inputs = member_bundle[1] if not isinstance(member_configs, list) or not isinstance(member_inputs, list): return member_configs = [(QPointF(*map(float, pos.split(','))), config) for pos, config in member_configs] self.parent.add_insertable_entity(member_configs) self.parent.add_insertable_input(member_inputs, member_bundle=member_bundle) except Exception as e: return def has_copied_items(self): try: clipboard = QApplication.clipboard() copied_data = clipboard.text() start_text = 'WORKFLOW_MEMBERS:' return copied_data.startswith(start_text) except Exception as e: return False def delete_selected_items(self): del_member_ids = set() del_inputs = set() all_del_objects = [] all_del_objects_old_brushes = [] all_del_objects_old_pens = [] workflow_settings = self.parent scene = workflow_settings.view.scene() for selected_item in scene.selectedItems(): all_del_objects.append(selected_item) if isinstance(selected_item, DraggableMember): del_member_ids.add(selected_item.id) # Loop through all lines to find the ones connected to the selected agent for key, line in workflow_settings.inputs_in_view.items(): source_member_id, target_member_id = key if target_member_id == selected_item.id or source_member_id == selected_item.id: del_inputs.add((source_member_id, target_member_id)) all_del_objects.append(line) elif isinstance(selected_item, ConnectionLine): del_inputs.add((selected_item.source_member_id, selected_item.target_member_id)) del_count = len(del_member_ids) + len(del_inputs) if del_count == 0: return # fill all objects with a red tint at 30% opacity, overlaying the current item image for item in all_del_objects: old_brush = item.brush() all_del_objects_old_brushes.append(old_brush) # modify old brush and add a 30% opacity red fill old_pixmap = old_brush.texture() new_pixmap = old_pixmap.copy() painter = QPainter(new_pixmap) painter.setCompositionMode(QPainter.CompositionMode_SourceAtop) painter.fillRect(new_pixmap.rect(), QColor(255, 0, 0, 126)) painter.end() new_brush = QBrush(new_pixmap) item.setBrush(new_brush) old_pen = item.pen() all_del_objects_old_pens.append(old_pen) new_pen = QPen(QColor(255, 0, 0, 255), old_pen.width()) item.setPen(new_pen) scene.update() # ask for confirmation retval = display_message_box( icon=QMessageBox.Warning, text="Are you sure you want to delete the selected items?", title="Delete Items", buttons=QMessageBox.Ok | QMessageBox.Cancel, ) if retval != QMessageBox.Ok: for item in all_del_objects: item.setBrush(all_del_objects_old_brushes.pop(0)) item.setPen(all_del_objects_old_pens.pop(0)) return for obj in all_del_objects: scene.removeItem(obj) for member_id in del_member_ids: workflow_settings.members_in_view.pop(member_id) for line_key in del_inputs: workflow_settings.inputs_in_view.pop(line_key) workflow_settings.update_config() if hasattr(workflow_settings.parent, 'top_bar'): workflow_settings.parent.load() def toggle_member_list(self): is_checked = self.btn_member_list.isChecked() self.parent.member_list.setVisible(is_checked) def toggle_workflow_params(self): self.untoggle_all(except_obj=self.btn_workflow_params) is_checked = self.btn_workflow_params.isChecked() self.parent.workflow_params.setVisible(is_checked) def toggle_workflow_config(self): self.untoggle_all(except_obj=self.btn_workflow_config) is_checked = self.btn_workflow_config.isChecked() self.parent.workflow_config.setVisible(is_checked) def untoggle_all(self, except_obj=None): if self.btn_workflow_params.isChecked() and except_obj != self.btn_workflow_params: self.btn_workflow_params.setChecked(False) self.parent.workflow_params.setVisible(False) if self.btn_workflow_config.isChecked() and except_obj != self.btn_workflow_config: self.btn_workflow_config.setChecked(False) self.parent.workflow_config.setVisible(False) def btn_view_clicked(self): menu = QMenu(self) show_hidden = menu.addAction('Show hidden bubbles') show_nested = menu.addAction('Show nested bubbles') show_hidden.setCheckable(True) show_nested.setCheckable(True) show_hidden.setChecked(self.show_hidden_bubbles) show_nested.setChecked(self.show_nested_bubbles) show_hidden.triggered.connect(partial(self.toggle_attribute, 'show_hidden_bubbles')) show_nested.triggered.connect(partial(self.toggle_attribute, 'show_nested_bubbles')) self.btn_view.setChecked(self.show_hidden_bubbles or self.show_nested_bubbles) # top right corner is at cursor position menu.exec_(QCursor.pos() - QPoint(menu.sizeHint().width(), 0)) def toggle_attribute(self, attr): setattr(self, attr, not getattr(self, attr)) self.parent.update_config() self.btn_view.setChecked(self.show_hidden_bubbles or self.show_nested_bubbles) if self.parent.linked_workflow(): self.parent.parent.load() class MemberList(QWidget): """This widget displays a list of members in the chat.""" def __init__(self, parent): super().__init__(parent) self.parent = parent self.block_flag = False # todo clean self.layout = CVBoxLayout(self) self.layout.setContentsMargins(0, 5, 0, 0) self.tree_members = BaseTreeWidget(self) self.schema = [ { 'text': 'Members', 'type': str, 'width': 150, 'image_key': 'avatar', }, { 'key': 'id', 'text': '', 'type': int, 'visible': False, }, { 'key': 'avatar', 'text': '', 'type': str, 'visible': False, }, ] self.tree_members.itemSelectionChanged.connect(self.on_selection_changed) self.tree_members.build_columns_from_schema(self.schema) self.tree_members.setFixedWidth(150) self.layout.addWidget(self.tree_members) self.layout.addStretch(1) def load(self): selected_ids = self.tree_members.get_selected_item_ids() data = [ [ get_member_name_from_config(m.config), m.member_id, get_avatar_paths_from_config(m.config, merge_multiple=True), ] for m in self.parent.linked_workflow().members.values() ] self.tree_members.load( data=data, folders_data=[], schema=self.schema, readonly=True, silent_select_id=selected_ids, ) # set height to fit all items & header height = self.tree_members.sizeHintForRow(0) * (len(data) + 1) self.tree_members.setFixedHeight(height) def on_selection_changed(self): # push selection to view all_selected_ids = self.tree_members.get_selected_item_ids() self.block_flag = True self.parent.select_ids(all_selected_ids, send_signal=False) self.block_flag = False def refresh_selected(self): # get selection from view if self.block_flag: return selected_objects = self.parent.scene.selectedItems() selected_members = [x for x in selected_objects if isinstance(x, DraggableMember)] selected_member_ids = [m.id for m in selected_members] with block_signals(self.tree_members): self.tree_members.select_items_by_id(selected_member_ids) class WorkflowParams(ConfigJsonTree): def __init__(self, parent): super().__init__(parent=parent, add_item_options={'title': 'NA', 'prompt': 'NA'}, del_item_options={'title': 'NA', 'prompt': 'NA'}) self.parent = parent self.hide() self.schema = [ { 'text': 'Name', 'type': str, 'width': 120, 'default': '< Enter a parameter name >', }, { 'text': 'Description', 'type': str, 'stretch': True, 'default': '', }, { 'text': 'Type', 'type': ('String', 'Int', 'Float', 'Bool',), 'width': 100, 'on_edit_reload': True, 'default': 'String', }, { 'text': 'Req', 'type': bool, 'default': True, }, ] class WorkflowConfig(ConfigJoined): def __init__(self, parent): super().__init__(parent=parent, layout_type='vertical') self.widgets = [ self.WorkflowFields(self), ] self.hide() class WorkflowFields(ConfigFields): def __init__(self, parent): super().__init__(parent=parent) self.parent = parent self.schema = [ { 'text': 'Filter role', 'type': 'RoleComboBox', 'width': 90, 'tooltip': 'Filter the output to a specific role. This is only used for the final member.', 'default': 'All', 'row_key': 0, }, { 'text': 'Member options', 'type': 'MemberPopupButton', 'use_namespace': 'group', 'member_type': 'agent', 'label_position': None, 'default': '', 'row_key': 0, }, ] class CustomGraphicsView(QGraphicsView): coordinatesChanged = Signal(QPoint) def __init__(self, scene, parent): super().__init__(scene, parent) self.setRenderHint(QPainter.Antialiasing) self.parent = parent self._is_panning = False self._mouse_press_pos = None self._mouse_press_scroll_x_val = None self._mouse_press_scroll_y_val = None self.temp_block_move_flag = False self.setMinimumHeight(200) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) from src.gui.style import TEXT_COLOR from src.utils.helpers import apply_alpha_to_hex self.setBackgroundBrush(QBrush(QColor(apply_alpha_to_hex(TEXT_COLOR, 0.05)))) self.setFrameShape(QFrame.Shape.NoFrame) self.setDragMode(QGraphicsView.RubberBandDrag) def contextMenuEvent(self, event): menu = QMenu(self) selected_items = self.parent.scene.selectedItems() if selected_items: # menu.addAction("Cut") menu.addAction("Copy") menu.addAction("Paste") if selected_items: menu.addAction("Delete") if len(selected_items) > 1: menu.addSeparator() menu.addAction("Group") # menu.addAction("Ungroup") # Show the menu and get the chosen action chosen_action = menu.exec(event.globalPos()) if chosen_action: if chosen_action.text() == "Copy": self.parent.workflow_buttons.copy_selected_items() elif chosen_action.text() == "Delete": self.parent.workflow_buttons.delete_selected_items() elif chosen_action.text() == "Paste": self.parent.workflow_buttons.paste_items() def cancel_new_line(self): # Remove the temporary line from the scene and delete it if self.parent.new_lines: for new_line in self.parent.new_lines: self.scene().removeItem(new_line) self.parent.new_lines = None if self.parent.adding_line: self.scene().removeItem(self.parent.adding_line) self.parent.adding_line = None self.update() def cancel_new_entity(self): # Remove the new entity from the scene and delete it for pos, entity in self.parent.new_agents: self.scene().removeItem(entity) self.parent.new_agents = None self.update() can_simplify_view = self.parent.can_simplify_view() # todo merge duplicate code if can_simplify_view: self.parent.toggle_view(False) # .view.hide() # !68! # 31 # Select the member so that it's config is shown, then hide the workflow panel until more members are added other_member_ids = [k for k, m in self.parent.members_in_view.items() if not m.member_config.get('_TYPE', 'agent') == 'user'] # .member_type != 'user'] if other_member_ids: self.parent.select_ids([other_member_ids[0]]) def mouse_is_over_member(self): mouse_scene_position = self.mapToScene(self.mapFromGlobal(QCursor.pos())) for member_id, member in self.parent.members_in_view.items(): # We need to map the scene position to the member's local coordinates member_local_pos = member.mapFromScene(mouse_scene_position) if member.contains(member_local_pos): return True return False def mouseReleaseEvent(self, event): self._is_panning = False self._mouse_press_pos = None self._mouse_press_scroll_x_val = None self._mouse_press_scroll_y_val = None super().mouseReleaseEvent(event) main = find_main_widget(self) main.mouseReleaseEvent(event) def mousePressEvent(self, event): self.temp_block_move_flag = False if self.parent.new_agents: self.parent.add_entity() return # Check if the mouse is over a member and want to drag it, and not activate panning if self.mouse_is_over_member(): self._is_panning = False self._mouse_press_pos = None self._mouse_press_scroll_x_val = None self._mouse_press_scroll_y_val = None else: # Otherwise, continue with the original behavior # left button and ctrl pressed if event.button() == Qt.LeftButton: if event.modifiers() == Qt.ControlModifier: self.setDragMode(QGraphicsView.NoDrag) self._is_panning = True self._mouse_press_pos = event.pos() self._mouse_press_scroll_x_val = self.horizontalScrollBar().value() self._mouse_press_scroll_y_val = self.verticalScrollBar().value() else: self.setDragMode(QGraphicsView.RubberBandDrag) mouse_scene_position = self.mapToScene(event.pos()) for member_id, member in self.parent.members_in_view.items(): if isinstance(member, DraggableMember): member_width = member.rect().width() input_rad = int(member_width / 2.5) if self.parent.adding_line: input_point_pos = member.input_point.scenePos() # if within 20px if (mouse_scene_position - input_point_pos).manhattanLength() <= 20: self.parent.add_input(member_id) return else: output_point_pos = member.output_point.scenePos() output_point_pos.setX(output_point_pos.x() + 2) x_diff_is_pos = (mouse_scene_position.x() - output_point_pos.x()) > 0 if x_diff_is_pos: input_rad = 20 # if within 20px if (mouse_scene_position - output_point_pos).manhattanLength() <= input_rad: self.parent.adding_line = ConnectionLine(self.parent, member) self.parent.scene.addItem(self.parent.adding_line) return # If click anywhere else, cancel the new line self.cancel_new_line() super().mousePressEvent(event) def mouseMoveEvent(self, event): update = False mouse_point = self.mapToScene(event.pos()) if self.parent.adding_line: self.parent.adding_line.updateEndPoint(mouse_point) update = True if self.parent.new_agents: for pos, entity in self.parent.new_agents: entity.setCentredPos(mouse_point + pos) update = True if self.parent.new_lines: for new_line in self.parent.new_lines: new_line.updatePath() update = True if update: if self.scene(): self.scene().update() self.update() if self._is_panning: delta = event.pos() - self._mouse_press_pos self.horizontalScrollBar().setValue(self._mouse_press_scroll_x_val - delta.x()) self.verticalScrollBar().setValue(self._mouse_press_scroll_y_val - delta.y()) super().mouseMoveEvent(event) def keyPressEvent(self, event): if event.key() == Qt.Key_Escape: if self.parent.new_lines or self.parent.adding_line: self.cancel_new_line() if self.parent.new_agents: self.cancel_new_entity() elif event.key() == Qt.Key_Delete: if self.parent.new_lines or self.parent.adding_line: self.cancel_new_line() return if self.parent.new_agents: self.cancel_new_entity() return self.parent.workflow_buttons.delete_selected_items() elif event.modifiers() == Qt.ControlModifier: if event.key() == Qt.Key_C: self.parent.workflow_buttons.copy_selected_items() elif event.key() == Qt.Key_V: self.parent.workflow_buttons.paste_items() else: super().keyPressEvent(event) def resizeEvent(self, event): # # set view to top left tl = self.mapToScene(self.viewport().rect().topLeft()) if tl.x() < 0 or tl.y() < 0: self.centerOn(tl) class InsertableMember(QGraphicsEllipseItem): def __init__(self, parent, config, pos): self.member_type = config.get('_TYPE', 'agent') self.member_config = config diameter = 50 if self.member_type != 'node' else 20 super().__init__(0, 0, diameter, diameter) from src.gui.style import TEXT_COLOR self.parent = parent member_type = config.get('_TYPE', 'agent') self.config: Dict[str, Any] = config self.input_point = ConnectionPoint(self, True) self.output_point = ConnectionPoint(self, False) # take into account the diameter of the points self.input_point.setPos(0, self.rect().height() / 2 - 2) self.output_point.setPos(self.rect().width() - 4, self.rect().height() / 2 - 2) pen = QPen(QColor(TEXT_COLOR), 1) if member_type not in ['user', 'agent']: pen = None self.setPen(pen if pen else Qt.NoPen) self.refresh_avatar() self.setCentredPos(pos) def refresh_avatar(self): from src.gui.style import TEXT_COLOR if self.member_type == 'node': self.setBrush(QBrush(QColor(TEXT_COLOR))) return hide_bubbles = self.config.get('group.hide_bubbles', False) opacity = 0.2 if hide_bubbles else 1 avatar_paths = get_avatar_paths_from_config(self.config) diameter = 50 pixmap = path_to_pixmap(avatar_paths, opacity=opacity, diameter=diameter) if pixmap: self.setBrush(QBrush(pixmap.scaled(diameter, diameter))) def setCentredPos(self, pos): self.setPos(pos.x() - self.rect().width() / 2, pos.y() - self.rect().height() / 2) class DraggableMember(QGraphicsEllipseItem): def __init__( self, parent: WorkflowSettings, member_id: str, loc_x: int, loc_y: int, member_config: Dict[str, Any] ): self.member_type = member_config.get('_TYPE', 'agent') self.member_config = member_config diameter = 50 if self.member_type != 'node' else 20 super().__init__(0, 0, diameter, diameter) from src.gui.style import TEXT_COLOR self.parent = parent self.id = member_id pen = QPen(QColor(TEXT_COLOR), 1) if self.member_type not in ['user', 'agent']: pen = None self.setPen(pen if pen else Qt.NoPen) self.setPos(loc_x, loc_y) self.refresh_avatar() self.setFlag(QGraphicsItem.ItemIsMovable) self.setFlag(QGraphicsItem.ItemIsSelectable) self.input_point = ConnectionPoint(self, True) self.output_point = ConnectionPoint(self, False) # take into account the diameter of the points self.input_point.setPos(0, self.rect().height() / 2 - 2) self.output_point.setPos(self.rect().width() - 4, self.rect().height() / 2 - 2) self.setAcceptHoverEvents(True) # Create the highlight background item self.highlight_background = self.HighlightBackground(self) self.highlight_background.setPos(self.rect().width()/2, self.rect().height()/2) self.highlight_background.hide() # Initially hidden # self.highlight_states = { # 'responding': '#0bde2b', # 'waiting': '#f7f7f7', # } def refresh_avatar(self): from src.gui.style import TEXT_COLOR if self.member_type == 'node': self.setBrush(QBrush(QColor(TEXT_COLOR))) return hide_bubbles = self.member_config.get('group.hide_bubbles', False) opacity = 0.2 if hide_bubbles else 1 avatar_paths = get_avatar_paths_from_config(self.member_config) diameter = 50 pixmap = path_to_pixmap(avatar_paths, opacity=opacity, diameter=diameter) # , def_avatar=def_avatar) # if pixmap is not null if pixmap: self.setBrush(QBrush(pixmap.scaled(diameter, diameter))) pass def toggle_highlight(self, enable, color=None): """Toggles the visual highlight on or off.""" if enable: self.highlight_background.use_color = color self.highlight_background.show() else: self.highlight_background.hide() def mouseMoveEvent(self, event): if self.output_point.contains(event.pos() - self.output_point.pos()): return if self.parent.adding_line: return if self.parent.view.temp_block_move_flag: return # # if mouse not inside scene, return cursor = event.scenePos() if cursor.x() < 0 or cursor.y() < 0: return super().mouseMoveEvent(event) for line in self.parent.inputs_in_view.values(): line.updatePosition() if self.member_type != 'node': self.parent.load_async_groups() self.parent.refresh_member_highlights() def mouseReleaseEvent(self, event): # this is faster super().mouseReleaseEvent(event) self.save_pos() def save_pos(self): new_loc_x = max(0, int(self.x())) new_loc_y = max(0, int(self.y())) members = self.parent.config.get('members', []) member = next((m for m in members if m['id'] == self.id), None) if member: if new_loc_x == member['loc_x'] and new_loc_y == member['loc_y']: return self.parent.update_member([ (self.id, 'loc_x', new_loc_x), (self.id, 'loc_y', new_loc_y) ]) self.parent.update_config() def hoverMoveEvent(self, event): # Check if the mouse is within 20 pixels of the output point if self.output_point.contains(event.pos() - self.output_point.pos()): self.output_point.setHighlighted(True) else: self.output_point.setHighlighted(False) super().hoverMoveEvent(event) def hoverLeaveEvent(self, event): self.output_point.setHighlighted(False) super().hoverLeaveEvent(event) class HighlightBackground(QGraphicsItem): def __init__(self, parent=None): super().__init__(parent) self.inner_diameter = parent.rect().width() # Diameter of the hole, same as the DraggableMember's ellipse self.outer_diameter = int(self.inner_diameter * 1.6) # Diameter including the gradient self.use_color = None # Uses text color when none def boundingRect(self): return QRectF(-self.outer_diameter / 2, -self.outer_diameter / 2, self.outer_diameter, self.outer_diameter) def paint(self, painter, option, widget=None): from src.gui.style import TEXT_COLOR gradient = QRadialGradient(QPointF(0, 0), self.outer_diameter / 2) # text_color_ = QColor(TEXT_COLOR) color = self.use_color or QColor(TEXT_COLOR) color.setAlpha(155) gradient.setColorAt(0, color) # Inner color of gradient gradient.setColorAt(1, QColor(255, 255, 0, 0)) # Outer color of gradient # Create a path for the outer ellipse (gradient) outer_path = QPainterPath() outer_path.addEllipse(-self.outer_diameter / 2, -self.outer_diameter / 2, self.outer_diameter, self.outer_diameter) # Create a path for the inner hole inner_path = QPainterPath() inner_path.addEllipse(-self.inner_diameter / 2, -self.inner_diameter / 2, self.inner_diameter, self.inner_diameter) # Subtract the inner hole from the outer path final_path = QPainterPath(outer_path) final_path = final_path.subtracted(inner_path) painter.setBrush(QBrush(gradient)) painter.setPen(Qt.NoPen) # No border painter.drawPath(final_path) class InsertableLine(QGraphicsPathItem): def __init__(self, parent, member_bundle, source_member_index, member_index, config=None): super().__init__() from src.gui.style import TEXT_COLOR self.parent = parent self.member_bundle = member_bundle.copy() self.source_member_index = source_member_index self.target_member_index = member_index self.start_point = self.parent.new_agents[self.source_member_index][1].output_point self.end_point = self.parent.new_agents[self.target_member_index][1].input_point self.selection_path = None self.looper_midpoint = None self.config: Dict[str, Any] = config if config else {} self.setAcceptHoverEvents(True) self.color = QColor(TEXT_COLOR) self.updatePath() self.setPen(QPen(self.color, 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) self.setZValue(-1) def paint(self, painter, option, widget): line_width = 4 if self.isSelected() else 2 current_pen = self.pen() current_pen.setWidth(line_width) has_no_mappings = len(self.config.get('mappings.data', [])) == 0 if has_no_mappings: current_pen.setStyle(Qt.DashLine) painter.setPen(current_pen) painter.drawPath(self.path()) # # make it an opaque triangle if self.looper_midpoint: painter.setBrush(QBrush(self.color)) painter.drawPolygon(QPolygonF([self.looper_midpoint, self.looper_midpoint + QPointF(10, 5), self.looper_midpoint + QPointF(10, -5)])) def updatePosition(self): self.updatePath() self.scene().update(self.scene().sceneRect()) def updatePath(self): start_point = self.start_point.scenePos() if isinstance(self.start_point, ConnectionPoint) else self.start_point end_point = self.end_point.scenePos() if isinstance(self.end_point, ConnectionPoint) else self.end_point # start point += (2, 2) start_point += QPointF(2, 2) end_point += QPointF(2, 2) is_looper = self.config.get('looper', False) if is_looper: line_is_under = start_point.y() >= end_point.y() if (line_is_under and start_point.y() > end_point.y()) or (start_point.y() < end_point.y() and not line_is_under): extender_side = 'left' else: extender_side = 'right' y_diff = abs(start_point.y() - end_point.y()) if not line_is_under: y_diff = -y_diff path = QPainterPath(start_point) x_rad = 25 y_rad = 25 if line_is_under else -25 # Draw half of the right side of the loop cp1 = QPointF(start_point.x() + x_rad, start_point.y()) cp2 = QPointF(start_point.x() + x_rad, start_point.y() + y_rad) path.cubicTo(cp1, cp2, QPointF(start_point.x() + x_rad, start_point.y() + y_rad)) if extender_side == 'right': # Draw a vertical line path.lineTo(QPointF(start_point.x() + x_rad, start_point.y() + y_rad + y_diff)) # Draw the other half of the right hand side loop var = y_diff if extender_side == 'right' else 0 cp3 = QPointF(start_point.x() + x_rad, start_point.y() + y_rad + var + y_rad) cp4 = QPointF(start_point.x(), start_point.y() + y_rad + var + y_rad) path.cubicTo(cp3, cp4, QPointF(start_point.x(), start_point.y() + y_rad + var + y_rad)) # Draw the horizontal line x_diff = start_point.x() - end_point.x() if x_diff < 50: x_diff = 50 path.lineTo(QPointF(start_point.x() - x_diff, start_point.y() + y_rad + var + y_rad)) self.looper_midpoint = QPointF(start_point.x() - (x_diff / 2), start_point.y() + y_rad + var + y_rad) # Draw half of the left side of the loop line_to = QPointF(start_point.x() - x_diff - x_rad, start_point.y() + y_rad + var) cp5 = QPointF(start_point.x() - x_diff - x_rad, start_point.y() + y_rad + var + y_rad) cp6 = line_to path.cubicTo(cp5, cp6, line_to) if extender_side == 'left': # Draw the vertical line up y_diff pixels line_to = QPointF(start_point.x() - x_diff - x_rad, start_point.y() + y_rad - y_diff) path.lineTo(line_to) else: # Draw the vertical line down y_diff pixels line_to = QPointF(start_point.x() - x_diff - x_rad, start_point.y() + y_rad + y_diff) path.lineTo(line_to) # Draw the other half of the left hand side loop # cp7 = QPointF(start_point.x() - x_diff - 25, start_point.y() + 25 - y_diff - 25) # cp8 = QPointF(start_point.x(), start_point.y() + 25 - y_diff - 25) diag_pt_top_right = QPointF(line_to.x() + x_rad, line_to.y() - y_rad) # diag_pt_top_right = line_to + QPointF(25, 25 * (-1 if line_is_under else 1)) cp7 = QPointF(diag_pt_top_right.x() - x_rad, diag_pt_top_right.y() + y_rad) cp8 = QPointF(diag_pt_top_right.x() - x_rad, diag_pt_top_right.y()) path.cubicTo(cp7, cp8, diag_pt_top_right) # Draw line to the end point path.lineTo(end_point) else: x_distance = (end_point - start_point).x() y_distance = abs((end_point - start_point).y()) # Set control points offsets to be a fraction of the horizontal distance fraction = 0.61 # Adjust the fraction as needed (e.g., 0.2 for 20%) offset = x_distance * fraction if offset < 0: offset *= 3 offset = min(offset, -40) else: offset = max(offset, 40) offset = min(offset, y_distance) offset = abs(offset) # max(abs(offset), 10) path = QPainterPath(start_point) ctrl_point1 = start_point + QPointF(offset, 0) ctrl_point2 = end_point - QPointF(offset, 0) path.cubicTo(ctrl_point1, ctrl_point2, end_point) self.looper_midpoint = None self.setPath(path) self.updateSelectionPath() def updateSelectionPath(self): stroker = QPainterPathStroker() stroker.setWidth(20) self.selection_path = stroker.createStroke(self.path()) def shape(self): if self.selection_path is None: return super().shape() return self.selection_path class ConnectionLine(QGraphicsPathItem): # todo dupe code above def __init__(self, parent, source_member, target_member=None, config=None): super().__init__() from src.gui.style import TEXT_COLOR self.parent = parent self.source_member_id = source_member.id self.target_member_id = target_member.id if target_member else None self.start_point = source_member.output_point self.end_point = target_member.input_point if target_member else None self.selection_path = None self.looper_midpoint = None self.config: Dict[str, Any] = config if config else {} self.setAcceptHoverEvents(True) self.setFlag(QGraphicsItem.ItemIsSelectable) self.color = QColor(TEXT_COLOR) self.updatePath() self.setPen(QPen(self.color, 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) self.setZValue(-1) def paint(self, painter, option, widget): line_width = 4 if self.isSelected() else 2 current_pen = self.pen() current_pen.setWidth(line_width) mappings_data = self.config.get('mappings.data', []) has_no_mappings = len(mappings_data) == 0 if has_no_mappings: current_pen.setStyle(Qt.DashLine) painter.setPen(current_pen) painter.drawPath(self.path()) else: from src.gui.style import TEXT_COLOR, PARAM_COLOR, STRUCTURE_COLOR color_codes = { "Output": QColor(TEXT_COLOR), "Message": QColor(TEXT_COLOR), "Param": QColor(PARAM_COLOR), "Structure": QColor(STRUCTURE_COLOR), } start_point = self.path().pointAtPercent(0) end_point = self.path().pointAtPercent(1) gradient = QLinearGradient(start_point, end_point) source_colors = [] target_colors = [] for mapping in mappings_data: source_color = color_codes.get(mapping['source'], QColor(TEXT_COLOR)) target_color = color_codes.get(mapping['target'], QColor(TEXT_COLOR)) if source_color not in source_colors: source_colors.append(source_color) if target_color not in target_colors: target_colors.append(target_color) dash_length = 10 total_length = self.path().length() num_dashes = int(total_length / dash_length) if len(source_colors) > 1 and len(target_colors) == 1: # Multiple sources, single target target_color = target_colors[0] for i in range(num_dashes): t1 = i / num_dashes t2 = (i + 1) / num_dashes source_color = source_colors[i % len(source_colors)] gradient.setColorAt(t1, source_color) gradient.setColorAt(t2, self.blend_colors(source_color, target_color, 0.5)) elif len(source_colors) > 1 or len(target_colors) > 1: # Multiple sources and multiple targets, or single source and multiple targets for i in range(num_dashes): t1 = i / num_dashes t2 = (i + 1) / num_dashes source_color = source_colors[i % len(source_colors)] target_color = target_colors[i % len(target_colors)] gradient.setColorAt(t1, source_color) gradient.setColorAt(t2, target_color) else: # Simple gradient from single source to single target source_color = source_colors[0] if source_colors else QColor(255, 255, 255) target_color = target_colors[0] if target_colors else QColor(255, 255, 255) gradient.setColorAt(0, source_color) gradient.setColorAt(1, target_color) current_pen.setBrush(gradient) painter.setPen(current_pen) painter.drawPath(self.path()) # Draw the looper triangle if self.looper_midpoint: painter.setBrush(QBrush(self.color)) painter.drawPolygon(QPolygonF( [self.looper_midpoint, self.looper_midpoint + QPointF(10, 5), self.looper_midpoint + QPointF(10, -5)])) @staticmethod def blend_colors(color1, color2, ratio): r = int(color1.red() * (1 - ratio) + color2.red() * ratio) g = int(color1.green() * (1 - ratio) + color2.green() * ratio) b = int(color1.blue() * (1 - ratio) + color2.blue() * ratio) return QColor(r, g, b) def updateEndPoint(self, end_point): # find the closest start point closest_member_id = None closest_start_point = None closest_distance = 1000 for member_id, member in self.parent.members_in_view.items(): if member_id == self.source_member_id: continue start_point = member.input_point.scenePos() distance = (start_point - end_point).manhattanLength() if distance < closest_distance: closest_distance = distance closest_start_point = start_point closest_member_id = member_id if closest_distance < 20: self.end_point = closest_start_point cr_check = self.parent.check_for_circular_references(closest_member_id, [self.source_member_id]) self.config['looper'] = True if cr_check else False else: self.end_point = end_point self.config['looper'] = False self.updatePath() def updatePosition(self): self.updatePath() self.scene().update(self.scene().sceneRect()) def updatePath(self): if self.end_point is None: return start_point = self.start_point.scenePos() if isinstance(self.start_point, ConnectionPoint) else self.start_point end_point = self.end_point.scenePos() if isinstance(self.end_point, ConnectionPoint) else self.end_point # start point += (2, 2) start_point = start_point + QPointF(2, 2) end_point = end_point + QPointF(2, 2) is_looper = self.config.get('looper', False) if is_looper: line_is_under = start_point.y() >= end_point.y() if (line_is_under and start_point.y() > end_point.y()) or (start_point.y() < end_point.y() and not line_is_under): extender_side = 'left' else: extender_side = 'right' y_diff = abs(start_point.y() - end_point.y()) if not line_is_under: y_diff = -y_diff path = QPainterPath(start_point) x_rad = 25 y_rad = 25 if line_is_under else -25 # Draw half of the right side of the loop cp1 = QPointF(start_point.x() + x_rad, start_point.y()) cp2 = QPointF(start_point.x() + x_rad, start_point.y() + y_rad) path.cubicTo(cp1, cp2, QPointF(start_point.x() + x_rad, start_point.y() + y_rad)) if extender_side == 'right': # Draw a vertical line path.lineTo(QPointF(start_point.x() + x_rad, start_point.y() + y_rad + y_diff)) # Draw the other half of the right hand side loop var = y_diff if extender_side == 'right' else 0 cp3 = QPointF(start_point.x() + x_rad, start_point.y() + y_rad + var + y_rad) cp4 = QPointF(start_point.x(), start_point.y() + y_rad + var + y_rad) path.cubicTo(cp3, cp4, QPointF(start_point.x(), start_point.y() + y_rad + var + y_rad)) # Draw the horizontal line x_diff = start_point.x() - end_point.x() if x_diff < 50: x_diff = 50 path.lineTo(QPointF(start_point.x() - x_diff, start_point.y() + y_rad + var + y_rad)) self.looper_midpoint = QPointF(start_point.x() - (x_diff / 2), start_point.y() + y_rad + var + y_rad) # Draw half of the left side of the loop line_to = QPointF(start_point.x() - x_diff - x_rad, start_point.y() + y_rad + var) cp5 = QPointF(start_point.x() - x_diff - x_rad, start_point.y() + y_rad + var + y_rad) cp6 = line_to path.cubicTo(cp5, cp6, line_to) if extender_side == 'left': # Draw the vertical line up y_diff pixels line_to = QPointF(start_point.x() - x_diff - x_rad, start_point.y() + y_rad - y_diff) path.lineTo(line_to) else: # Draw the vertical line down y_diff pixels line_to = QPointF(start_point.x() - x_diff - x_rad, start_point.y() + y_rad + y_diff) path.lineTo(line_to) # Draw the other half of the left hand side loop # cp7 = QPointF(start_point.x() - x_diff - 25, start_point.y() + 25 - y_diff - 25) # cp8 = QPointF(start_point.x(), start_point.y() + 25 - y_diff - 25) diag_pt_top_right = QPointF(line_to.x() + x_rad, line_to.y() - y_rad) # diag_pt_top_right = line_to + QPointF(25, 25 * (-1 if line_is_under else 1)) cp7 = QPointF(diag_pt_top_right.x() - x_rad, diag_pt_top_right.y() + y_rad) cp8 = QPointF(diag_pt_top_right.x() - x_rad, diag_pt_top_right.y()) path.cubicTo(cp7, cp8, diag_pt_top_right) # Draw line to the end point path.lineTo(end_point) else: x_distance = (end_point - start_point).x() y_distance = abs((end_point - start_point).y()) # Set control points offsets to be a fraction of the horizontal distance fraction = 0.61 # Adjust the fraction as needed (e.g., 0.2 for 20%) offset = x_distance * fraction if offset < 0: offset *= 3 offset = min(offset, -40) else: offset = max(offset, 40) offset = min(offset, y_distance) offset = abs(offset) # max(abs(offset), 10) path = QPainterPath(start_point) ctrl_point1 = start_point + QPointF(offset, 0) ctrl_point2 = end_point - QPointF(offset, 0) path.cubicTo(ctrl_point1, ctrl_point2, end_point) self.looper_midpoint = None self.setPath(path) self.updateSelectionPath() def updateSelectionPath(self): stroker = QPainterPathStroker() stroker.setWidth(20) self.selection_path = stroker.createStroke(self.path()) def shape(self): if self.selection_path is None: return super().shape() return self.selection_path class ConnectionPoint(QGraphicsEllipseItem): def __init__(self, parent, is_input): super().__init__(0, 0, 4, 4, parent) self.is_input = is_input self.setBrush(QBrush(Qt.darkGray)) self.connections = [] def setHighlighted(self, highlighted): if highlighted: self.setBrush(QBrush(Qt.red)) else: self.setBrush(QBrush(Qt.darkGray)) def contains(self, point): distance = (point - self.rect().center()).manhattanLength() return distance <= 12 class RoundedRectWidget(QGraphicsWidget): def __init__(self, parent, points, member_ids, rounding_radius=25): super().__init__() self.parent = parent self.member_ids = member_ids self.rounding_radius = rounding_radius self.setZValue(-2) # points is a list of QPointF points, all must be within the bounds lowest_x = min([point.x() for point in points]) lowest_y = min([point.y() for point in points]) btm_left = QPointF(lowest_x, lowest_y) highest_x = max([point.x() for point in points]) highest_y = max([point.y() for point in points]) top_right = QPointF(highest_x, highest_y) # Calculate width and height from l_bound and u_bound width = abs(btm_left.x() - top_right.x()) + 50 height = abs(btm_left.y() - top_right.y()) + 50 # Set size policy and preferred size self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.setPreferredSize(width, height) # Set the position based on the l_bound self.setPos(btm_left) def boundingRect(self): return QRectF(0, 0, self.preferredWidth(), self.preferredHeight()) def paint(self, painter, option, widget): from src.gui.style import TEXT_COLOR rect = self.boundingRect() painter.setRenderHint(QPainter.Antialiasing) # Set brush with 20% opacity color color = QColor(TEXT_COLOR) color.setAlpha(50) painter.setBrush(QBrush(color)) painter.setPen(Qt.NoPen) painter.drawRoundedRect(rect, self.rounding_radius, self.rounding_radius) class DynamicMemberConfigWidget(ConfigWidget): def __init__(self, parent): super().__init__(parent=parent) from src.system.plugins import get_plugin_agent_settings, get_plugin_block_settings self.parent = parent self.layout = CVBoxLayout(self) self.stacked_layout = QStackedLayout() self.layout.addLayout(self.stacked_layout) self.empty_widget = self.EmptySettings(parent) # parent=parent) self.agent_settings = get_plugin_agent_settings(None)(parent) self.user_settings = self.UserMemberSettings(parent) self.workflow_settings = None self.block_settings = get_plugin_block_settings(None)(parent) self.model_settings = get_plugin_model_settings(None)(parent) self.notif_settings = self.NotifMemberSettings(parent) self.input_settings = self.InputSettings(parent) self.user_settings.build_schema() self.agent_settings.build_schema() self.block_settings.build_schema() self.model_settings.build_schema() self.notif_settings.build_schema() self.input_settings.build_schema() self.stacked_layout.addWidget(self.empty_widget) self.stacked_layout.addWidget(self.agent_settings) self.stacked_layout.addWidget(self.user_settings) self.stacked_layout.addWidget(self.input_settings) self.stacked_layout.addWidget(self.block_settings) self.stacked_layout.addWidget(self.model_settings) self.stacked_layout.addWidget(self.notif_settings) def load(self, temp_only_config=False): pass def display_config_for_member(self, member): from src.system.plugins import get_plugin_agent_settings, get_plugin_block_settings # if member is None: # self.stacked_layout.setCurrentWidget(self.empty_widget) # return member_type = member.member_type member_config = member.member_config type_widgets = { 'agent': 'agent_settings', 'user': 'user_settings', 'block': 'block_settings', 'model': 'model_settings', 'workflow': 'workflow_settings', 'notif': 'notif_settings', 'node': 'empty_widget', } type_pluggable_classes = { 'agent': get_plugin_agent_settings, 'block': get_plugin_block_settings, 'model': get_plugin_model_settings, } widget_name = type_widgets[member_type] if member_type == "workflow": if self.workflow_settings is None: self.workflow_settings = self.WorkflowMemberSettings(self.parent) self.stacked_layout.addWidget(self.workflow_settings) self.workflow_settings.member_id = member.id self.workflow_settings.load_config(member_config) self.workflow_settings.load() self.stacked_layout.setCurrentWidget(self.workflow_settings) self.workflow_settings.reposition_view() return elif member_type in type_pluggable_classes: class_func = type_pluggable_classes[member_type] if member_type == "agent": plugin_field = member_config.get('info.use_plugin', '') elif member_type == "block": plugin_field = member_config.get('block_type', '') elif member_type == "model": plugin_field = member_config.get('model_type', '') else: # todo clean raise ValueError(f"Unsupported member type: {member_type}") self.load_pluggable_member_config(widget_name, plugin_field, class_func) elif member_type == 'node': self.stacked_layout.setCurrentWidget(self.empty_widget) return member_widget = getattr(self, widget_name) member_widget.member_id = member.id member_widget.load_config(member.member_config) member_widget.load() self.stacked_layout.setCurrentWidget(member_widget) def load_pluggable_member_config(self, widget_name, plugin_field, class_func): if plugin_field == '': plugin_field = None old_widget = getattr(self, widget_name) current_plugin = getattr(old_widget, '_plugin_name', '') is_different = plugin_field != current_plugin if is_different: plug_settings_class = class_func(plugin_field) setattr(self, widget_name, plug_settings_class(self.parent)) new_widget = getattr(self, widget_name) new_widget.build_schema() self.stacked_layout.addWidget(new_widget) self.stacked_layout.setCurrentWidget(new_widget) self.stacked_layout.removeWidget(old_widget) old_widget.deleteLater() def display_config_for_input(self, line): source_member_id, target_member_id = line.source_member_id, line.target_member_id self.stacked_layout.setCurrentWidget(self.input_settings) self.input_settings.input_key = (source_member_id, target_member_id) self.input_settings.load_config(line.config) self.input_settings.load() class UserMemberSettings(UserSettings): def __init__(self, parent): super().__init__(parent) def update_config(self): self.save_config() def save_config(self): conf = self.get_config() self.parent.members_in_view[self.member_id].member_config = conf self.parent.update_config() class NotifMemberSettings(NotifSettings): def __init__(self, parent): super().__init__(parent) def update_config(self): self.save_config() def save_config(self): conf = self.get_config() self.parent.members_in_view[self.member_id].member_config = conf self.parent.update_config() class WorkflowMemberSettings(WorkflowSettings): def __init__(self, parent): super().__init__(parent, compact_mode=True) def update_config(self): self.save_config() def save_config(self): conf = self.get_config() self.parent.members_in_view[self.member_id].member_config = conf self.parent.update_config() class EmptySettings(ConfigFields): def __init__(self, parent): super().__init__(parent) self.schema = [] class InputSettings(ConfigJoined): def __init__(self, parent): super().__init__(parent, add_stretch_to_end=True) self.input_key = None self.widgets = [ self.InputFields(self), self.InputMappings(self), ] def save_config(self): conf = self.get_config() is_looper = conf.get('looper', False) reload = False if not is_looper: # check circular references #(member_id, [input_member_id]) target_member_id = self.input_key[1] source_member_id = self.input_key[0] cr_check = self.parent.check_for_circular_references(target_member_id, [source_member_id]) if cr_check: display_message(self, message='Circular reference detected', icon=QMessageBox.Warning, ) conf['looper'] = True # todo bug self.parent.inputs_in_view[self.input_key].config = conf self.widgets[0].looper.setChecked(True) return self.parent.inputs_in_view[self.input_key].config = conf self.parent.update_config() # repaint all lines graphics_item = self.parent.inputs_in_view[self.input_key] graphics_item.updatePosition() if reload: # temp self.load() class InputFields(ConfigFields): def __init__(self, parent): super().__init__(parent) self.schema = [ { 'text': 'Looper', 'type': bool, 'default': False, }, ] class InputMappings(ConfigJsonTree): def __init__(self, parent): super().__init__(parent=parent, add_item_options={'title': 'NA', 'prompt': 'NA'}, del_item_options={'title': 'NA', 'prompt': 'NA'}, tree_header_resizable=False,) self.tree.setObjectName('input_items') self.conf_namespace = 'mappings' self.schema = [ { 'text': 'Source', 'type': 'InputSourceComboBox', 'width': 175, 'default': None, }, { 'text': 'Target', 'type': 'InputTargetComboBox', 'width': 175, 'default': None, }, ]