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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user