├── .flake8 ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── app.py ├── app.spec ├── docs ├── img.png ├── re-run-button.png └── view-options.png ├── models.conf.example ├── requirements-dev.txt ├── requirements.txt ├── resources ├── bulb.svg ├── clone.svg ├── delete.svg ├── fork.svg ├── icon.icns ├── icon.ico ├── icon.png └── ripple.svg ├── scripts └── install-macosx.sh └── tests ├── __init__.py ├── conftest.py ├── test_example.json └── test_main_window.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503, F403, F401 3 | max-line-length = 120 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4,B9 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ignore all root items 2 | /* 3 | 4 | # unignore folders 5 | !docs/ 6 | !resources/ 7 | !scripts/ 8 | !tests/ 9 | 10 | # unignore files 11 | !.gitignore 12 | !README.md 13 | !pyproject.toml 14 | !uv.lock 15 | !Makefile 16 | !.pre-commit-config.yaml 17 | !.editorconfig 18 | !app.py 19 | !app.spec 20 | !models.conf.example 21 | !requirements.txt 22 | !requirements-dev.txt 23 | !LICENSE 24 | 25 | __pycache__ 26 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.3.0 5 | hooks: 6 | - id: check-ast 7 | - id: check-builtin-literals 8 | - id: check-added-large-files 9 | exclude: "^resources/icon.icns" 10 | - id: check-merge-conflict 11 | - id: check-case-conflict 12 | - id: check-docstring-first 13 | - id: check-json 14 | - id: check-yaml 15 | - id: debug-statements 16 | - id: end-of-file-fixer 17 | - id: check-shebang-scripts-are-executable 18 | - id: check-symlinks 19 | - id: debug-statements 20 | - id: detect-private-key 21 | - id: trailing-whitespace 22 | exclude: '^docs/' 23 | - id: mixed-line-ending 24 | - repo: https://github.com/asottile/pyupgrade 25 | rev: v3.17.0 26 | hooks: 27 | - id: pyupgrade 28 | - repo: https://github.com/asottile/reorder-python-imports 29 | rev: v3.13.0 30 | hooks: 31 | - id: reorder-python-imports 32 | - repo: https://github.com/pycqa/autoflake 33 | rev: v2.3.1 34 | hooks: 35 | - id: autoflake 36 | entry: autoflake -r -i --remove-all-unused-imports --remove-unused-variables 37 | - repo: https://github.com/astral-sh/ruff-pre-commit 38 | rev: v0.6.4 39 | hooks: 40 | - id: ruff # linter 41 | types_or: [ python, pyi, jupyter, toml ] 42 | args: [ --fix ] 43 | - id: ruff-format # formatter 44 | types_or: [ python, pyi, jupyter, toml ] 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 namuan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export PROJECTNAME=$(shell basename "$(PWD)") 2 | export CONTEXT_DIR=build 3 | 4 | .SILENT: ; # no need for @ 5 | 6 | setup: ## Setup Virtual Env 7 | python3.11 -m venv venv 8 | ./venv/bin/pip3 install -r requirements-dev.txt 9 | ./venv/bin/python3 -m pip install --upgrade pip 10 | 11 | deps: ## Install dependencies 12 | ./venv/bin/pip3 install --upgrade -r requirements-dev.txt 13 | ./venv/bin/python3 -m pip install --upgrade pip 14 | 15 | pre-commit: ## Manually run all pre-commit hooks 16 | ./venv/bin/pre-commit install 17 | ./venv/bin/pre-commit run --all-files 18 | 19 | pre-commit-tool: ## Manually run a single pre-commit hook 20 | ./venv/bin/pre-commit run $(TOOL) --all-files 21 | 22 | clean: ## Clean package 23 | find . -type d -name '__pycache__' | xargs rm -rf 24 | rm -rf build dist 25 | 26 | package: clean pre-commit ## Run installer 27 | pyinstaller app.spec 28 | 29 | install-macosx: package ## Installs application in users Application folder 30 | ./scripts/install-macosx.sh ChatCircuit.app 31 | 32 | context: clean ## Build context file from application sources 33 | echo "Generating context in $(CONTEXT_DIR) directory" 34 | mkdir -p $(CONTEXT_DIR)/ 35 | llm-context-builder.py --extensions .py --ignored_dirs build dist generated venv .venv .idea .aider.tags.cache.v3 --print_contents > $(CONTEXT_DIR)/chat-circuit.py 36 | echo `pwd`/$(CONTEXT_DIR)/chat-circuit.py | pbcopy 37 | 38 | run: ## Runs the application 39 | export PYTHONPATH=`pwd`:$PYTHONPATH && ./venv/bin/python3 main.py 40 | 41 | test: ## Tests the application 42 | ./venv/bin/pytest -v 43 | 44 | .PHONY: help 45 | .DEFAULT_GOAL := help 46 | 47 | help: Makefile 48 | echo 49 | echo " Choose a command run in "$(PROJECTNAME)":" 50 | echo 51 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 52 | echo 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chat Circuit 2 | 3 | ![](docs/img.png) 4 | 5 | **Brief overview** 6 | 7 |

🔍 Added a small feature to zoom in using mouse selection. Handy for looking at deep branches #ChatCircuit

👉 August 22, 2024

8 | 9 | ### Short demos 10 | 11 | **Re-run all nodes in a branch** 12 |

Chat Circuit now makes it possible to re-run a branch of your conversation with LLM with a different prompt. It supports all local LLMs running on @ollama
💾 👉 August 6, 2024

13 | 14 | **Generate Follow up questions** 15 |

Implemented this idea in chat circuit. Here is a quick demo of the application along with generating follow up questions using #LLM August 20, 2024

16 | 17 | **Zoom in/out** 18 |

🔍 Added a small feature to zoom in using mouse selection. Handy for looking at deep branches #ChatCircuit

👉 August 22, 2024

19 | 20 | **Minimap Support** 21 | 22 |

#ChatCircuit Added a mini-map with the help of Sonnet 3.5 in @poe_platform.

Would have taken me days if not weeks to do it without any help. 🙏

~ 99% of code is written by Claude September 25, 2024

23 | 24 | **Export to JSON Canvas Document** 25 | 26 |

Added option to export to #JSON Canvas document that can be imported by any supported application like @obsdmd / @KinopioClub

👉 September 26, 2024

27 | 28 | ### Features 29 | 30 | **Multi-Branch Conversations** 31 | Create and manage multiple conversation branches seamlessly. 32 | 33 | **Contextual Forking** 34 | Fork conversation branches with accurate context retention. 35 | 36 | ### Editor Features 37 | 38 | **Save and Load Diagrams** 39 | 40 | **Undo and Redo** 41 | 42 | **Zoom and Pan** 43 | 44 | ![](docs/view-options.png) 45 | 46 | **Re-run nodes in a branch** 47 | 48 | It is possible to re-run all the nodes in a branch after changing the prompt it any node in the list. 49 | 50 | ![](docs/re-run-button.png) 51 | 52 | ### Running the Application 53 | 54 | To run this application, follow these steps: 55 | 56 | **Generate models configuration file** 57 | 58 | ```shell 59 | ollama list | tail -n +2 | awk '{print $1}' > models.conf 60 | ``` 61 | 62 | **Install dependencies** 63 | 64 | ```shell 65 | python3 -m pip install -r requirements.txt 66 | ``` 67 | 68 | **Run application** 69 | ```shell 70 | python3 main.py 71 | ``` 72 | 73 | ### Model Configuration 74 | 75 | The LLM models available are loaded from `models.conf` in the current directory 76 | See `models.conf.example` 77 | 78 | The default model is the first one in that list 79 | 80 | You can also run this command to generate the `models.conf` file 81 | 82 | ```shell 83 | ollama list | tail -n +2 | awk '{print "ollama_chat/"$1}' > models.conf 84 | ``` 85 | 86 | Note: If models.conf is not found, the application will use a default set of models. 87 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import faulthandler 2 | import json 3 | import math 4 | import os 5 | import random 6 | import re 7 | import sys 8 | import uuid 9 | from abc import ABC 10 | from abc import abstractmethod 11 | from collections import deque 12 | from os import linesep 13 | from pathlib import Path 14 | 15 | import keyring 16 | import mistune 17 | import requests 18 | from duckduckgo_search import DDGS 19 | from litellm import completion 20 | from PyQt6.QtCore import ( 21 | pyqtProperty, 22 | ) 23 | from PyQt6.QtCore import pyqtSignal 24 | from PyQt6.QtCore import QEasingCurve 25 | from PyQt6.QtCore import QEvent 26 | from PyQt6.QtCore import QObject 27 | from PyQt6.QtCore import QPoint 28 | from PyQt6.QtCore import QPointF 29 | from PyQt6.QtCore import QPropertyAnimation 30 | from PyQt6.QtCore import QRect 31 | from PyQt6.QtCore import QRectF 32 | from PyQt6.QtCore import QRunnable 33 | from PyQt6.QtCore import QSettings 34 | from PyQt6.QtCore import QSize 35 | from PyQt6.QtCore import QSizeF 36 | from PyQt6.QtCore import Qt 37 | from PyQt6.QtCore import QThreadPool 38 | from PyQt6.QtCore import QTimer 39 | from PyQt6.QtGui import QAction 40 | from PyQt6.QtGui import QBrush 41 | from PyQt6.QtGui import QColor 42 | from PyQt6.QtGui import QCursor 43 | from PyQt6.QtGui import QFont 44 | from PyQt6.QtGui import QFontMetrics 45 | from PyQt6.QtGui import QIcon 46 | from PyQt6.QtGui import QImage 47 | from PyQt6.QtGui import QKeyEvent 48 | from PyQt6.QtGui import QKeySequence 49 | from PyQt6.QtGui import QPainter 50 | from PyQt6.QtGui import QPen 51 | from PyQt6.QtGui import QPolygonF 52 | from PyQt6.QtGui import QTextDocument 53 | from PyQt6.QtGui import QTransform 54 | from PyQt6.QtWidgets import QApplication 55 | from PyQt6.QtWidgets import QComboBox 56 | from PyQt6.QtWidgets import QDialog 57 | from PyQt6.QtWidgets import QFileDialog 58 | from PyQt6.QtWidgets import QGraphicsEllipseItem 59 | from PyQt6.QtWidgets import QGraphicsItem 60 | from PyQt6.QtWidgets import QGraphicsItemGroup 61 | from PyQt6.QtWidgets import QGraphicsLinearLayout 62 | from PyQt6.QtWidgets import QGraphicsPolygonItem 63 | from PyQt6.QtWidgets import QGraphicsProxyWidget 64 | from PyQt6.QtWidgets import QGraphicsRectItem 65 | from PyQt6.QtWidgets import QGraphicsScene 66 | from PyQt6.QtWidgets import QGraphicsView 67 | from PyQt6.QtWidgets import QGraphicsWidget 68 | from PyQt6.QtWidgets import QHBoxLayout 69 | from PyQt6.QtWidgets import QLabel 70 | from PyQt6.QtWidgets import QLineEdit 71 | from PyQt6.QtWidgets import QListWidget 72 | from PyQt6.QtWidgets import QListWidgetItem 73 | from PyQt6.QtWidgets import QMainWindow 74 | from PyQt6.QtWidgets import QMenu 75 | from PyQt6.QtWidgets import QMessageBox 76 | from PyQt6.QtWidgets import QProgressBar 77 | from PyQt6.QtWidgets import QPushButton 78 | from PyQt6.QtWidgets import QRubberBand 79 | from PyQt6.QtWidgets import QScrollBar 80 | from PyQt6.QtWidgets import QSizePolicy 81 | from PyQt6.QtWidgets import QTextBrowser 82 | from PyQt6.QtWidgets import QTextEdit 83 | from PyQt6.QtWidgets import QVBoxLayout 84 | from PyQt6.QtWidgets import QWidget 85 | 86 | faulthandler.enable() 87 | faulthandler.dump_traceback(open("crash.log", "w")) 88 | 89 | APPLICATION_TITLE = "Chat Circuit" 90 | 91 | 92 | def resource_path(relative_path): 93 | """Get absolute path to resource, works for dev and for PyInstaller""" 94 | try: 95 | # PyInstaller creates a temp folder and stores path in _MEIPASS 96 | base_path = sys._MEIPASS 97 | except Exception: 98 | base_path = os.path.abspath(".") 99 | 100 | return os.path.join(base_path, relative_path) 101 | 102 | 103 | def load_models_from_config(config_file="models.conf"): 104 | config_path = os.path.join(os.path.dirname(__file__), config_file) 105 | 106 | try: 107 | with open(config_path) as f: 108 | models = [line.strip() for line in f if line.strip()] 109 | except FileNotFoundError: 110 | print(f"Config file {config_file} not found. Using default models.") 111 | models = [ 112 | "llama3:latest", 113 | "mistral:latest", 114 | "tinyllama:latest", 115 | ] 116 | 117 | return models 118 | 119 | 120 | LLM_MODELS = load_models_from_config() 121 | DEFAULT_LLM_MODEL = LLM_MODELS[0] 122 | 123 | thread_pool = QThreadPool() 124 | active_workers = 0 125 | 126 | 127 | class DuckDuckGo: 128 | def __init__(self): 129 | self.ddgs = DDGS() 130 | 131 | def search(self, query: str) -> str: 132 | results = self.ddgs.text(query, max_results=10) 133 | processed_results = [ 134 | f"**[{result['title']}]({result['href']})**\n\n{result['body']}\n\n{result['href']}" 135 | for result in results 136 | ] 137 | return "### Search Results\n\n" + "\n\n".join(processed_results) 138 | 139 | 140 | class CustomFilePicker(QWidget): 141 | def __init__(self, parent=None): 142 | super().__init__(parent) 143 | 144 | main_layout = QHBoxLayout() 145 | main_layout.setSpacing(0) 146 | main_layout.setContentsMargins(0, 0, 0, 0) 147 | 148 | button_style = """ 149 | background-color: #F0F0F0; 150 | text-align: center; 151 | border: 1px solid #808080; 152 | font-size: 18px; 153 | """ 154 | 155 | self.selected_file_paths = [] 156 | self.file_count_button = QPushButton("0") 157 | self.file_count_button.setFixedSize(28, 26) 158 | self.file_count_button.setStyleSheet(button_style + "border-right: none;") 159 | self.file_count_button.clicked.connect(self.show_file_list) 160 | 161 | self.attach_file_button = QPushButton() 162 | self.attach_file_button.setIcon(QIcon.fromTheme("mail-attachment")) 163 | self.attach_file_button.setStyleSheet(button_style + "border-left: none;") 164 | self.attach_file_button.clicked.connect(self.open_file_dialog) 165 | self.attach_file_button.setFixedSize(28, 26) 166 | 167 | main_layout.addWidget(self.file_count_button) 168 | main_layout.addWidget(self.attach_file_button) 169 | 170 | self.setLayout(main_layout) 171 | 172 | def get_selected_files(self): 173 | return self.selected_file_paths 174 | 175 | def set_selected_files(self, files): 176 | self.selected_file_paths = files 177 | self.update_file_count() 178 | 179 | def update_file_count(self): 180 | self.file_count_button.setText(str(len(self.selected_file_paths))) 181 | 182 | def open_file_dialog(self): 183 | file_dialog = QFileDialog(None) 184 | file_dialog.setWindowFlags(Qt.WindowType.FramelessWindowHint) 185 | file_dialog.setWindowTitle("Select a File") 186 | file_dialog.setFileMode(QFileDialog.FileMode.ExistingFile) 187 | if file_dialog.exec(): 188 | selected_file = file_dialog.selectedFiles()[0] 189 | if selected_file not in self.selected_file_paths: 190 | self.selected_file_paths.append(selected_file) 191 | self.update_file_count() 192 | 193 | def show_file_list(self): 194 | if self.selected_file_paths: 195 | file_list_dialog = QDialog(None) 196 | file_list_dialog.setWindowFlags( 197 | Qt.WindowType.FramelessWindowHint | Qt.WindowType.Tool 198 | ) 199 | 200 | dialog_layout = QVBoxLayout(file_list_dialog) 201 | dialog_layout.setContentsMargins(0, 0, 0, 0) 202 | dialog_layout.setSpacing(0) 203 | 204 | file_list_widget = QListWidget() 205 | file_list_widget.setStyleSheet(""" 206 | QListWidget { 207 | border: none; 208 | outline: none; 209 | background-color: white; 210 | } 211 | QListWidget::item { 212 | border: none; 213 | padding: 0; 214 | height: 30px; 215 | } 216 | QListWidget::item:selected { 217 | background: transparent; 218 | } 219 | """) 220 | 221 | for file_path in self.selected_file_paths: 222 | file_name = os.path.basename(file_path) 223 | file_item_widget = QWidget() 224 | file_item_layout = QHBoxLayout(file_item_widget) 225 | file_item_layout.setContentsMargins(5, 0, 5, 0) 226 | file_item_layout.setSpacing(5) 227 | 228 | remove_file_button = QPushButton("×") 229 | remove_file_button.setFixedSize(20, 20) 230 | remove_file_button.setStyleSheet(""" 231 | QPushButton { 232 | border: none; 233 | background-color: transparent; 234 | color: red; 235 | font-weight: bold; 236 | font-size: 18px; 237 | } 238 | QPushButton:hover { 239 | color: darkred; 240 | } 241 | """) 242 | remove_file_button.clicked.connect( 243 | lambda _, path=file_path: self.remove_file(path, file_list_widget) 244 | ) 245 | 246 | file_name_label = QLabel(file_name) 247 | file_name_label.setStyleSheet(""" 248 | color: #333; 249 | background: transparent; 250 | border: none; 251 | font-size: 13px; 252 | """) 253 | 254 | file_item_layout.addWidget(remove_file_button) 255 | file_item_layout.addWidget(file_name_label, 1) 256 | 257 | file_list_item = QListWidgetItem(file_list_widget) 258 | file_list_widget.addItem(file_list_item) 259 | file_list_widget.setItemWidget(file_list_item, file_item_widget) 260 | 261 | dialog_layout.addWidget(file_list_widget) 262 | file_list_dialog.setLayout(dialog_layout) 263 | 264 | item_height = 30 # This should match the height in the CSS 265 | num_items = len(self.selected_file_paths) 266 | calculated_height = num_items * item_height 267 | 268 | max_height = 300 269 | dialog_height = min(calculated_height, max_height) 270 | 271 | # Set the fixed width and calculated height 272 | file_list_dialog.setFixedSize(300, dialog_height) 273 | 274 | self.dialog = file_list_dialog 275 | self.update_list_position(calculated_height) 276 | 277 | class ClickOutsideFilter(QObject): 278 | def __init__(self, dialog): 279 | super().__init__() 280 | self.dialog = dialog 281 | 282 | def eventFilter(self, obj, event): 283 | if event.type() == QEvent.Type.Leave and obj == self.dialog: 284 | if not self.dialog.geometry().contains(QCursor.pos()): 285 | self.dialog.close() 286 | return True 287 | return False 288 | 289 | click_outside_filter = ClickOutsideFilter(file_list_dialog) 290 | file_list_dialog.installEventFilter(click_outside_filter) 291 | 292 | file_list_dialog.activateWindow() 293 | file_list_dialog.raise_() 294 | file_list_dialog.exec() 295 | 296 | file_list_dialog.removeEventFilter(click_outside_filter) 297 | 298 | def remove_file(self, file_path, file_list_widget): 299 | if file_path in self.selected_file_paths: 300 | index = self.selected_file_paths.index(file_path) 301 | del self.selected_file_paths[index] 302 | self.update_file_count() 303 | file_list_widget.takeItem(index) 304 | 305 | def update_list_position(self, list_height: int): 306 | file_count_button_pos = self.file_count_button.mapToGlobal( 307 | self.file_count_button.rect().topLeft() 308 | ) 309 | self.dialog.move( 310 | file_count_button_pos.x(), file_count_button_pos.y() - list_height 311 | ) 312 | 313 | 314 | class CommandInvoker: 315 | def __init__(self): 316 | self.history = [] 317 | self.redo_stack = [] 318 | 319 | def execute(self, command): 320 | command.execute() 321 | self.history.append(command) 322 | self.redo_stack.clear() 323 | 324 | def undo(self): 325 | if self.history: 326 | command = self.history.pop() 327 | command.undo() 328 | self.redo_stack.append(command) 329 | 330 | def redo(self): 331 | if self.redo_stack: 332 | command = self.redo_stack.pop() 333 | command.execute() 334 | self.history.append(command) 335 | 336 | 337 | def create_svg_icon(file_path): 338 | icon = QIcon(file_path) 339 | return icon 340 | 341 | 342 | def create_button(icon_path, tooltip, callback): 343 | button_widget = QGraphicsProxyWidget() 344 | button = QPushButton() 345 | button.setStyleSheet( 346 | """ 347 | QPushButton { 348 | border: 1px solid #808080; 349 | } 350 | """ 351 | ) 352 | icon = create_svg_icon(icon_path) 353 | button.setIcon(icon) 354 | button.setIconSize(QSize(24, 24)) 355 | button.setToolTip(tooltip) 356 | button.clicked.connect(callback) 357 | button_widget.setWidget(button) 358 | return button_widget 359 | 360 | 361 | def add_buttons(form_widget, picker): 362 | bottom_layout = QGraphicsLinearLayout(Qt.Orientation.Horizontal) 363 | 364 | # Define button configurations 365 | buttons = [ 366 | (resource_path("resources/ripple.svg"), "Re-Run", form_widget.re_run_all), 367 | (resource_path("resources/fork.svg"), "Fork", form_widget.clone_form), 368 | ( 369 | resource_path("resources/clone.svg"), 370 | "Clone Branch", 371 | form_widget.clone_branch, 372 | ), 373 | ( 374 | resource_path("resources/bulb.svg"), 375 | "Follow-up Questions", 376 | form_widget.generate_follow_up_questions, 377 | ), 378 | (resource_path("resources/delete.svg"), "Delete", form_widget.delete_form), 379 | ] 380 | 381 | picker_widget = QGraphicsProxyWidget() 382 | picker_widget.setWidget(picker) 383 | bottom_layout.addItem(picker_widget) 384 | 385 | # Create and add buttons 386 | for icon_path, tooltip, callback in buttons: 387 | button_widget = create_button(icon_path, tooltip, callback) 388 | bottom_layout.addItem(button_widget) 389 | 390 | return bottom_layout 391 | 392 | 393 | class Command(ABC): 394 | @abstractmethod 395 | def execute(self): 396 | pass 397 | 398 | @abstractmethod 399 | def undo(self): 400 | pass 401 | 402 | 403 | class CreateFormCommand(Command): 404 | def __init__(self, scene, parent_form=None, position=None, model=DEFAULT_LLM_MODEL): 405 | self.scene = scene 406 | self.parent_form = parent_form 407 | self.model = model 408 | self.created_form = None 409 | self.position = position 410 | self.link_line = None 411 | 412 | def execute(self): 413 | self.created_form = FormWidget(parent=self.parent_form, model=self.model) 414 | self.scene.addItem(self.created_form) 415 | if self.position: 416 | self.created_form.setPos(self.position) 417 | if self.parent_form: 418 | self.parent_form.child_forms.append(self.created_form) 419 | self.link_line = LinkLine(self.parent_form, self.created_form) 420 | self.scene.addItem(self.link_line) 421 | self.created_form.link_line = self.link_line 422 | 423 | def undo(self): 424 | if self.created_form: 425 | if self.created_form.scene() == self.scene: 426 | self.scene.removeItem(self.created_form) 427 | if self.parent_form and self.created_form in self.parent_form.child_forms: 428 | self.parent_form.child_forms.remove(self.created_form) 429 | if self.link_line and self.link_line.scene() == self.scene: 430 | self.scene.removeItem(self.link_line) 431 | self.created_form = None 432 | self.link_line = None 433 | 434 | 435 | class DeleteFormCommand(Command): 436 | def __init__(self, form): 437 | self.form = form 438 | self.parent_form = form.parent_form 439 | self.child_forms = form.child_forms[:] 440 | self.link_line = form.link_line 441 | self.scene = form.scene() 442 | self.pos = form.pos() 443 | self.deleted_subtree = [] 444 | 445 | def execute(self): 446 | self.deleted_subtree = self._delete_subtree(self.form) 447 | if self.parent_form and self.form in self.parent_form.child_forms: 448 | self.parent_form.child_forms.remove(self.form) 449 | 450 | def undo(self): 451 | self._restore_subtree(self.deleted_subtree) 452 | if self.parent_form: 453 | self.parent_form.child_forms.append(self.form) 454 | 455 | def _delete_subtree(self, form): 456 | deleted = [] 457 | for child in form.child_forms[:]: 458 | deleted.extend(self._delete_subtree(child)) 459 | 460 | if form.scene() == self.scene: 461 | self.scene.removeItem(form) 462 | if form.link_line and form.link_line.scene() == self.scene: 463 | self.scene.removeItem(form.link_line) 464 | 465 | deleted.append((form, form.pos(), form.link_line)) 466 | return deleted 467 | 468 | def _restore_subtree(self, deleted_items): 469 | for form, pos, link_line in reversed(deleted_items): 470 | self.scene.addItem(form) 471 | form.setPos(pos) 472 | if link_line: 473 | self.scene.addItem(link_line) 474 | form.link_line = link_line 475 | 476 | 477 | class MoveFormCommand(Command): 478 | def __init__(self, form, old_pos, new_pos): 479 | self.form = form 480 | self.old_pos = old_pos 481 | self.new_pos = new_pos 482 | 483 | def execute(self): 484 | self.form.setPos(self.new_pos) 485 | if self.form.link_line: 486 | self.form.link_line.update_position() 487 | 488 | def undo(self): 489 | self.form.setPos(self.old_pos) 490 | if self.form.link_line: 491 | self.form.link_line.update_position() 492 | 493 | 494 | class CloneBranchCommand(Command): 495 | def __init__(self, scene, source_form): 496 | self.scene = scene 497 | self.source_form = source_form 498 | self.cloned_forms = [] 499 | self.parent_form = None 500 | 501 | def execute(self): 502 | self.parent_form = self.source_form.parent_form 503 | new_pos = self.source_form.pos() + QPointF(200, 600) # Offset the new branch 504 | self.cloned_forms = self._clone_branch( 505 | self.source_form, self.parent_form, new_pos 506 | ) 507 | 508 | def undo(self): 509 | for form in self.cloned_forms: 510 | if form.scene() == self.scene: 511 | self.scene.removeItem(form) 512 | if form.link_line and form.link_line.scene() == self.scene: 513 | self.scene.removeItem(form.link_line) 514 | if self.parent_form: 515 | self.parent_form.child_forms = [ 516 | f for f in self.parent_form.child_forms if f not in self.cloned_forms 517 | ] 518 | 519 | def _clone_branch(self, source_form, parent_form, position): 520 | cloned_form = FormWidget(parent=parent_form, model=source_form.model) 521 | cloned_form.setPos(position) 522 | cloned_form.input_box.widget().setPlainText( 523 | source_form.input_box.widget().toPlainText() 524 | ) 525 | cloned_form.conversation_area.widget().setPlainText( 526 | source_form.conversation_area.widget().toPlainText() 527 | ) 528 | 529 | self.scene.addItem(cloned_form) 530 | 531 | if parent_form: 532 | parent_form.child_forms.append(cloned_form) 533 | link_line = LinkLine(parent_form, cloned_form) 534 | self.scene.addItem(link_line) 535 | cloned_form.link_line = link_line 536 | 537 | cloned_forms = [cloned_form] 538 | 539 | for child in source_form.child_forms: 540 | child_pos = cloned_form.pos() + (child.pos() - source_form.pos()) 541 | cloned_forms.extend(self._clone_branch(child, cloned_form, child_pos)) 542 | 543 | return cloned_forms 544 | 545 | 546 | class HeaderWidget(QGraphicsWidget): 547 | model_changed = pyqtSignal(str) 548 | 549 | def __init__(self, model_name): 550 | super().__init__() 551 | 552 | self.model_dropdown = QComboBox() 553 | self.progress_bar = QProgressBar() 554 | self.model_name = model_name 555 | self.is_initialized = False 556 | 557 | self.setMinimumHeight(30) # Reduced height 558 | self.setMaximumHeight(30) 559 | 560 | QTimer.singleShot(0, self.create_widgets) 561 | 562 | def create_widgets(self): 563 | container = QWidget() 564 | main_layout = QVBoxLayout(container) 565 | main_layout.setContentsMargins(0, 0, 0, 0) 566 | main_layout.setSpacing(0) 567 | 568 | self.model_dropdown.addItems(LLM_MODELS) 569 | self.model_dropdown.setStyleSheet( 570 | """ 571 | QComboBox { 572 | background-color: #2c3e50; 573 | color: #ecf0f1; 574 | border: 1px solid #34495e; 575 | border-radius: 3px; 576 | padding: 5px 25px 5px 5px; 577 | min-height: 20px; 578 | } 579 | QComboBox::drop-down { 580 | subcontrol-origin: padding; 581 | subcontrol-position: top right; 582 | width: 20px; 583 | border-left: 1px solid #34495e; 584 | border-top-right-radius: 3px; 585 | border-bottom-right-radius: 3px; 586 | } 587 | QComboBox::down-arrow { 588 | image: none; /* Remove default arrow image */ 589 | width: 10px; 590 | height: 15px; 591 | margin-right: 5px; 592 | } 593 | QComboBox::down-arrow:on { 594 | top: 1px; 595 | left: 1px; 596 | } 597 | QComboBox::down-arrow::after { 598 | content: ""; 599 | display: block; 600 | width: 0; 601 | height: 0; 602 | border-left: 5px solid transparent; 603 | border-right: 5px solid transparent; 604 | border-top: 5px solid #ecf0f1; /* Arrow color */ 605 | } 606 | QComboBox QAbstractItemView { 607 | background-color: #34495e; 608 | color: #ecf0f1; 609 | selection-background-color: #3498db; 610 | } 611 | """ 612 | ) 613 | self.model_dropdown.currentTextChanged.connect(self.on_model_changed) 614 | 615 | main_layout.addWidget(self.model_dropdown) 616 | 617 | self.progress_bar.setRange(0, 0) 618 | self.progress_bar.setTextVisible(False) 619 | self.progress_bar.setFixedHeight(3) 620 | self.progress_bar.setStyleSheet( 621 | """ 622 | QProgressBar { 623 | background-color: #ecf0f1; 624 | border: none; 625 | border-radius: 1px; 626 | } 627 | QProgressBar::chunk { 628 | background-color: #e74c3c; 629 | border-radius: 1px; 630 | } 631 | """ 632 | ) 633 | self.progress_bar.hide() 634 | 635 | main_layout.addWidget(self.progress_bar) 636 | 637 | proxy = QGraphicsProxyWidget(self) 638 | proxy.setWidget(container) 639 | 640 | layout = QGraphicsLinearLayout(Qt.Orientation.Vertical) 641 | layout.setContentsMargins(0, 0, 0, 0) 642 | layout.addItem(proxy) 643 | self.setLayout(layout) 644 | 645 | self.is_initialized = True 646 | self.update_model_name() 647 | 648 | def start_processing(self): 649 | if self.is_initialized and self.progress_bar: 650 | self.progress_bar.show() 651 | 652 | def stop_processing(self): 653 | if self.is_initialized and self.progress_bar: 654 | self.progress_bar.hide() 655 | 656 | def on_model_changed(self, new_model): 657 | self.model_name = new_model 658 | self.model_changed.emit(new_model) 659 | 660 | def update_model_name(self): 661 | if self.is_initialized and self.model_dropdown: 662 | self.model_dropdown.setCurrentText(self.model_name) 663 | 664 | 665 | class CircleAnimator(QObject): 666 | def __init__(self): 667 | super().__init__() 668 | self._scale = 1.0 669 | 670 | @pyqtProperty(float) 671 | def scale(self): 672 | return self._scale 673 | 674 | @scale.setter 675 | def scale(self, value): 676 | self._scale = value 677 | 678 | 679 | class HoverCircle(QGraphicsEllipseItem): 680 | def __init__(self, parent=None): 681 | super().__init__(parent) 682 | self.setAcceptHoverEvents(True) 683 | self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, True) 684 | self.normal_radius = 10 685 | self.hover_radius = 30 686 | self.setBrush(QBrush(QColor(70, 130, 180))) 687 | self.setPen(QPen(Qt.PenStyle.NoPen)) 688 | self.setRect(-10, -10, self.normal_radius * 2, self.normal_radius * 2) 689 | 690 | self.animator = CircleAnimator() 691 | self.animation = QPropertyAnimation(self.animator, b"scale") 692 | self.animation.setDuration(200) 693 | self.animation.valueChanged.connect(self.update_scale) 694 | 695 | self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton) 696 | self.dragging = False 697 | 698 | def mousePressEvent(self, event): 699 | if event.button() == Qt.MouseButton.LeftButton: 700 | self.dragging = True 701 | self.drag_start_pos = event.scenePos() 702 | event.accept() 703 | else: 704 | super().mousePressEvent(event) 705 | 706 | def mouseMoveEvent(self, event): 707 | if self.dragging: 708 | new_pos = event.scenePos() - self.drag_start_pos 709 | self.parentItem().moveBy(new_pos.x(), new_pos.y()) 710 | self.drag_start_pos = event.scenePos() 711 | event.accept() 712 | else: 713 | super().mouseMoveEvent(event) 714 | 715 | def mouseReleaseEvent(self, event): 716 | if event.button() == Qt.MouseButton.LeftButton: 717 | self.dragging = False 718 | event.accept() 719 | else: 720 | super().mouseReleaseEvent(event) 721 | 722 | def update_scale(self, scale): 723 | center = self.rect().center() 724 | new_radius = self.normal_radius * scale 725 | new_rect = QRectF(0, 0, new_radius * 2, new_radius * 2) 726 | new_rect.moveCenter(center) 727 | self.setRect(new_rect) 728 | 729 | def hoverEnterEvent(self, event): 730 | self.animation.setStartValue(1.0) 731 | self.animation.setEndValue(self.hover_radius / self.normal_radius) 732 | self.animation.setEasingCurve(QEasingCurve.Type.OutQuad) 733 | self.animation.start() 734 | 735 | def hoverLeaveEvent(self, event): 736 | self.animation.setStartValue(self.hover_radius / self.normal_radius) 737 | self.animation.setEndValue(1.0) 738 | self.animation.setEasingCurve(QEasingCurve.Type.OutQuad) 739 | self.animation.start() 740 | 741 | 742 | class LinkLine(QGraphicsItemGroup): 743 | def __init__(self, parent, child): 744 | super().__init__() 745 | self.parent = parent 746 | self.child = child 747 | self.chevrons = [] 748 | self.chevron_color = QColor(0, 158, 115) 749 | self.chevron_size = 10 750 | self.chevron_spacing = 30 751 | self.setZValue(-1) 752 | self.update_position() 753 | 754 | def create_chevron(self, pos, angle): 755 | chevron = QGraphicsPolygonItem(self) 756 | chevron.setBrush(self.chevron_color) 757 | chevron.setPen(QPen(self.chevron_color, 1)) 758 | 759 | # Create chevron points 760 | p1 = QPointF(-self.chevron_size / 2, -self.chevron_size / 2) 761 | p2 = QPointF(self.chevron_size / 2, 0) 762 | p3 = QPointF(-self.chevron_size / 2, self.chevron_size / 2) 763 | 764 | chevron.setPolygon(QPolygonF([p1, p2, p3])) 765 | chevron.setPos(pos) 766 | chevron.setRotation(math.degrees(angle)) 767 | return chevron 768 | 769 | def update_position(self): 770 | parent_center = self.parent.mapToScene(self.parent.boundingRect().center()) 771 | child_center = self.child.mapToScene(self.child.boundingRect().center()) 772 | 773 | # Calculate the direction vector 774 | dx = child_center.x() - parent_center.x() 775 | dy = child_center.y() - parent_center.y() 776 | length = math.sqrt(dx**2 + dy**2) 777 | 778 | # Clear existing chevrons 779 | for chevron in self.chevrons: 780 | self.removeFromGroup(chevron) 781 | self.chevrons.clear() 782 | 783 | if length == 0: 784 | return 785 | 786 | # Normalize the direction vector 787 | dx, dy = dx / length, dy / length 788 | 789 | # Calculate angle for chevrons 790 | angle = math.atan2(dy, dx) 791 | 792 | # Create chevrons along the line 793 | num_chevrons = int(length / self.chevron_spacing) 794 | for i in range(num_chevrons): 795 | pos = QPointF( 796 | parent_center.x() + dx * (i + 0.5) * self.chevron_spacing, 797 | parent_center.y() + dy * (i + 0.5) * self.chevron_spacing, 798 | ) 799 | chevron = self.create_chevron(pos, angle) 800 | self.addToGroup(chevron) 801 | self.chevrons.append(chevron) 802 | 803 | 804 | class HtmlRenderer(mistune.HTMLRenderer): 805 | def __init__(self): 806 | super().__init__() 807 | self.text_document = QTextDocument() 808 | 809 | def render(self, text): 810 | markdown = mistune.create_markdown(renderer=self) 811 | html = markdown(text) 812 | self.text_document.setHtml(html) 813 | return self.text_document 814 | 815 | 816 | class LlmWorkerSignals(QObject): 817 | update = pyqtSignal(str) 818 | finished = pyqtSignal() 819 | notify_child = pyqtSignal() 820 | error = pyqtSignal(str) 821 | 822 | 823 | class LlmWorker(QRunnable): 824 | def __init__(self, model, system_message, messages): 825 | super().__init__() 826 | self.model = model 827 | self.messages = messages 828 | self.system_message = system_message or "You are a helpful assistant." 829 | self.signals = LlmWorkerSignals() 830 | 831 | def run(self): 832 | try: 833 | formatted_messages = [] 834 | if self.system_message: 835 | formatted_messages.append( 836 | {"role": "system", "content": self.system_message} 837 | ) 838 | formatted_messages.extend(self.messages) 839 | 840 | response = completion( 841 | model=f"{self.model}", 842 | messages=formatted_messages, 843 | api_base="http://localhost:11434", 844 | ) 845 | 846 | content = response.choices[0].message.content 847 | self.signals.update.emit(content) 848 | self.signals.finished.emit() 849 | 850 | except Exception as e: 851 | self.signals.error.emit(str(e)) 852 | finally: 853 | self.signals.notify_child.emit() 854 | 855 | 856 | class SearchWorkerSignals(QObject): 857 | result = pyqtSignal(str) 858 | error = pyqtSignal(str) 859 | 860 | 861 | class SearchWorker(QRunnable): 862 | def __init__(self, query): 863 | super().__init__() 864 | self.search_engine = DuckDuckGo() 865 | self.query = query 866 | self.signals = SearchWorkerSignals() 867 | 868 | def run(self): 869 | try: 870 | search_results = self.search_engine.search(self.query) 871 | self.signals.result.emit(search_results) 872 | except Exception as e: 873 | self.signals.error.emit(str(e)) 874 | 875 | 876 | class JinaReaderWorkerSignals(QObject): 877 | result = pyqtSignal(str) 878 | error = pyqtSignal(str) 879 | 880 | 881 | class JinaReaderWorker(QRunnable): 882 | def __init__(self, url, jina_api_key): 883 | super().__init__() 884 | self.url = url 885 | self.jina_api_key = jina_api_key 886 | self.signals = JinaReaderWorkerSignals() 887 | 888 | def run(self): 889 | try: 890 | jina_url = f"https://r.jina.ai/{self.url}" 891 | headers = { 892 | "Authorization": f"Bearer {self.jina_api_key}", 893 | "x-engine": "readerlm-v2", 894 | } 895 | response = requests.get(jina_url, headers=headers) 896 | response.raise_for_status() 897 | 898 | content = response.text 899 | self.signals.result.emit(content) 900 | except Exception as e: 901 | self.signals.error.emit(str(e)) 902 | 903 | 904 | class ResizeHandle(QGraphicsWidget): 905 | resize_signal = pyqtSignal(QPointF) 906 | 907 | def __init__(self, parent=None): 908 | super().__init__(parent) 909 | self.setFlag(QGraphicsWidget.GraphicsItemFlag.ItemIsMovable, False) 910 | self.setFlag(QGraphicsWidget.GraphicsItemFlag.ItemIsSelectable, False) 911 | self.setCursor(QCursor(Qt.CursorShape.SizeFDiagCursor)) 912 | self.setZValue(2) 913 | self.setGeometry(QRectF(0, 0, 10, 10)) 914 | self.initial_pos = QPointF() 915 | self.initial_size = QSizeF() 916 | self.resizing = False 917 | 918 | def mousePressEvent(self, event): 919 | if event.button() == Qt.MouseButton.LeftButton: 920 | self.resizing = True 921 | self.initial_pos = event.scenePos() 922 | parent = self.parentItem() 923 | if isinstance(parent, FormWidget): 924 | self.initial_size = parent.size() 925 | event.accept() 926 | else: 927 | event.ignore() 928 | 929 | def mouseMoveEvent(self, event): 930 | if self.resizing: 931 | delta = event.scenePos() - self.initial_pos 932 | new_width = max( 933 | self.initial_size.width() + delta.x(), self.parentItem().minimumWidth() 934 | ) 935 | new_height = max( 936 | self.initial_size.height() + delta.y(), 937 | self.parentItem().minimumHeight(), 938 | ) 939 | self.resize_signal.emit(QPointF(new_width, new_height)) 940 | event.accept() 941 | else: 942 | event.ignore() 943 | 944 | def mouseReleaseEvent(self, event): 945 | if self.resizing: 946 | self.resizing = False 947 | event.accept() 948 | else: 949 | event.ignore() 950 | 951 | def paint(self, painter, option, widget): 952 | painter.setBrush(QBrush(QColor(Qt.GlobalColor.darkGray).lighter(128))) 953 | painter.setPen(QPen(Qt.GlobalColor.darkGray, 2)) 954 | painter.drawRect(self.boundingRect()) 955 | 956 | 957 | class FormWidget(QGraphicsWidget): 958 | def __init__(self, parent=None, model=None): 959 | super().__init__() 960 | # LLM 961 | self.model = model 962 | self.system_message = "You are a helpful assistant." 963 | 964 | self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable) 965 | self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) 966 | self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsFocusable) 967 | 968 | self.parent_form = parent 969 | self.child_forms = [] 970 | self.link_line = None 971 | 972 | # Re-Run all form nodes 973 | self.llm_worker = None 974 | self.jina_worker = None 975 | self.form_chain = deque() 976 | 977 | # Create main layout 978 | self.main_layout = QGraphicsLinearLayout(Qt.Orientation.Vertical) 979 | 980 | # Create and add header 981 | self.header = HeaderWidget(self.model) 982 | self.header.model_changed.connect(self.on_model_changed) 983 | self.header.setZValue(1) 984 | self.main_layout.addItem(self.header) 985 | self.header.update_model_name() 986 | 987 | # Create chat layout 988 | chat_layout = QGraphicsLinearLayout(Qt.Orientation.Vertical) 989 | 990 | # Conversation area 991 | self.markdown_content = "" 992 | self.custom_font = QFont("Fantasque Sans Mono", 18) 993 | self.conversation_area = QGraphicsProxyWidget() 994 | conversation_widget = QTextBrowser() 995 | conversation_widget.setReadOnly(True) 996 | conversation_widget.setAcceptRichText(True) 997 | conversation_widget.setStyleSheet( 998 | f""" 999 | QTextBrowser {{ 1000 | background-color: white; 1001 | border: 1px solid #ccc; 1002 | font-family: {self.custom_font.family()}; 1003 | font-size: {self.custom_font.pointSize()}pt; 1004 | }} 1005 | QTextBrowser a {{ 1006 | color: #0066cc; 1007 | text-decoration: none; 1008 | }} 1009 | QTextBrowser a:hover {{ 1010 | text-decoration: underline; 1011 | cursor: pointer; 1012 | }} 1013 | """ 1014 | ) 1015 | conversation_widget.setOpenExternalLinks(False) 1016 | conversation_widget.setOpenLinks(False) 1017 | conversation_widget.anchorClicked.connect(self.handle_link_click) 1018 | self.conversation_area.setWidget(conversation_widget) 1019 | chat_layout.addItem(self.conversation_area) 1020 | self.conversation_area.widget().setContextMenuPolicy( 1021 | Qt.ContextMenuPolicy.CustomContextMenu 1022 | ) 1023 | self.conversation_area.widget().customContextMenuRequested.connect( 1024 | self.show_context_menu 1025 | ) 1026 | 1027 | # Create a horizontal layout for emoji and input box 1028 | input_layout = QGraphicsLinearLayout(Qt.Orientation.Horizontal) 1029 | 1030 | # Input box 1031 | self.input_box = QGraphicsProxyWidget() 1032 | self.input_text_edit = QTextEdit() 1033 | self.input_text_edit.setPlaceholderText( 1034 | "Prompt (and press Ctrl+Enter to submit)" 1035 | ) 1036 | self.input_text_edit.setMinimumHeight(30) 1037 | self.input_text_edit.setSizePolicy( 1038 | QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum 1039 | ) 1040 | self.input_box.setWidget(self.input_text_edit) 1041 | self.input_box.setSizePolicy( 1042 | QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum 1043 | ) 1044 | 1045 | # Connect the key press event 1046 | self.input_text_edit.installEventFilter(self) 1047 | 1048 | input_layout.addItem(self.input_box) 1049 | 1050 | # Create labels 1051 | self.web_emoji_label = self.create_emoji_label( 1052 | emoji="🌐", click_handler=self.web_emoji_label_clicked 1053 | ) 1054 | self.emoji_label = self.create_emoji_label( 1055 | emoji="❓", click_handler=self.emoji_label_clicked 1056 | ) 1057 | self.emoji_container = QWidget() 1058 | emoji_container_layout = QHBoxLayout(self.emoji_container) 1059 | emoji_container_layout.setSpacing(2) 1060 | emoji_container_layout.addWidget(self.web_emoji_label) 1061 | emoji_container_layout.addWidget(self.emoji_label) 1062 | emoji_container_layout.setContentsMargins(0, 0, 0, 0) 1063 | self.emoji_proxy = QGraphicsProxyWidget() 1064 | self.emoji_proxy.setWidget(self.emoji_container) 1065 | input_layout.addItem(self.emoji_proxy) 1066 | 1067 | QTimer.singleShot(0, self.adjust_input_box_height) 1068 | 1069 | chat_layout.addItem(input_layout) 1070 | 1071 | # Add form layout to main layout 1072 | self.main_layout.addItem(chat_layout) 1073 | 1074 | # Create bottom buttons layout 1075 | self.picker = CustomFilePicker() 1076 | self.picker.setFixedSize(56, 26) 1077 | bottom_layout = add_buttons(self, self.picker) 1078 | 1079 | # Add bottom layout to main layout 1080 | self.main_layout.addItem(bottom_layout) 1081 | 1082 | # Set the layout for this widget 1083 | QTimer.singleShot(0, self.set_focus_to_input) 1084 | 1085 | self.background_item = QGraphicsRectItem(self.boundingRect(), self) 1086 | self.background_item.setBrush(QBrush(QColor(240, 240, 240))) 1087 | self.background_item.setZValue(-1) # Ensure it's behind other items 1088 | 1089 | self.highlight_color = QColor(255, 165, 0, 150) # Orange with alpha 150 1090 | self.original_color = QColor(240, 240, 240) # Light gray 1091 | 1092 | self.highlight_timer = QTimer(self) 1093 | self.highlight_timer.setSingleShot(True) 1094 | self.highlight_timer.timeout.connect(self.remove_highlight) 1095 | 1096 | self.setLayout(self.main_layout) 1097 | 1098 | self.resize_handle = ResizeHandle(self) 1099 | self.resize_handle.resize_signal.connect(self.resize_widget) 1100 | self.update_resize_handle() 1101 | 1102 | self.circle_item = HoverCircle(self) 1103 | self.circle_item.setZValue(2) 1104 | 1105 | self.animation = QPropertyAnimation(self, b"geometry") # NEW: Create animation 1106 | self.animation.setDuration(200) 1107 | self.animation.setEasingCurve(QEasingCurve.Type.OutQuad) 1108 | 1109 | def to_markdown(self): 1110 | """Convert form content to markdown format.""" 1111 | markdown = [] 1112 | 1113 | # Add prompt 1114 | prompt = self.input_box.widget().toPlainText().strip() 1115 | if prompt: 1116 | markdown.append("## ❓") 1117 | markdown.append(prompt + "\n") 1118 | 1119 | # Add response 1120 | response = self.conversation_area.widget().toPlainText().strip() 1121 | if response: 1122 | markdown.append("## 🤖") 1123 | markdown.append(response + "\n") 1124 | 1125 | # Add model info 1126 | if self.model: 1127 | markdown.append(f"*⚙️: {self.model}*\n") 1128 | 1129 | return "\n".join(markdown) 1130 | 1131 | def get_markdown_hierarchy(self, level=1): 1132 | """Get markdown content for this form and all its children.""" 1133 | markdown = [self.to_markdown()] 1134 | 1135 | # Add children content 1136 | for child in self.child_forms: 1137 | markdown.append(child.get_markdown_hierarchy(level + 1)) 1138 | 1139 | return "\n".join(markdown) 1140 | 1141 | def show_context_menu(self, position): 1142 | context_menu = QMenu() 1143 | create_new_form_action = QAction("Explain this ...", self) 1144 | create_new_form_action.triggered.connect(self.create_new_form_from_selection) 1145 | context_menu.addAction(create_new_form_action) 1146 | 1147 | # Show the context menu 1148 | context_menu.exec(self.conversation_area.widget().mapToGlobal(position)) 1149 | 1150 | def create_new_form_from_selection(self): 1151 | selected_text = self.conversation_area.widget().textCursor().selectedText() 1152 | if selected_text: 1153 | # Get the scene and create a new position for the new form 1154 | scene = self.scene() 1155 | new_pos = self.pos() + QPointF(500, 200) # Offset from current form 1156 | 1157 | # Create a new form using the existing CreateFormCommand 1158 | command = CreateFormCommand(scene, self, new_pos, self.model) 1159 | scene.command_invoker.execute(command) 1160 | new_form = command.created_form 1161 | new_form.input_box.widget().setPlainText(f"Explain {selected_text}") 1162 | 1163 | def expand_form(self): 1164 | text_edit = self.conversation_area.widget() 1165 | doc = text_edit.document() 1166 | text_height = doc.size().height() + 100 # Add some padding 1167 | 1168 | new_height = max( 1169 | text_height 1170 | + self.input_box.size().height() 1171 | + self.header.size().height() 1172 | + 50, 1173 | self.minimumHeight(), 1174 | ) 1175 | 1176 | self.animation.setStartValue(self.geometry()) 1177 | self.animation.setEndValue( 1178 | QRectF(self.pos(), QSizeF(self.size().width(), new_height)) 1179 | ) 1180 | self.animation.start() 1181 | 1182 | def create_emoji_label( 1183 | self, emoji, click_handler, font_size=14, hover_color="lightgray" 1184 | ): 1185 | emoji_label = QLabel(emoji) 1186 | emoji_label.setStyleSheet( 1187 | f""" 1188 | QLabel {{ 1189 | font-size: {font_size}px; 1190 | }} 1191 | QLabel:hover {{ 1192 | background-color: {hover_color}; 1193 | }} 1194 | """ 1195 | ) 1196 | emoji_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 1197 | emoji_label.setCursor(Qt.CursorShape.PointingHandCursor) 1198 | emoji_label.mousePressEvent = click_handler 1199 | return emoji_label 1200 | 1201 | def emoji_label_clicked(self, event): 1202 | if event.button() == Qt.MouseButton.LeftButton: 1203 | self.submit_form() 1204 | 1205 | def web_emoji_label_clicked(self, event): 1206 | if event.button() == Qt.MouseButton.LeftButton: 1207 | self.submit_search() 1208 | 1209 | def resize_widget(self, new_size: QPointF): 1210 | new_width = max(new_size.x(), self.minimumWidth()) 1211 | new_height = max(new_size.y(), self.minimumHeight()) 1212 | self.prepareGeometryChange() 1213 | self.resize(new_width, new_height) 1214 | self.update_resize_handle() 1215 | 1216 | def update_resize_handle(self): 1217 | if self.resize_handle: 1218 | self.resize_handle.setPos( 1219 | self.rect().width() - 10, self.rect().height() - 10 1220 | ) 1221 | 1222 | def eventFilter(self, obj, event): 1223 | if obj == self.input_text_edit and event.type() == event.Type.KeyPress: 1224 | if ( 1225 | event.key() == Qt.Key.Key_Return 1226 | and event.modifiers() & Qt.KeyboardModifier.ControlModifier 1227 | ): 1228 | self.submit_form() 1229 | return True 1230 | return super().eventFilter(obj, event) 1231 | 1232 | def adjust_input_box_height(self): 1233 | document = self.input_text_edit.document() 1234 | new_height = max( 1235 | document.size().height() + 10, 30 1236 | ) # Add some padding and set minimum height 1237 | if new_height != self.input_box.size().height(): 1238 | max_height = 150 1239 | new_height = min(int(new_height), max_height) 1240 | self.input_box.setMinimumHeight(new_height) 1241 | self.input_box.setMaximumHeight(new_height) 1242 | 1243 | # Adjust emoji container height 1244 | self.emoji_container.setFixedHeight(new_height) 1245 | 1246 | self.layout().invalidate() 1247 | QTimer.singleShot(0, self.updateGeometry) 1248 | 1249 | def highlight(self): 1250 | self.background_item.setBrush(QBrush(self.highlight_color)) 1251 | self.highlight_timer.start(1000) 1252 | 1253 | def remove_highlight(self): 1254 | self.background_item.setBrush(QBrush(self.original_color)) 1255 | 1256 | def highlight_hierarchy(self): 1257 | # Highlight this form 1258 | self.highlight() 1259 | 1260 | # Highlight parent form if it exists 1261 | if self.parent_form: 1262 | self.parent_form.highlight_hierarchy() 1263 | 1264 | def set_focus_to_input(self): 1265 | self.input_text_edit.setFocus() 1266 | 1267 | def moveBy(self, dx, dy): 1268 | super().moveBy(dx, dy) 1269 | self.update_link_lines() 1270 | 1271 | def resizeEvent(self, event): 1272 | super().resizeEvent(event) 1273 | self.background_item.setRect(self.boundingRect()) 1274 | self.update_resize_handle() 1275 | 1276 | def mousePressEvent(self, event): 1277 | self.setFocus() 1278 | if ( 1279 | event.button() == Qt.MouseButton.LeftButton 1280 | and self.header.boundingRect().contains(event.pos()) 1281 | and not self.circle_item.isUnderMouse() 1282 | ): 1283 | super().mousePressEvent(event) 1284 | else: 1285 | event.ignore() 1286 | 1287 | def mouseMoveEvent(self, event): 1288 | if ( 1289 | self.flags() & QGraphicsItem.GraphicsItemFlag.ItemIsMovable 1290 | and not self.circle_item.isUnderMouse() 1291 | ): 1292 | super().mouseMoveEvent(event) 1293 | self.update_link_lines() 1294 | else: 1295 | event.ignore() 1296 | 1297 | def generate_follow_up_questions(self): 1298 | # Gather the current conversation context 1299 | context_data = [] 1300 | for i, data in enumerate(self.gather_form_data()): 1301 | context = data["context"] 1302 | if context: 1303 | message = dict(role="user", content=context) 1304 | context_data.append(message) 1305 | 1306 | # Construct the prompt for generating follow-up questions 1307 | prompt = ( 1308 | "Based on the conversation above," 1309 | "please generate 3 follow-up questions." 1310 | "Keep them concise and relevant to the topic." 1311 | "Just list the 3 questions without any other text." 1312 | "Do not prefix the questions with a number." 1313 | ) 1314 | context_data.append(dict(role="user", content=prompt)) 1315 | 1316 | self.highlight_hierarchy() 1317 | self.start_processing() 1318 | 1319 | self.setup_llm_worker( 1320 | context_data, update_handler=self.handle_follow_up_questions 1321 | ) 1322 | 1323 | def handle_follow_up_questions(self, text): 1324 | try: 1325 | questions = text.split("\n") 1326 | form_width = self.boundingRect().width() 1327 | form_height = self.boundingRect().height() 1328 | x_offset = form_width + 200 1329 | for i, question in enumerate(questions): 1330 | if question.strip(): 1331 | y_offset = i * (form_height + 50) 1332 | new_pos = self.pos() + QPointF(x_offset, y_offset) 1333 | command = CreateFormCommand(self.scene(), self, new_pos, self.model) 1334 | self.scene().command_invoker.execute(command) 1335 | new_form = command.created_form 1336 | new_form.input_box.widget().setPlainText(question) 1337 | except Exception as e: 1338 | self.handle_error(f"Error parsing follow-up questions: {str(e)}") 1339 | 1340 | def clone_branch(self): 1341 | command = CloneBranchCommand(self.scene(), self) 1342 | self.scene().command_invoker.execute(command) 1343 | 1344 | def clone_form(self): 1345 | form_width = self.boundingRect().width() 1346 | min_gap = 100 1347 | 1348 | # Generate random offset for more natural spread 1349 | random_offset_x = random.randint(min_gap, min_gap * 3) 1350 | random_offset_y = random.randint(min_gap, min_gap * 3) 1351 | 1352 | # Calculate top right position instead of bottom right 1353 | clone_pos = self.pos() + QPointF(form_width + random_offset_x, -random_offset_y) 1354 | 1355 | command = CreateFormCommand(self.scene(), self, clone_pos, self.model) 1356 | self.scene().command_invoker.execute(command) 1357 | 1358 | def delete_form(self): 1359 | command = DeleteFormCommand(self) 1360 | self.scene().command_invoker.execute(command) 1361 | 1362 | def update_link_lines(self): 1363 | if self.link_line: 1364 | self.link_line.update_position() 1365 | for child in self.child_forms: 1366 | child.update_link_lines() 1367 | 1368 | def all_forms(self): 1369 | self.form_chain.appendleft(self) 1370 | current_form = self 1371 | while current_form: 1372 | current_form = current_form.parent_form 1373 | if current_form: 1374 | self.form_chain.appendleft(current_form) 1375 | 1376 | def process_next_form(self): 1377 | try: 1378 | form = self.form_chain.popleft() 1379 | print(f"❓{form.input_box.widget().toPlainText().strip()}") 1380 | form.submit_form() 1381 | form.llm_worker.signals.notify_child.connect(self.process_next_form) 1382 | except IndexError: 1383 | print("Processed all forms") 1384 | 1385 | def re_run_all(self): 1386 | self.all_forms() 1387 | self.process_next_form() 1388 | 1389 | def submit_search(self): 1390 | input_text = self.input_box.widget().toPlainText().strip() 1391 | if not input_text: 1392 | return 1393 | 1394 | self.highlight_hierarchy() 1395 | self.start_processing() 1396 | self.setup_search_worker(input_text, update_handler=self.handle_update) 1397 | 1398 | def submit_form(self): 1399 | input_text = self.input_box.widget().toPlainText().strip() 1400 | if not input_text: 1401 | return 1402 | 1403 | # Check if the input is a URL 1404 | url_pattern = re.compile( 1405 | r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+" 1406 | ) 1407 | if url_pattern.match(input_text): 1408 | self.fetch_jina_reader_content(input_text) 1409 | else: 1410 | self.process_llm_request(input_text) 1411 | 1412 | def fetch_jina_reader_content(self, url): 1413 | main_window = self.scene().views()[0].window() 1414 | jina_api_key = main_window.jina_api_key 1415 | 1416 | self.jina_worker = JinaReaderWorker(url, jina_api_key) 1417 | self.jina_worker.signals.result.connect(self.handle_jina_reader_content) 1418 | self.jina_worker.signals.error.connect(self.handle_error) 1419 | 1420 | self.highlight_hierarchy() 1421 | thread_pool.start(self.jina_worker) 1422 | self.start_processing() 1423 | 1424 | def handle_jina_reader_content(self, content): 1425 | context_with_prompt = f""" 1426 | Summarize the following content: 1427 | {content} 1428 | """ 1429 | context_data = [dict(role="user", content=context_with_prompt)] 1430 | self.setup_llm_worker(context_data, update_handler=self.handle_update) 1431 | 1432 | def process_llm_request(self, input_text): 1433 | form_data = self.gather_form_data() 1434 | context_data = [] 1435 | for data in form_data: 1436 | context = data["context"] 1437 | if context: 1438 | message = dict(role="user", content=context) 1439 | context_data.append(message) 1440 | 1441 | selected_files = self.picker.get_selected_files() 1442 | for selected_file in selected_files: 1443 | try: 1444 | file_content = Path(selected_file).read_text(encoding="utf-8") 1445 | file_message = dict( 1446 | role="user", content=linesep.join([selected_file, file_content]) 1447 | ) 1448 | context_data.append(file_message) 1449 | except OSError as e: 1450 | print(f"Unable to open file {selected_file}: {e}") 1451 | 1452 | current_message = dict(role="user", content=input_text) 1453 | context_data.append(current_message) 1454 | 1455 | self.highlight_hierarchy() 1456 | self.start_processing() 1457 | self.setup_llm_worker(context_data, update_handler=self.handle_update) 1458 | 1459 | def setup_llm_worker(self, context, update_handler): 1460 | self.llm_worker = LlmWorker(self.model, self.system_message, context) 1461 | self.llm_worker.signals.update.connect(update_handler) 1462 | self.llm_worker.signals.finished.connect(self.handle_finished) 1463 | self.llm_worker.signals.error.connect(self.handle_error) 1464 | thread_pool.start(self.llm_worker) 1465 | 1466 | def setup_search_worker(self, search_query, update_handler): 1467 | self.search_worker = SearchWorker(search_query) 1468 | self.search_worker.signals.result.connect(update_handler) 1469 | self.search_worker.signals.error.connect(self.handle_error) 1470 | thread_pool.start(self.search_worker) 1471 | 1472 | def start_processing(self): 1473 | global active_workers 1474 | active_workers += 1 1475 | self.header.start_processing() 1476 | 1477 | def stop_processing(self): 1478 | global active_workers 1479 | active_workers -= 1 1480 | self.header.stop_processing() 1481 | if self.llm_worker: 1482 | self.llm_worker.signals.notify_child.emit() 1483 | 1484 | def on_model_changed(self, new_model): 1485 | self.model = new_model 1486 | 1487 | def handle_update(self, text): 1488 | self.stop_processing() 1489 | self.update_answer(text) 1490 | 1491 | def handle_finished(self): 1492 | self.stop_processing() 1493 | 1494 | def handle_error(self, error): 1495 | self.stop_processing() 1496 | self.update_answer(f"Error occurred: {error}") 1497 | 1498 | def update_answer(self, message): 1499 | self.markdown_content = message 1500 | conversation_widget = self.conversation_area.widget() 1501 | renderer = HtmlRenderer() 1502 | conversation_widget.setDocument(renderer.render(self.markdown_content)) 1503 | 1504 | def gather_form_data(self): 1505 | data = [] 1506 | current_form = self.parent_form 1507 | while current_form: 1508 | form_data = { 1509 | "context": current_form.conversation_area.widget().toPlainText(), 1510 | } 1511 | data.append(form_data) 1512 | current_form = current_form.parent_form 1513 | return reversed(data) 1514 | 1515 | def to_dict(self): 1516 | return { 1517 | "pos_x": self.pos().x(), 1518 | "pos_y": self.pos().y(), 1519 | "width": self.size().width(), 1520 | "height": self.size().height(), 1521 | "input": self.input_box.widget().toPlainText(), 1522 | "context": self.markdown_content, 1523 | "children": [child.to_dict() for child in self.child_forms], 1524 | "model": self.model, 1525 | "selected_files": self.picker.get_selected_files(), 1526 | } 1527 | 1528 | @classmethod 1529 | def from_dict(cls, data, scene, parent=None): 1530 | form: FormWidget = cls(parent, model=data["model"]) 1531 | form.setPos(QPointF(data["pos_x"], data["pos_y"])) 1532 | 1533 | width = data.get("width", 300) 1534 | height = data.get("height", 200) 1535 | form.resize(width, height) 1536 | 1537 | form.input_box.widget().setPlainText(data["input"]) 1538 | form.markdown_content = data["context"] 1539 | form.update_answer(form.markdown_content) 1540 | if "model" in data: 1541 | form.model = data["model"] 1542 | form.header.update_model_name() 1543 | if "selected_files" in data: 1544 | form.picker.set_selected_files(data["selected_files"]) 1545 | 1546 | scene.addItem(form) 1547 | 1548 | for child_data in data["children"]: 1549 | child = cls.from_dict(child_data, scene, form) 1550 | form.child_forms.append(child) 1551 | link_line = LinkLine(form, child) 1552 | scene.addItem(link_line) 1553 | child.link_line = link_line 1554 | 1555 | return form 1556 | 1557 | def setup_conversation_widget(self, widget): 1558 | """Configure the conversation widget with link handling and interaction flags""" 1559 | widget.setOpenExternalLinks(False) 1560 | widget.anchorClicked.connect(self.handle_link_click) 1561 | widget.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) 1562 | widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) 1563 | widget.customContextMenuRequested.connect(self.show_context_menu) 1564 | 1565 | def handle_link_click(self, url): 1566 | """Handle clicks on links by opening them in the default browser""" 1567 | import webbrowser 1568 | 1569 | webbrowser.open(url.toString()) 1570 | 1571 | 1572 | class JsonCanvasExporter: 1573 | def __init__(self, scene): 1574 | self.scene = scene 1575 | 1576 | def export(self, file_name): 1577 | nodes = [] 1578 | edges = [] 1579 | form_ids = {} 1580 | 1581 | for i, item in enumerate(self.scene.items()): 1582 | if isinstance(item, FormWidget) and not item.parent_form: 1583 | form_id = str(uuid.uuid4()) 1584 | form_ids[item] = form_id 1585 | nodes.append(self.form_to_json_canvas_node(item, form_id)) 1586 | self.export_child_forms(item, form_id, nodes, edges, form_ids) 1587 | 1588 | canvas_data = {"nodes": nodes, "edges": edges} 1589 | 1590 | with open(file_name, "w") as f: 1591 | json.dump(canvas_data, f, indent=2) 1592 | 1593 | def export_child_forms(self, form, parent_id, nodes, edges, form_ids): 1594 | for child in form.child_forms: 1595 | child_id = str(uuid.uuid4()) 1596 | form_ids[child] = child_id 1597 | nodes.append(self.form_to_json_canvas_node(child, child_id)) 1598 | edges.append(self.create_edge(parent_id, child_id)) 1599 | self.export_child_forms(child, child_id, nodes, edges, form_ids) 1600 | 1601 | def form_to_json_canvas_node(self, form, form_id): 1602 | rect = form.mapToScene(form.boundingRect()).boundingRect() 1603 | x = int(rect.x()) 1604 | y = int(rect.y()) 1605 | width = int(rect.width()) 1606 | height = int(rect.height()) 1607 | 1608 | return { 1609 | "id": form_id, 1610 | "type": "text", 1611 | "x": x, 1612 | "y": y, 1613 | "width": width, 1614 | "height": height, 1615 | "text": form.input_box.widget().toPlainText() 1616 | + "\n\n" 1617 | + form.conversation_area.widget().toPlainText(), 1618 | } 1619 | 1620 | def create_edge(self, source_id, target_id): 1621 | edge_id = str(uuid.uuid4()) 1622 | return { 1623 | "id": edge_id, 1624 | "fromNode": source_id, 1625 | "toNode": target_id, 1626 | } 1627 | 1628 | 1629 | class ConfigDialog(QDialog): 1630 | def __init__(self, parent=None): 1631 | super().__init__(parent) 1632 | self.setWindowTitle("Configuration") 1633 | self.setModal(True) 1634 | 1635 | layout = QVBoxLayout(self) 1636 | 1637 | # Jina API Key input 1638 | api_key_layout = QHBoxLayout() 1639 | api_key_label = QLabel("Jina API Key:") 1640 | self.api_key_input = QLineEdit() 1641 | self.api_key_input.setEchoMode(QLineEdit.EchoMode.Password) 1642 | api_key_layout.addWidget(api_key_label) 1643 | api_key_layout.addWidget(self.api_key_input) 1644 | layout.addLayout(api_key_layout) 1645 | 1646 | # Buttons 1647 | button_layout = QHBoxLayout() 1648 | save_button = QPushButton("Save") 1649 | cancel_button = QPushButton("Cancel") 1650 | button_layout.addWidget(save_button) 1651 | button_layout.addWidget(cancel_button) 1652 | layout.addLayout(button_layout) 1653 | 1654 | # Connect buttons 1655 | save_button.clicked.connect(self.accept) 1656 | cancel_button.clicked.connect(self.reject) 1657 | 1658 | def get_jina_api_key(self): 1659 | return self.api_key_input.text() 1660 | 1661 | def set_jina_api_key(self, jina_api_key): 1662 | self.api_key_input.setText(jina_api_key) 1663 | 1664 | 1665 | class StateManager: 1666 | def __init__(self, company, application): 1667 | self.settings = QSettings(company, application) 1668 | self.keyring_service = f"{company}-{application}" 1669 | 1670 | def save_window_state(self, window): 1671 | self.settings.setValue("window_geometry", window.saveGeometry()) 1672 | self.settings.setValue("window_state", window.saveState()) 1673 | 1674 | def restore_window_state(self, window): 1675 | geometry = self.settings.value("window_geometry") 1676 | state = self.settings.value("window_state") 1677 | 1678 | if geometry and state: 1679 | window.restoreGeometry(geometry) 1680 | window.restoreState(state) 1681 | return True 1682 | return False 1683 | 1684 | def save_last_file(self, file_path): 1685 | self.settings.setValue("last_file", file_path) 1686 | 1687 | def get_last_file(self): 1688 | return self.settings.value("last_file") 1689 | 1690 | def clear_settings(self): 1691 | self.settings.clear() 1692 | 1693 | def save_jina_api_key(self, jina_api_key): 1694 | keyring.set_password(self.keyring_service, "jina_api_key", jina_api_key) 1695 | 1696 | def get_jina_api_key(self): 1697 | return keyring.get_password(self.keyring_service, "jina_api_key") or "" 1698 | 1699 | def clear_jina_api_key(self): 1700 | keyring.delete_password(self.keyring_service, "jina_api_key") 1701 | 1702 | 1703 | class MiniMap(QGraphicsView): 1704 | def __init__(self, main_view): 1705 | super().__init__() 1706 | self.main_view = main_view 1707 | self.setScene(QGraphicsScene(self)) 1708 | self.setFixedSize(200, 150) 1709 | self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 1710 | self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 1711 | self.setStyleSheet( 1712 | "background: rgba(200, 200, 200, 150); border: 1px solid gray;" 1713 | ) 1714 | self.viewport_rect = None 1715 | self.setRenderHint(QPainter.RenderHint.Antialiasing) 1716 | 1717 | def update_minimap(self): 1718 | self.scene().clear() 1719 | main_scene = self.main_view.scene() 1720 | if not main_scene: 1721 | return 1722 | 1723 | self.setSceneRect(main_scene.sceneRect()) 1724 | self.fitInView(self.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) 1725 | 1726 | for item in main_scene.items(): 1727 | if isinstance(item, QGraphicsRectItem): 1728 | mini_item = QGraphicsRectItem(item.rect()) 1729 | mini_item.setPos(item.scenePos()) 1730 | mini_item.setBrush(item.brush()) 1731 | mini_item.setPen(item.pen()) 1732 | self.scene().addItem(mini_item) 1733 | 1734 | viewport_rect = self.main_view.mapToScene( 1735 | self.main_view.viewport().rect() 1736 | ).boundingRect() 1737 | self.viewport_rect = QGraphicsRectItem(viewport_rect) 1738 | self.viewport_rect.setBrush(QBrush(QColor(0, 0, 255, 50))) 1739 | self.viewport_rect.setPen(QPen(Qt.PenStyle.NoPen)) 1740 | self.scene().addItem(self.viewport_rect) 1741 | 1742 | def mousePressEvent(self, event): 1743 | self.pan_minimap(event.pos()) 1744 | 1745 | def mouseMoveEvent(self, event): 1746 | if event.buttons() == Qt.MouseButton.LeftButton: 1747 | self.pan_minimap(event.pos()) 1748 | 1749 | def pan_minimap(self, pos): 1750 | scene_pos = self.mapToScene(pos) 1751 | self.main_view.centerOn(scene_pos) 1752 | self.update_minimap() 1753 | 1754 | 1755 | class CustomGraphicsView(QGraphicsView): 1756 | zoomChanged = pyqtSignal(float) 1757 | 1758 | def __init__(self, scene, initial_zoom=1.0): 1759 | super().__init__(scene) 1760 | self.setRenderHint(QPainter.RenderHint.Antialiasing) 1761 | self.setViewportUpdateMode(QGraphicsView.ViewportUpdateMode.FullViewportUpdate) 1762 | 1763 | # Create instruction text item 1764 | self.instruction_text = "\nCommand/Ctrl + Click to create node" 1765 | self.instruction_text += "\nDrag node by using the blue circle" 1766 | self.instruction_text += "\nHold Shift + Click and drag to select and zoom" 1767 | self.instruction_font = QFont("Arial", 16, QFont.Weight.Bold) 1768 | self.text_color = QColor(100, 100, 100, 255) 1769 | self.bg_color = QColor(0, 0, 0, 50) 1770 | self.icon_color = QColor(255, 255, 255) # White color for the icon 1771 | 1772 | # Zoom selection variables 1773 | self.rubberBand = None 1774 | self.origin = QPoint() 1775 | self.is_selecting = False 1776 | 1777 | # Create mini-map 1778 | self.minimap = MiniMap(self) 1779 | self.minimap.setParent(self.viewport()) 1780 | self.minimap.hide() # Hide initially, show after the first resizeEvent 1781 | 1782 | # Set minimum and maximum zoom levels 1783 | self.min_zoom = 0.1 1784 | self.max_zoom = 5.0 1785 | self.current_zoom = initial_zoom 1786 | 1787 | # Create zoom scroll bar 1788 | self.zoom_scrollbar = QScrollBar(Qt.Orientation.Horizontal, self) 1789 | self.zoom_scrollbar.setRange(0, 100) 1790 | initial_scrollbar_value = int( 1791 | ((self.current_zoom - self.min_zoom) / (self.max_zoom - self.min_zoom)) 1792 | * 100 1793 | ) 1794 | self.zoom_scrollbar.setValue(initial_scrollbar_value) 1795 | self.zoom_scrollbar.valueChanged.connect(self.zoom_scrollbar_changed) 1796 | 1797 | # Apply initial zoom 1798 | self.zoom_to(self.current_zoom) 1799 | 1800 | # Update mini-map periodically 1801 | self.update_timer = QTimer(self) 1802 | self.update_timer.timeout.connect(self.update_minimap) 1803 | self.update_timer.start(100) # Update every 100 ms 1804 | 1805 | # Animation variables 1806 | self._instruction_rect = QRectF() 1807 | self.animation = QPropertyAnimation(self, b"instruction_rect") 1808 | self.animation.setEasingCurve(QEasingCurve.Type.InOutQuad) 1809 | self.animation.setDuration(1000) # 1 second duration 1810 | self.animation.finished.connect(self.on_animation_finished) 1811 | 1812 | self.is_expanded = True 1813 | self.hover_timer = QTimer(self) 1814 | self.hover_timer.setSingleShot(True) 1815 | self.hover_timer.timeout.connect(self.expand_instruction_rect) 1816 | 1817 | # Start the animation after a delay 1818 | QTimer.singleShot(3000, self.start_animation) # 3 seconds delay 1819 | 1820 | def paintEvent(self, event): 1821 | super().paintEvent(event) 1822 | painter = QPainter(self.viewport()) 1823 | self.drawForeground(painter, QRectF(self.viewport().rect())) 1824 | painter.end() 1825 | 1826 | def drawForeground(self, painter, rect): 1827 | super().drawForeground(painter, rect) 1828 | 1829 | if rect.topLeft() != QPointF(0, 0): 1830 | return 1831 | 1832 | # Draw instruction label 1833 | painter.setFont(self.instruction_font) 1834 | fm = QFontMetrics(self.instruction_font) 1835 | 1836 | # Draw background 1837 | painter.setBrush(self.bg_color) 1838 | painter.setPen(Qt.PenStyle.NoPen) 1839 | painter.drawRoundedRect(self._instruction_rect, 5, 5) 1840 | 1841 | # Calculate available space 1842 | available_width = ( 1843 | self._instruction_rect.width() - 20 1844 | ) # 10px padding on each side 1845 | available_height = ( 1846 | self._instruction_rect.height() - 20 1847 | ) # 10px padding on top and bottom 1848 | 1849 | # Draw text only if there's enough space 1850 | if available_width > 100 and available_height > fm.height() * 3: 1851 | painter.setPen(self.text_color) 1852 | y = int(self._instruction_rect.top() + 10) 1853 | for line in self.instruction_text.split("\n"): 1854 | painter.drawText(int(self._instruction_rect.left() + 10), y, line) 1855 | y += fm.height() 1856 | else: 1857 | # Draw an icon or symbol when the rect is small 1858 | painter.setPen(self.icon_color) 1859 | painter.drawText(self._instruction_rect, Qt.AlignmentFlag.AlignCenter, "?") 1860 | 1861 | def resizeEvent(self, event): 1862 | super().resizeEvent(event) 1863 | self.update_minimap_and_scrollbar() 1864 | self.viewport().update() 1865 | 1866 | # Update the instruction rectangle size 1867 | self.update_instruction_rect() 1868 | 1869 | def update_minimap_and_scrollbar(self): 1870 | minimap_width = 200 1871 | minimap_height = 150 1872 | scrollbar_height = 15 1873 | margin = 10 1874 | 1875 | # Position zoom scrollbar 1876 | self.zoom_scrollbar.setGeometry( 1877 | margin, 1878 | self.height() - minimap_height - scrollbar_height - margin, 1879 | minimap_width, 1880 | scrollbar_height, 1881 | ) 1882 | 1883 | # Position minimap 1884 | self.minimap.setGeometry( 1885 | margin, 1886 | self.height() - minimap_height - margin, 1887 | minimap_width, 1888 | minimap_height, 1889 | ) 1890 | self.minimap.show() 1891 | self.zoom_scrollbar.show() 1892 | 1893 | def set_instruction_rect(self, rect): 1894 | if self._instruction_rect != rect: 1895 | self._instruction_rect = rect 1896 | self.viewport().update() 1897 | 1898 | def get_instruction_rect(self): 1899 | return self._instruction_rect 1900 | 1901 | instruction_rect = pyqtProperty(QRectF, get_instruction_rect, set_instruction_rect) 1902 | 1903 | def update_instruction_rect(self): 1904 | fm = QFontMetrics(self.instruction_font) 1905 | text_width = max( 1906 | fm.horizontalAdvance(line) for line in self.instruction_text.split("\n") 1907 | ) 1908 | text_height = fm.height() * len(self.instruction_text.split("\n")) 1909 | padding = 10 1910 | self.full_width = text_width + 2 * padding 1911 | self.full_height = text_height + 2 * padding 1912 | self.small_width = 30 1913 | self.small_height = 30 1914 | 1915 | if self.is_expanded: 1916 | self._instruction_rect = QRectF( 1917 | padding, padding, self.full_width, self.full_height 1918 | ) 1919 | else: 1920 | self._instruction_rect = QRectF( 1921 | padding, padding, self.small_width, self.small_height 1922 | ) 1923 | 1924 | def start_animation(self): 1925 | if self.is_expanded: 1926 | self.is_expanded = False 1927 | self.update_instruction_rect() 1928 | self.animation.setStartValue( 1929 | QRectF(10, 10, self.full_width, self.full_height) 1930 | ) 1931 | self.animation.setEndValue( 1932 | QRectF(10, 10, self.small_width, self.small_height) 1933 | ) 1934 | self.animation.start() 1935 | 1936 | def expand_instruction_rect(self): 1937 | self.is_expanded = True 1938 | self.animation.setStartValue(self._instruction_rect) 1939 | self.animation.setEndValue(QRectF(10, 10, self.full_width, self.full_height)) 1940 | self.animation.start() 1941 | 1942 | def shrink_instruction_rect(self): 1943 | self.is_expanded = False 1944 | self.animation.setStartValue(self._instruction_rect) 1945 | self.animation.setEndValue(QRectF(10, 10, self.small_width, self.small_height)) 1946 | self.animation.start() 1947 | 1948 | def on_animation_finished(self): 1949 | self.viewport().update() 1950 | 1951 | def mousePressEvent(self, event): 1952 | if ( 1953 | event.button() == Qt.MouseButton.LeftButton 1954 | and event.modifiers() & Qt.KeyboardModifier.ShiftModifier 1955 | ) or (event.button() == Qt.MouseButton.MiddleButton): 1956 | self.is_selecting = True 1957 | self.origin = event.pos() 1958 | if not self.rubberBand: 1959 | self.rubberBand = QRubberBand(QRubberBand.Shape.Rectangle, self) 1960 | self.rubberBand.setGeometry(QRect(self.origin, QSize())) 1961 | self.rubberBand.show() 1962 | else: 1963 | super().mousePressEvent(event) 1964 | 1965 | def mouseMoveEvent(self, event): 1966 | if self.is_selecting: 1967 | self.rubberBand.setGeometry(QRect(self.origin, event.pos()).normalized()) 1968 | else: 1969 | super().mouseMoveEvent(event) 1970 | self.minimap.update_minimap() 1971 | 1972 | # Convert QPoint to QPointF 1973 | pos = QPointF(event.pos()) 1974 | 1975 | if self._instruction_rect.contains(pos): 1976 | if not self.is_expanded: 1977 | self.hover_timer.start(300) # Start expand after 300ms hover 1978 | self.setCursor(Qt.CursorShape.PointingHandCursor) 1979 | else: 1980 | self.hover_timer.stop() 1981 | if self.is_expanded: 1982 | self.shrink_instruction_rect() 1983 | self.setCursor(Qt.CursorShape.ArrowCursor) 1984 | 1985 | def mouseReleaseEvent(self, event): 1986 | if self.is_selecting and ( 1987 | ( 1988 | event.button() == Qt.MouseButton.LeftButton 1989 | and event.modifiers() & Qt.KeyboardModifier.ShiftModifier 1990 | ) 1991 | or (event.button() == Qt.MouseButton.MiddleButton) 1992 | ): 1993 | self.is_selecting = False 1994 | if self.rubberBand: 1995 | self.rubberBand.hide() 1996 | selection_rect = self.mapToScene( 1997 | self.rubberBand.geometry() 1998 | ).boundingRect() 1999 | self.zoom_to_rect(selection_rect) 2000 | else: 2001 | super().mouseReleaseEvent(event) 2002 | self.update_minimap() 2003 | 2004 | def leaveEvent(self, event): 2005 | super().leaveEvent(event) 2006 | self.hover_timer.stop() 2007 | if self.is_expanded: 2008 | self.shrink_instruction_rect() 2009 | 2010 | def zoom_to_rect(self, rect): 2011 | if not rect.isEmpty(): 2012 | self.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio) 2013 | self.updateSceneRect(self.sceneRect().united(rect)) 2014 | self.update_zoom_factor() 2015 | self.update_minimap() 2016 | 2017 | def update_minimap(self): 2018 | if self.minimap.isVisible(): 2019 | self.minimap.update_minimap() 2020 | 2021 | def update_zoom_factor(self): 2022 | current_transform = self.transform() 2023 | current_scale = current_transform.m11() # Horizontal scale factor 2024 | self.current_zoom = current_scale 2025 | self.zoomChanged.emit(current_scale) 2026 | 2027 | # Update scrollbar value 2028 | scrollbar_value = int( 2029 | ((self.current_zoom - self.min_zoom) / (self.max_zoom - self.min_zoom)) 2030 | * 100 2031 | ) 2032 | self.zoom_scrollbar.setValue(scrollbar_value) 2033 | 2034 | def wheelEvent(self, event): 2035 | if event.modifiers() & Qt.KeyboardModifier.ControlModifier: 2036 | zoom_in_factor = 1.2 2037 | zoom_out_factor = 1 / zoom_in_factor 2038 | 2039 | if event.angleDelta().y() > 0: 2040 | zoom_factor = zoom_in_factor 2041 | else: 2042 | zoom_factor = zoom_out_factor 2043 | 2044 | resulting_zoom = self.current_zoom * zoom_factor 2045 | if self.min_zoom <= resulting_zoom <= self.max_zoom: 2046 | self.current_zoom = resulting_zoom 2047 | self.zoom_to(self.current_zoom) 2048 | 2049 | # Update scrollbar value 2050 | scrollbar_value = int( 2051 | ( 2052 | (self.current_zoom - self.min_zoom) 2053 | / (self.max_zoom - self.min_zoom) 2054 | ) 2055 | * 100 2056 | ) 2057 | self.zoom_scrollbar.setValue(scrollbar_value) 2058 | else: 2059 | super().wheelEvent(event) 2060 | 2061 | def zoom_scrollbar_changed(self, value): 2062 | zoom_factor = self.min_zoom + (value / 100) * (self.max_zoom - self.min_zoom) 2063 | self.zoom_to(zoom_factor) 2064 | 2065 | def zoom_to(self, factor): 2066 | self.current_zoom = factor 2067 | self.setTransform(QTransform().scale(factor, factor)) 2068 | self.zoomChanged.emit(factor) 2069 | self.update_minimap() 2070 | 2071 | 2072 | class GraphicsScene(QGraphicsScene): 2073 | itemAdded = pyqtSignal() 2074 | itemMoved = pyqtSignal() 2075 | 2076 | def __init__(self, parent=None): 2077 | super().__init__(parent) 2078 | self.command_invoker = CommandInvoker() 2079 | self.update_timer = QTimer() 2080 | self.update_timer.setSingleShot(True) 2081 | self.update_timer.timeout.connect(self.itemMoved.emit) 2082 | 2083 | def addItem(self, item): 2084 | super().addItem(item) 2085 | self.itemAdded.emit() 2086 | 2087 | def mouseMoveEvent(self, event): 2088 | super().mouseMoveEvent(event) 2089 | self.update_timer.start(100) 2090 | 2091 | def mousePressEvent(self, event): 2092 | if ( 2093 | event.button() == Qt.MouseButton.LeftButton 2094 | and event.modifiers() & Qt.KeyboardModifier.ControlModifier 2095 | ): 2096 | self.create_new_form(event.scenePos()) 2097 | else: 2098 | super().mousePressEvent(event) 2099 | 2100 | def keyPressEvent(self, event: QKeyEvent): 2101 | if ( 2102 | event.key() == Qt.Key.Key_I 2103 | and event.modifiers() & Qt.KeyboardModifier.ControlModifier 2104 | ): 2105 | view = self.views()[0] 2106 | center = view.mapToScene(view.viewport().rect().center()) 2107 | self.create_new_form(center) 2108 | else: 2109 | super().keyPressEvent(event) 2110 | 2111 | def apply_expansion_recursively(self, expand=True): 2112 | for item in self.items(): 2113 | if isinstance(item, FormWidget): 2114 | self._apply_expansion_to_form(item, expand) 2115 | 2116 | def _apply_expansion_to_form(self, form, expand): 2117 | form.expand_form() 2118 | 2119 | def create_new_form(self, position): 2120 | command = CreateFormCommand(self) 2121 | self.command_invoker.execute(command) 2122 | new_form = command.created_form 2123 | new_form.setPos(position) 2124 | 2125 | 2126 | class MainWindow(QMainWindow): 2127 | def __init__(self, auto_load_state=True): 2128 | super().__init__() 2129 | self.state_manager = StateManager("deskriders", "chatcircuit") 2130 | self.setWindowTitle(APPLICATION_TITLE) 2131 | 2132 | self.scene = GraphicsScene() 2133 | self.view = CustomGraphicsView(self.scene, initial_zoom=1.0) 2134 | self.view.zoomChanged.connect(self.on_zoom_changed) 2135 | self.setCentralWidget(self.view) 2136 | 2137 | self.zoom_factor = 1.0 2138 | self.create_menu() 2139 | 2140 | self.is_updating_scene_rect = False 2141 | 2142 | self.jina_api_key = self.state_manager.get_jina_api_key() 2143 | 2144 | if auto_load_state: 2145 | self.restore_application_state() 2146 | 2147 | def export_to_markdown(self): 2148 | """Export all chat content to a markdown file.""" 2149 | file_name, _ = QFileDialog.getSaveFileName( 2150 | self, "Export to Markdown", "", "Markdown Files (*.md)" 2151 | ) 2152 | 2153 | if not file_name: 2154 | return 2155 | 2156 | markdown_content = [] 2157 | 2158 | # Get content from all root forms (forms without parents) 2159 | for item in self.scene.items(): 2160 | if isinstance(item, FormWidget) and not item.parent_form: 2161 | markdown_content.append(item.get_markdown_hierarchy()) 2162 | 2163 | # Write to file 2164 | try: 2165 | with open(file_name, "w", encoding="utf-8") as f: 2166 | f.write("\n\n".join(markdown_content)) 2167 | 2168 | QMessageBox.information( 2169 | self, 2170 | "Export Successful", 2171 | f"Chat content has been exported to {file_name}", 2172 | ) 2173 | except Exception as e: 2174 | QMessageBox.critical( 2175 | self, "Export Failed", f"Failed to export chat content: {str(e)}" 2176 | ) 2177 | 2178 | def update_scene_rect(self): 2179 | self.scene.apply_expansion_recursively(True) 2180 | 2181 | if self.is_updating_scene_rect: 2182 | return 2183 | self.is_updating_scene_rect = True 2184 | 2185 | # Calculate the bounding rect of all items 2186 | items_rect = QRectF() 2187 | for item in self.scene.items(): 2188 | items_rect = items_rect.united(item.sceneBoundingRect()) 2189 | 2190 | # Add some margin 2191 | margin = 1000 2192 | new_rect = items_rect.adjusted(-margin, -margin, margin, margin) 2193 | 2194 | # Update the scene rect 2195 | self.scene.setSceneRect(new_rect) 2196 | self.view.updateSceneRect(new_rect) 2197 | 2198 | self.is_updating_scene_rect = False 2199 | 2200 | def on_zoom_changed(self, zoom_factor): 2201 | self.zoom_factor = zoom_factor 2202 | self.update_zoom() 2203 | 2204 | def create_menu(self): 2205 | # File menu 2206 | file_menu = self.menuBar().addMenu("File") 2207 | 2208 | new_action = QAction("New", self) 2209 | new_action.setShortcut(QKeySequence.StandardKey.New) 2210 | new_action.triggered.connect(self.new_document) 2211 | file_menu.addAction(new_action) 2212 | 2213 | save_action = QAction("Save", self) 2214 | save_action.setShortcut(QKeySequence.StandardKey.Save) 2215 | save_action.triggered.connect(self.save_state) 2216 | file_menu.addAction(save_action) 2217 | 2218 | load_action = QAction("Load", self) 2219 | load_action.setShortcut(QKeySequence.StandardKey.Open) 2220 | load_action.triggered.connect(self.load_state) 2221 | file_menu.addAction(load_action) 2222 | 2223 | file_menu.addSeparator() 2224 | 2225 | export_markdown_action = QAction("Export to Markdown", self) 2226 | export_markdown_action.setShortcut(QKeySequence("Ctrl+Shift+M")) 2227 | export_markdown_action.triggered.connect(self.export_to_markdown) 2228 | file_menu.addAction(export_markdown_action) 2229 | 2230 | export_action = QAction("Export to JSON Canvas", self) 2231 | export_action.triggered.connect(self.export_to_json_canvas) 2232 | file_menu.addAction(export_action) 2233 | 2234 | # New PNG export action 2235 | export_png_action = QAction("Export to PNG", self) 2236 | export_png_action.setShortcut(QKeySequence("Ctrl+Shift+P")) 2237 | export_png_action.triggered.connect(self.export_to_png) 2238 | file_menu.addAction(export_png_action) 2239 | 2240 | # Edit menu 2241 | edit_menu = self.menuBar().addMenu("Edit") 2242 | 2243 | undo_action = QAction("Undo", self) 2244 | undo_action.setShortcut(QKeySequence.StandardKey.Undo) 2245 | undo_action.triggered.connect(self.undo) 2246 | edit_menu.addAction(undo_action) 2247 | 2248 | redo_action = QAction("Redo", self) 2249 | redo_action.setShortcut(QKeySequence.StandardKey.Redo) 2250 | redo_action.triggered.connect(self.redo) 2251 | edit_menu.addAction(redo_action) 2252 | 2253 | # View menu 2254 | view_menu = self.menuBar().addMenu("View") 2255 | 2256 | zoom_in_action = QAction("Zoom In", self) 2257 | zoom_in_action.setShortcut(QKeySequence.StandardKey.ZoomIn) 2258 | zoom_in_action.triggered.connect(self.zoom_in) 2259 | view_menu.addAction(zoom_in_action) 2260 | 2261 | zoom_out_action = QAction("Zoom Out", self) 2262 | zoom_out_action.setShortcut(QKeySequence.StandardKey.ZoomOut) 2263 | zoom_out_action.triggered.connect(self.zoom_out) 2264 | view_menu.addAction(zoom_out_action) 2265 | 2266 | reset_zoom_action = QAction("Reset Zoom", self) 2267 | reset_zoom_action.setShortcut(QKeySequence("Ctrl+0")) 2268 | reset_zoom_action.triggered.connect(self.reset_zoom) 2269 | view_menu.addAction(reset_zoom_action) 2270 | 2271 | view_menu.addSeparator() 2272 | 2273 | fit_scene_action = QAction("Fit Scene", self) 2274 | fit_scene_action.setShortcut(QKeySequence("Ctrl+R")) 2275 | fit_scene_action.triggered.connect(self.update_scene_rect) 2276 | view_menu.addAction(fit_scene_action) 2277 | 2278 | config_menu = self.menuBar().addMenu("Configuration") 2279 | 2280 | api_config_action = QAction("API Configuration", self) 2281 | api_config_action.triggered.connect(self.show_config_dialog) 2282 | config_menu.addAction(api_config_action) 2283 | 2284 | def show_config_dialog(self): 2285 | dialog = ConfigDialog(self) 2286 | dialog.set_jina_api_key(self.jina_api_key) 2287 | if dialog.exec() == QDialog.DialogCode.Accepted: 2288 | self.jina_api_key = dialog.get_jina_api_key() 2289 | self.state_manager.save_jina_api_key(self.jina_api_key) 2290 | QMessageBox.information( 2291 | self, "Configuration", "Jina API Key saved successfully!" 2292 | ) 2293 | 2294 | def export_to_png(self): 2295 | """Export the entire canvas as a high-quality PNG image.""" 2296 | file_name, _ = QFileDialog.getSaveFileName( 2297 | self, "Export to PNG", "", "PNG Files (*.png)" 2298 | ) 2299 | 2300 | if not file_name: 2301 | return 2302 | 2303 | # Calculate the scene bounding rect 2304 | scene_rect = self.scene.itemsBoundingRect() 2305 | scene_rect = scene_rect.adjusted(-100, -100, 100, 100) # Add some padding 2306 | 2307 | # Render the scene to a QImage 2308 | image = QImage(scene_rect.size().toSize(), QImage.Format.Format_ARGB32) 2309 | image.fill(Qt.GlobalColor.white) # Fill with white background 2310 | painter = QPainter(image) 2311 | self.scene.render(painter, QRectF(image.rect()), scene_rect) 2312 | painter.end() 2313 | 2314 | # Save the image 2315 | if image.save(file_name, "PNG", 100): 2316 | QMessageBox.information( 2317 | self, "Export Successful", f"Canvas has been exported to {file_name}" 2318 | ) 2319 | else: 2320 | QMessageBox.critical( 2321 | self, "Export Failed", "Failed to export canvas to PNG." 2322 | ) 2323 | 2324 | def export_to_json_canvas(self): 2325 | file_name, _ = QFileDialog.getSaveFileName( 2326 | self, "Export to JSON Canvas", "", "Canvas Files (*.canvas)" 2327 | ) 2328 | if not file_name: 2329 | return 2330 | 2331 | exporter = JsonCanvasExporter(self.scene) 2332 | exporter.export(file_name) 2333 | 2334 | def new_document(self): 2335 | self.state_manager.save_last_file("") 2336 | self.scene.clear() 2337 | self.save_state() 2338 | 2339 | def save_state(self): 2340 | file_name = self.state_manager.get_last_file() 2341 | if not file_name: 2342 | file_name, _ = QFileDialog.getSaveFileName( 2343 | self, "Save File", "", "JSON Files (*.json)" 2344 | ) 2345 | 2346 | if not file_name: 2347 | return 2348 | 2349 | states = [] 2350 | for item in self.scene.items(): 2351 | if isinstance(item, FormWidget) and not item.parent_form: 2352 | states.append(item.to_dict()) 2353 | 2354 | document_data = dict(zoom_factor=self.zoom_factor, canvas_state=states) 2355 | with open(file_name, "w") as f: 2356 | json.dump(document_data, f, indent=2) 2357 | 2358 | self.setWindowTitle(f"{APPLICATION_TITLE} - {file_name}") 2359 | self.state_manager.save_last_file(file_name) 2360 | 2361 | def load_state(self): 2362 | file_name, _ = QFileDialog.getOpenFileName( 2363 | self, "Open File", "", "JSON Files (*.json)" 2364 | ) 2365 | if os.path.exists(file_name): 2366 | self.state_manager.save_last_file(file_name) 2367 | self.load_from_file(file_name) 2368 | else: 2369 | print(f"File {file_name} not found.") 2370 | 2371 | def load_from_file(self, file_name): 2372 | if os.path.exists(file_name): 2373 | with open(file_name) as f: 2374 | document_data = json.load(f) 2375 | else: 2376 | raise LookupError(f"Unable to find file {file_name}") 2377 | 2378 | self.zoom_factor = document_data.get("zoom_factor", self.zoom_factor) 2379 | self.view.zoom_to(self.zoom_factor) 2380 | 2381 | self.scene.clear() 2382 | for form_data in document_data.get("canvas_state", []): 2383 | FormWidget.from_dict(form_data, self.scene) 2384 | self.setWindowTitle(f"{APPLICATION_TITLE} - {file_name}") 2385 | 2386 | def undo(self): 2387 | self.scene.command_invoker.undo() 2388 | 2389 | def redo(self): 2390 | self.scene.command_invoker.redo() 2391 | 2392 | def zoom_in(self): 2393 | self.zoom_factor *= 1.2 2394 | self.update_zoom() 2395 | 2396 | def zoom_out(self): 2397 | self.zoom_factor /= 1.2 2398 | self.update_zoom() 2399 | 2400 | def reset_zoom(self): 2401 | self.zoom_factor = 1.0 2402 | self.update_zoom() 2403 | 2404 | def update_zoom(self): 2405 | transform = QTransform() 2406 | transform.scale(self.zoom_factor, self.zoom_factor) 2407 | self.view.setTransform(transform) 2408 | 2409 | def wheelEvent(self, event): 2410 | if event.modifiers() & Qt.KeyboardModifier.ControlModifier: 2411 | if event.angleDelta().y() > 0: 2412 | self.zoom_in() 2413 | else: 2414 | self.zoom_out() 2415 | else: 2416 | super().wheelEvent(event) 2417 | 2418 | def restore_application_state(self): 2419 | if not self.state_manager.restore_window_state(self): 2420 | self.showMaximized() 2421 | 2422 | file_name = self.state_manager.get_last_file() 2423 | if file_name and isinstance(file_name, str) and os.path.exists(file_name): 2424 | self.load_from_file(file_name) 2425 | else: 2426 | QMessageBox.warning( 2427 | self, "Error", f"Failed to load the last file: {file_name}" 2428 | ) 2429 | 2430 | def closeEvent(self, event): 2431 | self.state_manager.save_window_state(self) 2432 | self.save_state() 2433 | super().closeEvent(event) 2434 | 2435 | 2436 | if __name__ == "__main__": 2437 | app = QApplication(sys.argv) 2438 | app.setWindowIcon(QIcon(resource_path("resources/icon.png"))) 2439 | window = MainWindow() 2440 | window.show() 2441 | sys.exit(app.exec()) 2442 | -------------------------------------------------------------------------------- /app.spec: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from PyInstaller.utils.hooks import collect_data_files 4 | import site 5 | 6 | block_cipher = None 7 | 8 | # Find the site-packages directory 9 | site_packages = site.getsitepackages()[0] 10 | 11 | # Construct the path to the tokenizer file 12 | tokenizer_path = os.path.join(site_packages, 'litellm', 'llms', 'tokenizers', 'anthropic_tokenizer.json') 13 | 14 | a = Analysis(['app.py'], 15 | pathex=['.'], 16 | binaries=None, 17 | datas=[ 18 | ('resources', 'resources'), 19 | (tokenizer_path, 'litellm/llms/tokenizers'), 20 | ('models.conf', '.') 21 | ], 22 | hiddenimports=['tiktoken_ext.openai_public', 'tiktoken_ext'], 23 | hookspath=None, 24 | runtime_hooks=None, 25 | excludes=None, 26 | cipher=block_cipher) 27 | 28 | pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) 29 | 30 | exe = EXE(pyz, 31 | a.scripts, 32 | exclude_binaries=True, 33 | name='app', 34 | debug=False, 35 | strip=False, 36 | upx=True, 37 | console=False, 38 | icon='resources\\icon.ico') 39 | 40 | coll = COLLECT(exe, 41 | a.binaries, 42 | a.zipfiles, 43 | a.datas, 44 | strip=False, 45 | upx=True, 46 | name='ChatCircuit') 47 | 48 | app = BUNDLE(coll, 49 | name='ChatCircuit.app', 50 | icon='resources/icon.icns', 51 | bundle_identifier='com.github.namuan.chatcircuit', 52 | info_plist={ 53 | 'CFBundleName': 'ChatCircuit', 54 | 'CFBundleVersion': '1.0.0', 55 | 'CFBundleShortVersionString': '1.0.0', 56 | 'NSPrincipalClass': 'NSApplication', 57 | 'NSHighResolutionCapable': 'True' 58 | } 59 | ) 60 | -------------------------------------------------------------------------------- /docs/img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/namuan/chat-circuit/21ace75bc370ad9e556cc4eb1469828a18ee683c/docs/img.png -------------------------------------------------------------------------------- /docs/re-run-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/namuan/chat-circuit/21ace75bc370ad9e556cc4eb1469828a18ee683c/docs/re-run-button.png -------------------------------------------------------------------------------- /docs/view-options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/namuan/chat-circuit/21ace75bc370ad9e556cc4eb1469828a18ee683c/docs/view-options.png -------------------------------------------------------------------------------- /models.conf.example: -------------------------------------------------------------------------------- 1 | llama3:latest 2 | gemma2:27b 3 | mistral:latest 4 | tinyllama:latest 5 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pre-commit 3 | autoflake 4 | pyupgrade 5 | pyinstaller 6 | # Testing 7 | pytest 8 | pytest-qt 9 | pytest-mock 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyQt6==6.7.0 2 | mistune 3 | litellm 4 | keyring 5 | requests 6 | duckduckgo_search 7 | -------------------------------------------------------------------------------- /resources/bulb.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/clone.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/delete.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/fork.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/namuan/chat-circuit/21ace75bc370ad9e556cc4eb1469828a18ee683c/resources/icon.icns -------------------------------------------------------------------------------- /resources/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/namuan/chat-circuit/21ace75bc370ad9e556cc4eb1469828a18ee683c/resources/icon.ico -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/namuan/chat-circuit/21ace75bc370ad9e556cc4eb1469828a18ee683c/resources/icon.png -------------------------------------------------------------------------------- /resources/ripple.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /scripts/install-macosx.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | APP_NAME=$1 3 | test -f $HOME/Applications/${APP_NAME} || rm -rf $HOME/Applications/${APP_NAME} 4 | mv ./dist/${APP_NAME} $HOME/Applications 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/namuan/chat-circuit/21ace75bc370ad9e556cc4eb1469828a18ee683c/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from PyQt6.QtWidgets import QApplication 5 | 6 | from app import MainWindow 7 | 8 | 9 | @pytest.fixture(scope="session") 10 | def app(): 11 | """Creates a QApplication instance for all tests.""" 12 | app = QApplication.instance() 13 | if not app: 14 | app = QApplication([]) 15 | yield app 16 | # No need to quit the QApplication after tests 17 | 18 | 19 | @pytest.fixture 20 | def main_window(qtbot): 21 | """Creates an instance of MainWindow and adds it to qtbot.""" 22 | window = MainWindow(auto_load_state=False) 23 | window.load_from_file(Path.cwd() / "tests" / "test_example.json") 24 | window.showMaximized() 25 | window.show() 26 | qtbot.addWidget(window) 27 | return window 28 | -------------------------------------------------------------------------------- /tests/test_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "zoom_factor": 1.0, 3 | "canvas_state": [] 4 | } 5 | -------------------------------------------------------------------------------- /tests/test_main_window.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from PyQt6.QtCore import QEvent 5 | from PyQt6.QtCore import Qt 6 | from PyQt6.QtGui import QKeyEvent 7 | 8 | from app import CreateFormCommand 9 | from app import FormWidget 10 | from app import JsonCanvasExporter 11 | 12 | 13 | def interact(qtbot): 14 | qtbot.stopForInteraction() 15 | 16 | 17 | def test_main_window_opens(main_window, qtbot): 18 | assert main_window.windowTitle().startswith("Chat Circuit") is True 19 | assert main_window.isVisible() 20 | 21 | 22 | def test_create_new_form_via_shortcut(main_window, qtbot): 23 | """Checks if a new form is created when Ctrl+N is pressed.""" 24 | initial_form_count = len( 25 | [item for item in main_window.scene.items() if isinstance(item, FormWidget)] 26 | ) 27 | 28 | key_event = QKeyEvent( 29 | QEvent.Type.KeyPress, Qt.Key.Key_I, Qt.KeyboardModifier.ControlModifier, "I" 30 | ) 31 | main_window.scene.keyPressEvent(key_event) 32 | 33 | # Allow time for event processing 34 | qtbot.wait(200) 35 | 36 | new_form_count = len( 37 | [item for item in main_window.scene.items() if isinstance(item, FormWidget)] 38 | ) 39 | assert new_form_count == initial_form_count + 1 40 | 41 | 42 | def test_undo_redo_create_form(main_window, qtbot): 43 | """Tests Undo and Redo functionality for creating a form.""" 44 | initial_form_count = len( 45 | [item for item in main_window.scene.items() if isinstance(item, FormWidget)] 46 | ) 47 | 48 | # Create a new form 49 | command = CreateFormCommand(main_window.scene) 50 | main_window.scene.command_invoker.execute(command) 51 | qtbot.wait(100) 52 | assert ( 53 | len( 54 | [item for item in main_window.scene.items() if isinstance(item, FormWidget)] 55 | ) 56 | == initial_form_count + 1 57 | ) 58 | 59 | # Undo creation 60 | main_window.undo() 61 | qtbot.wait(100) 62 | assert ( 63 | len( 64 | [item for item in main_window.scene.items() if isinstance(item, FormWidget)] 65 | ) 66 | == initial_form_count 67 | ) 68 | 69 | # Redo creation 70 | main_window.redo() 71 | qtbot.wait(100) 72 | assert ( 73 | len( 74 | [item for item in main_window.scene.items() if isinstance(item, FormWidget)] 75 | ) 76 | == initial_form_count + 1 77 | ) 78 | 79 | 80 | def test_save_and_load_state(tmp_path, main_window, qtbot): 81 | """Checks if the application state is saved and loaded correctly.""" 82 | # Create multiple forms 83 | for _ in range(3): 84 | command = CreateFormCommand(main_window.scene) 85 | main_window.scene.command_invoker.execute(command) 86 | qtbot.wait(100) 87 | 88 | form_count_before = len( 89 | [item for item in main_window.scene.items() if isinstance(item, FormWidget)] 90 | ) 91 | assert form_count_before == 3 92 | 93 | # Save state to a temporary file 94 | save_file = tmp_path / "test_save.json" 95 | main_window.state_manager.save_last_file(str(save_file)) 96 | main_window.save_state() 97 | 98 | # Clear the scene 99 | main_window.scene.clear() 100 | qtbot.wait(100) 101 | form_count_cleared = len( 102 | [item for item in main_window.scene.items() if isinstance(item, FormWidget)] 103 | ) 104 | assert form_count_cleared == 0 105 | 106 | # Load state from the file 107 | main_window.load_from_file(str(save_file)) 108 | qtbot.wait(100) 109 | form_count_loaded = len( 110 | [item for item in main_window.scene.items() if isinstance(item, FormWidget)] 111 | ) 112 | assert form_count_loaded == 3 113 | 114 | 115 | def test_export_to_json_canvas(tmp_path, main_window, qtbot): 116 | """Ensures that exporting the canvas to a JSON file works correctly.""" 117 | # Create multiple forms 118 | for _ in range(2): 119 | command = CreateFormCommand(main_window.scene) 120 | main_window.scene.command_invoker.execute(command) 121 | qtbot.wait(100) 122 | 123 | # Export the canvas 124 | export_file = tmp_path / "export.canvas" 125 | exporter = JsonCanvasExporter(main_window.scene) 126 | exporter.export(str(export_file)) 127 | 128 | # Check if the file was created 129 | assert os.path.exists(export_file) 130 | 131 | # Verify the contents of the file 132 | with open(export_file) as f: 133 | data = json.load(f) 134 | 135 | assert "nodes" in data 136 | assert "edges" in data 137 | assert len(data["nodes"]) == 2 138 | 139 | 140 | # tests/test_llm_worker.py 141 | 142 | 143 | def test_llm_worker_handle_update(main_window, qtbot, mocker): 144 | """Ensures that LLM responses are handled and displayed correctly.""" 145 | # Mock the 'completion' function from litellm 146 | mock_completion = mocker.patch("app.completion") 147 | mock_response = mocker.Mock() 148 | mock_response.choices = [ 149 | mocker.Mock(message=mocker.Mock(content="Test LLM response")) 150 | ] 151 | mock_completion.return_value = mock_response 152 | 153 | # Create a new form 154 | command = CreateFormCommand(main_window.scene) 155 | main_window.scene.command_invoker.execute(command) 156 | qtbot.wait(100) 157 | new_form = [ 158 | item for item in main_window.scene.items() if isinstance(item, FormWidget) 159 | ][-1] 160 | 161 | # Enter text and submit the form 162 | qtbot.keyClicks(new_form.input_box.widget(), "Hello, LLM!") 163 | qtbot.mouseClick(new_form.emoji_label, Qt.MouseButton.LeftButton) 164 | 165 | # Allow time for the worker to process 166 | qtbot.wait(500) 167 | 168 | # Check if the response is displayed correctly 169 | assert ( 170 | new_form.conversation_area.widget() 171 | .toPlainText() 172 | .startswith("Test LLM response") 173 | is True 174 | ) 175 | --------------------------------------------------------------------------------