MooseCP/tools.py
2026-07-21 04:36:16 -07:00

323 lines
11 KiB
Python

import os
import base64
import mimetypes
import logging
import asyncio
import io
import datetime
import time
from typing import Any, Callable, Dict, List, Union
from PIL import Image, ImageDraw, ImageFont
logger = logging.getLogger("MattCP")
def format_relative_time(timestamp: float) -> str:
"""Converts a timestamp to a human-readable relative format."""
now = time.time()
diff = now - timestamp
seconds = int(diff)
if seconds < 60:
return "Just now"
minutes = seconds // 60
if minutes < 60:
return f"{minutes}m ago"
hours = minutes // 60
if hours < 24:
return f"{hours}h ago"
days = hours // 24
if days == 1:
return "Yesterday"
if days < 7:
return f"{days}d ago"
weeks = days // 7
if weeks < 4:
return f"{weeks}w ago"
dt = datetime.datetime.fromtimestamp(timestamp)
return dt.strftime('%Y-%m-%d')
class Tool:
def __init__(self, name: str, description: str, schema: Dict[str, Any], handler: Callable):
self.name = name
self.description = description
self.schema = schema
self.handler = handler
def to_mcp_dict(self):
return {
"name": self.name,
"description": self.description,
"inputSchema": self.schema
}
async def read_image_handler(args: Dict[str, Any]):
path = args.get("path")
if not path:
return {"text": "Error: Missing path argument"}
mime_type, _ = mimetypes.guess_type(path)
if not mime_type or not mime_type.startswith("image/"):
return {"text": f"Error: File {path} is not a supported image format."}
try:
with open(path, "rb") as f:
data = base64.b64encode(f.read()).decode("utf-8")
return {"type": "image", "data": data, "mimeType": mime_type}
except Exception as e:
return {"text": f"Error reading image: {str(e)}"}
async def read_png_metadata_handler(args: Dict[str, Any]):
path = args.get("path")
if not path:
return {"text": "Error: Missing path argument"}
try:
with Image.open(path) as img:
if img.format != 'PNG':
return {"text": "Error: File is not a PNG image."}
metadata = img.info
if not metadata:
return {"text": "No metadata found in this PNG."}
output = "\n".join([f"{k}: {v}" for k, v in metadata.items()])
return {"text": f"PNG Metadata:\n{output}"}
except Exception as e:
return {"text": f"Error reading PNG metadata: {str(e)}"}
async def list_thumbnails_handler(args: Dict[str, Any]):
dir_path = args.get("path")
page = int(args.get("page", 1))
page_size = int(args.get("page_size", 64))
sort_by = args.get("sort_by", "mtime")
if not dir_path:
return {"text": "Error: Missing path argument"}
if not os.path.isdir(dir_path):
return {"text": f"Error: {dir_path} is not a directory."}
exts = ('.png', '.jpg', '.jpeg', '.webp', '.bmp')
file_info_list = []
for f in os.listdir(dir_path):
if f.lower().endswith(exts):
full_path = os.path.join(dir_path, f)
try:
stats = os.stat(full_path)
file_info_list.append({
"name": f,
"path": full_path,
"mtime": stats.st_mtime,
"size": stats.st_size
})
except Exception as e:
logger.error(f"Could not stat {f}: {e}")
if not file_info_list:
return {"text": "No supported images found in the directory."}
if sort_by == "mtime":
file_info_list.sort(key=lambda x: x["mtime"], reverse=True)
elif sort_by == "size":
file_info_list.sort(key=lambda x: x["size"], reverse=True)
else:
file_info_list.sort(key=lambda x: x["name"].lower())
total_files = len(file_info_list)
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paged_files = file_info_list[start_idx:end_idx]
if not paged_files:
return {"text": f"No images found on page {page}."}
thumb_size = 160
padding = 15
label_height = 35
num_files = len(paged_files)
cols = int(num_files**0.5) if num_files > 0 else 1
if cols == 0: cols = 1
rows = (num_files + cols - 1) // cols
canvas_w = cols * (thumb_size + padding) + padding
canvas_h = rows * (thumb_size + label_height + padding) + padding
canvas = Image.new('RGB', (canvas_w, canvas_h), (30, 30, 30))
draw = ImageDraw.Draw(canvas)
font = None
font_paths = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
]
for path in font_paths:
if os.path.exists(path):
try:
font = ImageFont.truetype(path, 20)
break
except Exception:
continue
if font is None:
font = ImageFont.load_default()
file_details = []
for i, info in enumerate(paged_files):
filename = info["name"]
full_path = info["path"]
row = i // cols
col = i % cols
x = padding + col * (thumb_size + padding)
y = padding + row * (thumb_size + label_height + padding)
# Continuous numbering across pages
global_index = start_idx + i + 1
try:
with Image.open(full_path) as img:
width, height = img.size
img.thumbnail((thumb_size, thumb_size))
off_x = (thumb_size - img.width) // 2
off_y = (thumb_size - img.height) // 2
canvas.paste(img, (x + off_x, y + off_y))
mod_time_rel = format_relative_time(info["mtime"])
size_kb = info["size"] / 1024
file_details.append(f"{global_index}. {filename} | {width}x{height} | {size_kb:.1f}KB | {mod_time_rel}")
text = str(global_index)
draw.text((x + 2, y + thumb_size + 2), text, fill=(255, 255, 255), font=font)
except Exception as e:
logger.error(f"Failed to process {filename}: {e}")
draw.text((x, y + thumb_size // 2), "Error", fill=(255, 0, 0), font=font)
file_details.append(f"{global_index}. {filename} | ERROR")
buf = io.BytesIO()
canvas.save(buf, format='PNG')
img_data = base64.b64encode(buf.getvalue()).decode("utf-8")
total_pages = (total_files + page_size - 1) // page_size
header_text = f"Directory: {dir_path}\nPage {page} of {total_pages} ({total_files} total images). Showing {start_idx+1}-{min(end_idx, total_files)}."
details_text = "\n".join(file_details)
return [
{"type": "text", "text": f"{header_text}\n\nFile List:\n{details_text}"},
{"type": "image", "data": img_data, "mimeType": "image/png"}
]
async def list_directory_details_handler(args: Dict[str, Any]):
path = args.get("path")
page = int(args.get("page", 1))
page_size = int(args.get("page_size", 64))
if not path:
return {"text": "Error: Missing path argument"}
if not os.path.isdir(path):
return {"text": f"Error: {path} is not a directory."}
try:
entries = os.listdir(path)
except Exception as e:
return {"text": f"Error reading directory: {e}"}
all_items = []
for entry in entries:
full_path = os.path.join(path, entry)
try:
stats = os.stat(full_path)
mtime_rel = format_relative_time(stats.st_mtime)
if os.path.isdir(full_path):
try:
count = len(os.listdir(full_path))
except:
count = 0
all_items.append({
"type": "dir",
"name": entry,
"info": f"{count} items",
"mtime": mtime_rel
})
else:
size_kb = stats.st_size / 1024
all_items.append({
"type": "file",
"name": entry,
"info": f"{size_kb:.1f}KB",
"mtime": mtime_rel
})
except Exception as e:
logger.error(f"Could not stat {entry}: {e}")
all_items.sort(key=lambda x: (x["type"] == "file", x["name"].lower()))
total_items = len(all_items)
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paged_items = all_items[start_idx:end_idx]
if not paged_items:
return {"text": f"No items found on page {page}."}
output = []
for item in paged_items:
if item["type"] == "dir":
output.append(f"[DIR] {item['name']} | {item['info']} | {item['mtime']}")
else:
output.append(f" {item['name']} | {item['info']} | {item['mtime']}")
total_pages = (total_items + page_size - 1) // page_size
header = f"Listing of {path}\nPage {page} of {total_pages} ({total_items} total items). Showing {start_idx+1}-{min(end_idx, total_items)}."
return {"text": f"{header}\n\n" + "\n".join(output)}
TOOL_REGISTRY = [
Tool(
name="read_image",
description="Reads an image from the disk and returns it as an image content object.",
schema={
"type": "object",
"properties": {"path": {"type": "string", "description": "Path to the image file"}},
"required": ["path"],
},
handler=read_image_handler
),
Tool(
name="read_png_metadata",
description="Reads the metadata (tEXt, zTXt, iTXt) from a PNG image to fetch info such as AI prompts.",
schema={
"type": "object",
"properties": {"path": {"type": "string", "description": "Path to the PNG file"}},
"required": ["path"],
},
handler=read_png_metadata_handler
),
Tool(
name="list_thumbnails",
description="Generates a thumbnail grid of images in a directory. Supports pagination and sorting.",
schema={
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the directory containing images"},
"page": {"type": "integer", "description": "The page number to retrieve (1-indexed)", "default": 1},
"page_size": {"type": "integer", "description": "Number of images per page", "default": 64},
"sort_by": {"type": "string", "description": "Sort order: 'mtime' (newest first, default), 'name' (alphabetical), or 'size' (largest first)", "default": "mtime"},
},
"required": ["path"],
},
handler=list_thumbnails_handler
),
Tool(
name="list_directory",
description="Lists the contents of a directory in a file browser format. Directories first, then files. Supports pagination.",
schema={
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the directory to list"},
"page": {"type": "integer", "description": "The page number to retrieve (1-indexed)", "default": 1},
"page_size": {"type": "integer", "description": "Number of items per page", "default": 64},
},
"required": ["path"],
},
handler=list_directory_details_handler
),
]