1035 lines
37 KiB
Python
1035 lines
37 KiB
Python
"""Step editor dialogs."""
|
|
|
|
import ast
|
|
import json
|
|
|
|
from PySide6.QtWidgets import (
|
|
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
|
|
QComboBox, QPushButton, QHBoxLayout, QLabel,
|
|
QPlainTextEdit, QSplitter, QGroupBox, QTextEdit, QMessageBox,
|
|
QCheckBox, QSpinBox, QTableWidget, QTableWidgetItem, QHeaderView,
|
|
QWidget, QAbstractItemView
|
|
)
|
|
from PySide6.QtCore import Qt, QThread, Signal
|
|
|
|
from ...tool import PromptStep, CodeStep, ToolStep, list_tools, load_tool
|
|
from ...providers import load_providers, call_provider
|
|
from ...profiles import list_profiles
|
|
|
|
|
|
class SchemaBuilderDialog(QDialog):
|
|
"""Visual builder for JSON output schemas."""
|
|
|
|
TYPES = ["string", "number", "integer", "boolean", "array", "object"]
|
|
|
|
def __init__(self, parent, schema: dict = None):
|
|
super().__init__(parent)
|
|
self.setWindowTitle("Output Schema Builder")
|
|
self.setMinimumSize(850, 500)
|
|
self._setup_ui()
|
|
|
|
if schema:
|
|
self._load_schema(schema)
|
|
|
|
def _setup_ui(self):
|
|
"""Set up the UI."""
|
|
layout = QVBoxLayout(self)
|
|
layout.setSpacing(12)
|
|
|
|
# Instructions
|
|
info = QLabel(
|
|
"Define the fields that the AI should return. "
|
|
"The AI will be instructed to respond with JSON matching this schema."
|
|
)
|
|
info.setWordWrap(True)
|
|
info.setStyleSheet("color: #718096;")
|
|
layout.addWidget(info)
|
|
|
|
# Fields table
|
|
self.table = QTableWidget()
|
|
self.table.setColumnCount(5)
|
|
self.table.setHorizontalHeaderLabels(["Field Name", "Type", "Description", "Required", "Delete"])
|
|
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Interactive)
|
|
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Interactive)
|
|
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
|
|
self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Fixed)
|
|
self.table.horizontalHeader().setSectionResizeMode(4, QHeaderView.Fixed)
|
|
self.table.setColumnWidth(0, 150) # Field Name
|
|
self.table.setColumnWidth(1, 120) # Type
|
|
self.table.setColumnWidth(3, 80) # Required checkbox
|
|
self.table.setColumnWidth(4, 100) # Delete button
|
|
self.table.verticalHeader().setDefaultSectionSize(50) # Row height
|
|
self.table.setSelectionMode(QAbstractItemView.NoSelection) # No selection needed
|
|
self.table.setFocusPolicy(Qt.NoFocus) # Remove focus rectangle
|
|
self.table.verticalHeader().setVisible(False)
|
|
layout.addWidget(self.table, 1)
|
|
|
|
# Add field button
|
|
btn_row = QHBoxLayout()
|
|
self.btn_add = QPushButton("+ Add Field")
|
|
self.btn_add.clicked.connect(self._add_field)
|
|
btn_row.addWidget(self.btn_add)
|
|
btn_row.addStretch()
|
|
layout.addLayout(btn_row)
|
|
|
|
# Preview section
|
|
preview_group = QGroupBox("Schema Preview (JSON)")
|
|
preview_layout = QVBoxLayout(preview_group)
|
|
self.preview = QPlainTextEdit()
|
|
self.preview.setReadOnly(True)
|
|
self.preview.setMaximumHeight(120)
|
|
font = self.preview.font()
|
|
font.setFamily("Consolas, Monaco, monospace")
|
|
font.setPointSize(9)
|
|
self.preview.setFont(font)
|
|
preview_layout.addWidget(self.preview)
|
|
layout.addWidget(preview_group)
|
|
|
|
# Buttons
|
|
buttons = QHBoxLayout()
|
|
|
|
self.btn_clear = QPushButton("Clear All")
|
|
self.btn_clear.clicked.connect(self._clear_all)
|
|
buttons.addWidget(self.btn_clear)
|
|
|
|
buttons.addStretch()
|
|
|
|
self.btn_cancel = QPushButton("Cancel")
|
|
self.btn_cancel.setObjectName("secondary")
|
|
self.btn_cancel.clicked.connect(self.reject)
|
|
buttons.addWidget(self.btn_cancel)
|
|
|
|
self.btn_ok = QPushButton("Apply Schema")
|
|
self.btn_ok.clicked.connect(self.accept)
|
|
buttons.addWidget(self.btn_ok)
|
|
|
|
layout.addLayout(buttons)
|
|
|
|
# Add default fields for new schema
|
|
if not self.table.rowCount():
|
|
self._add_field("output", "string", "The main output content", True)
|
|
self._add_field("reasoning", "string", "Explanation of the response", False)
|
|
|
|
self._update_preview()
|
|
|
|
def _add_field(self, name: str = "", field_type: str = "string",
|
|
description: str = "", required: bool = True):
|
|
"""Add a field row to the table."""
|
|
row = self.table.rowCount()
|
|
self.table.insertRow(row)
|
|
|
|
# Field name
|
|
name_input = QLineEdit(name)
|
|
name_input.setPlaceholderText("field_name")
|
|
name_input.setMinimumHeight(36)
|
|
name_input.textChanged.connect(self._update_preview)
|
|
self.table.setCellWidget(row, 0, name_input)
|
|
|
|
# Type combo
|
|
type_combo = QComboBox()
|
|
type_combo.addItems(self.TYPES)
|
|
type_combo.setMinimumHeight(36)
|
|
if field_type in self.TYPES:
|
|
type_combo.setCurrentText(field_type)
|
|
type_combo.currentTextChanged.connect(self._update_preview)
|
|
self.table.setCellWidget(row, 1, type_combo)
|
|
|
|
# Description
|
|
desc_input = QLineEdit(description)
|
|
desc_input.setPlaceholderText("Description of this field")
|
|
desc_input.setMinimumHeight(36)
|
|
desc_input.textChanged.connect(self._update_preview)
|
|
self.table.setCellWidget(row, 2, desc_input)
|
|
|
|
# Required checkbox - center it
|
|
req_widget = QWidget()
|
|
req_layout = QHBoxLayout(req_widget)
|
|
req_layout.setContentsMargins(0, 0, 0, 0)
|
|
req_layout.setAlignment(Qt.AlignCenter)
|
|
req_check = QCheckBox()
|
|
req_check.setChecked(required)
|
|
req_check.stateChanged.connect(self._update_preview)
|
|
req_layout.addWidget(req_check)
|
|
self.table.setCellWidget(row, 3, req_widget)
|
|
|
|
# Delete button
|
|
btn_delete = QPushButton("Delete")
|
|
btn_delete.setMinimumHeight(36)
|
|
btn_delete.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #e53e3e;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
padding: 4px 8px;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #c53030;
|
|
}
|
|
""")
|
|
btn_delete.clicked.connect(lambda: self._remove_field(row))
|
|
self.table.setCellWidget(row, 4, btn_delete)
|
|
|
|
self._update_preview()
|
|
|
|
def _remove_field(self, row: int):
|
|
"""Remove a field row."""
|
|
# Find the actual row (it may have shifted)
|
|
sender = self.sender()
|
|
for r in range(self.table.rowCount()):
|
|
if self.table.cellWidget(r, 4) == sender:
|
|
self.table.removeRow(r)
|
|
break
|
|
self._update_preview()
|
|
|
|
def _clear_all(self):
|
|
"""Clear all fields."""
|
|
self.table.setRowCount(0)
|
|
self._update_preview()
|
|
|
|
def _update_preview(self):
|
|
"""Update the JSON schema preview."""
|
|
schema = self.get_schema()
|
|
if schema:
|
|
self.preview.setPlainText(json.dumps(schema, indent=2))
|
|
else:
|
|
self.preview.setPlainText("(no fields defined)")
|
|
|
|
def _load_schema(self, schema: dict):
|
|
"""Load an existing schema into the builder."""
|
|
self.table.setRowCount(0)
|
|
|
|
if not schema or "properties" not in schema:
|
|
return
|
|
|
|
required = schema.get("required", [])
|
|
properties = schema.get("properties", {})
|
|
|
|
for name, prop in properties.items():
|
|
field_type = prop.get("type", "string")
|
|
description = prop.get("description", "")
|
|
is_required = name in required
|
|
self._add_field(name, field_type, description, is_required)
|
|
|
|
def get_schema(self) -> dict:
|
|
"""Build and return the JSON schema from the table."""
|
|
properties = {}
|
|
required = []
|
|
|
|
for row in range(self.table.rowCount()):
|
|
name_widget = self.table.cellWidget(row, 0)
|
|
type_widget = self.table.cellWidget(row, 1)
|
|
desc_widget = self.table.cellWidget(row, 2)
|
|
req_widget = self.table.cellWidget(row, 3)
|
|
|
|
if not name_widget:
|
|
continue
|
|
|
|
name = name_widget.text().strip()
|
|
if not name:
|
|
continue
|
|
|
|
field_type = type_widget.currentText() if type_widget else "string"
|
|
description = desc_widget.text().strip() if desc_widget else ""
|
|
|
|
# Get checkbox from the widget container
|
|
req_check = req_widget.findChild(QCheckBox) if req_widget else None
|
|
is_required = req_check.isChecked() if req_check else False
|
|
|
|
prop = {"type": field_type}
|
|
if description:
|
|
prop["description"] = description
|
|
|
|
# Add array items type
|
|
if field_type == "array":
|
|
prop["items"] = {"type": "string"}
|
|
|
|
properties[name] = prop
|
|
|
|
if is_required:
|
|
required.append(name)
|
|
|
|
if not properties:
|
|
return None
|
|
|
|
schema = {
|
|
"type": "object",
|
|
"properties": properties
|
|
}
|
|
if required:
|
|
schema["required"] = required
|
|
|
|
return schema
|
|
|
|
|
|
class PromptStepDialog(QDialog):
|
|
"""Dialog for editing prompt steps."""
|
|
|
|
def __init__(self, parent, step: PromptStep = None):
|
|
super().__init__(parent)
|
|
self.setWindowTitle("Edit Prompt Step" if step else "Add Prompt Step")
|
|
self.setMinimumSize(500, 500)
|
|
self._step = step
|
|
self._output_schema = step.output_schema if step else None
|
|
self._setup_ui()
|
|
|
|
if step:
|
|
self._load_step(step)
|
|
|
|
def _setup_ui(self):
|
|
"""Set up the UI."""
|
|
layout = QVBoxLayout(self)
|
|
layout.setSpacing(16)
|
|
|
|
# Form
|
|
form = QFormLayout()
|
|
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
|
|
self.provider_combo = QComboBox()
|
|
providers = load_providers()
|
|
for provider in sorted(providers, key=lambda p: p.name):
|
|
self.provider_combo.addItem(provider.name)
|
|
# Add common defaults if not present
|
|
for default in ["claude", "gpt", "mock"]:
|
|
if self.provider_combo.findText(default) < 0:
|
|
self.provider_combo.addItem(default)
|
|
form.addRow("Provider:", self.provider_combo)
|
|
|
|
# Profile selection
|
|
self.profile_combo = QComboBox()
|
|
profiles = list_profiles()
|
|
for profile in profiles:
|
|
self.profile_combo.addItem(profile.name)
|
|
# Set tooltip with description
|
|
idx = self.profile_combo.count() - 1
|
|
self.profile_combo.setItemData(idx, profile.description, Qt.ToolTipRole)
|
|
form.addRow("Profile:", self.profile_combo)
|
|
|
|
# Output variable
|
|
self.output_input = QLineEdit()
|
|
self.output_input.setPlaceholderText("response")
|
|
self.output_input.setText("response")
|
|
form.addRow("Output variable:", self.output_input)
|
|
|
|
# Strip fences checkbox
|
|
self.strip_fences_check = QCheckBox("Strip markdown code fences from output")
|
|
form.addRow("", self.strip_fences_check)
|
|
|
|
self.allow_fallback_check = QCheckBox(
|
|
"Allow configured fallback providers if this provider fails"
|
|
)
|
|
self.allow_fallback_check.setChecked(True)
|
|
self.allow_fallback_check.setToolTip(
|
|
"Disable for privacy-sensitive or reproducible steps that must fail closed."
|
|
)
|
|
form.addRow("", self.allow_fallback_check)
|
|
|
|
# Structured output options
|
|
form.addRow(QLabel("")) # Spacer
|
|
structured_label = QLabel("<b>Structured Output</b>")
|
|
form.addRow(structured_label)
|
|
|
|
# Plain text checkbox (bypass structured output)
|
|
self.plain_text_check = QCheckBox("Plain text mode (bypass JSON validation)")
|
|
self.plain_text_check.setToolTip(
|
|
"When checked, output is returned as-is without JSON parsing.\n"
|
|
"When unchecked, output must be valid JSON matching a schema."
|
|
)
|
|
self.plain_text_check.stateChanged.connect(self._on_plain_text_changed)
|
|
form.addRow("", self.plain_text_check)
|
|
|
|
# Max retries (only visible when structured output is enabled)
|
|
self.retries_spin = QSpinBox()
|
|
self.retries_spin.setRange(0, 5)
|
|
self.retries_spin.setValue(1)
|
|
self.retries_spin.setToolTip(
|
|
"Number of retries if JSON validation fails.\n"
|
|
"The AI will receive error feedback and try again."
|
|
)
|
|
self.retries_label = QLabel("Max retries:")
|
|
form.addRow(self.retries_label, self.retries_spin)
|
|
|
|
# Output schema builder
|
|
schema_row = QHBoxLayout()
|
|
self.schema_status = QLabel("Default schema (output, reasoning)")
|
|
self.schema_status.setStyleSheet("color: #718096;")
|
|
schema_row.addWidget(self.schema_status)
|
|
schema_row.addStretch()
|
|
self.btn_edit_schema = QPushButton("Edit Schema...")
|
|
self.btn_edit_schema.clicked.connect(self._edit_schema)
|
|
schema_row.addWidget(self.btn_edit_schema)
|
|
self.btn_clear_schema = QPushButton("Reset")
|
|
self.btn_clear_schema.setToolTip("Reset to default schema")
|
|
self.btn_clear_schema.clicked.connect(self._clear_schema)
|
|
self.btn_clear_schema.setVisible(False)
|
|
schema_row.addWidget(self.btn_clear_schema)
|
|
|
|
schema_widget = QWidget()
|
|
schema_widget.setLayout(schema_row)
|
|
self.schema_label = QLabel("Output schema:")
|
|
form.addRow(self.schema_label, schema_widget)
|
|
|
|
layout.addLayout(form)
|
|
|
|
# Prompt text
|
|
prompt_label = QLabel("Prompt template:")
|
|
layout.addWidget(prompt_label)
|
|
|
|
self.prompt_input = QPlainTextEdit()
|
|
self.prompt_input.setPlaceholderText(
|
|
"Enter your prompt here...\n\n"
|
|
"Use {input} for stdin input.\n"
|
|
"Use {variable} for argument values.\n"
|
|
"Use {prev_output} for previous step output."
|
|
)
|
|
layout.addWidget(self.prompt_input, 1)
|
|
|
|
# Buttons
|
|
buttons = QHBoxLayout()
|
|
buttons.addStretch()
|
|
|
|
self.btn_cancel = QPushButton("Cancel")
|
|
self.btn_cancel.setObjectName("secondary")
|
|
self.btn_cancel.clicked.connect(self.reject)
|
|
buttons.addWidget(self.btn_cancel)
|
|
|
|
self.btn_ok = QPushButton("OK")
|
|
self.btn_ok.clicked.connect(self._validate_and_accept)
|
|
buttons.addWidget(self.btn_ok)
|
|
|
|
layout.addLayout(buttons)
|
|
|
|
def _on_plain_text_changed(self, state):
|
|
"""Show/hide structured output options based on plain_text checkbox."""
|
|
is_plain = bool(state)
|
|
self.retries_label.setVisible(not is_plain)
|
|
self.retries_spin.setVisible(not is_plain)
|
|
self.schema_label.setVisible(not is_plain)
|
|
self.schema_status.setVisible(not is_plain)
|
|
self.btn_edit_schema.setVisible(not is_plain)
|
|
self.btn_clear_schema.setVisible(not is_plain and self._output_schema is not None)
|
|
|
|
def _edit_schema(self):
|
|
"""Open the schema builder dialog."""
|
|
dialog = SchemaBuilderDialog(self, self._output_schema)
|
|
if dialog.exec():
|
|
self._output_schema = dialog.get_schema()
|
|
self._update_schema_status()
|
|
|
|
def _clear_schema(self):
|
|
"""Reset to default schema."""
|
|
self._output_schema = None
|
|
self._update_schema_status()
|
|
|
|
def _update_schema_status(self):
|
|
"""Update the schema status label."""
|
|
if self._output_schema:
|
|
fields = list(self._output_schema.get("properties", {}).keys())
|
|
if len(fields) <= 3:
|
|
fields_text = ", ".join(fields)
|
|
else:
|
|
fields_text = ", ".join(fields[:3]) + f" +{len(fields) - 3} more"
|
|
self.schema_status.setText(f"Custom schema ({fields_text})")
|
|
self.schema_status.setStyleSheet("color: #38a169;") # Green
|
|
self.btn_clear_schema.setVisible(True)
|
|
else:
|
|
self.schema_status.setText("Default schema (output, reasoning)")
|
|
self.schema_status.setStyleSheet("color: #718096;") # Gray
|
|
self.btn_clear_schema.setVisible(False)
|
|
|
|
def _load_step(self, step: PromptStep):
|
|
"""Load step data into form."""
|
|
# Load name
|
|
if step.name:
|
|
self.name_input.setText(step.name)
|
|
|
|
idx = self.provider_combo.findText(step.provider)
|
|
if idx >= 0:
|
|
self.provider_combo.setCurrentIndex(idx)
|
|
else:
|
|
self.provider_combo.setCurrentText(step.provider)
|
|
|
|
# Load profile
|
|
if step.profile:
|
|
idx = self.profile_combo.findText(step.profile)
|
|
if idx >= 0:
|
|
self.profile_combo.setCurrentIndex(idx)
|
|
|
|
self.output_input.setText(step.output_var)
|
|
self.prompt_input.setPlainText(step.prompt)
|
|
self.strip_fences_check.setChecked(step.strip_fences)
|
|
self.allow_fallback_check.setChecked(step.fallback_policy != "deny")
|
|
|
|
# Structured output fields
|
|
self.plain_text_check.setChecked(step.plain_text)
|
|
self.retries_spin.setValue(step.max_retries)
|
|
self._output_schema = step.output_schema
|
|
self._update_schema_status()
|
|
self._on_plain_text_changed(step.plain_text)
|
|
|
|
def _validate_and_accept(self):
|
|
"""Validate and accept."""
|
|
prompt = self.prompt_input.toPlainText().strip()
|
|
if not prompt:
|
|
self.prompt_input.setFocus()
|
|
return
|
|
|
|
output = self.output_input.text().strip()
|
|
if not output:
|
|
self.output_input.setFocus()
|
|
return
|
|
|
|
self.accept()
|
|
|
|
def get_step(self) -> PromptStep:
|
|
"""Get the step from form data."""
|
|
profile = self.profile_combo.currentText()
|
|
# Don't store "None" profile
|
|
if profile == "None":
|
|
profile = None
|
|
# Get name, use None if empty
|
|
name = self.name_input.text().strip() or None
|
|
# Preserve prompt_file from original step if editing
|
|
prompt_file = self._step.prompt_file if self._step else None
|
|
return PromptStep(
|
|
prompt=self.prompt_input.toPlainText(),
|
|
provider=self.provider_combo.currentText(),
|
|
output_var=self.output_input.text().strip(),
|
|
prompt_file=prompt_file,
|
|
profile=profile,
|
|
name=name,
|
|
strip_fences=self.strip_fences_check.isChecked(),
|
|
output_schema=self._output_schema,
|
|
plain_text=self.plain_text_check.isChecked(),
|
|
max_retries=self.retries_spin.value(),
|
|
fallback_policy=(
|
|
"allow" if self.allow_fallback_check.isChecked() else "deny"
|
|
),
|
|
)
|
|
|
|
|
|
class AIGenerateWorker(QThread):
|
|
"""Background worker for AI code generation."""
|
|
finished = Signal(str)
|
|
error = Signal(str)
|
|
|
|
def __init__(self, provider: str, prompt: str):
|
|
super().__init__()
|
|
self.provider = provider
|
|
self.prompt = prompt
|
|
|
|
def run(self):
|
|
try:
|
|
result = call_provider(self.provider, self.prompt)
|
|
if result.success:
|
|
self.finished.emit(result.text)
|
|
else:
|
|
self.error.emit(result.error or "Unknown error")
|
|
except Exception as e:
|
|
self.error.emit(str(e))
|
|
|
|
|
|
class CodeStepDialog(QDialog):
|
|
"""Dialog for editing code steps with AI assist."""
|
|
|
|
def __init__(self, parent, step: CodeStep = None, available_vars: list = None):
|
|
super().__init__(parent)
|
|
self.setWindowTitle("Edit Code Step" if step else "Add Code Step")
|
|
self.setMinimumSize(900, 750)
|
|
self._step = step
|
|
self._available_vars = available_vars or ["input"]
|
|
self._worker = None
|
|
self._setup_ui()
|
|
|
|
if step:
|
|
self._load_step(step)
|
|
|
|
def _setup_ui(self):
|
|
"""Set up the UI with code editor and AI assist panel."""
|
|
layout = QVBoxLayout(self)
|
|
layout.setSpacing(12)
|
|
|
|
# Top: Step name and Output variable
|
|
form = QFormLayout()
|
|
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.setPlaceholderText("result")
|
|
self.output_input.setText("result")
|
|
form.addRow("Output variable:", self.output_input)
|
|
layout.addLayout(form)
|
|
|
|
# Available variables display
|
|
vars_text = ", ".join(self._available_vars)
|
|
vars_label = QLabel(f"Available variables: {vars_text}")
|
|
vars_label.setStyleSheet("color: #718096; font-size: 11px;")
|
|
layout.addWidget(vars_label)
|
|
|
|
# Main splitter: Code editor | AI Assist
|
|
splitter = QSplitter(Qt.Horizontal)
|
|
|
|
# Left: Code editor
|
|
code_group = QGroupBox("Code")
|
|
code_layout = QVBoxLayout(code_group)
|
|
|
|
self.code_input = QPlainTextEdit()
|
|
self.code_input.setPlaceholderText(
|
|
"# Python code here\n"
|
|
"# Access input with: input\n"
|
|
"# Access args with their variable names\n\n"
|
|
"result = input.upper()"
|
|
)
|
|
font = self.code_input.font()
|
|
font.setFamily("Consolas, Monaco, monospace")
|
|
self.code_input.setFont(font)
|
|
code_layout.addWidget(self.code_input)
|
|
|
|
splitter.addWidget(code_group)
|
|
|
|
# Right: AI Assist panel
|
|
ai_group = QGroupBox("AI Assisted Code Generation")
|
|
ai_layout = QVBoxLayout(ai_group)
|
|
ai_layout.setSpacing(8)
|
|
|
|
# Provider selector
|
|
provider_layout = QHBoxLayout()
|
|
provider_layout.addWidget(QLabel("Provider:"))
|
|
self.ai_provider_combo = QComboBox()
|
|
providers = load_providers()
|
|
for provider in sorted(providers, key=lambda p: p.name):
|
|
self.ai_provider_combo.addItem(provider.name)
|
|
# Add common defaults if not present
|
|
for default in ["claude", "gpt", "mock"]:
|
|
if self.ai_provider_combo.findText(default) < 0:
|
|
self.ai_provider_combo.addItem(default)
|
|
self.ai_provider_combo.setMinimumWidth(150)
|
|
provider_layout.addWidget(self.ai_provider_combo)
|
|
provider_layout.addStretch()
|
|
ai_layout.addLayout(provider_layout)
|
|
|
|
# User instruction input (always visible)
|
|
instruction_label = QLabel("Describe what you want the code to do:")
|
|
ai_layout.addWidget(instruction_label)
|
|
|
|
self.user_instruction_input = QPlainTextEdit()
|
|
self.user_instruction_input.setPlaceholderText(
|
|
"Example: Parse the input as JSON and extract the 'name' field"
|
|
)
|
|
self.user_instruction_input.setMaximumHeight(100)
|
|
ai_layout.addWidget(self.user_instruction_input, 1)
|
|
|
|
# Collapsible prompt wrapper section
|
|
self.wrapper_toggle = QPushButton("Edit prompt wrapper ▶")
|
|
self.wrapper_toggle.setFlat(True)
|
|
self.wrapper_toggle.setStyleSheet(
|
|
"QPushButton { color: #4a5568; font-size: 12px; text-align: left; "
|
|
"padding: 2px 0; } QPushButton:hover { color: #2d3748; }"
|
|
)
|
|
self.wrapper_toggle.clicked.connect(self._toggle_wrapper)
|
|
ai_layout.addWidget(self.wrapper_toggle)
|
|
|
|
self.ai_prompt_input = QPlainTextEdit()
|
|
self._set_default_prompt()
|
|
self.ai_prompt_input.hide()
|
|
ai_layout.addWidget(self.ai_prompt_input, 2)
|
|
|
|
# Generate button
|
|
btn_layout = QHBoxLayout()
|
|
self.btn_generate = QPushButton("Generate Code")
|
|
self.btn_generate.clicked.connect(self._generate_code)
|
|
self.btn_generate.setMinimumHeight(32)
|
|
btn_layout.addStretch()
|
|
btn_layout.addWidget(self.btn_generate)
|
|
btn_layout.addStretch()
|
|
ai_layout.addLayout(btn_layout)
|
|
|
|
# Output/feedback area
|
|
feedback_label = QLabel("AI Response:")
|
|
ai_layout.addWidget(feedback_label)
|
|
|
|
self.ai_output = QTextEdit()
|
|
self.ai_output.setReadOnly(True)
|
|
self.ai_output.setPlaceholderText("AI response will appear here...")
|
|
self.ai_output.setMaximumHeight(100)
|
|
ai_layout.addWidget(self.ai_output, 1)
|
|
|
|
splitter.addWidget(ai_group)
|
|
splitter.setSizes([450, 450])
|
|
|
|
layout.addWidget(splitter, 1)
|
|
|
|
# Bottom: OK/Cancel buttons
|
|
buttons = QHBoxLayout()
|
|
buttons.addStretch()
|
|
|
|
self.btn_cancel = QPushButton("Cancel")
|
|
self.btn_cancel.setObjectName("secondary")
|
|
self.btn_cancel.clicked.connect(self.reject)
|
|
buttons.addWidget(self.btn_cancel)
|
|
|
|
self.btn_ok = QPushButton("OK")
|
|
self.btn_ok.clicked.connect(self._validate_and_accept)
|
|
buttons.addWidget(self.btn_ok)
|
|
|
|
layout.addLayout(buttons)
|
|
|
|
def _toggle_wrapper(self):
|
|
"""Toggle visibility of the prompt wrapper editor."""
|
|
if self.ai_prompt_input.isVisible():
|
|
self.ai_prompt_input.hide()
|
|
self.wrapper_toggle.setText("Edit prompt wrapper ▶")
|
|
else:
|
|
self.ai_prompt_input.show()
|
|
self.wrapper_toggle.setText("Edit prompt wrapper ▼")
|
|
|
|
def _set_default_prompt(self):
|
|
"""Set the default AI prompt template."""
|
|
vars_formatted = ', '.join(f'"{{{v}}}"' for v in self._available_vars)
|
|
default_prompt = f"""Write inline Python code (NOT a function definition) according to my instruction.
|
|
|
|
The code runs directly with variable substitution. Assign any available variables to a local variable first using triple quotes for multi-line content safety.
|
|
|
|
Example:
|
|
my_var = \"\"\"{{input}}\"\"\"
|
|
result = my_var.upper()
|
|
|
|
INSTRUCTION: {{user_instruction}}
|
|
|
|
CURRENT CODE:
|
|
{{code}}
|
|
|
|
AVAILABLE VARIABLES: {vars_formatted}
|
|
|
|
IMPORTANT: Return ONLY executable inline Python code. No function definitions, no markdown fencing, no explanations - just the code."""
|
|
self.ai_prompt_input.setPlainText(default_prompt)
|
|
|
|
def _generate_code(self):
|
|
"""Generate code using AI."""
|
|
user_instruction = self.user_instruction_input.toPlainText().strip()
|
|
if not user_instruction:
|
|
self.ai_output.setHtml("<span style='color: #e53e3e;'>Please describe what you want the code to do</span>")
|
|
return
|
|
|
|
prompt_template = self.ai_prompt_input.toPlainText().strip()
|
|
if not prompt_template:
|
|
self.ai_output.setHtml("<span style='color: #e53e3e;'>Prompt wrapper is empty</span>")
|
|
return
|
|
|
|
# Inject user instruction and current code into the wrapper
|
|
current_code = self.code_input.toPlainText().strip() or "# No code yet"
|
|
prompt = prompt_template.replace("{user_instruction}", user_instruction)
|
|
prompt = prompt.replace("{code}", current_code)
|
|
|
|
provider = self.ai_provider_combo.currentText()
|
|
|
|
# Disable button and show loading state
|
|
self.btn_generate.setEnabled(False)
|
|
self.btn_generate.setText("Generating...")
|
|
self.ai_output.setHtml(f"<span style='color: #718096;'>Calling {provider}...</span>")
|
|
|
|
# Start worker thread
|
|
self._worker = AIGenerateWorker(provider, prompt)
|
|
self._worker.finished.connect(self._on_generate_finished)
|
|
self._worker.error.connect(self._on_generate_error)
|
|
self._worker.start()
|
|
|
|
def _on_generate_finished(self, result: str):
|
|
"""Handle successful AI generation."""
|
|
self.btn_generate.setEnabled(True)
|
|
self.btn_generate.setText("Generate Code")
|
|
|
|
# Clean up the result - strip markdown code fences if present
|
|
code = result.strip()
|
|
if code.startswith("```python"):
|
|
code = code[9:]
|
|
elif code.startswith("```"):
|
|
code = code[3:]
|
|
if code.endswith("```"):
|
|
code = code[:-3]
|
|
code = code.strip()
|
|
|
|
# Update code editor
|
|
self.code_input.setPlainText(code)
|
|
|
|
# Show success message
|
|
self.ai_output.setHtml(
|
|
f"<span style='color: #38a169;'>Code generated successfully!</span><br>"
|
|
f"<span style='color: #718096;'>Response length: {len(result)} chars</span>"
|
|
)
|
|
|
|
def _on_generate_error(self, error: str):
|
|
"""Handle AI generation error."""
|
|
self.btn_generate.setEnabled(True)
|
|
self.btn_generate.setText("Generate Code")
|
|
self.ai_output.setHtml(f"<span style='color: #e53e3e;'>Error: {error}</span>")
|
|
|
|
def _load_step(self, step: CodeStep):
|
|
"""Load step data into form."""
|
|
# Load name
|
|
if step.name:
|
|
self.name_input.setText(step.name)
|
|
self.output_input.setText(step.output_var)
|
|
self.code_input.setPlainText(step.code)
|
|
|
|
def _validate_and_accept(self):
|
|
"""Validate and accept."""
|
|
code = self.code_input.toPlainText().strip()
|
|
if not code:
|
|
self.code_input.setFocus()
|
|
return
|
|
|
|
output = self.output_input.text().strip()
|
|
if not output:
|
|
self.output_input.setFocus()
|
|
return
|
|
|
|
# Syntax check the Python code
|
|
try:
|
|
ast.parse(code)
|
|
except SyntaxError as e:
|
|
line_info = f" (line {e.lineno})" if e.lineno else ""
|
|
QMessageBox.warning(
|
|
self, "Syntax Error",
|
|
f"Python syntax error{line_info}:\n\n{e.msg}"
|
|
)
|
|
self.code_input.setFocus()
|
|
return
|
|
|
|
self.accept()
|
|
|
|
def get_step(self) -> CodeStep:
|
|
"""Get the step from form data."""
|
|
# Get name, use None if empty
|
|
name = self.name_input.text().strip() or None
|
|
return CodeStep(
|
|
code=self.code_input.toPlainText(),
|
|
output_var=self.output_input.text().strip(),
|
|
name=name
|
|
)
|
|
|
|
|
|
class ToolStepDialog(QDialog):
|
|
"""Dialog for adding/editing tool steps (calling another tool)."""
|
|
|
|
def __init__(self, parent, step: ToolStep = None, available_vars: list = None, current_tool_name: str = None):
|
|
super().__init__(parent)
|
|
self.setWindowTitle("Edit Tool Step" if step else "Add Tool Step")
|
|
self.setMinimumSize(550, 500)
|
|
self._step = step
|
|
self._available_vars = available_vars or ["input"]
|
|
self._current_tool_name = current_tool_name # To prevent self-referencing
|
|
self._tool_args = {} # Cache of tool arguments
|
|
self._setup_ui()
|
|
|
|
if step:
|
|
self._load_step(step)
|
|
|
|
def _setup_ui(self):
|
|
"""Set up the UI."""
|
|
layout = QVBoxLayout(self)
|
|
layout.setSpacing(16)
|
|
|
|
# Form
|
|
form = QFormLayout()
|
|
form.setSpacing(12)
|
|
|
|
# Step name (optional)
|
|
self.name_input = QLineEdit()
|
|
self.name_input.setPlaceholderText("Optional display name")
|
|
form.addRow("Step name:", self.name_input)
|
|
|
|
# Tool selection
|
|
self.tool_combo = QComboBox()
|
|
self._populate_tools()
|
|
self.tool_combo.currentTextChanged.connect(self._on_tool_changed)
|
|
form.addRow("Tool:", self.tool_combo)
|
|
|
|
# Tool description (read-only)
|
|
self.tool_desc = QLabel("")
|
|
self.tool_desc.setStyleSheet("color: #718096; font-size: 11px;")
|
|
self.tool_desc.setWordWrap(True)
|
|
form.addRow("", self.tool_desc)
|
|
|
|
# Output variable
|
|
self.output_input = QLineEdit()
|
|
self.output_input.setPlaceholderText("tool_output")
|
|
self.output_input.setText("tool_output")
|
|
form.addRow("Output variable:", self.output_input)
|
|
|
|
# Provider override (optional)
|
|
self.provider_combo = QComboBox()
|
|
self.provider_combo.addItem("(use tool's default)")
|
|
providers = load_providers()
|
|
for provider in sorted(providers, key=lambda p: p.name):
|
|
self.provider_combo.addItem(provider.name)
|
|
form.addRow("Provider override:", self.provider_combo)
|
|
|
|
layout.addLayout(form)
|
|
|
|
# Input template
|
|
input_group = QGroupBox("Input")
|
|
input_layout = QVBoxLayout(input_group)
|
|
|
|
vars_text = ", ".join(f"{{{v}}}" for v in self._available_vars)
|
|
input_help = QLabel(f"Available variables: {vars_text}")
|
|
input_help.setStyleSheet("color: #718096; font-size: 11px;")
|
|
input_layout.addWidget(input_help)
|
|
|
|
self.input_template = QPlainTextEdit()
|
|
self.input_template.setPlaceholderText("{input}")
|
|
self.input_template.setPlainText("{input}")
|
|
self.input_template.setMaximumHeight(80)
|
|
input_layout.addWidget(self.input_template)
|
|
|
|
layout.addWidget(input_group)
|
|
|
|
# Tool arguments
|
|
self.args_group = QGroupBox("Tool Arguments")
|
|
self.args_layout = QFormLayout(self.args_group)
|
|
self.args_layout.setSpacing(8)
|
|
self._arg_inputs = {} # variable -> QLineEdit
|
|
layout.addWidget(self.args_group)
|
|
|
|
# Buttons
|
|
buttons = QHBoxLayout()
|
|
buttons.addStretch()
|
|
|
|
self.btn_cancel = QPushButton("Cancel")
|
|
self.btn_cancel.setObjectName("secondary")
|
|
self.btn_cancel.clicked.connect(self.reject)
|
|
buttons.addWidget(self.btn_cancel)
|
|
|
|
self.btn_ok = QPushButton("OK")
|
|
self.btn_ok.clicked.connect(self._validate_and_accept)
|
|
buttons.addWidget(self.btn_ok)
|
|
|
|
layout.addLayout(buttons)
|
|
|
|
# Trigger initial tool selection
|
|
if self.tool_combo.count() > 0:
|
|
self._on_tool_changed(self.tool_combo.currentText())
|
|
|
|
def _populate_tools(self):
|
|
"""Populate the tool dropdown."""
|
|
tools = list_tools()
|
|
for tool_name in sorted(tools):
|
|
# Skip the current tool to prevent self-referencing
|
|
if tool_name != self._current_tool_name:
|
|
self.tool_combo.addItem(tool_name)
|
|
|
|
def _on_tool_changed(self, tool_name: str):
|
|
"""Handle tool selection change."""
|
|
# Clear existing arg inputs
|
|
for widget in self._arg_inputs.values():
|
|
self.args_layout.removeRow(widget)
|
|
self._arg_inputs.clear()
|
|
|
|
if not tool_name:
|
|
self.tool_desc.setText("")
|
|
return
|
|
|
|
tool = load_tool(tool_name)
|
|
if not tool:
|
|
self.tool_desc.setText("(Tool not found)")
|
|
return
|
|
|
|
# Update description
|
|
self.tool_desc.setText(tool.description or "(No description)")
|
|
|
|
# Cache arguments
|
|
self._tool_args[tool_name] = tool.arguments
|
|
|
|
# Create input fields for each argument
|
|
vars_text = ", ".join(f"{{{v}}}" for v in self._available_vars)
|
|
for arg in tool.arguments:
|
|
line = QLineEdit()
|
|
line.setPlaceholderText(f"Default: {arg.default}" if arg.default else f"Variable: {vars_text}")
|
|
if arg.default:
|
|
line.setText(arg.default)
|
|
self._arg_inputs[arg.variable] = line
|
|
|
|
label = f"{arg.flag}:"
|
|
if arg.description:
|
|
label = f"{arg.flag} ({arg.description}):"
|
|
self.args_layout.addRow(label, line)
|
|
|
|
def _load_step(self, step: ToolStep):
|
|
"""Load step data into form."""
|
|
# Load name
|
|
if step.name:
|
|
self.name_input.setText(step.name)
|
|
|
|
# Select tool
|
|
idx = self.tool_combo.findText(step.tool)
|
|
if idx >= 0:
|
|
self.tool_combo.setCurrentIndex(idx)
|
|
else:
|
|
# Tool might be qualified name - try to add it
|
|
self.tool_combo.addItem(step.tool)
|
|
self.tool_combo.setCurrentText(step.tool)
|
|
|
|
self.output_input.setText(step.output_var)
|
|
self.input_template.setPlainText(step.input_template)
|
|
|
|
# Set provider override
|
|
if step.provider:
|
|
idx = self.provider_combo.findText(step.provider)
|
|
if idx >= 0:
|
|
self.provider_combo.setCurrentIndex(idx)
|
|
|
|
# Set argument values (after tool is selected and args are loaded)
|
|
for var, value in step.args.items():
|
|
if var in self._arg_inputs:
|
|
self._arg_inputs[var].setText(str(value))
|
|
|
|
def _validate_and_accept(self):
|
|
"""Validate and accept."""
|
|
tool = self.tool_combo.currentText().strip()
|
|
if not tool:
|
|
self.tool_combo.setFocus()
|
|
return
|
|
|
|
output = self.output_input.text().strip()
|
|
if not output:
|
|
self.output_input.setFocus()
|
|
return
|
|
|
|
self.accept()
|
|
|
|
def get_step(self) -> ToolStep:
|
|
"""Get the step from form data."""
|
|
# Collect arguments
|
|
args = {}
|
|
for var, line in self._arg_inputs.items():
|
|
value = line.text().strip()
|
|
if value:
|
|
args[var] = value
|
|
|
|
# Get provider override
|
|
provider = self.provider_combo.currentText()
|
|
if provider == "(use tool's default)":
|
|
provider = None
|
|
|
|
# Get name, use None if empty
|
|
name = self.name_input.text().strip() or None
|
|
|
|
return ToolStep(
|
|
tool=self.tool_combo.currentText(),
|
|
output_var=self.output_input.text().strip(),
|
|
input_template=self.input_template.toPlainText() or "{input}",
|
|
args=args,
|
|
provider=provider,
|
|
name=name
|
|
)
|