Add registry sort/pagination features and improve code step dialog
Registry:
- Add owner and rating sort options to both server endpoints
- Add letter-based prefix filtering (A-Z, #) with server-side validation
- LEFT JOIN tool_stats for average_rating/rating_count in responses
- Add interactive numbered page buttons with sliding window and ellipsis
- Add letter bar UI (shown when sorting by name) with highlight state
- Auto-select asc/desc order based on sort field
- Disable cell editing on results table
- Client: add order and prefix params to list_tools/search_tools
Code step dialog:
- Split AI prompt into user instruction input and collapsible wrapper
- User types plain instruction, wrapper is hidden by default
- Injection of user text into wrapper via {user_instruction} placeholder
- Increase dialog minimum height to 750px
Runner:
- Support variable substitution in prompt step provider name
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0ee21f27f7
commit
b00115a52e
|
|
@ -177,7 +177,7 @@ class CodeStepDialog(QDialog):
|
||||||
def __init__(self, parent, step: CodeStep = None, available_vars: list = None):
|
def __init__(self, parent, step: CodeStep = None, available_vars: list = None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Edit Code Step" if step else "Add Code Step")
|
self.setWindowTitle("Edit Code Step" if step else "Add Code Step")
|
||||||
self.setMinimumSize(900, 600)
|
self.setMinimumSize(900, 750)
|
||||||
self._step = step
|
self._step = step
|
||||||
self._available_vars = available_vars or ["input"]
|
self._available_vars = available_vars or ["input"]
|
||||||
self._worker = None
|
self._worker = None
|
||||||
|
|
@ -254,16 +254,30 @@ class CodeStepDialog(QDialog):
|
||||||
provider_layout.addStretch()
|
provider_layout.addStretch()
|
||||||
ai_layout.addLayout(provider_layout)
|
ai_layout.addLayout(provider_layout)
|
||||||
|
|
||||||
# Prompt editor
|
# User instruction input (always visible)
|
||||||
prompt_label = QLabel("Describe what you want the code to do:")
|
instruction_label = QLabel("Describe what you want the code to do:")
|
||||||
ai_layout.addWidget(prompt_label)
|
ai_layout.addWidget(instruction_label)
|
||||||
|
|
||||||
self.ai_prompt_input = QPlainTextEdit()
|
self.user_instruction_input = QPlainTextEdit()
|
||||||
self.ai_prompt_input.setPlaceholderText(
|
self.user_instruction_input.setPlaceholderText(
|
||||||
"Example: Parse the input as JSON and extract the 'name' field"
|
"Example: Parse the input as JSON and extract the 'name' field"
|
||||||
)
|
)
|
||||||
# Set default prompt template
|
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._set_default_prompt()
|
||||||
|
self.ai_prompt_input.hide()
|
||||||
ai_layout.addWidget(self.ai_prompt_input, 2)
|
ai_layout.addWidget(self.ai_prompt_input, 2)
|
||||||
|
|
||||||
# Generate button
|
# Generate button
|
||||||
|
|
@ -306,6 +320,15 @@ class CodeStepDialog(QDialog):
|
||||||
|
|
||||||
layout.addLayout(buttons)
|
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):
|
def _set_default_prompt(self):
|
||||||
"""Set the default AI prompt template."""
|
"""Set the default AI prompt template."""
|
||||||
vars_formatted = ', '.join(f'"{{{v}}}"' for v in self._available_vars)
|
vars_formatted = ', '.join(f'"{{{v}}}"' for v in self._available_vars)
|
||||||
|
|
@ -317,7 +340,7 @@ Example:
|
||||||
my_var = \"\"\"{{input}}\"\"\"
|
my_var = \"\"\"{{input}}\"\"\"
|
||||||
result = my_var.upper()
|
result = my_var.upper()
|
||||||
|
|
||||||
INSTRUCTION: [Describe what you want the code to do]
|
INSTRUCTION: {{user_instruction}}
|
||||||
|
|
||||||
CURRENT CODE:
|
CURRENT CODE:
|
||||||
{{code}}
|
{{code}}
|
||||||
|
|
@ -329,14 +352,20 @@ IMPORTANT: Return ONLY executable inline Python code. No function definitions, n
|
||||||
|
|
||||||
def _generate_code(self):
|
def _generate_code(self):
|
||||||
"""Generate code using AI."""
|
"""Generate code using AI."""
|
||||||
prompt_template = self.ai_prompt_input.toPlainText().strip()
|
user_instruction = self.user_instruction_input.toPlainText().strip()
|
||||||
if not prompt_template:
|
if not user_instruction:
|
||||||
self.ai_output.setHtml("<span style='color: #e53e3e;'>Please enter a prompt</span>")
|
self.ai_output.setHtml("<span style='color: #e53e3e;'>Please describe what you want the code to do</span>")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Replace {code} placeholder with current code
|
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"
|
current_code = self.code_input.toPlainText().strip() or "# No code yet"
|
||||||
prompt = prompt_template.replace("{code}", current_code)
|
prompt = prompt_template.replace("{user_instruction}", user_instruction)
|
||||||
|
prompt = prompt.replace("{code}", current_code)
|
||||||
|
|
||||||
provider = self.ai_provider_combo.currentText()
|
provider = self.ai_provider_combo.currentText()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,24 @@ from ...config import load_config
|
||||||
from ...tool import list_tools, load_tool, get_all_categories
|
from ...tool import list_tools, load_tool, get_all_categories
|
||||||
|
|
||||||
|
|
||||||
|
# Sort fields that should default to ascending order
|
||||||
|
ASC_SORTS = {"name", "owner"}
|
||||||
|
|
||||||
|
|
||||||
class SearchWorker(QThread):
|
class SearchWorker(QThread):
|
||||||
"""Background worker for registry search."""
|
"""Background worker for registry search."""
|
||||||
finished = Signal(object) # PaginatedResponse
|
finished = Signal(object) # PaginatedResponse
|
||||||
error = Signal(str)
|
error = Signal(str)
|
||||||
|
|
||||||
def __init__(self, query: str = "", category: str = None, sort: str = "downloads",
|
def __init__(self, query: str = "", category: str = None, sort: str = "downloads",
|
||||||
|
order: str = "desc", prefix: str = None,
|
||||||
tags: list = None, page: int = 1, per_page: int = 20):
|
tags: list = None, page: int = 1, per_page: int = 20):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.query = query
|
self.query = query
|
||||||
self.category = category
|
self.category = category
|
||||||
self.sort = sort
|
self.sort = sort
|
||||||
|
self.order = order
|
||||||
|
self.prefix = prefix
|
||||||
self.tags = tags
|
self.tags = tags
|
||||||
self.page = page
|
self.page = page
|
||||||
self.per_page = per_page
|
self.per_page = per_page
|
||||||
|
|
@ -40,7 +47,9 @@ class SearchWorker(QThread):
|
||||||
category=category,
|
category=category,
|
||||||
page=self.page,
|
page=self.page,
|
||||||
per_page=self.per_page,
|
per_page=self.per_page,
|
||||||
sort=self.sort
|
sort=self.sort,
|
||||||
|
order=self.order,
|
||||||
|
prefix=self.prefix
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = client.search_tools(
|
result = client.search_tools(
|
||||||
|
|
@ -50,6 +59,8 @@ class SearchWorker(QThread):
|
||||||
page=self.page,
|
page=self.page,
|
||||||
per_page=self.per_page,
|
per_page=self.per_page,
|
||||||
sort=self.sort,
|
sort=self.sort,
|
||||||
|
order=self.order,
|
||||||
|
prefix=self.prefix,
|
||||||
include_facets=True
|
include_facets=True
|
||||||
)
|
)
|
||||||
self.finished.emit(result)
|
self.finished.emit(result)
|
||||||
|
|
@ -111,6 +122,7 @@ class RegistryPage(QWidget):
|
||||||
self._current_page = 1
|
self._current_page = 1
|
||||||
self._total_pages = 1
|
self._total_pages = 1
|
||||||
self._current_tags = []
|
self._current_tags = []
|
||||||
|
self._current_prefix = None # Active letter filter (None = no filter)
|
||||||
self._installed_tools = {} # name -> version
|
self._installed_tools = {} # name -> version
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
|
|
||||||
|
|
@ -152,8 +164,11 @@ class RegistryPage(QWidget):
|
||||||
cat_label = QLabel("Category:")
|
cat_label = QLabel("Category:")
|
||||||
filters_layout.addWidget(cat_label)
|
filters_layout.addWidget(cat_label)
|
||||||
self.category_combo = QComboBox()
|
self.category_combo = QComboBox()
|
||||||
|
self.category_combo.setEditable(True)
|
||||||
|
self.category_combo.lineEdit().setReadOnly(True)
|
||||||
self.category_combo.addItems(["All"] + get_all_categories())
|
self.category_combo.addItems(["All"] + get_all_categories())
|
||||||
self.category_combo.setMinimumWidth(100)
|
self.category_combo.setMinimumWidth(100)
|
||||||
|
self.category_combo.setMinimumHeight(28)
|
||||||
self.category_combo.setToolTip("Filter by tool category")
|
self.category_combo.setToolTip("Filter by tool category")
|
||||||
self.category_combo.currentTextChanged.connect(self._on_filter_changed)
|
self.category_combo.currentTextChanged.connect(self._on_filter_changed)
|
||||||
filters_layout.addWidget(self.category_combo)
|
filters_layout.addWidget(self.category_combo)
|
||||||
|
|
@ -162,12 +177,17 @@ class RegistryPage(QWidget):
|
||||||
sort_label = QLabel("Sort:")
|
sort_label = QLabel("Sort:")
|
||||||
filters_layout.addWidget(sort_label)
|
filters_layout.addWidget(sort_label)
|
||||||
self.sort_combo = QComboBox()
|
self.sort_combo = QComboBox()
|
||||||
|
self.sort_combo.setEditable(True)
|
||||||
|
self.sort_combo.lineEdit().setReadOnly(True)
|
||||||
self.sort_combo.addItem("Most Popular", "downloads")
|
self.sort_combo.addItem("Most Popular", "downloads")
|
||||||
self.sort_combo.addItem("Newest", "published_at")
|
self.sort_combo.addItem("Newest", "published_at")
|
||||||
self.sort_combo.addItem("Name (A-Z)", "name")
|
self.sort_combo.addItem("Name (A-Z)", "name")
|
||||||
|
self.sort_combo.addItem("Owner", "owner")
|
||||||
|
self.sort_combo.addItem("Rating", "average_rating")
|
||||||
self.sort_combo.setMinimumWidth(120)
|
self.sort_combo.setMinimumWidth(120)
|
||||||
|
self.sort_combo.setMinimumHeight(28)
|
||||||
self.sort_combo.setToolTip("Change sort order of results")
|
self.sort_combo.setToolTip("Change sort order of results")
|
||||||
self.sort_combo.currentIndexChanged.connect(self._on_filter_changed)
|
self.sort_combo.currentIndexChanged.connect(self._on_sort_changed)
|
||||||
filters_layout.addWidget(self.sort_combo)
|
filters_layout.addWidget(self.sort_combo)
|
||||||
|
|
||||||
# Search button
|
# Search button
|
||||||
|
|
@ -190,6 +210,42 @@ class RegistryPage(QWidget):
|
||||||
self.tags_widget.hide()
|
self.tags_widget.hide()
|
||||||
layout.addWidget(self.tags_widget)
|
layout.addWidget(self.tags_widget)
|
||||||
|
|
||||||
|
# Letter bar (shown only when sorting by name)
|
||||||
|
self.letter_bar_widget = QWidget()
|
||||||
|
self.letter_bar_widget.setMinimumHeight(40)
|
||||||
|
letter_bar_layout = QHBoxLayout(self.letter_bar_widget)
|
||||||
|
letter_bar_layout.setContentsMargins(0, 4, 0, 4)
|
||||||
|
letter_bar_layout.setSpacing(2)
|
||||||
|
self._letter_buttons = {}
|
||||||
|
letters = ["#"] + [chr(c) for c in range(ord("A"), ord("Z") + 1)]
|
||||||
|
for letter in letters:
|
||||||
|
btn = QPushButton(letter)
|
||||||
|
btn.setMinimumSize(26, 26)
|
||||||
|
btn.setMaximumSize(30, 30)
|
||||||
|
btn.setStyleSheet(
|
||||||
|
"QPushButton { border: 1px solid #a0aec0; border-radius: 4px; "
|
||||||
|
"font-size: 12px; padding: 2px 0; background: #edf2f7; color: #1a202c; }"
|
||||||
|
"QPushButton:hover { background: #cbd5e0; }"
|
||||||
|
)
|
||||||
|
btn.clicked.connect(lambda checked, l=letter: self._on_letter_clicked(l))
|
||||||
|
letter_bar_layout.addWidget(btn)
|
||||||
|
self._letter_buttons[letter] = btn
|
||||||
|
letter_bar_layout.addStretch()
|
||||||
|
# "All" button to clear letter filter
|
||||||
|
btn_all = QPushButton("All")
|
||||||
|
btn_all.setMinimumHeight(26)
|
||||||
|
btn_all.setMaximumHeight(30)
|
||||||
|
btn_all.setStyleSheet(
|
||||||
|
"QPushButton { border: 1px solid #a0aec0; border-radius: 4px; "
|
||||||
|
"font-size: 12px; padding: 2px 8px; background: #edf2f7; color: #1a202c; }"
|
||||||
|
"QPushButton:hover { background: #cbd5e0; }"
|
||||||
|
)
|
||||||
|
btn_all.clicked.connect(self._clear_letter_filter)
|
||||||
|
letter_bar_layout.addWidget(btn_all)
|
||||||
|
self._letter_buttons["All"] = btn_all
|
||||||
|
self.letter_bar_widget.hide()
|
||||||
|
layout.addWidget(self.letter_bar_widget)
|
||||||
|
|
||||||
# Content splitter
|
# Content splitter
|
||||||
splitter = QSplitter(Qt.Horizontal)
|
splitter = QSplitter(Qt.Horizontal)
|
||||||
|
|
||||||
|
|
@ -209,6 +265,7 @@ class RegistryPage(QWidget):
|
||||||
self.results_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
self.results_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||||||
self.results_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
self.results_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
||||||
self.results_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents)
|
self.results_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents)
|
||||||
|
self.results_table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||||
self.results_table.setSelectionBehavior(QTableWidget.SelectRows)
|
self.results_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||||
self.results_table.setSelectionMode(QTableWidget.SingleSelection)
|
self.results_table.setSelectionMode(QTableWidget.SingleSelection)
|
||||||
self.results_table.verticalHeader().setVisible(False)
|
self.results_table.verticalHeader().setVisible(False)
|
||||||
|
|
@ -217,10 +274,11 @@ class RegistryPage(QWidget):
|
||||||
|
|
||||||
# Pagination controls
|
# Pagination controls
|
||||||
pagination = QWidget()
|
pagination = QWidget()
|
||||||
|
pagination.setMinimumHeight(44)
|
||||||
pag_layout = QHBoxLayout(pagination)
|
pag_layout = QHBoxLayout(pagination)
|
||||||
pag_layout.setContentsMargins(0, 8, 0, 0)
|
pag_layout.setContentsMargins(0, 6, 0, 6)
|
||||||
|
|
||||||
self.btn_prev = QPushButton("← Previous")
|
self.btn_prev = QPushButton("← Prev")
|
||||||
self.btn_prev.setToolTip("Go to previous page of results")
|
self.btn_prev.setToolTip("Go to previous page of results")
|
||||||
self.btn_prev.clicked.connect(self._prev_page)
|
self.btn_prev.clicked.connect(self._prev_page)
|
||||||
self.btn_prev.setEnabled(False)
|
self.btn_prev.setEnabled(False)
|
||||||
|
|
@ -228,9 +286,13 @@ class RegistryPage(QWidget):
|
||||||
|
|
||||||
pag_layout.addStretch()
|
pag_layout.addStretch()
|
||||||
|
|
||||||
self.page_label = QLabel("Page 1 of 1")
|
# Page buttons container (replaces static label)
|
||||||
self.page_label.setStyleSheet("color: #718096;")
|
self.page_buttons_widget = QWidget()
|
||||||
pag_layout.addWidget(self.page_label)
|
self.page_buttons_widget.setMinimumHeight(32)
|
||||||
|
self.page_buttons_layout = QHBoxLayout(self.page_buttons_widget)
|
||||||
|
self.page_buttons_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.page_buttons_layout.setSpacing(4)
|
||||||
|
pag_layout.addWidget(self.page_buttons_widget)
|
||||||
|
|
||||||
pag_layout.addStretch()
|
pag_layout.addStretch()
|
||||||
|
|
||||||
|
|
@ -323,18 +385,79 @@ class RegistryPage(QWidget):
|
||||||
"""Browse all tools (no search query)."""
|
"""Browse all tools (no search query)."""
|
||||||
self.search_input.clear()
|
self.search_input.clear()
|
||||||
self._current_page = 1
|
self._current_page = 1
|
||||||
|
self._current_prefix = None
|
||||||
self._do_search()
|
self._do_search()
|
||||||
|
|
||||||
|
def _get_sort_order(self, sort_field: str) -> str:
|
||||||
|
"""Get the appropriate sort order for a sort field."""
|
||||||
|
if sort_field in ASC_SORTS:
|
||||||
|
return "asc"
|
||||||
|
return "desc"
|
||||||
|
|
||||||
def _on_filter_changed(self):
|
def _on_filter_changed(self):
|
||||||
"""Handle filter/sort change."""
|
"""Handle category filter change."""
|
||||||
|
self._current_page = 1
|
||||||
|
self._current_prefix = None
|
||||||
|
self._do_search()
|
||||||
|
|
||||||
|
def _on_sort_changed(self):
|
||||||
|
"""Handle sort change — show/hide letter bar, reset prefix."""
|
||||||
|
sort = self.sort_combo.itemData(self.sort_combo.currentIndex())
|
||||||
|
if sort == "name":
|
||||||
|
self.letter_bar_widget.show()
|
||||||
|
else:
|
||||||
|
self.letter_bar_widget.hide()
|
||||||
|
self._current_prefix = None
|
||||||
|
self._update_letter_bar_highlight()
|
||||||
self._current_page = 1
|
self._current_page = 1
|
||||||
self._do_search()
|
self._do_search()
|
||||||
|
|
||||||
|
def _on_letter_clicked(self, letter: str):
|
||||||
|
"""Handle letter bar click."""
|
||||||
|
self._current_prefix = letter
|
||||||
|
self._current_page = 1
|
||||||
|
self._update_letter_bar_highlight()
|
||||||
|
self._do_search()
|
||||||
|
|
||||||
|
def _clear_letter_filter(self):
|
||||||
|
"""Clear letter filter — show all."""
|
||||||
|
self._current_prefix = None
|
||||||
|
self._current_page = 1
|
||||||
|
self._update_letter_bar_highlight()
|
||||||
|
self._do_search()
|
||||||
|
|
||||||
|
def _update_letter_bar_highlight(self):
|
||||||
|
"""Highlight the active letter button."""
|
||||||
|
active_style = (
|
||||||
|
"QPushButton { border: 1px solid #4299e1; border-radius: 4px; "
|
||||||
|
"font-size: 12px; padding: 2px 0; background: #4299e1; color: white; font-weight: bold; }"
|
||||||
|
)
|
||||||
|
normal_style = (
|
||||||
|
"QPushButton { border: 1px solid #a0aec0; border-radius: 4px; "
|
||||||
|
"font-size: 12px; padding: 2px 0; background: #edf2f7; color: #1a202c; }"
|
||||||
|
"QPushButton:hover { background: #cbd5e0; }"
|
||||||
|
)
|
||||||
|
normal_all_style = (
|
||||||
|
"QPushButton { border: 1px solid #a0aec0; border-radius: 4px; "
|
||||||
|
"font-size: 12px; padding: 2px 8px; background: #edf2f7; color: #1a202c; }"
|
||||||
|
"QPushButton:hover { background: #cbd5e0; }"
|
||||||
|
)
|
||||||
|
active_all_style = (
|
||||||
|
"QPushButton { border: 1px solid #4299e1; border-radius: 4px; "
|
||||||
|
"font-size: 12px; padding: 2px 8px; background: #4299e1; color: white; font-weight: bold; }"
|
||||||
|
)
|
||||||
|
for letter, btn in self._letter_buttons.items():
|
||||||
|
if letter == "All":
|
||||||
|
btn.setStyleSheet(active_all_style if self._current_prefix is None else normal_all_style)
|
||||||
|
else:
|
||||||
|
btn.setStyleSheet(active_style if letter == self._current_prefix else normal_style)
|
||||||
|
|
||||||
def _do_search(self):
|
def _do_search(self):
|
||||||
"""Perform search with current filters."""
|
"""Perform search with current filters."""
|
||||||
query = self.search_input.text().strip()
|
query = self.search_input.text().strip()
|
||||||
category = self.category_combo.currentText()
|
category = self.category_combo.currentText()
|
||||||
sort = self.sort_combo.currentData()
|
sort = self.sort_combo.itemData(self.sort_combo.currentIndex())
|
||||||
|
order = self._get_sort_order(sort)
|
||||||
|
|
||||||
self.btn_search.setEnabled(False)
|
self.btn_search.setEnabled(False)
|
||||||
self.btn_browse.setEnabled(False)
|
self.btn_browse.setEnabled(False)
|
||||||
|
|
@ -345,6 +468,8 @@ class RegistryPage(QWidget):
|
||||||
query=query,
|
query=query,
|
||||||
category=category,
|
category=category,
|
||||||
sort=sort,
|
sort=sort,
|
||||||
|
order=order,
|
||||||
|
prefix=self._current_prefix,
|
||||||
tags=self._current_tags if self._current_tags else None,
|
tags=self._current_tags if self._current_tags else None,
|
||||||
page=self._current_page,
|
page=self._current_page,
|
||||||
per_page=20
|
per_page=20
|
||||||
|
|
@ -363,11 +488,11 @@ class RegistryPage(QWidget):
|
||||||
total = result.total or len(tools)
|
total = result.total or len(tools)
|
||||||
|
|
||||||
self.status_label.setText(f"Found {total} tools")
|
self.status_label.setText(f"Found {total} tools")
|
||||||
self.page_label.setText(f"Page {self._current_page} of {self._total_pages}")
|
|
||||||
|
|
||||||
# Update pagination buttons
|
# Update pagination buttons
|
||||||
self.btn_prev.setEnabled(self._current_page > 1)
|
self.btn_prev.setEnabled(self._current_page > 1)
|
||||||
self.btn_next.setEnabled(self._current_page < self._total_pages)
|
self.btn_next.setEnabled(self._current_page < self._total_pages)
|
||||||
|
self._update_pagination()
|
||||||
|
|
||||||
self.results_table.setRowCount(len(tools))
|
self.results_table.setRowCount(len(tools))
|
||||||
for row, tool in enumerate(tools):
|
for row, tool in enumerate(tools):
|
||||||
|
|
@ -413,6 +538,83 @@ class RegistryPage(QWidget):
|
||||||
# Version
|
# Version
|
||||||
self.results_table.setItem(row, 5, QTableWidgetItem(tool.get("version", "1.0.0")))
|
self.results_table.setItem(row, 5, QTableWidgetItem(tool.get("version", "1.0.0")))
|
||||||
|
|
||||||
|
def _update_pagination(self):
|
||||||
|
"""Rebuild interactive page number buttons."""
|
||||||
|
# Clear existing buttons
|
||||||
|
while self.page_buttons_layout.count():
|
||||||
|
item = self.page_buttons_layout.takeAt(0)
|
||||||
|
if item.widget():
|
||||||
|
item.widget().deleteLater()
|
||||||
|
|
||||||
|
if self._total_pages <= 1:
|
||||||
|
lbl = QLabel("Page 1 of 1")
|
||||||
|
lbl.setStyleSheet("color: #718096;")
|
||||||
|
self.page_buttons_layout.addWidget(lbl)
|
||||||
|
return
|
||||||
|
|
||||||
|
current = self._current_page
|
||||||
|
total = self._total_pages
|
||||||
|
|
||||||
|
# Determine which page numbers to show
|
||||||
|
if total <= 7:
|
||||||
|
pages = list(range(1, total + 1))
|
||||||
|
else:
|
||||||
|
# Sliding window of 5 centered on current page
|
||||||
|
half = 2
|
||||||
|
start = max(1, current - half)
|
||||||
|
end = min(total, current + half)
|
||||||
|
# Adjust if near edges
|
||||||
|
if current - half < 1:
|
||||||
|
end = min(total, end + (1 - (current - half)))
|
||||||
|
if current + half > total:
|
||||||
|
start = max(1, start - (current + half - total))
|
||||||
|
pages = list(range(start, end + 1))
|
||||||
|
|
||||||
|
# Leading ellipsis
|
||||||
|
if pages and pages[0] > 1:
|
||||||
|
self._add_page_button(1)
|
||||||
|
if pages[0] > 2:
|
||||||
|
lbl = QLabel("...")
|
||||||
|
lbl.setStyleSheet("color: #718096; padding: 0 2px;")
|
||||||
|
self.page_buttons_layout.addWidget(lbl)
|
||||||
|
|
||||||
|
for p in pages:
|
||||||
|
self._add_page_button(p)
|
||||||
|
|
||||||
|
# Trailing ellipsis
|
||||||
|
if pages and pages[-1] < total:
|
||||||
|
if pages[-1] < total - 1:
|
||||||
|
lbl = QLabel("...")
|
||||||
|
lbl.setStyleSheet("color: #718096; padding: 0 2px;")
|
||||||
|
self.page_buttons_layout.addWidget(lbl)
|
||||||
|
self._add_page_button(total)
|
||||||
|
|
||||||
|
def _add_page_button(self, page_num: int):
|
||||||
|
"""Add a single page number button."""
|
||||||
|
btn = QPushButton(str(page_num))
|
||||||
|
btn.setMinimumSize(32, 28)
|
||||||
|
btn.setMaximumHeight(30)
|
||||||
|
if page_num == self._current_page:
|
||||||
|
btn.setStyleSheet(
|
||||||
|
"QPushButton { background: #4299e1; color: white; border: none; "
|
||||||
|
"border-radius: 4px; font-weight: bold; font-size: 12px; "
|
||||||
|
"padding: 2px 6px; }"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
btn.setStyleSheet(
|
||||||
|
"QPushButton { background: #edf2f7; border: 1px solid #a0aec0; "
|
||||||
|
"border-radius: 4px; color: #1a202c; font-size: 12px; "
|
||||||
|
"padding: 2px 6px; }"
|
||||||
|
"QPushButton:hover { background: #cbd5e0; }"
|
||||||
|
)
|
||||||
|
btn.clicked.connect(lambda checked, p=page_num: self._go_to_page(p))
|
||||||
|
self.page_buttons_layout.addWidget(btn)
|
||||||
|
|
||||||
|
def _go_to_page(self, page: int):
|
||||||
|
"""Navigate to a specific page."""
|
||||||
|
self._current_page = page
|
||||||
|
self._do_search()
|
||||||
|
|
||||||
def _rating_to_stars(self, rating: float) -> str:
|
def _rating_to_stars(self, rating: float) -> str:
|
||||||
"""Convert rating to star display."""
|
"""Convert rating to star display."""
|
||||||
if rating <= 0:
|
if rating <= 0:
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,8 @@ RATE_LIMITS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
ALLOWED_SORT = {
|
ALLOWED_SORT = {
|
||||||
"/tools": {"downloads", "published_at", "name"},
|
"/tools": {"downloads", "published_at", "name", "owner", "average_rating"},
|
||||||
"/tools/search": {"relevance", "downloads", "published_at"},
|
"/tools/search": {"relevance", "downloads", "published_at", "name", "owner", "average_rating"},
|
||||||
"/categories": {"name", "tool_count"},
|
"/categories": {"name", "tool_count"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -602,8 +602,18 @@ def create_app() -> Flask:
|
||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
category = request.args.get("category")
|
category = request.args.get("category")
|
||||||
|
prefix = request.args.get("prefix", "").strip()
|
||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
# Validate prefix: single letter A-Z or '#'
|
||||||
|
if prefix:
|
||||||
|
if prefix == "#":
|
||||||
|
pass # valid
|
||||||
|
elif len(prefix) == 1 and prefix.isalpha():
|
||||||
|
prefix = prefix.upper()
|
||||||
|
else:
|
||||||
|
prefix = "" # invalid, ignore
|
||||||
|
|
||||||
# Build visibility filter
|
# Build visibility filter
|
||||||
vis_filter, vis_params = build_visibility_filter()
|
vis_filter, vis_params = build_visibility_filter()
|
||||||
|
|
||||||
|
|
@ -612,6 +622,11 @@ def create_app() -> Flask:
|
||||||
if category:
|
if category:
|
||||||
base_where += " AND category = ?"
|
base_where += " AND category = ?"
|
||||||
params.append(category)
|
params.append(category)
|
||||||
|
if prefix == "#":
|
||||||
|
base_where += " AND name NOT GLOB '[A-Za-z]*'"
|
||||||
|
elif prefix:
|
||||||
|
base_where += " AND LOWER(name) LIKE ?"
|
||||||
|
params.append(f"{prefix.lower()}%")
|
||||||
# Add visibility filter
|
# Add visibility filter
|
||||||
base_where += vis_filter
|
base_where += vis_filter
|
||||||
params.extend(vis_params)
|
params.extend(vis_params)
|
||||||
|
|
@ -624,7 +639,13 @@ def create_app() -> Flask:
|
||||||
total = int(count_row["total"]) if count_row else 0
|
total = int(count_row["total"]) if count_row else 0
|
||||||
|
|
||||||
order_dir = "DESC" if order == "desc" else "ASC"
|
order_dir = "DESC" if order == "desc" else "ASC"
|
||||||
order_sql = f"{sort} {order_dir}, published_at DESC, id DESC"
|
if sort == "average_rating":
|
||||||
|
order_sql = f"COALESCE(ts.average_rating, 0) {order_dir}, published_at DESC, t.id DESC"
|
||||||
|
else:
|
||||||
|
order_sql = f"{sort} {order_dir}, published_at DESC, id DESC"
|
||||||
|
|
||||||
|
# LEFT JOIN tool_stats for rating data
|
||||||
|
stats_join = "LEFT JOIN tool_stats ts ON ts.tool_id = t.id"
|
||||||
|
|
||||||
rows = query_all(
|
rows = query_all(
|
||||||
g.db,
|
g.db,
|
||||||
|
|
@ -641,7 +662,10 @@ def create_app() -> Flask:
|
||||||
{base_where} AND version NOT LIKE '%-%'
|
{base_where} AND version NOT LIKE '%-%'
|
||||||
GROUP BY owner, name
|
GROUP BY owner, name
|
||||||
)
|
)
|
||||||
SELECT t.* FROM tools t
|
SELECT t.*, COALESCE(ts.average_rating, 0) AS average_rating,
|
||||||
|
COALESCE(ts.rating_count, 0) AS rating_count
|
||||||
|
FROM tools t
|
||||||
|
{stats_join}
|
||||||
JOIN (
|
JOIN (
|
||||||
SELECT a.owner, a.name, COALESCE(s.max_id, a.max_id) AS max_id
|
SELECT a.owner, a.name, COALESCE(s.max_id, a.max_id) AS max_id
|
||||||
FROM latest_any a
|
FROM latest_any a
|
||||||
|
|
@ -665,6 +689,8 @@ def create_app() -> Flask:
|
||||||
"tags": json.loads(row["tags"] or "[]"),
|
"tags": json.loads(row["tags"] or "[]"),
|
||||||
"downloads": row["downloads"],
|
"downloads": row["downloads"],
|
||||||
"published_at": row["published_at"],
|
"published_at": row["published_at"],
|
||||||
|
"average_rating": row["average_rating"],
|
||||||
|
"rating_count": row["rating_count"],
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonify({"data": data, "meta": paginate(page, per_page, total)})
|
return jsonify({"data": data, "meta": paginate(page, per_page, total)})
|
||||||
|
|
@ -693,6 +719,16 @@ def create_app() -> Flask:
|
||||||
return error
|
return error
|
||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
# Prefix filter
|
||||||
|
prefix = request.args.get("prefix", "").strip()
|
||||||
|
if prefix:
|
||||||
|
if prefix == "#":
|
||||||
|
pass
|
||||||
|
elif len(prefix) == 1 and prefix.isalpha():
|
||||||
|
prefix = prefix.upper()
|
||||||
|
else:
|
||||||
|
prefix = ""
|
||||||
|
|
||||||
# Parse filter parameters
|
# Parse filter parameters
|
||||||
category = request.args.get("category") # Single category (backward compat)
|
category = request.args.get("category") # Single category (backward compat)
|
||||||
categories_param = request.args.get("categories", "") # Multi-category (OR logic)
|
categories_param = request.args.get("categories", "") # Multi-category (OR logic)
|
||||||
|
|
@ -743,6 +779,12 @@ def create_app() -> Flask:
|
||||||
if not include_deprecated:
|
if not include_deprecated:
|
||||||
where_clauses.append("tools.deprecated = 0")
|
where_clauses.append("tools.deprecated = 0")
|
||||||
|
|
||||||
|
if prefix == "#":
|
||||||
|
where_clauses.append("tools.name NOT GLOB '[A-Za-z]*'")
|
||||||
|
elif prefix:
|
||||||
|
where_clauses.append("LOWER(tools.name) LIKE ?")
|
||||||
|
params.append(f"{prefix.lower()}%")
|
||||||
|
|
||||||
# Add visibility filtering
|
# Add visibility filtering
|
||||||
vis_filter, vis_params = build_visibility_filter("tools")
|
vis_filter, vis_params = build_visibility_filter("tools")
|
||||||
if vis_filter:
|
if vis_filter:
|
||||||
|
|
@ -772,9 +814,13 @@ def create_app() -> Flask:
|
||||||
|
|
||||||
order_dir = "DESC" if order == "desc" else "ASC"
|
order_dir = "DESC" if order == "desc" else "ASC"
|
||||||
if sort == "relevance":
|
if sort == "relevance":
|
||||||
order_sql = f"rank {order_dir}, downloads DESC, published_at DESC, id DESC"
|
order_sql = "rank, downloads DESC, published_at DESC, f.id DESC"
|
||||||
|
elif sort == "average_rating":
|
||||||
|
order_sql = f"COALESCE(ts.average_rating, 0) {order_dir}, downloads DESC, published_at DESC, f.id DESC"
|
||||||
else:
|
else:
|
||||||
order_sql = f"{sort} {order_dir}, published_at DESC, id DESC"
|
order_sql = f"{sort} {order_dir}, published_at DESC, f.id DESC"
|
||||||
|
|
||||||
|
stats_join = "LEFT JOIN tool_stats ts ON ts.tool_id = f.id"
|
||||||
|
|
||||||
rows = query_all(
|
rows = query_all(
|
||||||
g.db,
|
g.db,
|
||||||
|
|
@ -801,7 +847,10 @@ def create_app() -> Flask:
|
||||||
WHERE version NOT LIKE '%-%'
|
WHERE version NOT LIKE '%-%'
|
||||||
GROUP BY owner, name
|
GROUP BY owner, name
|
||||||
)
|
)
|
||||||
SELECT f.* FROM filtered f
|
SELECT f.*, COALESCE(ts.average_rating, 0) AS average_rating,
|
||||||
|
COALESCE(ts.rating_count, 0) AS rating_count
|
||||||
|
FROM filtered f
|
||||||
|
{stats_join}
|
||||||
JOIN (
|
JOIN (
|
||||||
SELECT a.owner, a.name, COALESCE(s.max_id, a.max_id) AS max_id
|
SELECT a.owner, a.name, COALESCE(s.max_id, a.max_id) AS max_id
|
||||||
FROM latest_any a
|
FROM latest_any a
|
||||||
|
|
@ -847,6 +896,8 @@ def create_app() -> Flask:
|
||||||
"tags": json.loads(row["tags"] or "[]"),
|
"tags": json.loads(row["tags"] or "[]"),
|
||||||
"downloads": row["downloads"],
|
"downloads": row["downloads"],
|
||||||
"published_at": row["published_at"],
|
"published_at": row["published_at"],
|
||||||
|
"average_rating": row["average_rating"],
|
||||||
|
"rating_count": row["rating_count"],
|
||||||
"score": score,
|
"score": score,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -290,7 +290,8 @@ class RegistryClient:
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
per_page: int = 20,
|
per_page: int = 20,
|
||||||
sort: str = "downloads",
|
sort: str = "downloads",
|
||||||
order: str = "desc"
|
order: str = "desc",
|
||||||
|
prefix: Optional[str] = None
|
||||||
) -> PaginatedResponse:
|
) -> PaginatedResponse:
|
||||||
"""
|
"""
|
||||||
List tools from the registry.
|
List tools from the registry.
|
||||||
|
|
@ -299,8 +300,9 @@ class RegistryClient:
|
||||||
category: Filter by category
|
category: Filter by category
|
||||||
page: Page number (1-indexed)
|
page: Page number (1-indexed)
|
||||||
per_page: Items per page (max 100)
|
per_page: Items per page (max 100)
|
||||||
sort: Sort field (downloads, published_at, name)
|
sort: Sort field (downloads, published_at, name, owner, average_rating)
|
||||||
order: Sort order (asc, desc)
|
order: Sort order (asc, desc)
|
||||||
|
prefix: Letter prefix filter (A-Z or '#' for non-alpha)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PaginatedResponse with tool data
|
PaginatedResponse with tool data
|
||||||
|
|
@ -313,6 +315,8 @@ class RegistryClient:
|
||||||
}
|
}
|
||||||
if category:
|
if category:
|
||||||
params["category"] = category
|
params["category"] = category
|
||||||
|
if prefix:
|
||||||
|
params["prefix"] = prefix
|
||||||
|
|
||||||
response = self._request("GET", "/tools", params=params)
|
response = self._request("GET", "/tools", params=params)
|
||||||
|
|
||||||
|
|
@ -345,7 +349,9 @@ class RegistryClient:
|
||||||
include_facets: bool = False,
|
include_facets: bool = False,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
per_page: int = 20,
|
per_page: int = 20,
|
||||||
sort: str = "relevance"
|
sort: str = "relevance",
|
||||||
|
order: str = "desc",
|
||||||
|
prefix: Optional[str] = None
|
||||||
) -> PaginatedResponse:
|
) -> PaginatedResponse:
|
||||||
"""
|
"""
|
||||||
Search for tools in the registry.
|
Search for tools in the registry.
|
||||||
|
|
@ -364,7 +370,9 @@ class RegistryClient:
|
||||||
include_facets: Include category/tag/owner counts in response
|
include_facets: Include category/tag/owner counts in response
|
||||||
page: Page number
|
page: Page number
|
||||||
per_page: Items per page
|
per_page: Items per page
|
||||||
sort: Sort field (relevance, downloads, published_at)
|
sort: Sort field (relevance, downloads, published_at, name, owner, average_rating)
|
||||||
|
order: Sort order (asc, desc)
|
||||||
|
prefix: Letter prefix filter (A-Z or '#' for non-alpha)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PaginatedResponse with matching tools (and facets if requested)
|
PaginatedResponse with matching tools (and facets if requested)
|
||||||
|
|
@ -373,7 +381,8 @@ class RegistryClient:
|
||||||
"q": query,
|
"q": query,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": min(per_page, 100),
|
"per_page": min(per_page, 100),
|
||||||
"sort": sort
|
"sort": sort,
|
||||||
|
"order": order
|
||||||
}
|
}
|
||||||
if category:
|
if category:
|
||||||
params["category"] = category
|
params["category"] = category
|
||||||
|
|
@ -395,6 +404,8 @@ class RegistryClient:
|
||||||
params["deprecated"] = "true"
|
params["deprecated"] = "true"
|
||||||
if include_facets:
|
if include_facets:
|
||||||
params["include_facets"] = "true"
|
params["include_facets"] = "true"
|
||||||
|
if prefix:
|
||||||
|
params["prefix"] = prefix
|
||||||
|
|
||||||
response = self._request("GET", "/tools/search", params=params)
|
response = self._request("GET", "/tools/search", params=params)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -209,8 +209,8 @@ def execute_prompt_step(
|
||||||
# Prepend system prompt to user prompt
|
# Prepend system prompt to user prompt
|
||||||
prompt = f"{profile.system_prompt}\n\n---\n\n{prompt}"
|
prompt = f"{profile.system_prompt}\n\n---\n\n{prompt}"
|
||||||
|
|
||||||
# Call provider
|
# Call provider (support variable substitution in provider name, e.g. {settings.provider})
|
||||||
provider = provider_override or step.provider
|
provider = provider_override or substitute_variables(step.provider, variables, warn_non_scalar=verbose)
|
||||||
|
|
||||||
if provider.lower() == "mock":
|
if provider.lower() == "mock":
|
||||||
result = mock_provider(prompt)
|
result = mock_provider(prompt)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue