410 lines
14 KiB
Python
410 lines
14 KiB
Python
"""Tools page - main view for managing tools."""
|
|
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
from typing import Optional, Tuple
|
|
|
|
import yaml
|
|
|
|
from PySide6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
|
|
QTreeWidget, QTreeWidgetItem, QTextEdit, QLabel,
|
|
QPushButton, QGroupBox, QMessageBox, QFrame
|
|
)
|
|
from PySide6.QtCore import Qt
|
|
from PySide6.QtGui import QFont, QColor, QBrush
|
|
|
|
from ...tool import (
|
|
Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
|
|
list_tools, load_tool, delete_tool, DEFAULT_CATEGORIES,
|
|
get_tools_dir
|
|
)
|
|
from ...config import load_config
|
|
from ...hash_utils import compute_config_hash
|
|
|
|
|
|
def get_tool_publish_state(tool_name: str) -> Tuple[str, Optional[str]]:
|
|
"""
|
|
Get the publish state of a tool.
|
|
|
|
Returns:
|
|
Tuple of (state, registry_hash) where state is:
|
|
- "published" - has registry_hash and current hash matches
|
|
- "modified" - has registry_hash but current hash differs
|
|
- "local" - no registry_hash (never published/downloaded)
|
|
"""
|
|
config_path = get_tools_dir() / tool_name / "config.yaml"
|
|
if not config_path.exists():
|
|
return ("local", None)
|
|
|
|
try:
|
|
config = yaml.safe_load(config_path.read_text())
|
|
registry_hash = config.get("registry_hash")
|
|
|
|
if not registry_hash:
|
|
return ("local", None)
|
|
|
|
# Compute current hash (excluding hash fields)
|
|
current_hash = compute_config_hash(config)
|
|
|
|
if current_hash == registry_hash:
|
|
return ("published", registry_hash)
|
|
else:
|
|
return ("modified", registry_hash)
|
|
except Exception:
|
|
return ("local", None)
|
|
|
|
|
|
class ToolsPage(QWidget):
|
|
"""Main tools management page."""
|
|
|
|
def __init__(self, main_window):
|
|
super().__init__()
|
|
self.main_window = main_window
|
|
self._current_tool = None
|
|
self._setup_ui()
|
|
self.refresh()
|
|
|
|
def _setup_ui(self):
|
|
"""Set up the UI."""
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(24, 24, 24, 24)
|
|
layout.setSpacing(16)
|
|
|
|
# Header
|
|
header = QWidget()
|
|
header_layout = QHBoxLayout(header)
|
|
header_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
title = QLabel("My Tools")
|
|
title.setObjectName("heading")
|
|
header_layout.addWidget(title)
|
|
|
|
header_layout.addStretch()
|
|
|
|
# Connection status
|
|
config = load_config()
|
|
if config.registry.token:
|
|
status = QLabel("Connected to Registry")
|
|
status.setStyleSheet("color: #38a169; font-weight: 500;")
|
|
else:
|
|
status = QLabel("Not connected")
|
|
status.setStyleSheet("color: #718096;")
|
|
header_layout.addWidget(status)
|
|
|
|
layout.addWidget(header)
|
|
|
|
# Main content splitter
|
|
splitter = QSplitter(Qt.Horizontal)
|
|
|
|
# Left side: Tool list
|
|
left = QWidget()
|
|
left_layout = QVBoxLayout(left)
|
|
left_layout.setContentsMargins(0, 0, 0, 0)
|
|
left_layout.setSpacing(8)
|
|
|
|
self.tool_tree = QTreeWidget()
|
|
self.tool_tree.setHeaderHidden(True)
|
|
self.tool_tree.setIndentation(16)
|
|
self.tool_tree.setRootIsDecorated(True)
|
|
self.tool_tree.itemSelectionChanged.connect(self._on_selection_changed)
|
|
self.tool_tree.itemDoubleClicked.connect(self._on_double_click)
|
|
left_layout.addWidget(self.tool_tree, 1)
|
|
|
|
splitter.addWidget(left)
|
|
|
|
# Right side: Info panel
|
|
right = QWidget()
|
|
right_layout = QVBoxLayout(right)
|
|
right_layout.setContentsMargins(0, 0, 0, 0)
|
|
right_layout.setSpacing(16)
|
|
|
|
# Tool info
|
|
info_box = QGroupBox("Tool Details")
|
|
info_layout = QVBoxLayout(info_box)
|
|
|
|
self.info_text = QTextEdit()
|
|
self.info_text.setReadOnly(True)
|
|
self.info_text.setPlaceholderText("Select a tool to view details")
|
|
info_layout.addWidget(self.info_text)
|
|
|
|
right_layout.addWidget(info_box, 1)
|
|
|
|
splitter.addWidget(right)
|
|
|
|
# Set splitter sizes
|
|
splitter.setSizes([350, 650])
|
|
|
|
layout.addWidget(splitter, 1)
|
|
|
|
# Action buttons
|
|
buttons = QWidget()
|
|
btn_layout = QHBoxLayout(buttons)
|
|
btn_layout.setContentsMargins(0, 0, 0, 0)
|
|
btn_layout.setSpacing(12)
|
|
|
|
self.btn_create = QPushButton("Create")
|
|
self.btn_create.clicked.connect(self._create_tool)
|
|
btn_layout.addWidget(self.btn_create)
|
|
|
|
self.btn_edit = QPushButton("Edit")
|
|
self.btn_edit.setObjectName("secondary")
|
|
self.btn_edit.clicked.connect(self._edit_tool)
|
|
self.btn_edit.setEnabled(False)
|
|
btn_layout.addWidget(self.btn_edit)
|
|
|
|
self.btn_delete = QPushButton("Delete")
|
|
self.btn_delete.setObjectName("danger")
|
|
self.btn_delete.clicked.connect(self._delete_tool)
|
|
self.btn_delete.setEnabled(False)
|
|
btn_layout.addWidget(self.btn_delete)
|
|
|
|
btn_layout.addStretch()
|
|
|
|
# Connect/Publish button
|
|
config = load_config()
|
|
if config.registry.token:
|
|
self.btn_publish = QPushButton("Publish")
|
|
self.btn_publish.clicked.connect(self._publish_tool)
|
|
self.btn_publish.setEnabled(False)
|
|
else:
|
|
self.btn_publish = QPushButton("Connect")
|
|
self.btn_publish.clicked.connect(self._connect_registry)
|
|
btn_layout.addWidget(self.btn_publish)
|
|
|
|
layout.addWidget(buttons)
|
|
|
|
def refresh(self):
|
|
"""Refresh the tool list."""
|
|
self.tool_tree.clear()
|
|
self._current_tool = None
|
|
self.info_text.clear()
|
|
|
|
tools = list_tools()
|
|
|
|
# Group tools by category
|
|
tools_by_category = defaultdict(list)
|
|
for name in tools:
|
|
tool = load_tool(name)
|
|
if tool:
|
|
category = tool.category if tool.category else "Other"
|
|
tools_by_category[category].append((name, tool))
|
|
|
|
# Build tree with categories
|
|
all_categories = list(DEFAULT_CATEGORIES)
|
|
for cat in tools_by_category:
|
|
if cat not in all_categories:
|
|
all_categories.append(cat)
|
|
|
|
for category in all_categories:
|
|
if category in tools_by_category and tools_by_category[category]:
|
|
# Category item
|
|
cat_item = QTreeWidgetItem([category])
|
|
cat_item.setExpanded(True)
|
|
font = cat_item.font(0)
|
|
font.setBold(True)
|
|
cat_item.setFont(0, font)
|
|
cat_item.setFlags(cat_item.flags() & ~Qt.ItemIsSelectable)
|
|
|
|
# Tools in category
|
|
for name, tool in sorted(tools_by_category[category], key=lambda x: x[0]):
|
|
# Get publish state
|
|
state, registry_hash = get_tool_publish_state(name)
|
|
|
|
# Show state indicator in display name
|
|
if state == "published":
|
|
display_name = f"{name} ✓"
|
|
tooltip = "Published to registry - up to date"
|
|
color = QColor(56, 161, 105) # Green
|
|
elif state == "modified":
|
|
display_name = f"{name} ●"
|
|
tooltip = "Published to registry - local modifications"
|
|
color = QColor(221, 107, 32) # Orange
|
|
else:
|
|
display_name = name
|
|
tooltip = "Local tool - not published"
|
|
color = None
|
|
|
|
tool_item = QTreeWidgetItem([display_name])
|
|
tool_item.setData(0, Qt.UserRole, name)
|
|
|
|
if color:
|
|
tool_item.setForeground(0, QBrush(color))
|
|
|
|
# Build tooltip
|
|
if tool.source and tool.source.type == "imported":
|
|
tooltip = f"Imported from {tool.source.url or 'registry'}"
|
|
tool_item.setToolTip(0, tooltip)
|
|
|
|
cat_item.addChild(tool_item)
|
|
|
|
self.tool_tree.addTopLevelItem(cat_item)
|
|
|
|
# Update button states
|
|
self._update_buttons()
|
|
|
|
if not tools:
|
|
self.info_text.setPlaceholderText(
|
|
"No tools found.\n\nClick 'Create' to build your first tool, "
|
|
"or browse the Registry to install community tools."
|
|
)
|
|
|
|
def _on_selection_changed(self):
|
|
"""Handle tool selection change."""
|
|
items = self.tool_tree.selectedItems()
|
|
if not items:
|
|
self._current_tool = None
|
|
self.info_text.clear()
|
|
self._update_buttons()
|
|
return
|
|
|
|
item = items[0]
|
|
tool_name = item.data(0, Qt.UserRole)
|
|
if not tool_name:
|
|
self._current_tool = None
|
|
self.info_text.clear()
|
|
self._update_buttons()
|
|
return
|
|
|
|
tool = load_tool(tool_name)
|
|
if tool:
|
|
self._current_tool = tool
|
|
self._show_tool_info(tool)
|
|
self._update_buttons()
|
|
|
|
def _on_double_click(self, item, column):
|
|
"""Handle double-click on tool."""
|
|
tool_name = item.data(0, Qt.UserRole)
|
|
if tool_name:
|
|
self._edit_tool()
|
|
|
|
def _show_tool_info(self, tool: Tool):
|
|
"""Display tool information."""
|
|
lines = []
|
|
|
|
# Name and description
|
|
lines.append(f"<h2 style='margin: 0 0 8px 0; color: #2d3748;'>{tool.name}</h2>")
|
|
if tool.description:
|
|
lines.append(f"<p style='color: #4a5568; margin-bottom: 16px;'>{tool.description}</p>")
|
|
|
|
# Publish state
|
|
state, registry_hash = get_tool_publish_state(tool.name)
|
|
if state == "published":
|
|
lines.append(
|
|
"<p style='background: #c6f6d5; color: #276749; padding: 6px 10px; "
|
|
"border-radius: 4px; margin-bottom: 12px; font-size: 12px;'>"
|
|
"✓ Published to registry - up to date</p>"
|
|
)
|
|
elif state == "modified":
|
|
lines.append(
|
|
"<p style='background: #feebc8; color: #c05621; padding: 6px 10px; "
|
|
"border-radius: 4px; margin-bottom: 12px; font-size: 12px;'>"
|
|
"● Modified since last publish - republish to update registry</p>"
|
|
)
|
|
|
|
# Source info
|
|
if tool.source:
|
|
source_type = tool.source.type
|
|
if source_type == "imported":
|
|
source_url = tool.source.url or "registry"
|
|
lines.append(f"<p style='color: #718096; font-size: 12px;'>Imported from {source_url}</p>")
|
|
elif source_type == "forked":
|
|
lines.append(f"<p style='color: #718096; font-size: 12px;'>Forked from {tool.source.original_tool}</p>")
|
|
|
|
# Arguments
|
|
if tool.arguments:
|
|
lines.append("<h3 style='color: #4a5568; margin-top: 16px;'>Arguments</h3>")
|
|
lines.append("<ul style='margin: 8px 0;'>")
|
|
for arg in tool.arguments:
|
|
default = f" (default: {arg.default})" if arg.default else ""
|
|
lines.append(f"<li><code>{arg.flag}</code> → <code>${arg.variable}</code>{default}</li>")
|
|
lines.append("</ul>")
|
|
|
|
# Steps
|
|
if tool.steps:
|
|
lines.append("<h3 style='color: #4a5568; margin-top: 16px;'>Steps</h3>")
|
|
lines.append("<ol style='margin: 8px 0;'>")
|
|
for i, step in enumerate(tool.steps, 1):
|
|
if isinstance(step, PromptStep):
|
|
lines.append(f"<li><strong>Prompt</strong> using <code>{step.provider}</code> → <code>${step.output_var}</code></li>")
|
|
elif isinstance(step, CodeStep):
|
|
lines.append(f"<li><strong>Code</strong> (python) → <code>${step.output_var}</code></li>")
|
|
elif isinstance(step, ToolStep):
|
|
lines.append(f"<li><strong>Tool</strong>: <code>{step.tool}</code> → <code>${step.output_var}</code></li>")
|
|
lines.append("</ol>")
|
|
|
|
# Output
|
|
if tool.output:
|
|
lines.append("<h3 style='color: #4a5568; margin-top: 16px;'>Output Template</h3>")
|
|
output_escaped = tool.output.replace("<", "<").replace(">", ">")
|
|
lines.append(f"<pre style='background: #edf2f7; padding: 8px; border-radius: 4px;'>{output_escaped}</pre>")
|
|
|
|
# Category
|
|
if tool.category:
|
|
lines.append(f"<p style='color: #718096; font-size: 12px; margin-top: 16px;'>Category: {tool.category}</p>")
|
|
|
|
self.info_text.setHtml("\n".join(lines))
|
|
|
|
def _update_buttons(self):
|
|
"""Update button enabled states."""
|
|
has_selection = self._current_tool is not None
|
|
self.btn_edit.setEnabled(has_selection)
|
|
self.btn_delete.setEnabled(has_selection)
|
|
|
|
config = load_config()
|
|
if config.registry.token:
|
|
self.btn_publish.setEnabled(has_selection)
|
|
|
|
def _create_tool(self):
|
|
"""Create a new tool."""
|
|
self.main_window.open_tool_builder()
|
|
|
|
def _edit_tool(self):
|
|
"""Edit the selected tool."""
|
|
if self._current_tool:
|
|
self.main_window.open_tool_builder(self._current_tool.name)
|
|
|
|
def _delete_tool(self):
|
|
"""Delete the selected tool."""
|
|
if not self._current_tool:
|
|
return
|
|
|
|
reply = QMessageBox.question(
|
|
self,
|
|
"Delete Tool",
|
|
f"Are you sure you want to delete '{self._current_tool.name}'?\n\n"
|
|
"This will remove the tool configuration and wrapper script.",
|
|
QMessageBox.Yes | QMessageBox.No,
|
|
QMessageBox.No
|
|
)
|
|
|
|
if reply == QMessageBox.Yes:
|
|
try:
|
|
delete_tool(self._current_tool.name)
|
|
self.main_window.show_status(f"Deleted tool '{self._current_tool.name}'")
|
|
self.refresh()
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Error", f"Failed to delete tool:\n{e}")
|
|
|
|
def _connect_registry(self):
|
|
"""Open connect dialog."""
|
|
from ..dialogs.connect_dialog import ConnectDialog
|
|
dialog = ConnectDialog(self)
|
|
if dialog.exec():
|
|
self.refresh()
|
|
self.main_window.show_status("Connected to registry")
|
|
# Recreate publish button
|
|
self.btn_publish.setText("Publish")
|
|
self.btn_publish.clicked.disconnect()
|
|
self.btn_publish.clicked.connect(self._publish_tool)
|
|
|
|
def _publish_tool(self):
|
|
"""Publish the selected tool."""
|
|
if not self._current_tool:
|
|
return
|
|
|
|
from ..dialogs.publish_dialog import PublishDialog
|
|
dialog = PublishDialog(self, self._current_tool)
|
|
if dialog.exec():
|
|
self.main_window.show_status(f"Published '{self._current_tool.name}'")
|