Add provider fallback, visual schema builder, and UI improvements

Provider fallback system:
- Add optional fallback field to Provider dataclass
- Automatically try fallback provider when primary fails (timeout, error, offline)
- Prevent infinite loops with circular fallback detection
- Add fallback column and dropdown in providers UI

Visual schema builder for prompt steps:
- Add SchemaBuilderDialog with table-based field editor
- Support field name, type, description, and required flag
- Live JSON schema preview
- Integrate into PromptStepDialog with Edit Schema button
- Fix output_schema being lost when editing prompt steps

Other improvements:
- Add search box to tools page
- Improve schema example generation for structured output
- Update CLAUDE.md documentation

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-02-21 00:50:18 -04:00
co-authored by Claude Opus 4.5
parent 8581775002
commit 71c6358b9d
9 changed files with 892 additions and 99 deletions
+25 -5
View File
@@ -2,10 +2,10 @@
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
QPushButton, QHBoxLayout, QLabel
QPushButton, QHBoxLayout, QLabel, QComboBox
)
from ...providers import Provider, add_provider
from ...providers import Provider, add_provider, load_providers
class ProviderDialog(QDialog):
@@ -43,12 +43,20 @@ class ProviderDialog(QDialog):
self.desc_input.setPlaceholderText("Claude AI via claude-cli")
form.addRow("Description:", self.desc_input)
self.fallback_combo = QComboBox()
self.fallback_combo.addItem("(none)", None)
# Populate with available providers
for p in sorted(load_providers(), key=lambda x: x.name):
self.fallback_combo.addItem(p.name, p.name)
form.addRow("Fallback:", self.fallback_combo)
layout.addLayout(form)
# Help text
help_text = QLabel(
"The command should accept input on stdin and output to stdout.\n"
"Example commands: claude-cli, sgpt, llm, mods"
"Example commands: claude-cli, sgpt, llm, mods\n"
"Fallback: Provider to use if this one fails (timeout, error, offline)"
)
help_text.setStyleSheet("color: #718096; font-size: 11px;")
help_text.setWordWrap(True)
@@ -77,6 +85,11 @@ class ProviderDialog(QDialog):
self.name_input.setEnabled(False) # Can't rename
self.cmd_input.setText(provider.command)
self.desc_input.setText(provider.description or "")
# Set fallback selection
if provider.fallback:
idx = self.fallback_combo.findData(provider.fallback)
if idx >= 0:
self.fallback_combo.setCurrentIndex(idx)
def _save(self):
"""Save the provider."""
@@ -91,10 +104,17 @@ class ProviderDialog(QDialog):
self.cmd_input.setFocus()
return
description = self.desc_input.text().strip() or None
description = self.desc_input.text().strip()
fallback = self.fallback_combo.currentData()
# Prevent self-referential fallback
if fallback == name:
from PySide6.QtWidgets import QMessageBox
QMessageBox.warning(self, "Invalid", "A provider cannot fall back to itself.")
return
try:
add_provider(name, command, description)
add_provider(Provider(name, command, description, fallback))
self.accept()
except Exception as e:
from PySide6.QtWidgets import QMessageBox
+308 -2
View File
@@ -1,12 +1,14 @@
"""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
QCheckBox, QSpinBox, QTableWidget, QTableWidgetItem, QHeaderView,
QWidget, QAbstractItemView
)
from PySide6.QtCore import Qt, QThread, Signal
@@ -15,14 +17,260 @@ 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, 400)
self.setMinimumSize(500, 500)
self._step = step
self._output_schema = step.output_schema if step else None
self._setup_ui()
if step:
@@ -98,6 +346,26 @@ class PromptStepDialog(QDialog):
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
@@ -133,6 +401,38 @@ class PromptStepDialog(QDialog):
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."""
@@ -159,6 +459,8 @@ class PromptStepDialog(QDialog):
# 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):
@@ -183,13 +485,17 @@ class PromptStepDialog(QDialog):
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()
)