395 lines
13 KiB
Python
395 lines
13 KiB
Python
"""Tool builder page - create and edit tools."""
|
|
|
|
from PySide6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout,
|
|
QLineEdit, QTextEdit, QComboBox, QPushButton,
|
|
QGroupBox, QListWidget, QListWidgetItem, QLabel,
|
|
QMessageBox, QSplitter, QFrame
|
|
)
|
|
from PySide6.QtCore import Qt
|
|
|
|
from ...tool import (
|
|
Tool, ToolArgument, PromptStep, CodeStep,
|
|
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES
|
|
)
|
|
|
|
|
|
class ToolBuilderPage(QWidget):
|
|
"""Tool builder/editor page."""
|
|
|
|
def __init__(self, main_window, tool_name: str = None):
|
|
super().__init__()
|
|
self.main_window = main_window
|
|
self.editing = tool_name is not None
|
|
self.original_name = tool_name
|
|
self._tool = None
|
|
|
|
self._setup_ui()
|
|
|
|
if tool_name:
|
|
self._load_tool(tool_name)
|
|
|
|
def _setup_ui(self):
|
|
"""Set up the UI."""
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(24, 24, 24, 24)
|
|
layout.setSpacing(16)
|
|
|
|
# Header
|
|
header = QWidget()
|
|
header_layout = QHBoxLayout(header)
|
|
header_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
title = QLabel("Edit Tool" if self.editing else "Create Tool")
|
|
title.setObjectName("heading")
|
|
header_layout.addWidget(title)
|
|
|
|
header_layout.addStretch()
|
|
|
|
self.btn_cancel = QPushButton("Cancel")
|
|
self.btn_cancel.setObjectName("secondary")
|
|
self.btn_cancel.clicked.connect(self._cancel)
|
|
header_layout.addWidget(self.btn_cancel)
|
|
|
|
self.btn_save = QPushButton("Save")
|
|
self.btn_save.clicked.connect(self._save)
|
|
header_layout.addWidget(self.btn_save)
|
|
|
|
layout.addWidget(header)
|
|
|
|
# Main form splitter
|
|
splitter = QSplitter(Qt.Horizontal)
|
|
|
|
# Left: Basic info
|
|
left = QWidget()
|
|
left_layout = QVBoxLayout(left)
|
|
left_layout.setContentsMargins(0, 0, 0, 0)
|
|
left_layout.setSpacing(16)
|
|
|
|
# Basic info group
|
|
info_box = QGroupBox("Basic Information")
|
|
info_layout = QFormLayout(info_box)
|
|
info_layout.setSpacing(12)
|
|
|
|
self.name_input = QLineEdit()
|
|
self.name_input.setPlaceholderText("my-tool")
|
|
info_layout.addRow("Name:", self.name_input)
|
|
|
|
self.desc_input = QLineEdit()
|
|
self.desc_input.setPlaceholderText("A brief description of what this tool does")
|
|
info_layout.addRow("Description:", self.desc_input)
|
|
|
|
self.category_combo = QComboBox()
|
|
self.category_combo.setEditable(True)
|
|
for cat in DEFAULT_CATEGORIES:
|
|
self.category_combo.addItem(cat)
|
|
info_layout.addRow("Category:", self.category_combo)
|
|
|
|
left_layout.addWidget(info_box)
|
|
|
|
# Arguments group
|
|
args_box = QGroupBox("Arguments")
|
|
args_layout = QVBoxLayout(args_box)
|
|
|
|
self.args_list = QListWidget()
|
|
self.args_list.itemDoubleClicked.connect(self._edit_argument)
|
|
args_layout.addWidget(self.args_list)
|
|
|
|
args_btns = QHBoxLayout()
|
|
self.btn_add_arg = QPushButton("Add")
|
|
self.btn_add_arg.clicked.connect(self._add_argument)
|
|
args_btns.addWidget(self.btn_add_arg)
|
|
|
|
self.btn_edit_arg = QPushButton("Edit")
|
|
self.btn_edit_arg.setObjectName("secondary")
|
|
self.btn_edit_arg.clicked.connect(self._edit_argument)
|
|
args_btns.addWidget(self.btn_edit_arg)
|
|
|
|
self.btn_del_arg = QPushButton("Delete")
|
|
self.btn_del_arg.setObjectName("danger")
|
|
self.btn_del_arg.clicked.connect(self._delete_argument)
|
|
args_btns.addWidget(self.btn_del_arg)
|
|
|
|
args_btns.addStretch()
|
|
args_layout.addLayout(args_btns)
|
|
|
|
left_layout.addWidget(args_box)
|
|
|
|
splitter.addWidget(left)
|
|
|
|
# Right: Steps and output
|
|
right = QWidget()
|
|
right_layout = QVBoxLayout(right)
|
|
right_layout.setContentsMargins(0, 0, 0, 0)
|
|
right_layout.setSpacing(16)
|
|
|
|
# Steps group
|
|
steps_box = QGroupBox("Steps")
|
|
steps_layout = QVBoxLayout(steps_box)
|
|
|
|
self.steps_list = QListWidget()
|
|
self.steps_list.itemDoubleClicked.connect(self._edit_step)
|
|
steps_layout.addWidget(self.steps_list)
|
|
|
|
steps_btns = QHBoxLayout()
|
|
self.btn_add_prompt = QPushButton("Add Prompt")
|
|
self.btn_add_prompt.clicked.connect(self._add_prompt_step)
|
|
steps_btns.addWidget(self.btn_add_prompt)
|
|
|
|
self.btn_add_code = QPushButton("Add Code")
|
|
self.btn_add_code.clicked.connect(self._add_code_step)
|
|
steps_btns.addWidget(self.btn_add_code)
|
|
|
|
self.btn_edit_step = QPushButton("Edit")
|
|
self.btn_edit_step.setObjectName("secondary")
|
|
self.btn_edit_step.clicked.connect(self._edit_step)
|
|
steps_btns.addWidget(self.btn_edit_step)
|
|
|
|
self.btn_del_step = QPushButton("Delete")
|
|
self.btn_del_step.setObjectName("danger")
|
|
self.btn_del_step.clicked.connect(self._delete_step)
|
|
steps_btns.addWidget(self.btn_del_step)
|
|
|
|
steps_btns.addStretch()
|
|
steps_layout.addLayout(steps_btns)
|
|
|
|
right_layout.addWidget(steps_box, 1) # Steps box gets stretch priority
|
|
|
|
# Output group
|
|
output_box = QGroupBox("Output Template")
|
|
output_layout = QVBoxLayout(output_box)
|
|
|
|
self.output_input = QTextEdit()
|
|
self.output_input.setPlaceholderText("Use {variable} to reference step outputs, e.g. {response}")
|
|
self.output_input.setPlainText("{response}") # Default value
|
|
self.output_input.setMinimumHeight(80)
|
|
self.output_input.setMaximumHeight(120)
|
|
output_layout.addWidget(self.output_input)
|
|
|
|
right_layout.addWidget(output_box, 0) # Output box stays fixed size
|
|
|
|
splitter.addWidget(right)
|
|
splitter.setSizes([400, 600])
|
|
|
|
layout.addWidget(splitter, 1)
|
|
|
|
def _load_tool(self, name: str):
|
|
"""Load an existing tool for editing."""
|
|
tool = load_tool(name)
|
|
if not tool:
|
|
QMessageBox.critical(self, "Error", f"Tool '{name}' not found")
|
|
self._cancel()
|
|
return
|
|
|
|
self._tool = tool
|
|
self.name_input.setText(tool.name)
|
|
self.name_input.setEnabled(False) # Can't rename
|
|
self.desc_input.setText(tool.description or "")
|
|
|
|
# Set category
|
|
if tool.category:
|
|
idx = self.category_combo.findText(tool.category)
|
|
if idx >= 0:
|
|
self.category_combo.setCurrentIndex(idx)
|
|
else:
|
|
self.category_combo.setCurrentText(tool.category)
|
|
|
|
# Load arguments
|
|
self._refresh_arguments()
|
|
|
|
# Load steps
|
|
self._refresh_steps()
|
|
|
|
# Set output
|
|
self.output_input.setPlainText(tool.output or "{response}")
|
|
|
|
def _refresh_arguments(self):
|
|
"""Refresh arguments list."""
|
|
self.args_list.clear()
|
|
if self._tool and self._tool.arguments:
|
|
for arg in self._tool.arguments:
|
|
item = QListWidgetItem(f"{arg.flag} → ${arg.variable}")
|
|
item.setData(Qt.UserRole, arg)
|
|
self.args_list.addItem(item)
|
|
|
|
def _refresh_steps(self):
|
|
"""Refresh steps list."""
|
|
self.steps_list.clear()
|
|
if self._tool and self._tool.steps:
|
|
for i, step in enumerate(self._tool.steps, 1):
|
|
if isinstance(step, PromptStep):
|
|
text = f"{i}. Prompt [{step.provider}] → ${step.output_var}"
|
|
elif isinstance(step, CodeStep):
|
|
text = f"{i}. Code [python] → ${step.output_var}"
|
|
else:
|
|
text = f"{i}. Unknown step"
|
|
item = QListWidgetItem(text)
|
|
item.setData(Qt.UserRole, step)
|
|
self.steps_list.addItem(item)
|
|
|
|
def _add_argument(self):
|
|
"""Add a new argument."""
|
|
from ..dialogs.argument_dialog import ArgumentDialog
|
|
dialog = ArgumentDialog(self)
|
|
if dialog.exec():
|
|
arg = dialog.get_argument()
|
|
if not self._tool:
|
|
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
|
|
self._tool.arguments.append(arg)
|
|
self._refresh_arguments()
|
|
|
|
def _edit_argument(self):
|
|
"""Edit selected argument."""
|
|
items = self.args_list.selectedItems()
|
|
if not items:
|
|
return
|
|
|
|
arg = items[0].data(Qt.UserRole)
|
|
idx = self.args_list.row(items[0])
|
|
|
|
from ..dialogs.argument_dialog import ArgumentDialog
|
|
dialog = ArgumentDialog(self, arg)
|
|
if dialog.exec():
|
|
self._tool.arguments[idx] = dialog.get_argument()
|
|
self._refresh_arguments()
|
|
|
|
def _delete_argument(self):
|
|
"""Delete selected argument."""
|
|
items = self.args_list.selectedItems()
|
|
if not items:
|
|
return
|
|
|
|
idx = self.args_list.row(items[0])
|
|
del self._tool.arguments[idx]
|
|
self._refresh_arguments()
|
|
|
|
def _get_available_vars(self, up_to_step: int = -1) -> list:
|
|
"""Get available variables for a step.
|
|
|
|
Args:
|
|
up_to_step: Include variables from steps before this index.
|
|
-1 means include all existing steps (for new steps).
|
|
"""
|
|
vars_list = ["input"]
|
|
|
|
# Add argument variables
|
|
if self._tool and self._tool.arguments:
|
|
for arg in self._tool.arguments:
|
|
if arg.variable and arg.variable not in vars_list:
|
|
vars_list.append(arg.variable)
|
|
|
|
# Add output variables from previous steps
|
|
if self._tool and self._tool.steps:
|
|
steps_to_check = self._tool.steps if up_to_step < 0 else self._tool.steps[:up_to_step]
|
|
for step in steps_to_check:
|
|
if hasattr(step, 'output_var') and step.output_var:
|
|
# Handle comma-separated output vars
|
|
for var in step.output_var.split(','):
|
|
var = var.strip()
|
|
if var and var not in vars_list:
|
|
vars_list.append(var)
|
|
|
|
return vars_list
|
|
|
|
def _add_prompt_step(self):
|
|
"""Add a prompt step."""
|
|
from ..dialogs.step_dialog import PromptStepDialog
|
|
dialog = PromptStepDialog(self)
|
|
if dialog.exec():
|
|
step = dialog.get_step()
|
|
if not self._tool:
|
|
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
|
|
self._tool.steps.append(step)
|
|
self._refresh_steps()
|
|
|
|
def _add_code_step(self):
|
|
"""Add a code step."""
|
|
from ..dialogs.step_dialog import CodeStepDialog
|
|
available_vars = self._get_available_vars()
|
|
dialog = CodeStepDialog(self, available_vars=available_vars)
|
|
if dialog.exec():
|
|
step = dialog.get_step()
|
|
if not self._tool:
|
|
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
|
|
self._tool.steps.append(step)
|
|
self._refresh_steps()
|
|
|
|
def _edit_step(self):
|
|
"""Edit selected step."""
|
|
items = self.steps_list.selectedItems()
|
|
if not items:
|
|
return
|
|
|
|
step = items[0].data(Qt.UserRole)
|
|
idx = self.steps_list.row(items[0])
|
|
|
|
if isinstance(step, PromptStep):
|
|
from ..dialogs.step_dialog import PromptStepDialog
|
|
dialog = PromptStepDialog(self, step)
|
|
elif isinstance(step, CodeStep):
|
|
from ..dialogs.step_dialog import CodeStepDialog
|
|
available_vars = self._get_available_vars(up_to_step=idx)
|
|
dialog = CodeStepDialog(self, step, available_vars=available_vars)
|
|
else:
|
|
return
|
|
|
|
if dialog.exec():
|
|
self._tool.steps[idx] = dialog.get_step()
|
|
self._refresh_steps()
|
|
|
|
def _delete_step(self):
|
|
"""Delete selected step."""
|
|
items = self.steps_list.selectedItems()
|
|
if not items:
|
|
return
|
|
|
|
idx = self.steps_list.row(items[0])
|
|
del self._tool.steps[idx]
|
|
self._refresh_steps()
|
|
|
|
def _save(self):
|
|
"""Save the tool."""
|
|
name = self.name_input.text().strip()
|
|
if not name:
|
|
QMessageBox.warning(self, "Validation", "Tool name is required")
|
|
return
|
|
|
|
# Validate name
|
|
error = validate_tool_name(name)
|
|
if error:
|
|
QMessageBox.warning(self, "Validation", error)
|
|
return
|
|
|
|
description = self.desc_input.text().strip()
|
|
category = self.category_combo.currentText()
|
|
output = self.output_input.toPlainText().strip() or "{response}"
|
|
|
|
# Build tool object
|
|
tool = Tool(
|
|
name=name,
|
|
description=description,
|
|
category=category,
|
|
arguments=self._tool.arguments if self._tool else [],
|
|
steps=self._tool.steps if self._tool else [],
|
|
output=output
|
|
)
|
|
|
|
# Preserve source if editing
|
|
if self._tool and self._tool.source:
|
|
tool.source = self._tool.source
|
|
|
|
try:
|
|
save_tool(tool)
|
|
self.main_window.show_status(f"Saved tool '{name}'")
|
|
self.main_window.close_tool_builder()
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Error", f"Failed to save tool:\n{e}")
|
|
|
|
def _cancel(self):
|
|
"""Cancel and return to tools page."""
|
|
self.main_window.close_tool_builder()
|
|
|
|
def save_tool(self):
|
|
"""Public method for keyboard shortcut to save the tool."""
|
|
self._save()
|