Compare commits
14 Commits
a5e0948881
...
8e6b03cade
| Author | SHA1 | Date |
|---|---|---|
|
|
8e6b03cade | |
|
|
bc970fb9f7 | |
|
|
683a8a6d8f | |
|
|
d1f0c2f893 | |
|
|
260ebf1b2f | |
|
|
b8760eb208 | |
|
|
8ab0fba67c | |
|
|
827f0f9eef | |
|
|
fb879d09ea | |
|
|
4d9a0e5943 | |
|
|
88c9a8a1e7 | |
|
|
3c39cc9cda | |
|
|
ad9a59283c | |
|
|
042be8b49b |
|
|
@ -0,0 +1,80 @@
|
||||||
|
# PySide6 6.7+ Compatibility Issue: setSelectionArea API Change
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
|
||||||
|
When using NodeGraphQt-QuiltiX-fork with PySide6 6.7+, dragging a rubber band selection in the node graph causes repeated TypeError exceptions:
|
||||||
|
|
||||||
|
```
|
||||||
|
TypeError: 'PySide6.QtWidgets.QGraphicsScene.setSelectionArea' called with wrong argument types:
|
||||||
|
PySide6.QtWidgets.QGraphicsScene.setSelectionArea(QPainterPath, ItemSelectionMode)
|
||||||
|
Supported signatures:
|
||||||
|
PySide6.QtWidgets.QGraphicsScene.setSelectionArea(PySide6.QtGui.QPainterPath, PySide6.QtGui.QTransform)
|
||||||
|
PySide6.QtWidgets.QGraphicsScene.setSelectionArea(PySide6.QtGui.QPainterPath, PySide6.QtCore.Qt.ItemSelectionOperation = Instance(Qt.ReplaceSelection), PySide6.QtCore.Qt.ItemSelectionMode = Instance(Qt.IntersectsItemShape), PySide6.QtGui.QTransform = Default(QTransform))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
The `QGraphicsScene.setSelectionArea()` method signature changed in PySide6 6.7. The old signature allowed:
|
||||||
|
|
||||||
|
```python
|
||||||
|
setSelectionArea(path, mode) # mode = Qt.ItemSelectionMode
|
||||||
|
```
|
||||||
|
|
||||||
|
The new signature requires:
|
||||||
|
|
||||||
|
```python
|
||||||
|
setSelectionArea(path, operation, mode) # operation = Qt.ItemSelectionOperation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Affected File
|
||||||
|
|
||||||
|
`NodeGraphQt/widgets/viewer.py` line 524-526
|
||||||
|
|
||||||
|
## Current Code
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.scene().setSelectionArea(
|
||||||
|
path, QtCore.Qt.IntersectsItemShape
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.scene().setSelectionArea(
|
||||||
|
path, QtCore.Qt.ReplaceSelection,
|
||||||
|
QtCore.Qt.IntersectsItemShape
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- Python: 3.12
|
||||||
|
- PySide6: 6.6.3.1 (installed by NodeGraphQt-QuiltiX-fork, but issue affects 6.7+)
|
||||||
|
- NodeGraphQt-QuiltiX-fork: 0.7.0
|
||||||
|
|
||||||
|
## Workaround
|
||||||
|
|
||||||
|
Apply this patch after installation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In viewer.py, change line 524-526 from:
|
||||||
|
self.scene().setSelectionArea(
|
||||||
|
path, QtCore.Qt.IntersectsItemShape
|
||||||
|
)
|
||||||
|
|
||||||
|
# To:
|
||||||
|
self.scene().setSelectionArea(
|
||||||
|
path, QtCore.Qt.ReplaceSelection,
|
||||||
|
QtCore.Qt.IntersectsItemShape
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
This issue may also exist in:
|
||||||
|
- Original NodeGraphQt
|
||||||
|
- OdenGraphQt
|
||||||
|
- Other forks
|
||||||
|
|
||||||
|
The fix is backward compatible with older PySide6 versions that support the 3-argument signature.
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
--- a/NodeGraphQt/widgets/viewer.py
|
||||||
|
+++ b/NodeGraphQt/widgets/viewer.py
|
||||||
|
@@ -521,8 +521,10 @@ class NodeViewer(QtWidgets.QGraphicsView):
|
||||||
|
path = QtGui.QPainterPath()
|
||||||
|
path.addRect(map_rect)
|
||||||
|
self._rubber_band.setGeometry(rect)
|
||||||
|
+ # PySide6 6.7+ requires ItemSelectionOperation parameter
|
||||||
|
self.scene().setSelectionArea(
|
||||||
|
- path, QtCore.Qt.IntersectsItemShape
|
||||||
|
+ path, QtCore.Qt.ReplaceSelection,
|
||||||
|
+ QtCore.Qt.IntersectsItemShape
|
||||||
|
)
|
||||||
|
self.scene().update(map_rect)
|
||||||
|
|
@ -46,11 +46,17 @@ registry = [
|
||||||
"sentry-sdk[flask]>=1.0",
|
"sentry-sdk[flask]>=1.0",
|
||||||
"gunicorn>=21.0",
|
"gunicorn>=21.0",
|
||||||
]
|
]
|
||||||
|
flow = [
|
||||||
|
"NodeGraphQt-QuiltiX-fork[pyside6]>=0.7.0",
|
||||||
|
"setuptools", # Required for distutils compatibility
|
||||||
|
]
|
||||||
all = [
|
all = [
|
||||||
"Flask>=2.3",
|
"Flask>=2.3",
|
||||||
"argon2-cffi>=21.0",
|
"argon2-cffi>=21.0",
|
||||||
"sentry-sdk[flask]>=1.0",
|
"sentry-sdk[flask]>=1.0",
|
||||||
"gunicorn>=21.0",
|
"gunicorn>=21.0",
|
||||||
|
"NodeGraphQt-QuiltiX-fork[pyside6]>=0.7.0",
|
||||||
|
"setuptools",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Patch NodeGraphQt for PySide6 6.7+ compatibility.
|
||||||
|
|
||||||
|
The setSelectionArea() API changed in PySide6 6.7 to require an
|
||||||
|
ItemSelectionOperation parameter. This script patches the installed
|
||||||
|
NodeGraphQt package to work with newer PySide6 versions.
|
||||||
|
|
||||||
|
Issue: setSelectionArea(path, mode) -> setSelectionArea(path, operation, mode)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/patch_nodegraphqt.py
|
||||||
|
|
||||||
|
This can be run after `pip install NodeGraphQt-QuiltiX-fork[pyside6]`
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import site
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def find_viewer_file():
|
||||||
|
"""Find the NodeGraphQt viewer.py file in site-packages."""
|
||||||
|
# Check common locations
|
||||||
|
for site_dir in site.getsitepackages() + [site.getusersitepackages()]:
|
||||||
|
viewer_path = Path(site_dir) / "NodeGraphQt" / "widgets" / "viewer.py"
|
||||||
|
if viewer_path.exists():
|
||||||
|
return viewer_path
|
||||||
|
|
||||||
|
# Check virtual environment
|
||||||
|
venv_path = Path(sys.prefix) / "lib"
|
||||||
|
for python_dir in venv_path.glob("python*"):
|
||||||
|
viewer_path = python_dir / "site-packages" / "NodeGraphQt" / "widgets" / "viewer.py"
|
||||||
|
if viewer_path.exists():
|
||||||
|
return viewer_path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def patch_file(viewer_path: Path) -> bool:
|
||||||
|
"""Apply the PySide6 compatibility patch."""
|
||||||
|
content = viewer_path.read_text()
|
||||||
|
|
||||||
|
# Check if already patched
|
||||||
|
if "Qt.ReplaceSelection" in content:
|
||||||
|
print(f"Already patched: {viewer_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# The old code pattern
|
||||||
|
old_code = """self.scene().setSelectionArea(
|
||||||
|
path, QtCore.Qt.IntersectsItemShape
|
||||||
|
)"""
|
||||||
|
|
||||||
|
# The new code with ItemSelectionOperation
|
||||||
|
new_code = """self.scene().setSelectionArea(
|
||||||
|
path, QtCore.Qt.ReplaceSelection,
|
||||||
|
QtCore.Qt.IntersectsItemShape
|
||||||
|
)"""
|
||||||
|
|
||||||
|
if old_code not in content:
|
||||||
|
print(f"Warning: Could not find code to patch in {viewer_path}")
|
||||||
|
print("The file may have a different format or already be modified.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Apply patch
|
||||||
|
patched_content = content.replace(old_code, new_code)
|
||||||
|
|
||||||
|
# Backup original
|
||||||
|
backup_path = viewer_path.with_suffix(".py.bak")
|
||||||
|
if not backup_path.exists():
|
||||||
|
viewer_path.rename(backup_path)
|
||||||
|
print(f"Backup created: {backup_path}")
|
||||||
|
|
||||||
|
# Write patched file
|
||||||
|
viewer_path.write_text(patched_content)
|
||||||
|
print(f"Patched successfully: {viewer_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("NodeGraphQt PySide6 Compatibility Patch")
|
||||||
|
print("=" * 40)
|
||||||
|
|
||||||
|
viewer_path = find_viewer_file()
|
||||||
|
|
||||||
|
if not viewer_path:
|
||||||
|
print("Error: Could not find NodeGraphQt installation.")
|
||||||
|
print("Make sure NodeGraphQt-QuiltiX-fork is installed:")
|
||||||
|
print(" pip install 'NodeGraphQt-QuiltiX-fork[pyside6]'")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"Found: {viewer_path}")
|
||||||
|
|
||||||
|
if patch_file(viewer_path):
|
||||||
|
print("\nPatch applied successfully!")
|
||||||
|
print("The rubber band selection should now work in PySide6 6.7+")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print("\nPatch failed.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test script for NodeGraphQt integration."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget
|
||||||
|
from NodeGraphQt import NodeGraph, BaseNode
|
||||||
|
|
||||||
|
|
||||||
|
class InputNode(BaseNode):
|
||||||
|
"""Node representing tool input."""
|
||||||
|
|
||||||
|
__identifier__ = 'cmdforge.nodes'
|
||||||
|
NODE_NAME = 'Input'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.set_color(100, 100, 180) # Blue-ish
|
||||||
|
self.add_output('input', color=(180, 180, 250))
|
||||||
|
self.add_output('file', color=(180, 180, 250))
|
||||||
|
|
||||||
|
|
||||||
|
class PromptNode(BaseNode):
|
||||||
|
"""Node representing a prompt step."""
|
||||||
|
|
||||||
|
__identifier__ = 'cmdforge.nodes'
|
||||||
|
NODE_NAME = 'Prompt'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.set_color(80, 120, 180) # Indigo-ish
|
||||||
|
self.add_input('in', color=(180, 180, 250))
|
||||||
|
self.add_output('response', color=(180, 250, 180))
|
||||||
|
|
||||||
|
# Add properties
|
||||||
|
self.add_text_input('provider', 'Provider', text='claude')
|
||||||
|
self.add_text_input('output_var', 'Output Var', text='response')
|
||||||
|
|
||||||
|
|
||||||
|
class CodeNode(BaseNode):
|
||||||
|
"""Node representing a code step."""
|
||||||
|
|
||||||
|
__identifier__ = 'cmdforge.nodes'
|
||||||
|
NODE_NAME = 'Code'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.set_color(80, 160, 100) # Green-ish
|
||||||
|
self.add_input('in', color=(180, 250, 180))
|
||||||
|
self.add_output('result', color=(250, 220, 180))
|
||||||
|
|
||||||
|
# Add properties
|
||||||
|
self.add_text_input('output_var', 'Output Var', text='result')
|
||||||
|
|
||||||
|
|
||||||
|
class OutputNode(BaseNode):
|
||||||
|
"""Node representing tool output."""
|
||||||
|
|
||||||
|
__identifier__ = 'cmdforge.nodes'
|
||||||
|
NODE_NAME = 'Output'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.set_color(180, 120, 80) # Orange-ish
|
||||||
|
self.add_input('in', color=(250, 220, 180))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
|
||||||
|
# Create main window
|
||||||
|
window = QMainWindow()
|
||||||
|
window.setWindowTitle('CmdForge Flow Visualization - Prototype')
|
||||||
|
window.setGeometry(100, 100, 1000, 700)
|
||||||
|
|
||||||
|
# Create node graph
|
||||||
|
graph = NodeGraph()
|
||||||
|
|
||||||
|
# Register our custom nodes
|
||||||
|
graph.register_node(InputNode)
|
||||||
|
graph.register_node(PromptNode)
|
||||||
|
graph.register_node(CodeNode)
|
||||||
|
graph.register_node(OutputNode)
|
||||||
|
|
||||||
|
# Get the graph widget
|
||||||
|
graph_widget = graph.widget
|
||||||
|
|
||||||
|
# Set as central widget
|
||||||
|
window.setCentralWidget(graph_widget)
|
||||||
|
|
||||||
|
# Create a sample tool visualization
|
||||||
|
input_node = graph.create_node('cmdforge.nodes.InputNode', name='Input')
|
||||||
|
input_node.set_pos(-300, 0)
|
||||||
|
|
||||||
|
prompt_node = graph.create_node('cmdforge.nodes.PromptNode', name='Summarize')
|
||||||
|
prompt_node.set_pos(-50, 0)
|
||||||
|
prompt_node.set_property('provider', 'claude')
|
||||||
|
prompt_node.set_property('output_var', 'response')
|
||||||
|
|
||||||
|
code_node = graph.create_node('cmdforge.nodes.CodeNode', name='Format')
|
||||||
|
code_node.set_pos(200, 0)
|
||||||
|
code_node.set_property('output_var', 'formatted')
|
||||||
|
|
||||||
|
output_node = graph.create_node('cmdforge.nodes.OutputNode', name='Output')
|
||||||
|
output_node.set_pos(450, 0)
|
||||||
|
|
||||||
|
# Connect nodes
|
||||||
|
input_node.output(0).connect_to(prompt_node.input(0))
|
||||||
|
prompt_node.output(0).connect_to(code_node.input(0))
|
||||||
|
code_node.output(0).connect_to(output_node.input(0))
|
||||||
|
|
||||||
|
# Auto-layout
|
||||||
|
graph.auto_layout_nodes()
|
||||||
|
|
||||||
|
# Fit view
|
||||||
|
graph.fit_to_selection()
|
||||||
|
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
print("Flow visualization prototype running...")
|
||||||
|
print("Nodes created: Input -> Prompt -> Code -> Output")
|
||||||
|
print("Close the window to exit.")
|
||||||
|
|
||||||
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|
@ -36,6 +36,11 @@ class PromptStepDialog(QDialog):
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
form.setSpacing(12)
|
form.setSpacing(12)
|
||||||
|
|
||||||
|
# Step name (optional)
|
||||||
|
self.name_input = QLineEdit()
|
||||||
|
self.name_input.setPlaceholderText("Optional display name")
|
||||||
|
form.addRow("Step name:", self.name_input)
|
||||||
|
|
||||||
# Provider selection
|
# Provider selection
|
||||||
self.provider_combo = QComboBox()
|
self.provider_combo = QComboBox()
|
||||||
providers = load_providers()
|
providers = load_providers()
|
||||||
|
|
@ -95,6 +100,10 @@ class PromptStepDialog(QDialog):
|
||||||
|
|
||||||
def _load_step(self, step: PromptStep):
|
def _load_step(self, step: PromptStep):
|
||||||
"""Load step data into form."""
|
"""Load step data into form."""
|
||||||
|
# Load name
|
||||||
|
if step.name:
|
||||||
|
self.name_input.setText(step.name)
|
||||||
|
|
||||||
idx = self.provider_combo.findText(step.provider)
|
idx = self.provider_combo.findText(step.provider)
|
||||||
if idx >= 0:
|
if idx >= 0:
|
||||||
self.provider_combo.setCurrentIndex(idx)
|
self.provider_combo.setCurrentIndex(idx)
|
||||||
|
|
@ -130,11 +139,14 @@ class PromptStepDialog(QDialog):
|
||||||
# Don't store "None" profile
|
# Don't store "None" profile
|
||||||
if profile == "None":
|
if profile == "None":
|
||||||
profile = None
|
profile = None
|
||||||
|
# Get name, use None if empty
|
||||||
|
name = self.name_input.text().strip() or None
|
||||||
return PromptStep(
|
return PromptStep(
|
||||||
prompt=self.prompt_input.toPlainText(),
|
prompt=self.prompt_input.toPlainText(),
|
||||||
provider=self.provider_combo.currentText(),
|
provider=self.provider_combo.currentText(),
|
||||||
output_var=self.output_input.text().strip(),
|
output_var=self.output_input.text().strip(),
|
||||||
profile=profile
|
profile=profile,
|
||||||
|
name=name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -179,9 +191,15 @@ class CodeStepDialog(QDialog):
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setSpacing(12)
|
layout.setSpacing(12)
|
||||||
|
|
||||||
# Top: Output variable
|
# Top: Step name and Output variable
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
form.setSpacing(8)
|
form.setSpacing(8)
|
||||||
|
|
||||||
|
# Step name (optional)
|
||||||
|
self.name_input = QLineEdit()
|
||||||
|
self.name_input.setPlaceholderText("Optional display name")
|
||||||
|
form.addRow("Step name:", self.name_input)
|
||||||
|
|
||||||
self.output_input = QLineEdit()
|
self.output_input = QLineEdit()
|
||||||
self.output_input.setPlaceholderText("result")
|
self.output_input.setPlaceholderText("result")
|
||||||
self.output_input.setText("result")
|
self.output_input.setText("result")
|
||||||
|
|
@ -365,6 +383,9 @@ IMPORTANT: Return ONLY executable inline Python code. No function definitions, n
|
||||||
|
|
||||||
def _load_step(self, step: CodeStep):
|
def _load_step(self, step: CodeStep):
|
||||||
"""Load step data into form."""
|
"""Load step data into form."""
|
||||||
|
# Load name
|
||||||
|
if step.name:
|
||||||
|
self.name_input.setText(step.name)
|
||||||
self.output_input.setText(step.output_var)
|
self.output_input.setText(step.output_var)
|
||||||
self.code_input.setPlainText(step.code)
|
self.code_input.setPlainText(step.code)
|
||||||
|
|
||||||
|
|
@ -396,7 +417,10 @@ IMPORTANT: Return ONLY executable inline Python code. No function definitions, n
|
||||||
|
|
||||||
def get_step(self) -> CodeStep:
|
def get_step(self) -> CodeStep:
|
||||||
"""Get the step from form data."""
|
"""Get the step from form data."""
|
||||||
|
# Get name, use None if empty
|
||||||
|
name = self.name_input.text().strip() or None
|
||||||
return CodeStep(
|
return CodeStep(
|
||||||
code=self.code_input.toPlainText(),
|
code=self.code_input.toPlainText(),
|
||||||
output_var=self.output_input.text().strip()
|
output_var=self.output_input.text().strip(),
|
||||||
|
name=name
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ class MainWindow(QMainWindow):
|
||||||
def _setup_sidebar(self):
|
def _setup_sidebar(self):
|
||||||
"""Set up sidebar navigation items."""
|
"""Set up sidebar navigation items."""
|
||||||
items = [
|
items = [
|
||||||
|
("CmdForge", "Welcome to CmdForge"),
|
||||||
("Tools", "Manage your tools"),
|
("Tools", "Manage your tools"),
|
||||||
("Registry", "Browse and install tools"),
|
("Registry", "Browse and install tools"),
|
||||||
("Providers", "Configure AI providers"),
|
("Providers", "Configure AI providers"),
|
||||||
|
|
@ -80,26 +81,34 @@ class MainWindow(QMainWindow):
|
||||||
font = QFont()
|
font = QFont()
|
||||||
font.setPointSize(11)
|
font.setPointSize(11)
|
||||||
|
|
||||||
for name, tooltip in items:
|
for i, (name, tooltip) in enumerate(items):
|
||||||
item = QListWidgetItem(name)
|
item = QListWidgetItem(name)
|
||||||
item.setFont(font)
|
item.setFont(font)
|
||||||
item.setToolTip(tooltip)
|
item.setToolTip(tooltip)
|
||||||
item.setSizeHint(QSize(180, 48))
|
item.setSizeHint(QSize(180, 48))
|
||||||
|
# Style the CmdForge item differently
|
||||||
|
if i == 0:
|
||||||
|
bold_font = QFont(font)
|
||||||
|
bold_font.setBold(True)
|
||||||
|
item.setFont(bold_font)
|
||||||
self.sidebar.addItem(item)
|
self.sidebar.addItem(item)
|
||||||
|
|
||||||
def _setup_pages(self):
|
def _setup_pages(self):
|
||||||
"""Set up content pages."""
|
"""Set up content pages."""
|
||||||
# Import pages here to avoid circular imports
|
# Import pages here to avoid circular imports
|
||||||
|
from .pages.welcome_page import WelcomePage
|
||||||
from .pages.tools_page import ToolsPage
|
from .pages.tools_page import ToolsPage
|
||||||
from .pages.registry_page import RegistryPage
|
from .pages.registry_page import RegistryPage
|
||||||
from .pages.providers_page import ProvidersPage
|
from .pages.providers_page import ProvidersPage
|
||||||
from .pages.profiles_page import ProfilesPage
|
from .pages.profiles_page import ProfilesPage
|
||||||
|
|
||||||
|
self.welcome_page = WelcomePage(self)
|
||||||
self.tools_page = ToolsPage(self)
|
self.tools_page = ToolsPage(self)
|
||||||
self.registry_page = RegistryPage(self)
|
self.registry_page = RegistryPage(self)
|
||||||
self.providers_page = ProvidersPage(self)
|
self.providers_page = ProvidersPage(self)
|
||||||
self.profiles_page = ProfilesPage(self)
|
self.profiles_page = ProfilesPage(self)
|
||||||
|
|
||||||
|
self.pages.addWidget(self.welcome_page)
|
||||||
self.pages.addWidget(self.tools_page)
|
self.pages.addWidget(self.tools_page)
|
||||||
self.pages.addWidget(self.registry_page)
|
self.pages.addWidget(self.registry_page)
|
||||||
self.pages.addWidget(self.providers_page)
|
self.pages.addWidget(self.providers_page)
|
||||||
|
|
@ -121,10 +130,12 @@ class MainWindow(QMainWindow):
|
||||||
def navigate_to(self, page_name: str):
|
def navigate_to(self, page_name: str):
|
||||||
"""Navigate to a specific page by name."""
|
"""Navigate to a specific page by name."""
|
||||||
page_map = {
|
page_map = {
|
||||||
"tools": 0,
|
"welcome": 0,
|
||||||
"registry": 1,
|
"cmdforge": 0,
|
||||||
"providers": 2,
|
"tools": 1,
|
||||||
"profiles": 3,
|
"registry": 2,
|
||||||
|
"providers": 3,
|
||||||
|
"profiles": 4,
|
||||||
}
|
}
|
||||||
if page_name.lower() in page_map:
|
if page_name.lower() in page_map:
|
||||||
self.sidebar.setCurrentRow(page_map[page_name.lower()])
|
self.sidebar.setCurrentRow(page_map[page_name.lower()])
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"""GUI pages."""
|
"""GUI pages."""
|
||||||
|
|
||||||
|
from .welcome_page import WelcomePage
|
||||||
from .tools_page import ToolsPage
|
from .tools_page import ToolsPage
|
||||||
from .tool_builder_page import ToolBuilderPage
|
from .tool_builder_page import ToolBuilderPage
|
||||||
from .registry_page import RegistryPage
|
from .registry_page import RegistryPage
|
||||||
from .providers_page import ProvidersPage
|
from .providers_page import ProvidersPage
|
||||||
|
|
||||||
__all__ = ["ToolsPage", "ToolBuilderPage", "RegistryPage", "ProvidersPage"]
|
__all__ = ["WelcomePage", "ToolsPage", "ToolBuilderPage", "RegistryPage", "ProvidersPage"]
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ from PySide6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout,
|
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout,
|
||||||
QLineEdit, QTextEdit, QComboBox, QPushButton,
|
QLineEdit, QTextEdit, QComboBox, QPushButton,
|
||||||
QGroupBox, QListWidget, QListWidgetItem, QLabel,
|
QGroupBox, QListWidget, QListWidgetItem, QLabel,
|
||||||
QMessageBox, QSplitter, QFrame
|
QMessageBox, QSplitter, QFrame, QStackedWidget,
|
||||||
|
QButtonGroup
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
|
@ -12,6 +13,7 @@ from ...tool import (
|
||||||
Tool, ToolArgument, PromptStep, CodeStep,
|
Tool, ToolArgument, PromptStep, CodeStep,
|
||||||
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES
|
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES
|
||||||
)
|
)
|
||||||
|
from ..widgets.icons import get_prompt_icon, get_code_icon
|
||||||
|
|
||||||
|
|
||||||
class ToolBuilderPage(QWidget):
|
class ToolBuilderPage(QWidget):
|
||||||
|
|
@ -23,6 +25,7 @@ class ToolBuilderPage(QWidget):
|
||||||
self.editing = tool_name is not None
|
self.editing = tool_name is not None
|
||||||
self.original_name = tool_name
|
self.original_name = tool_name
|
||||||
self._tool = None
|
self._tool = None
|
||||||
|
self._flow_widget = None # Lazy-loaded
|
||||||
|
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
|
|
||||||
|
|
@ -123,14 +126,61 @@ class ToolBuilderPage(QWidget):
|
||||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
right_layout.setSpacing(16)
|
right_layout.setSpacing(16)
|
||||||
|
|
||||||
# Steps group
|
# Steps group with view toggle
|
||||||
steps_box = QGroupBox("Steps")
|
steps_box = QGroupBox("Steps")
|
||||||
steps_layout = QVBoxLayout(steps_box)
|
steps_layout = QVBoxLayout(steps_box)
|
||||||
|
|
||||||
|
# View toggle header
|
||||||
|
view_header = QHBoxLayout()
|
||||||
|
|
||||||
|
self.btn_list_view = QPushButton("List")
|
||||||
|
self.btn_list_view.setCheckable(True)
|
||||||
|
self.btn_list_view.setChecked(True)
|
||||||
|
self.btn_list_view.setObjectName("viewToggle")
|
||||||
|
self.btn_list_view.clicked.connect(lambda: self._set_view_mode(0))
|
||||||
|
|
||||||
|
self.btn_flow_view = QPushButton("Flow")
|
||||||
|
self.btn_flow_view.setCheckable(True)
|
||||||
|
self.btn_flow_view.setObjectName("viewToggle")
|
||||||
|
self.btn_flow_view.clicked.connect(lambda: self._set_view_mode(1))
|
||||||
|
|
||||||
|
# Button group for mutual exclusivity
|
||||||
|
self.view_group = QButtonGroup(self)
|
||||||
|
self.view_group.addButton(self.btn_list_view, 0)
|
||||||
|
self.view_group.addButton(self.btn_flow_view, 1)
|
||||||
|
|
||||||
|
view_header.addWidget(self.btn_list_view)
|
||||||
|
view_header.addWidget(self.btn_flow_view)
|
||||||
|
view_header.addStretch()
|
||||||
|
steps_layout.addLayout(view_header)
|
||||||
|
|
||||||
|
# Stacked widget for list/flow views
|
||||||
|
self.steps_stack = QStackedWidget()
|
||||||
|
|
||||||
|
# List view (index 0)
|
||||||
|
list_container = QWidget()
|
||||||
|
list_layout = QVBoxLayout(list_container)
|
||||||
|
list_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
self.steps_list = QListWidget()
|
self.steps_list = QListWidget()
|
||||||
self.steps_list.itemDoubleClicked.connect(self._edit_step)
|
self.steps_list.itemDoubleClicked.connect(self._edit_step)
|
||||||
steps_layout.addWidget(self.steps_list)
|
# Enable drag-drop reordering
|
||||||
|
self.steps_list.setDragDropMode(QListWidget.InternalMove)
|
||||||
|
self.steps_list.setDefaultDropAction(Qt.MoveAction)
|
||||||
|
self.steps_list.model().rowsMoved.connect(self._on_steps_reordered)
|
||||||
|
list_layout.addWidget(self.steps_list)
|
||||||
|
|
||||||
|
self.steps_stack.addWidget(list_container)
|
||||||
|
|
||||||
|
# Flow view placeholder (index 1) - lazy loaded
|
||||||
|
flow_placeholder = QLabel("Loading flow visualization...")
|
||||||
|
flow_placeholder.setAlignment(Qt.AlignCenter)
|
||||||
|
flow_placeholder.setStyleSheet("color: #718096; padding: 40px;")
|
||||||
|
self.steps_stack.addWidget(flow_placeholder)
|
||||||
|
|
||||||
|
steps_layout.addWidget(self.steps_stack)
|
||||||
|
|
||||||
|
# Step action buttons
|
||||||
steps_btns = QHBoxLayout()
|
steps_btns = QHBoxLayout()
|
||||||
self.btn_add_prompt = QPushButton("Add Prompt")
|
self.btn_add_prompt = QPushButton("Add Prompt")
|
||||||
self.btn_add_prompt.clicked.connect(self._add_prompt_step)
|
self.btn_add_prompt.clicked.connect(self._add_prompt_step)
|
||||||
|
|
@ -173,6 +223,108 @@ class ToolBuilderPage(QWidget):
|
||||||
|
|
||||||
layout.addWidget(splitter, 1)
|
layout.addWidget(splitter, 1)
|
||||||
|
|
||||||
|
def _set_view_mode(self, mode: int):
|
||||||
|
"""Switch between list (0) and flow (1) views."""
|
||||||
|
if mode == 1 and self._flow_widget is None:
|
||||||
|
# Lazy-load flow widget
|
||||||
|
self._init_flow_widget()
|
||||||
|
|
||||||
|
self.steps_stack.setCurrentIndex(mode)
|
||||||
|
|
||||||
|
# Update flow widget if visible
|
||||||
|
if mode == 1 and self._flow_widget:
|
||||||
|
self._flow_widget.set_tool(self._tool)
|
||||||
|
|
||||||
|
def _init_flow_widget(self):
|
||||||
|
"""Initialize the flow visualization widget."""
|
||||||
|
try:
|
||||||
|
from ..widgets.flow_graph import FlowGraphWidget
|
||||||
|
self._flow_widget = FlowGraphWidget()
|
||||||
|
self._flow_widget.node_double_clicked.connect(self._on_flow_node_double_clicked)
|
||||||
|
self._flow_widget.steps_deleted.connect(self._on_flow_steps_deleted)
|
||||||
|
self._flow_widget.steps_reordered.connect(self._on_flow_steps_reordered)
|
||||||
|
self._flow_widget.step_name_changed.connect(self._on_flow_step_name_changed)
|
||||||
|
|
||||||
|
# Replace placeholder
|
||||||
|
old_widget = self.steps_stack.widget(1)
|
||||||
|
self.steps_stack.removeWidget(old_widget)
|
||||||
|
old_widget.deleteLater()
|
||||||
|
self.steps_stack.insertWidget(1, self._flow_widget)
|
||||||
|
|
||||||
|
# Set current tool
|
||||||
|
if self._tool:
|
||||||
|
self._flow_widget.set_tool(self._tool)
|
||||||
|
except ImportError as e:
|
||||||
|
# NodeGraphQt not available
|
||||||
|
error_label = QLabel(f"Flow visualization unavailable:\n{e}")
|
||||||
|
error_label.setAlignment(Qt.AlignCenter)
|
||||||
|
error_label.setStyleSheet("color: #e53e3e; padding: 40px;")
|
||||||
|
old_widget = self.steps_stack.widget(1)
|
||||||
|
self.steps_stack.removeWidget(old_widget)
|
||||||
|
old_widget.deleteLater()
|
||||||
|
self.steps_stack.insertWidget(1, error_label)
|
||||||
|
|
||||||
|
def _on_flow_node_double_clicked(self, step_index: int, step_type: str):
|
||||||
|
"""Handle double-click on a flow node."""
|
||||||
|
if not self._tool or step_index < 0 or step_index >= len(self._tool.steps):
|
||||||
|
return
|
||||||
|
|
||||||
|
step = self._tool.steps[step_index]
|
||||||
|
self._edit_step_at_index(step_index, step)
|
||||||
|
|
||||||
|
def _on_flow_steps_deleted(self, indices: list):
|
||||||
|
"""Handle step deletion from flow view."""
|
||||||
|
if not self._tool:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Delete steps in reverse order (indices are already sorted reverse)
|
||||||
|
for idx in indices:
|
||||||
|
if 0 <= idx < len(self._tool.steps):
|
||||||
|
del self._tool.steps[idx]
|
||||||
|
|
||||||
|
# Refresh the list view (flow view will be refreshed by set_tool)
|
||||||
|
self._refresh_steps()
|
||||||
|
|
||||||
|
def _on_flow_step_name_changed(self, step_index: int, new_name: str):
|
||||||
|
"""Handle step name change from flow view inline editing."""
|
||||||
|
if not self._tool or step_index < 0 or step_index >= len(self._tool.steps):
|
||||||
|
return
|
||||||
|
|
||||||
|
step = self._tool.steps[step_index]
|
||||||
|
step.name = new_name
|
||||||
|
|
||||||
|
# Refresh list view to show updated name
|
||||||
|
self._refresh_steps_list_only()
|
||||||
|
|
||||||
|
def _on_flow_steps_reordered(self, new_order: list):
|
||||||
|
"""Handle step reordering from flow view.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
new_order: List of old indices in new order.
|
||||||
|
e.g., [0, 2, 1] means step 0 stays first,
|
||||||
|
step 2 moves to second, step 1 moves to third.
|
||||||
|
"""
|
||||||
|
if not self._tool or not self._tool.steps:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Reorder steps according to new_order
|
||||||
|
old_steps = self._tool.steps[:]
|
||||||
|
new_steps = [old_steps[i] for i in new_order if i < len(old_steps)]
|
||||||
|
|
||||||
|
# Validate the new order
|
||||||
|
warnings = self._validate_step_order(new_steps)
|
||||||
|
|
||||||
|
if warnings:
|
||||||
|
# Show warning but allow the reorder
|
||||||
|
warning_msg = "Variable dependency warnings:\n\n" + "\n".join(warnings)
|
||||||
|
warning_msg += "\n\nThe reorder has been applied. You may need to fix these issues."
|
||||||
|
QMessageBox.warning(self, "Dependency Warning", warning_msg)
|
||||||
|
|
||||||
|
self._tool.steps = new_steps
|
||||||
|
|
||||||
|
# Refresh both views
|
||||||
|
self._refresh_steps()
|
||||||
|
|
||||||
def _load_tool(self, name: str):
|
def _load_tool(self, name: str):
|
||||||
"""Load an existing tool for editing."""
|
"""Load an existing tool for editing."""
|
||||||
tool = load_tool(name)
|
tool = load_tool(name)
|
||||||
|
|
@ -213,20 +365,113 @@ class ToolBuilderPage(QWidget):
|
||||||
self.args_list.addItem(item)
|
self.args_list.addItem(item)
|
||||||
|
|
||||||
def _refresh_steps(self):
|
def _refresh_steps(self):
|
||||||
"""Refresh steps list."""
|
"""Refresh steps list and flow view."""
|
||||||
self.steps_list.clear()
|
self.steps_list.clear()
|
||||||
if self._tool and self._tool.steps:
|
if self._tool and self._tool.steps:
|
||||||
|
# Count steps by type for default naming
|
||||||
|
prompt_count = 0
|
||||||
|
code_count = 0
|
||||||
|
|
||||||
|
# Track available variables for dependency checking
|
||||||
|
available_vars = {"input"}
|
||||||
|
if self._tool.arguments:
|
||||||
|
for arg in self._tool.arguments:
|
||||||
|
if arg.variable:
|
||||||
|
available_vars.add(arg.variable)
|
||||||
|
|
||||||
for i, step in enumerate(self._tool.steps, 1):
|
for i, step in enumerate(self._tool.steps, 1):
|
||||||
if isinstance(step, PromptStep):
|
if isinstance(step, PromptStep):
|
||||||
text = f"{i}. Prompt [{step.provider}] → ${step.output_var}"
|
prompt_count += 1
|
||||||
|
# Use custom name if set, otherwise default per-type naming
|
||||||
|
step_name = step.name if step.name else f"Prompt {prompt_count}"
|
||||||
|
text = f"{step_name} [{step.provider}] → ${step.output_var}"
|
||||||
|
icon = get_prompt_icon(20)
|
||||||
elif isinstance(step, CodeStep):
|
elif isinstance(step, CodeStep):
|
||||||
text = f"{i}. Code [python] → ${step.output_var}"
|
code_count += 1
|
||||||
|
step_name = step.name if step.name else f"Code {code_count}"
|
||||||
|
text = f"{step_name} [python] → ${step.output_var}"
|
||||||
|
icon = get_code_icon(20)
|
||||||
else:
|
else:
|
||||||
text = f"{i}. Unknown step"
|
text = f"Unknown step"
|
||||||
|
icon = None
|
||||||
|
|
||||||
item = QListWidgetItem(text)
|
item = QListWidgetItem(text)
|
||||||
|
if icon:
|
||||||
|
item.setIcon(icon)
|
||||||
item.setData(Qt.UserRole, step)
|
item.setData(Qt.UserRole, step)
|
||||||
|
|
||||||
|
# Check for broken dependencies
|
||||||
|
refs = self._get_step_variable_refs(step)
|
||||||
|
broken_refs = [r for r in refs if r not in available_vars]
|
||||||
|
if broken_refs:
|
||||||
|
# Mark item with warning color and tooltip
|
||||||
|
item.setForeground(Qt.red)
|
||||||
|
item.setToolTip(f"Missing variables: {', '.join(broken_refs)}")
|
||||||
|
|
||||||
self.steps_list.addItem(item)
|
self.steps_list.addItem(item)
|
||||||
|
|
||||||
|
# Add this step's output to available vars for next iteration
|
||||||
|
if hasattr(step, 'output_var') and step.output_var:
|
||||||
|
for var in step.output_var.split(','):
|
||||||
|
available_vars.add(var.strip())
|
||||||
|
|
||||||
|
# Update flow widget if initialized
|
||||||
|
if self._flow_widget:
|
||||||
|
self._flow_widget.set_tool(self._tool)
|
||||||
|
|
||||||
|
def _refresh_steps_list_only(self):
|
||||||
|
"""Refresh only the steps list (not flow view).
|
||||||
|
|
||||||
|
Used when a change originated from the flow view to avoid
|
||||||
|
rebuilding the graph unnecessarily.
|
||||||
|
"""
|
||||||
|
self.steps_list.clear()
|
||||||
|
if self._tool and self._tool.steps:
|
||||||
|
# Count steps by type for default naming
|
||||||
|
prompt_count = 0
|
||||||
|
code_count = 0
|
||||||
|
|
||||||
|
# Track available variables for dependency checking
|
||||||
|
available_vars = {"input"}
|
||||||
|
if self._tool.arguments:
|
||||||
|
for arg in self._tool.arguments:
|
||||||
|
if arg.variable:
|
||||||
|
available_vars.add(arg.variable)
|
||||||
|
|
||||||
|
for i, step in enumerate(self._tool.steps, 1):
|
||||||
|
if isinstance(step, PromptStep):
|
||||||
|
prompt_count += 1
|
||||||
|
step_name = step.name if step.name else f"Prompt {prompt_count}"
|
||||||
|
text = f"{step_name} [{step.provider}] → ${step.output_var}"
|
||||||
|
icon = get_prompt_icon(20)
|
||||||
|
elif isinstance(step, CodeStep):
|
||||||
|
code_count += 1
|
||||||
|
step_name = step.name if step.name else f"Code {code_count}"
|
||||||
|
text = f"{step_name} [python] → ${step.output_var}"
|
||||||
|
icon = get_code_icon(20)
|
||||||
|
else:
|
||||||
|
text = f"Unknown step"
|
||||||
|
icon = None
|
||||||
|
|
||||||
|
item = QListWidgetItem(text)
|
||||||
|
if icon:
|
||||||
|
item.setIcon(icon)
|
||||||
|
item.setData(Qt.UserRole, step)
|
||||||
|
|
||||||
|
# Check for broken dependencies
|
||||||
|
refs = self._get_step_variable_refs(step)
|
||||||
|
broken_refs = [r for r in refs if r not in available_vars]
|
||||||
|
if broken_refs:
|
||||||
|
item.setForeground(Qt.red)
|
||||||
|
item.setToolTip(f"Missing variables: {', '.join(broken_refs)}")
|
||||||
|
|
||||||
|
self.steps_list.addItem(item)
|
||||||
|
|
||||||
|
# Add this step's output to available vars for next iteration
|
||||||
|
if hasattr(step, 'output_var') and step.output_var:
|
||||||
|
for var in step.output_var.split(','):
|
||||||
|
available_vars.add(var.strip())
|
||||||
|
|
||||||
def _add_argument(self):
|
def _add_argument(self):
|
||||||
"""Add a new argument."""
|
"""Add a new argument."""
|
||||||
from ..dialogs.argument_dialog import ArgumentDialog
|
from ..dialogs.argument_dialog import ArgumentDialog
|
||||||
|
|
@ -315,14 +560,17 @@ class ToolBuilderPage(QWidget):
|
||||||
self._refresh_steps()
|
self._refresh_steps()
|
||||||
|
|
||||||
def _edit_step(self):
|
def _edit_step(self):
|
||||||
"""Edit selected step."""
|
"""Edit selected step from list view."""
|
||||||
items = self.steps_list.selectedItems()
|
items = self.steps_list.selectedItems()
|
||||||
if not items:
|
if not items:
|
||||||
return
|
return
|
||||||
|
|
||||||
step = items[0].data(Qt.UserRole)
|
step = items[0].data(Qt.UserRole)
|
||||||
idx = self.steps_list.row(items[0])
|
idx = self.steps_list.row(items[0])
|
||||||
|
self._edit_step_at_index(idx, step)
|
||||||
|
|
||||||
|
def _edit_step_at_index(self, idx: int, step):
|
||||||
|
"""Edit a step at a specific index."""
|
||||||
if isinstance(step, PromptStep):
|
if isinstance(step, PromptStep):
|
||||||
from ..dialogs.step_dialog import PromptStepDialog
|
from ..dialogs.step_dialog import PromptStepDialog
|
||||||
dialog = PromptStepDialog(self, step)
|
dialog = PromptStepDialog(self, step)
|
||||||
|
|
@ -347,6 +595,90 @@ class ToolBuilderPage(QWidget):
|
||||||
del self._tool.steps[idx]
|
del self._tool.steps[idx]
|
||||||
self._refresh_steps()
|
self._refresh_steps()
|
||||||
|
|
||||||
|
def _on_steps_reordered(self, parent, start, end, dest, row):
|
||||||
|
"""Handle step reordering via drag-drop."""
|
||||||
|
if not self._tool or not self._tool.steps:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Rebuild steps list from current list widget order
|
||||||
|
new_steps = []
|
||||||
|
for i in range(self.steps_list.count()):
|
||||||
|
item = self.steps_list.item(i)
|
||||||
|
step = item.data(Qt.UserRole)
|
||||||
|
if step:
|
||||||
|
new_steps.append(step)
|
||||||
|
|
||||||
|
# Validate the new order
|
||||||
|
warnings = self._validate_step_order(new_steps)
|
||||||
|
|
||||||
|
if warnings:
|
||||||
|
# Show warning but allow the reorder
|
||||||
|
warning_msg = "Variable dependency warnings:\n\n" + "\n".join(warnings)
|
||||||
|
warning_msg += "\n\nThe reorder has been applied. You may need to fix these issues."
|
||||||
|
QMessageBox.warning(self, "Dependency Warning", warning_msg)
|
||||||
|
|
||||||
|
# Apply the new order
|
||||||
|
self._tool.steps = new_steps
|
||||||
|
|
||||||
|
# Refresh to update numbering and flow view
|
||||||
|
self._refresh_steps()
|
||||||
|
|
||||||
|
def _validate_step_order(self, steps: list) -> list:
|
||||||
|
"""Validate step order for variable dependencies.
|
||||||
|
|
||||||
|
Returns a list of warning messages for broken dependencies.
|
||||||
|
"""
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
# Track available variables at each position
|
||||||
|
available = {"input"}
|
||||||
|
|
||||||
|
# Add argument variables
|
||||||
|
if self._tool and self._tool.arguments:
|
||||||
|
for arg in self._tool.arguments:
|
||||||
|
if arg.variable:
|
||||||
|
available.add(arg.variable)
|
||||||
|
|
||||||
|
for i, step in enumerate(steps):
|
||||||
|
# Get variables this step references
|
||||||
|
refs = self._get_step_variable_refs(step)
|
||||||
|
|
||||||
|
# Check for undefined references
|
||||||
|
for ref in refs:
|
||||||
|
if ref not in available:
|
||||||
|
step_name = step.name if step.name else f"Step {i+1}"
|
||||||
|
warnings.append(f"{step_name}: references '{{{ref}}}' which is not defined yet")
|
||||||
|
|
||||||
|
# Add this step's output to available vars
|
||||||
|
if hasattr(step, 'output_var') and step.output_var:
|
||||||
|
for var in step.output_var.split(','):
|
||||||
|
available.add(var.strip())
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
def _get_step_variable_refs(self, step) -> set:
|
||||||
|
"""Extract variable references from a step.
|
||||||
|
|
||||||
|
Parses {variable} patterns from prompt templates.
|
||||||
|
Note: CodeSteps are NOT parsed because variables are available directly
|
||||||
|
as Python variables, and {var} in code is typically Python f-string
|
||||||
|
or .format() syntax, not CmdForge substitution.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
refs = set()
|
||||||
|
|
||||||
|
# Pattern to match {variable} but not {{escaped}}
|
||||||
|
pattern = r'\{(\w+)\}'
|
||||||
|
|
||||||
|
if isinstance(step, PromptStep):
|
||||||
|
# Parse prompt template - these ARE CmdForge substitutions
|
||||||
|
matches = re.findall(pattern, step.prompt or "")
|
||||||
|
refs.update(matches)
|
||||||
|
# CodeStep: Don't parse - variables are available as Python vars directly
|
||||||
|
# and {var} syntax is typically Python string formatting
|
||||||
|
|
||||||
|
return refs
|
||||||
|
|
||||||
def _save(self):
|
def _save(self):
|
||||||
"""Save the tool."""
|
"""Save the tool."""
|
||||||
name = self.name_input.text().strip()
|
name = self.name_input.text().strip()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
"""Welcome page - CmdForge landing page."""
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||||
|
QFrame, QGridLayout, QSizePolicy
|
||||||
|
)
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
|
||||||
|
class WelcomePage(QWidget):
|
||||||
|
"""CmdForge welcome/landing page."""
|
||||||
|
|
||||||
|
def __init__(self, main_window):
|
||||||
|
super().__init__()
|
||||||
|
self.main_window = main_window
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
"""Set up the UI."""
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(48, 48, 48, 48)
|
||||||
|
layout.setSpacing(32)
|
||||||
|
|
||||||
|
# Top spacer
|
||||||
|
layout.addStretch(1)
|
||||||
|
|
||||||
|
# Logo/title
|
||||||
|
title = QLabel("CmdForge")
|
||||||
|
title.setStyleSheet("""
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #667eea;
|
||||||
|
""")
|
||||||
|
title.setAlignment(Qt.AlignCenter)
|
||||||
|
layout.addWidget(title)
|
||||||
|
|
||||||
|
# Tagline
|
||||||
|
tagline = QLabel("Build AI-powered command-line tools")
|
||||||
|
tagline.setStyleSheet("""
|
||||||
|
font-size: 18px;
|
||||||
|
color: #718096;
|
||||||
|
""")
|
||||||
|
tagline.setAlignment(Qt.AlignCenter)
|
||||||
|
layout.addWidget(tagline)
|
||||||
|
|
||||||
|
layout.addSpacing(24)
|
||||||
|
|
||||||
|
# Quick actions
|
||||||
|
actions_container = QWidget()
|
||||||
|
actions_container.setMaximumWidth(600)
|
||||||
|
actions_layout = QGridLayout(actions_container)
|
||||||
|
actions_layout.setSpacing(16)
|
||||||
|
|
||||||
|
# Create tool card
|
||||||
|
create_card = self._create_action_card(
|
||||||
|
"Create Tool",
|
||||||
|
"Build a new AI-powered command-line tool with prompts, code, and data flow",
|
||||||
|
"New Tool",
|
||||||
|
self._on_create_tool
|
||||||
|
)
|
||||||
|
actions_layout.addWidget(create_card, 0, 0)
|
||||||
|
|
||||||
|
# Browse registry card
|
||||||
|
registry_card = self._create_action_card(
|
||||||
|
"Browse Registry",
|
||||||
|
"Discover and install tools shared by the community",
|
||||||
|
"Open Registry",
|
||||||
|
self._on_browse_registry
|
||||||
|
)
|
||||||
|
actions_layout.addWidget(registry_card, 0, 1)
|
||||||
|
|
||||||
|
# Manage tools card
|
||||||
|
tools_card = self._create_action_card(
|
||||||
|
"My Tools",
|
||||||
|
"View, edit, and run your existing tools",
|
||||||
|
"Open Tools",
|
||||||
|
self._on_open_tools
|
||||||
|
)
|
||||||
|
actions_layout.addWidget(tools_card, 1, 0)
|
||||||
|
|
||||||
|
# Providers card
|
||||||
|
providers_card = self._create_action_card(
|
||||||
|
"AI Providers",
|
||||||
|
"Configure AI backends like Claude, GPT, and custom providers",
|
||||||
|
"Configure",
|
||||||
|
self._on_open_providers
|
||||||
|
)
|
||||||
|
actions_layout.addWidget(providers_card, 1, 1)
|
||||||
|
|
||||||
|
# Center the actions container
|
||||||
|
actions_wrapper = QHBoxLayout()
|
||||||
|
actions_wrapper.addStretch()
|
||||||
|
actions_wrapper.addWidget(actions_container)
|
||||||
|
actions_wrapper.addStretch()
|
||||||
|
layout.addLayout(actions_wrapper)
|
||||||
|
|
||||||
|
# Bottom spacer
|
||||||
|
layout.addStretch(2)
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
footer = QLabel("CmdForge - AI Tool Builder")
|
||||||
|
footer.setStyleSheet("color: #a0aec0; font-size: 12px;")
|
||||||
|
footer.setAlignment(Qt.AlignCenter)
|
||||||
|
layout.addWidget(footer)
|
||||||
|
|
||||||
|
def _create_action_card(self, title: str, description: str, button_text: str, callback) -> QFrame:
|
||||||
|
"""Create an action card widget."""
|
||||||
|
card = QFrame()
|
||||||
|
card.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
QFrame:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
card.setMinimumSize(260, 140)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(card)
|
||||||
|
layout.setContentsMargins(16, 16, 16, 16)
|
||||||
|
layout.setSpacing(8)
|
||||||
|
|
||||||
|
# Title
|
||||||
|
title_label = QLabel(title)
|
||||||
|
title_label.setStyleSheet("font-size: 16px; font-weight: 600; color: #2d3748; border: none;")
|
||||||
|
layout.addWidget(title_label)
|
||||||
|
|
||||||
|
# Description
|
||||||
|
desc_label = QLabel(description)
|
||||||
|
desc_label.setWordWrap(True)
|
||||||
|
desc_label.setStyleSheet("font-size: 13px; color: #718096; border: none;")
|
||||||
|
layout.addWidget(desc_label, 1)
|
||||||
|
|
||||||
|
# Button
|
||||||
|
btn = QPushButton(button_text)
|
||||||
|
btn.clicked.connect(callback)
|
||||||
|
btn.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: #5a67d8;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
layout.addWidget(btn)
|
||||||
|
|
||||||
|
return card
|
||||||
|
|
||||||
|
def _on_create_tool(self):
|
||||||
|
"""Handle create tool action."""
|
||||||
|
self.main_window.open_tool_builder()
|
||||||
|
|
||||||
|
def _on_browse_registry(self):
|
||||||
|
"""Handle browse registry action."""
|
||||||
|
self.main_window.navigate_to("registry")
|
||||||
|
|
||||||
|
def _on_open_tools(self):
|
||||||
|
"""Handle open tools action."""
|
||||||
|
self.main_window.navigate_to("tools")
|
||||||
|
|
||||||
|
def _on_open_providers(self):
|
||||||
|
"""Handle open providers action."""
|
||||||
|
self.main_window.navigate_to("providers")
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
"""Refresh the page (nothing to refresh)."""
|
||||||
|
pass
|
||||||
|
|
@ -76,6 +76,25 @@ QPushButton#danger:hover {
|
||||||
background-color: #c53030;
|
background-color: #c53030;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* View toggle buttons */
|
||||||
|
QPushButton#viewToggle {
|
||||||
|
background-color: #e2e8f0;
|
||||||
|
color: #4a5568;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
min-height: 24px;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton#viewToggle:checked {
|
||||||
|
background-color: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton#viewToggle:hover:!checked {
|
||||||
|
background-color: #cbd5e0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Input fields */
|
/* Input fields */
|
||||||
QLineEdit, QTextEdit, QPlainTextEdit {
|
QLineEdit, QTextEdit, QPlainTextEdit {
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
|
|
|
||||||
|
|
@ -1 +1,5 @@
|
||||||
"""Custom widgets for CmdForge GUI."""
|
"""Custom widgets for CmdForge GUI."""
|
||||||
|
|
||||||
|
from .flow_graph import FlowGraphWidget
|
||||||
|
|
||||||
|
__all__ = ['FlowGraphWidget']
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,491 @@
|
||||||
|
"""Flow visualization widget using NodeGraphQt."""
|
||||||
|
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QMenu
|
||||||
|
from PySide6.QtCore import Signal, Qt, QTimer, QEvent, QPropertyAnimation, QEasingCurve
|
||||||
|
from PySide6.QtGui import QKeyEvent, QAction
|
||||||
|
|
||||||
|
from NodeGraphQt import NodeGraph, BaseNode
|
||||||
|
|
||||||
|
from ...tool import Tool, PromptStep, CodeStep
|
||||||
|
from .icons import (
|
||||||
|
get_prompt_icon_path, get_code_icon_path,
|
||||||
|
get_input_icon_path, get_output_icon_path
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Custom Node Types
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class CmdForgeBaseNode(BaseNode):
|
||||||
|
"""Base class for CmdForge nodes with common styling."""
|
||||||
|
|
||||||
|
__identifier__ = 'cmdforge'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
# Store reference to the step data
|
||||||
|
self._step_data = None
|
||||||
|
self._step_index = -1
|
||||||
|
|
||||||
|
|
||||||
|
class InputNode(CmdForgeBaseNode):
|
||||||
|
"""Node representing tool input (stdin and arguments)."""
|
||||||
|
|
||||||
|
NODE_NAME = 'Input'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.set_color(90, 90, 160) # Indigo
|
||||||
|
self.set_icon(get_input_icon_path(16))
|
||||||
|
# Default output for stdin
|
||||||
|
self.add_output('input', color=(180, 180, 250))
|
||||||
|
|
||||||
|
def set_arguments(self, arguments: list):
|
||||||
|
"""Add output ports for each argument variable."""
|
||||||
|
for arg in arguments:
|
||||||
|
if arg.variable:
|
||||||
|
# Check if port already exists
|
||||||
|
existing = [p.name() for p in self.output_ports()]
|
||||||
|
if arg.variable not in existing:
|
||||||
|
self.add_output(arg.variable, color=(180, 180, 250))
|
||||||
|
|
||||||
|
|
||||||
|
class PromptNode(CmdForgeBaseNode):
|
||||||
|
"""Node representing a prompt step."""
|
||||||
|
|
||||||
|
NODE_NAME = 'Prompt'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.set_color(102, 126, 234) # Indigo (matching CmdForge theme)
|
||||||
|
self.set_icon(get_prompt_icon_path(16))
|
||||||
|
self.add_input('in', color=(180, 180, 250))
|
||||||
|
self.add_output('out', color=(180, 250, 180))
|
||||||
|
|
||||||
|
# Add properties for display
|
||||||
|
self.add_text_input('provider', 'Provider', text='claude')
|
||||||
|
self.add_text_input('output_var', 'Output', text='response')
|
||||||
|
|
||||||
|
def set_step(self, step: PromptStep, index: int):
|
||||||
|
"""Configure node from a PromptStep."""
|
||||||
|
self._step_data = step
|
||||||
|
self._step_index = index
|
||||||
|
self.set_property('provider', step.provider or 'claude')
|
||||||
|
self.set_property('output_var', step.output_var or 'response')
|
||||||
|
# Update output port name
|
||||||
|
if self.output_ports():
|
||||||
|
self.output_ports()[0]._name = step.output_var or 'response'
|
||||||
|
|
||||||
|
|
||||||
|
class CodeNode(CmdForgeBaseNode):
|
||||||
|
"""Node representing a code step."""
|
||||||
|
|
||||||
|
NODE_NAME = 'Code'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.set_color(72, 187, 120) # Green
|
||||||
|
self.set_icon(get_code_icon_path(16))
|
||||||
|
self.add_input('in', color=(180, 250, 180))
|
||||||
|
self.add_output('out', color=(250, 220, 180))
|
||||||
|
|
||||||
|
# Add properties
|
||||||
|
self.add_text_input('output_var', 'Output', text='result')
|
||||||
|
|
||||||
|
def set_step(self, step: CodeStep, index: int):
|
||||||
|
"""Configure node from a CodeStep."""
|
||||||
|
self._step_data = step
|
||||||
|
self._step_index = index
|
||||||
|
self.set_property('output_var', step.output_var or 'result')
|
||||||
|
# Update output port name
|
||||||
|
if self.output_ports():
|
||||||
|
self.output_ports()[0]._name = step.output_var or 'result'
|
||||||
|
|
||||||
|
|
||||||
|
class OutputNode(CmdForgeBaseNode):
|
||||||
|
"""Node representing tool output."""
|
||||||
|
|
||||||
|
NODE_NAME = 'Output'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.set_color(237, 137, 54) # Orange
|
||||||
|
self.set_icon(get_output_icon_path(16))
|
||||||
|
self.add_input('in', color=(250, 220, 180))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Flow Graph Widget
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class FlowGraphWidget(QWidget):
|
||||||
|
"""Widget for visualizing tool flow as a node graph.
|
||||||
|
|
||||||
|
Note: Currently the flow view is read-only for connections. Reordering
|
||||||
|
steps by manipulating connections is a future enhancement (TODO).
|
||||||
|
Use the list view for reordering steps via drag-drop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Emitted when a node is double-clicked (step_index, step_type)
|
||||||
|
node_double_clicked = Signal(int, str)
|
||||||
|
|
||||||
|
# Emitted when the flow structure changes
|
||||||
|
flow_changed = Signal()
|
||||||
|
|
||||||
|
# Emitted when steps are deleted from flow view (list of step indices)
|
||||||
|
steps_deleted = Signal(list)
|
||||||
|
|
||||||
|
# Emitted when steps are reordered (new order as list of step indices)
|
||||||
|
steps_reordered = Signal(list)
|
||||||
|
|
||||||
|
# Emitted when a step name is changed (step_index, new_name)
|
||||||
|
step_name_changed = Signal(int, str)
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._tool: Optional[Tool] = None
|
||||||
|
self._graph: Optional[NodeGraph] = None
|
||||||
|
self._input_node = None
|
||||||
|
self._output_node = None
|
||||||
|
self._step_nodes: List[CmdForgeBaseNode] = []
|
||||||
|
self._rebuilding = False # Flag to ignore signals during rebuild
|
||||||
|
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
"""Set up the UI."""
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
|
||||||
|
# Create node graph
|
||||||
|
self._graph = NodeGraph()
|
||||||
|
|
||||||
|
# Register custom node types
|
||||||
|
self._graph.register_node(InputNode)
|
||||||
|
self._graph.register_node(PromptNode)
|
||||||
|
self._graph.register_node(CodeNode)
|
||||||
|
self._graph.register_node(OutputNode)
|
||||||
|
|
||||||
|
# Connect signals
|
||||||
|
self._graph.node_double_clicked.connect(self._on_node_double_clicked)
|
||||||
|
self._graph.nodes_deleted.connect(self._on_nodes_deleted)
|
||||||
|
self._graph.property_changed.connect(self._on_property_changed)
|
||||||
|
|
||||||
|
# Add graph widget
|
||||||
|
layout.addWidget(self._graph.widget, 1)
|
||||||
|
|
||||||
|
# Install event filter to catch F key and focus events on the graph widget
|
||||||
|
self._graph.widget.installEventFilter(self)
|
||||||
|
|
||||||
|
# Create floating help banner (overlay on graph widget)
|
||||||
|
self._help_banner = QLabel(self._graph.widget)
|
||||||
|
self._help_banner.setText(
|
||||||
|
"Pan: Middle-drag | "
|
||||||
|
"Zoom: Scroll | "
|
||||||
|
"Select: Click/drag | "
|
||||||
|
"Edit: Double-click | "
|
||||||
|
"Select All: A | "
|
||||||
|
"Fit: F"
|
||||||
|
)
|
||||||
|
self._help_banner.setStyleSheet("""
|
||||||
|
QLabel {
|
||||||
|
background-color: rgba(45, 55, 72, 0.9);
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
self._help_banner.adjustSize()
|
||||||
|
self._help_banner.hide()
|
||||||
|
|
||||||
|
# Animation for fading
|
||||||
|
self._fade_animation = QPropertyAnimation(self._help_banner, b"windowOpacity")
|
||||||
|
self._fade_animation.setDuration(500)
|
||||||
|
self._fade_animation.setEasingCurve(QEasingCurve.InOutQuad)
|
||||||
|
|
||||||
|
# Timer for auto-hide
|
||||||
|
self._hide_timer = QTimer(self)
|
||||||
|
self._hide_timer.setSingleShot(True)
|
||||||
|
self._hide_timer.timeout.connect(self._fade_out_banner)
|
||||||
|
|
||||||
|
# Track if mouse is over banner
|
||||||
|
self._help_banner.setMouseTracking(True)
|
||||||
|
self._help_banner.enterEvent = self._on_banner_enter
|
||||||
|
self._help_banner.leaveEvent = self._on_banner_leave
|
||||||
|
|
||||||
|
# Set up context menu
|
||||||
|
self._graph.widget.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
|
self._graph.widget.customContextMenuRequested.connect(self._show_context_menu)
|
||||||
|
|
||||||
|
def set_tool(self, tool: Optional[Tool]):
|
||||||
|
"""Set the tool to visualize."""
|
||||||
|
self._tool = tool
|
||||||
|
self._rebuild_graph()
|
||||||
|
|
||||||
|
def _rebuild_graph(self):
|
||||||
|
"""Rebuild the graph from the tool data."""
|
||||||
|
if not self._graph:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Set flag to ignore signals during rebuild
|
||||||
|
self._rebuilding = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Clear existing nodes
|
||||||
|
self._graph.clear_session()
|
||||||
|
except Exception:
|
||||||
|
pass # Ignore errors during clear
|
||||||
|
|
||||||
|
self._input_node = None
|
||||||
|
self._output_node = None
|
||||||
|
self._step_nodes = []
|
||||||
|
|
||||||
|
if not self._tool:
|
||||||
|
self._rebuilding = False
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create input node
|
||||||
|
self._input_node = self._graph.create_node(
|
||||||
|
'cmdforge.InputNode',
|
||||||
|
name='Input',
|
||||||
|
pos=[-300, 0]
|
||||||
|
)
|
||||||
|
if self._tool.arguments:
|
||||||
|
self._input_node.set_arguments(self._tool.arguments)
|
||||||
|
|
||||||
|
# Create step nodes
|
||||||
|
x_pos = 0
|
||||||
|
prev_node = self._input_node
|
||||||
|
|
||||||
|
# Count steps by type for default naming
|
||||||
|
prompt_count = 0
|
||||||
|
code_count = 0
|
||||||
|
|
||||||
|
for i, step in enumerate(self._tool.steps or []):
|
||||||
|
if isinstance(step, PromptStep):
|
||||||
|
prompt_count += 1
|
||||||
|
# Use custom name if set, otherwise default to "Prompt N" (per type)
|
||||||
|
node_name = step.name if step.name else f'Prompt {prompt_count}'
|
||||||
|
node = self._graph.create_node(
|
||||||
|
'cmdforge.PromptNode',
|
||||||
|
name=node_name,
|
||||||
|
pos=[x_pos, 0]
|
||||||
|
)
|
||||||
|
node.set_step(step, i)
|
||||||
|
elif isinstance(step, CodeStep):
|
||||||
|
code_count += 1
|
||||||
|
# Use custom name if set, otherwise default to "Code N" (per type)
|
||||||
|
node_name = step.name if step.name else f'Code {code_count}'
|
||||||
|
node = self._graph.create_node(
|
||||||
|
'cmdforge.CodeNode',
|
||||||
|
name=node_name,
|
||||||
|
pos=[x_pos, 0]
|
||||||
|
)
|
||||||
|
node.set_step(step, i)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._step_nodes.append(node)
|
||||||
|
|
||||||
|
# Connect to previous node
|
||||||
|
if prev_node and prev_node.output_ports():
|
||||||
|
prev_node.output_ports()[0].connect_to(node.input_ports()[0])
|
||||||
|
|
||||||
|
prev_node = node
|
||||||
|
x_pos += 250
|
||||||
|
|
||||||
|
# Create output node
|
||||||
|
self._output_node = self._graph.create_node(
|
||||||
|
'cmdforge.OutputNode',
|
||||||
|
name='Output',
|
||||||
|
pos=[x_pos, 0]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Connect last step to output
|
||||||
|
if prev_node and prev_node.output_ports():
|
||||||
|
prev_node.output_ports()[0].connect_to(self._output_node.input_ports()[0])
|
||||||
|
|
||||||
|
# Auto-layout and fit view
|
||||||
|
self._graph.auto_layout_nodes()
|
||||||
|
|
||||||
|
# Select all nodes then fit to selection, then clear selection
|
||||||
|
all_nodes = self._graph.all_nodes()
|
||||||
|
for node in all_nodes:
|
||||||
|
node.set_selected(True)
|
||||||
|
|
||||||
|
# Use a timer to fit after the widget is fully rendered
|
||||||
|
# Also clears the rebuilding flag after everything settles
|
||||||
|
QTimer.singleShot(50, self._fit_and_clear_selection)
|
||||||
|
|
||||||
|
def _fit_and_clear_selection(self):
|
||||||
|
"""Fit view to all nodes and clear selection."""
|
||||||
|
if self._graph:
|
||||||
|
self._graph.fit_to_selection()
|
||||||
|
# Clear selection
|
||||||
|
self._graph.clear_selection()
|
||||||
|
|
||||||
|
# Clear rebuilding flag - now safe to handle user interactions
|
||||||
|
self._rebuilding = False
|
||||||
|
|
||||||
|
def select_all_nodes(self):
|
||||||
|
"""Select all nodes in the graph."""
|
||||||
|
if not self._graph:
|
||||||
|
return
|
||||||
|
|
||||||
|
all_nodes = self._graph.all_nodes()
|
||||||
|
for node in all_nodes:
|
||||||
|
node.set_selected(True)
|
||||||
|
|
||||||
|
def fit_selection(self):
|
||||||
|
"""Fit view to show selected nodes (or all if none selected)."""
|
||||||
|
if not self._graph:
|
||||||
|
return
|
||||||
|
|
||||||
|
selected = self._graph.selected_nodes()
|
||||||
|
if not selected:
|
||||||
|
# No selection - fit all nodes
|
||||||
|
all_nodes = self._graph.all_nodes()
|
||||||
|
if not all_nodes:
|
||||||
|
return
|
||||||
|
for node in all_nodes:
|
||||||
|
node.set_selected(True)
|
||||||
|
self._graph.fit_to_selection()
|
||||||
|
self._graph.clear_selection()
|
||||||
|
else:
|
||||||
|
# Fit to current selection (don't clear it)
|
||||||
|
self._graph.fit_to_selection()
|
||||||
|
|
||||||
|
def keyPressEvent(self, event: QKeyEvent):
|
||||||
|
"""Handle keyboard shortcuts."""
|
||||||
|
if event.key() == Qt.Key_F:
|
||||||
|
self.fit_selection()
|
||||||
|
event.accept()
|
||||||
|
elif event.key() == Qt.Key_A:
|
||||||
|
self.select_all_nodes()
|
||||||
|
event.accept()
|
||||||
|
else:
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
|
||||||
|
def showEvent(self, event):
|
||||||
|
"""Handle widget becoming visible."""
|
||||||
|
super().showEvent(event)
|
||||||
|
# Show help banner when flow view becomes visible
|
||||||
|
QTimer.singleShot(100, self._show_help_banner)
|
||||||
|
|
||||||
|
def eventFilter(self, obj, event):
|
||||||
|
"""Filter events from the graph widget to catch keyboard shortcuts."""
|
||||||
|
if event.type() == QEvent.KeyPress:
|
||||||
|
if event.key() == Qt.Key_F:
|
||||||
|
self.fit_selection()
|
||||||
|
return True # Event handled
|
||||||
|
elif event.key() == Qt.Key_A:
|
||||||
|
self.select_all_nodes()
|
||||||
|
return True # Event handled
|
||||||
|
elif event.type() == QEvent.Enter:
|
||||||
|
# Show banner when mouse enters the graph area
|
||||||
|
self._show_help_banner()
|
||||||
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
|
def _show_help_banner(self):
|
||||||
|
"""Show the help banner with fade effect."""
|
||||||
|
if not self._help_banner or not self._graph:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Make sure size is calculated
|
||||||
|
self._help_banner.adjustSize()
|
||||||
|
|
||||||
|
# Position at top center of the graph widget
|
||||||
|
parent_width = self._graph.widget.width()
|
||||||
|
banner_width = self._help_banner.width()
|
||||||
|
x = max(10, (parent_width - banner_width) // 2)
|
||||||
|
self._help_banner.move(x, 10)
|
||||||
|
|
||||||
|
# Raise to front and show
|
||||||
|
self._help_banner.raise_()
|
||||||
|
self._help_banner.show()
|
||||||
|
|
||||||
|
# Start auto-hide timer
|
||||||
|
self._hide_timer.start(3000) # Hide after 3 seconds
|
||||||
|
|
||||||
|
def _fade_out_banner(self):
|
||||||
|
"""Fade out the help banner."""
|
||||||
|
if self._help_banner and self._help_banner.isVisible():
|
||||||
|
# Use a simple hide with timer since windowOpacity doesn't work well on child widgets
|
||||||
|
self._help_banner.hide()
|
||||||
|
|
||||||
|
def _on_banner_enter(self, event):
|
||||||
|
"""Mouse entered banner - stop hide timer."""
|
||||||
|
self._hide_timer.stop()
|
||||||
|
|
||||||
|
def _on_banner_leave(self, event):
|
||||||
|
"""Mouse left banner - restart hide timer."""
|
||||||
|
self._hide_timer.start(1500) # Hide 1.5 seconds after mouse leaves
|
||||||
|
|
||||||
|
def _show_context_menu(self, pos):
|
||||||
|
"""Show context menu."""
|
||||||
|
menu = QMenu(self._graph.widget)
|
||||||
|
|
||||||
|
select_all_action = QAction("Select All (A)", menu)
|
||||||
|
select_all_action.triggered.connect(self.select_all_nodes)
|
||||||
|
menu.addAction(select_all_action)
|
||||||
|
|
||||||
|
fit_action = QAction("Fit Selection (F)", menu)
|
||||||
|
fit_action.triggered.connect(self.fit_selection)
|
||||||
|
menu.addAction(fit_action)
|
||||||
|
|
||||||
|
menu.exec_(self._graph.widget.mapToGlobal(pos))
|
||||||
|
|
||||||
|
def _on_node_double_clicked(self, node):
|
||||||
|
"""Handle node double-click."""
|
||||||
|
if hasattr(node, '_step_index') and node._step_index >= 0:
|
||||||
|
step_type = 'prompt' if isinstance(node, PromptNode) else 'code'
|
||||||
|
self.node_double_clicked.emit(node._step_index, step_type)
|
||||||
|
|
||||||
|
def _on_nodes_deleted(self, nodes):
|
||||||
|
"""Handle node deletion from the graph."""
|
||||||
|
# Ignore during rebuild
|
||||||
|
if self._rebuilding:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Collect step indices of deleted step nodes (not Input/Output nodes)
|
||||||
|
deleted_indices = []
|
||||||
|
for node in nodes:
|
||||||
|
if hasattr(node, '_step_index') and node._step_index >= 0:
|
||||||
|
deleted_indices.append(node._step_index)
|
||||||
|
|
||||||
|
if deleted_indices:
|
||||||
|
# Sort in reverse order so we delete from end first
|
||||||
|
deleted_indices.sort(reverse=True)
|
||||||
|
self.steps_deleted.emit(deleted_indices)
|
||||||
|
self.flow_changed.emit()
|
||||||
|
|
||||||
|
def _on_property_changed(self, node, prop_name, value):
|
||||||
|
"""Handle node property change.
|
||||||
|
|
||||||
|
Filters for 'name' property to detect step name changes.
|
||||||
|
"""
|
||||||
|
# Ignore during rebuild
|
||||||
|
if self._rebuilding:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only interested in name changes
|
||||||
|
if prop_name != 'name':
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only handle step nodes (not Input/Output)
|
||||||
|
if hasattr(node, '_step_index') and node._step_index >= 0:
|
||||||
|
self.step_name_changed.emit(node._step_index, value)
|
||||||
|
self.flow_changed.emit()
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
"""Refresh the graph from current tool data."""
|
||||||
|
self._rebuild_graph()
|
||||||
|
|
||||||
|
def get_graph(self) -> Optional[NodeGraph]:
|
||||||
|
"""Get the underlying NodeGraph instance."""
|
||||||
|
return self._graph
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
"""Step type icons for CmdForge GUI."""
|
||||||
|
|
||||||
|
from PySide6.QtGui import QIcon, QPixmap, QPainter, QColor, QPen, QBrush, QPainterPath, QFont
|
||||||
|
from PySide6.QtCore import Qt, QRect, QRectF
|
||||||
|
|
||||||
|
|
||||||
|
def create_prompt_icon(size: int = 24, color: QColor = None) -> QIcon:
|
||||||
|
"""Create a speech bubble icon for prompt steps.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size: Icon size in pixels
|
||||||
|
color: Icon color (defaults to indigo #667eea)
|
||||||
|
"""
|
||||||
|
if color is None:
|
||||||
|
color = QColor(102, 126, 234) # Indigo
|
||||||
|
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.transparent)
|
||||||
|
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
|
||||||
|
# Draw speech bubble
|
||||||
|
pen = QPen(color, 1.5)
|
||||||
|
painter.setPen(pen)
|
||||||
|
painter.setBrush(QBrush(color))
|
||||||
|
|
||||||
|
# Bubble body
|
||||||
|
margin = size * 0.1
|
||||||
|
bubble_rect = QRectF(margin, margin, size - 2 * margin, size * 0.65)
|
||||||
|
painter.drawRoundedRect(bubble_rect, 4, 4)
|
||||||
|
|
||||||
|
# Bubble tail (triangle pointing down-left)
|
||||||
|
path = QPainterPath()
|
||||||
|
tail_x = margin + size * 0.2
|
||||||
|
tail_y = bubble_rect.bottom()
|
||||||
|
path.moveTo(tail_x, tail_y - 2)
|
||||||
|
path.lineTo(tail_x - size * 0.1, tail_y + size * 0.2)
|
||||||
|
path.lineTo(tail_x + size * 0.15, tail_y - 2)
|
||||||
|
path.closeSubpath()
|
||||||
|
painter.drawPath(path)
|
||||||
|
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
|
||||||
|
def create_code_icon(size: int = 24, color: QColor = None) -> QIcon:
|
||||||
|
"""Create a code brackets icon for code steps.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size: Icon size in pixels
|
||||||
|
color: Icon color (defaults to green #48bb78)
|
||||||
|
"""
|
||||||
|
if color is None:
|
||||||
|
color = QColor(72, 187, 120) # Green
|
||||||
|
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.transparent)
|
||||||
|
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
|
||||||
|
pen = QPen(color, 2.0)
|
||||||
|
pen.setCapStyle(Qt.RoundCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
|
||||||
|
# Draw < > brackets
|
||||||
|
margin = size * 0.15
|
||||||
|
mid_y = size / 2
|
||||||
|
bracket_height = size * 0.5
|
||||||
|
|
||||||
|
# Left bracket <
|
||||||
|
left_x = margin + size * 0.1
|
||||||
|
painter.drawLine(
|
||||||
|
int(left_x + size * 0.2), int(mid_y - bracket_height / 2),
|
||||||
|
int(left_x), int(mid_y)
|
||||||
|
)
|
||||||
|
painter.drawLine(
|
||||||
|
int(left_x), int(mid_y),
|
||||||
|
int(left_x + size * 0.2), int(mid_y + bracket_height / 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Right bracket >
|
||||||
|
right_x = size - margin - size * 0.1
|
||||||
|
painter.drawLine(
|
||||||
|
int(right_x - size * 0.2), int(mid_y - bracket_height / 2),
|
||||||
|
int(right_x), int(mid_y)
|
||||||
|
)
|
||||||
|
painter.drawLine(
|
||||||
|
int(right_x), int(mid_y),
|
||||||
|
int(right_x - size * 0.2), int(mid_y + bracket_height / 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Slash in middle /
|
||||||
|
slash_width = size * 0.12
|
||||||
|
painter.drawLine(
|
||||||
|
int(size / 2 + slash_width), int(mid_y - bracket_height / 2.5),
|
||||||
|
int(size / 2 - slash_width), int(mid_y + bracket_height / 2.5)
|
||||||
|
)
|
||||||
|
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
|
||||||
|
def create_input_icon(size: int = 24, color: QColor = None) -> QIcon:
|
||||||
|
"""Create an input icon (arrow pointing right into box).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size: Icon size in pixels
|
||||||
|
color: Icon color (defaults to indigo #5a5a9f)
|
||||||
|
"""
|
||||||
|
if color is None:
|
||||||
|
color = QColor(90, 90, 160) # Indigo
|
||||||
|
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.transparent)
|
||||||
|
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
|
||||||
|
pen = QPen(color, 2.0)
|
||||||
|
pen.setCapStyle(Qt.RoundCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
|
||||||
|
margin = size * 0.15
|
||||||
|
mid_y = size / 2
|
||||||
|
|
||||||
|
# Arrow pointing right
|
||||||
|
arrow_start = margin
|
||||||
|
arrow_end = size - margin - size * 0.15
|
||||||
|
|
||||||
|
# Arrow shaft
|
||||||
|
painter.drawLine(int(arrow_start), int(mid_y), int(arrow_end), int(mid_y))
|
||||||
|
|
||||||
|
# Arrow head
|
||||||
|
head_size = size * 0.2
|
||||||
|
painter.drawLine(
|
||||||
|
int(arrow_end), int(mid_y),
|
||||||
|
int(arrow_end - head_size), int(mid_y - head_size)
|
||||||
|
)
|
||||||
|
painter.drawLine(
|
||||||
|
int(arrow_end), int(mid_y),
|
||||||
|
int(arrow_end - head_size), int(mid_y + head_size)
|
||||||
|
)
|
||||||
|
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
|
||||||
|
def create_output_icon(size: int = 24, color: QColor = None) -> QIcon:
|
||||||
|
"""Create an output icon (arrow pointing right out of box).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size: Icon size in pixels
|
||||||
|
color: Icon color (defaults to orange #ed8936)
|
||||||
|
"""
|
||||||
|
if color is None:
|
||||||
|
color = QColor(237, 137, 54) # Orange
|
||||||
|
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.transparent)
|
||||||
|
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
|
||||||
|
pen = QPen(color, 2.0)
|
||||||
|
pen.setCapStyle(Qt.RoundCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
|
||||||
|
margin = size * 0.15
|
||||||
|
mid_y = size / 2
|
||||||
|
|
||||||
|
# Arrow pointing right (export style)
|
||||||
|
arrow_start = margin + size * 0.15
|
||||||
|
arrow_end = size - margin
|
||||||
|
|
||||||
|
# Arrow shaft
|
||||||
|
painter.drawLine(int(arrow_start), int(mid_y), int(arrow_end), int(mid_y))
|
||||||
|
|
||||||
|
# Arrow head
|
||||||
|
head_size = size * 0.2
|
||||||
|
painter.drawLine(
|
||||||
|
int(arrow_end), int(mid_y),
|
||||||
|
int(arrow_end - head_size), int(mid_y - head_size)
|
||||||
|
)
|
||||||
|
painter.drawLine(
|
||||||
|
int(arrow_end), int(mid_y),
|
||||||
|
int(arrow_end - head_size), int(mid_y + head_size)
|
||||||
|
)
|
||||||
|
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
|
||||||
|
# Cached icons for reuse
|
||||||
|
_icon_cache = {}
|
||||||
|
|
||||||
|
# Cached icon file paths for NodeGraphQt (which requires file paths)
|
||||||
|
_icon_path_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_icon_path(icon_type: str, size: int, create_func) -> str:
|
||||||
|
"""Get or create a cached icon file path.
|
||||||
|
|
||||||
|
NodeGraphQt requires file paths for icons, so we save to temp files.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
key = (icon_type, size)
|
||||||
|
if key not in _icon_path_cache:
|
||||||
|
icon = create_func(size)
|
||||||
|
pixmap = icon.pixmap(size, size)
|
||||||
|
|
||||||
|
# Save to temp directory with a stable name
|
||||||
|
temp_dir = os.path.join(tempfile.gettempdir(), 'cmdforge_icons')
|
||||||
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
|
|
||||||
|
path = os.path.join(temp_dir, f'{icon_type}_{size}.png')
|
||||||
|
pixmap.save(path, 'PNG')
|
||||||
|
_icon_path_cache[key] = path
|
||||||
|
|
||||||
|
return _icon_path_cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt_icon_path(size: int = 16) -> str:
|
||||||
|
"""Get file path to prompt icon (for NodeGraphQt)."""
|
||||||
|
return _get_icon_path('prompt', size, create_prompt_icon)
|
||||||
|
|
||||||
|
|
||||||
|
def get_code_icon_path(size: int = 16) -> str:
|
||||||
|
"""Get file path to code icon (for NodeGraphQt)."""
|
||||||
|
return _get_icon_path('code', size, create_code_icon)
|
||||||
|
|
||||||
|
|
||||||
|
def get_input_icon_path(size: int = 16) -> str:
|
||||||
|
"""Get file path to input icon (for NodeGraphQt)."""
|
||||||
|
return _get_icon_path('input', size, create_input_icon)
|
||||||
|
|
||||||
|
|
||||||
|
def get_output_icon_path(size: int = 16) -> str:
|
||||||
|
"""Get file path to output icon (for NodeGraphQt)."""
|
||||||
|
return _get_icon_path('output', size, create_output_icon)
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt_icon(size: int = 24) -> QIcon:
|
||||||
|
"""Get cached prompt icon."""
|
||||||
|
key = ('prompt', size)
|
||||||
|
if key not in _icon_cache:
|
||||||
|
_icon_cache[key] = create_prompt_icon(size)
|
||||||
|
return _icon_cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def get_code_icon(size: int = 24) -> QIcon:
|
||||||
|
"""Get cached code icon."""
|
||||||
|
key = ('code', size)
|
||||||
|
if key not in _icon_cache:
|
||||||
|
_icon_cache[key] = create_code_icon(size)
|
||||||
|
return _icon_cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def get_input_icon(size: int = 24) -> QIcon:
|
||||||
|
"""Get cached input icon."""
|
||||||
|
key = ('input', size)
|
||||||
|
if key not in _icon_cache:
|
||||||
|
_icon_cache[key] = create_input_icon(size)
|
||||||
|
return _icon_cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def get_output_icon(size: int = 24) -> QIcon:
|
||||||
|
"""Get cached output icon."""
|
||||||
|
key = ('output', size)
|
||||||
|
if key not in _icon_cache:
|
||||||
|
_icon_cache[key] = create_output_icon(size)
|
||||||
|
return _icon_cache[key]
|
||||||
|
|
@ -50,6 +50,7 @@ class PromptStep:
|
||||||
output_var: str # Variable to store output
|
output_var: str # Variable to store output
|
||||||
prompt_file: Optional[str] = None # Optional filename for external prompt
|
prompt_file: Optional[str] = None # Optional filename for external prompt
|
||||||
profile: Optional[str] = None # Optional AI persona profile name
|
profile: Optional[str] = None # Optional AI persona profile name
|
||||||
|
name: Optional[str] = None # Optional display name for the step
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = {
|
d = {
|
||||||
|
|
@ -62,6 +63,8 @@ class PromptStep:
|
||||||
d["prompt_file"] = self.prompt_file
|
d["prompt_file"] = self.prompt_file
|
||||||
if self.profile:
|
if self.profile:
|
||||||
d["profile"] = self.profile
|
d["profile"] = self.profile
|
||||||
|
if self.name:
|
||||||
|
d["name"] = self.name
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -71,7 +74,8 @@ class PromptStep:
|
||||||
provider=data["provider"],
|
provider=data["provider"],
|
||||||
output_var=data["output_var"],
|
output_var=data["output_var"],
|
||||||
prompt_file=data.get("prompt_file"),
|
prompt_file=data.get("prompt_file"),
|
||||||
profile=data.get("profile")
|
profile=data.get("profile"),
|
||||||
|
name=data.get("name")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -81,6 +85,7 @@ class CodeStep:
|
||||||
code: str # Python code (inline or loaded from file)
|
code: str # Python code (inline or loaded from file)
|
||||||
output_var: str # Variable name(s) to capture (comma-separated for multiple)
|
output_var: str # Variable name(s) to capture (comma-separated for multiple)
|
||||||
code_file: Optional[str] = None # Optional filename for external code
|
code_file: Optional[str] = None # Optional filename for external code
|
||||||
|
name: Optional[str] = None # Optional display name for the step
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = {
|
d = {
|
||||||
|
|
@ -90,6 +95,8 @@ class CodeStep:
|
||||||
}
|
}
|
||||||
if self.code_file:
|
if self.code_file:
|
||||||
d["code_file"] = self.code_file
|
d["code_file"] = self.code_file
|
||||||
|
if self.name:
|
||||||
|
d["name"] = self.name
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -97,7 +104,8 @@ class CodeStep:
|
||||||
return cls(
|
return cls(
|
||||||
code=data.get("code", ""),
|
code=data.get("code", ""),
|
||||||
output_var=data["output_var"],
|
output_var=data["output_var"],
|
||||||
code_file=data.get("code_file")
|
code_file=data.get("code_file"),
|
||||||
|
name=data.get("name")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -109,6 +117,7 @@ class ToolStep:
|
||||||
input_template: str = "{input}" # Input template (supports variable substitution)
|
input_template: str = "{input}" # Input template (supports variable substitution)
|
||||||
args: dict = field(default_factory=dict) # Arguments to pass to the tool
|
args: dict = field(default_factory=dict) # Arguments to pass to the tool
|
||||||
provider: Optional[str] = None # Provider override for the called tool
|
provider: Optional[str] = None # Provider override for the called tool
|
||||||
|
name: Optional[str] = None # Optional display name for the step
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = {
|
d = {
|
||||||
|
|
@ -122,6 +131,8 @@ class ToolStep:
|
||||||
d["args"] = self.args
|
d["args"] = self.args
|
||||||
if self.provider:
|
if self.provider:
|
||||||
d["provider"] = self.provider
|
d["provider"] = self.provider
|
||||||
|
if self.name:
|
||||||
|
d["name"] = self.name
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -131,7 +142,8 @@ class ToolStep:
|
||||||
output_var=data["output_var"],
|
output_var=data["output_var"],
|
||||||
input_template=data.get("input", "{input}"),
|
input_template=data.get("input", "{input}"),
|
||||||
args=data.get("args", {}),
|
args=data.get("args", {}),
|
||||||
provider=data.get("provider")
|
provider=data.get("provider"),
|
||||||
|
name=data.get("name")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue