Skip to main content
Glama
keizman

MCP Feedback Collector

by keizman
server.py17.5 kB
""" 交互式反馈收集器 MCP 服务器 AI调用时会汇报工作内容,用户可以提供文本反馈和/或图片反馈 """ import io import base64 import sys import threading import queue from pathlib import Path from datetime import datetime import os import tkinter as tk from tkinter import ttk, filedialog, messagebox, scrolledtext from tkinter import font as tkFont from PIL import Image, ImageTk from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp.utilities.types import Image as MCPImage # 创建MCP服务器 mcp = FastMCP( "交互式反馈收集器", dependencies=["pillow"] ) # 配置超时时间(秒) DEFAULT_DIALOG_TIMEOUT = 300 # 5分钟 DIALOG_TIMEOUT = int(os.getenv("MCP_DIALOG_TIMEOUT", DEFAULT_DIALOG_TIMEOUT)) class FeedbackDialog: def __init__(self, work_summary: str = "", timeout_seconds: int = DIALOG_TIMEOUT): self.work_summary = work_summary self.timeout_seconds = timeout_seconds self.selected_images = [] self.result = None self.image_frames = [] # 创建主窗口 self.root = tk.Tk() self.root.title("🎯 工作完成汇报与反馈收集") self.root.geometry("650x750") self.root.resizable(False, False) # 居中显示 self.center_window() # 设置样式 self.setup_styles() # 创建UI self.create_ui() # 设置超时 self.root.after(timeout_seconds * 1000, self.timeout_handler) def center_window(self): """将窗口居中显示""" self.root.update_idletasks() width = self.root.winfo_width() height = self.root.winfo_height() x = (self.root.winfo_screenwidth() // 2) - (width // 2) y = (self.root.winfo_screenheight() // 2) - (height // 2) self.root.geometry(f'{width}x{height}+{x}+{y}') def setup_styles(self): """设置样式""" self.root.configure(bg='#f8fafc') # 配置ttk样式 style = ttk.Style() style.theme_use('clam') # 按钮样式 style.configure( 'Main.TButton', font=('Segoe UI', 10, 'bold'), relief='flat', borderwidth=0, focuscolor='none' ) def create_ui(self): """创建用户界面""" # 主框架 main_frame = tk.Frame(self.root, bg='#f8fafc', padx=15, pady=15) main_frame.pack(fill=tk.BOTH, expand=True) # 标题 title_font = tkFont.Font(family="Segoe UI", size=20, weight="bold") title_label = tk.Label( main_frame, text="🎯 工作完成汇报与反馈收集", font=title_font, bg='#f8fafc', fg='#2c3e50' ) title_label.pack(pady=(0, 20)) # 1. 工作汇报区域 report_frame = tk.LabelFrame( main_frame, text="📋 AI工作完成汇报", font=('Segoe UI', 11, 'bold'), bg='#ffffff', fg='#2d3748', padx=8, pady=8 ) report_frame.pack(fill=tk.X, pady=(0, 12)) self.report_text = scrolledtext.ScrolledText( report_frame, height=5, font=('Segoe UI', 9), bg='#ecf0f1', fg='#2c3e50', state=tk.DISABLED, wrap=tk.WORD ) self.report_text.pack(fill=tk.X) # 设置工作汇报内容 self.report_text.config(state=tk.NORMAL) self.report_text.insert(tk.END, self.work_summary or "本次对话中完成的工作内容...") self.report_text.config(state=tk.DISABLED) # 2. 用户反馈文本区域 feedback_frame = tk.LabelFrame( main_frame, text="💬 您的文字反馈(可选)", font=('Segoe UI', 11, 'bold'), bg='#ffffff', fg='#2d3748', padx=8, pady=8 ) feedback_frame.pack(fill=tk.X, pady=(0, 12)) self.feedback_text = scrolledtext.ScrolledText( feedback_frame, height=6, font=('Segoe UI', 9), bg='#ffffff', fg='#2d3748', wrap=tk.WORD ) self.feedback_text.pack(fill=tk.X) self.feedback_text.insert(tk.END, "请在此输入您的反馈、建议或问题...") self.feedback_text.bind('<FocusIn>', self.clear_placeholder) # 3. 图片区域 image_frame = tk.LabelFrame( main_frame, text="🖼️ 图片反馈(可选,支持多张)", font=('Segoe UI', 11, 'bold'), bg='#ffffff', fg='#2d3748', padx=8, pady=8 ) image_frame.pack(fill=tk.X, pady=(0, 12)) # 图片按钮 button_frame = tk.Frame(image_frame, bg='#ffffff') button_frame.pack(fill=tk.X, pady=(0, 8)) tk.Button( button_frame, text="📁 选择文件", command=self.select_image_file, bg='#4299e1', fg='white', font=('Segoe UI', 9, 'bold'), relief=tk.FLAT, padx=12, pady=6 ).pack(side=tk.LEFT, padx=(0, 8)) tk.Button( button_frame, text="📋 粘贴图片", command=self.paste_from_clipboard, bg='#48bb78', fg='white', font=('Segoe UI', 9, 'bold'), relief=tk.FLAT, padx=12, pady=6 ).pack(side=tk.LEFT, padx=(0, 8)) tk.Button( button_frame, text="❌ 清除", command=self.clear_all_images, bg='#f56565', fg='white', font=('Segoe UI', 9, 'bold'), relief=tk.FLAT, padx=12, pady=6 ).pack(side=tk.LEFT) # 图片预览区域 self.image_canvas_frame = tk.Frame(image_frame, bg='#f7fafc', height=120) self.image_canvas_frame.pack(fill=tk.X) self.image_canvas_frame.pack_propagate(False) canvas = tk.Canvas(self.image_canvas_frame, bg='#f7fafc', height=120, highlightthickness=0) scrollbar = ttk.Scrollbar(self.image_canvas_frame, orient="horizontal", command=canvas.xview) self.image_preview_frame = tk.Frame(canvas, bg='#f7fafc') canvas.configure(xscrollcommand=scrollbar.set) canvas.pack(side="top", fill="both", expand=True) scrollbar.pack(side="bottom", fill="x") canvas.create_window((0, 0), window=self.image_preview_frame, anchor="nw") self.image_preview_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) self.canvas = canvas # 4. 底部按钮 bottom_frame = tk.Frame(main_frame, bg='#f8fafc') bottom_frame.pack(fill=tk.X, pady=(15, 0)) tk.Button( bottom_frame, text="✅ 提交反馈", command=self.submit_feedback, bg='#48bb78', fg='white', font=('Segoe UI', 11, 'bold'), relief=tk.FLAT, padx=20, pady=8 ).pack(side=tk.RIGHT, padx=(10, 0)) tk.Button( bottom_frame, text="❌ 取消", command=self.cancel, bg='#a0aec0', fg='white', font=('Segoe UI', 11, 'bold'), relief=tk.FLAT, padx=20, pady=8 ).pack(side=tk.RIGHT) def clear_placeholder(self, event): """清除占位符文本""" if self.feedback_text.get("1.0", tk.END).strip() == "请在此输入您的反馈、建议或问题...": self.feedback_text.delete("1.0", tk.END) def select_image_file(self): """选择图片文件""" file_types = [ ("图片文件", "*.png *.jpg *.jpeg *.gif *.bmp *.webp"), ("PNG files", "*.png"), ("JPEG files", "*.jpg *.jpeg"), ("所有文件", "*.*") ] file_paths = filedialog.askopenfilenames( title="选择图片文件", filetypes=file_types ) for file_path in file_paths: if file_path and file_path not in self.selected_images: self.selected_images.append(file_path) self.update_image_preview() def paste_from_clipboard(self): """从剪贴板粘贴图片""" try: # 尝试从剪贴板获取图片 img = ImageTk.PhotoImage(self.root.clipboard_get()) # 保存到临时文件 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") temp_path = f"temp_clipboard_{timestamp}.png" # 这里需要将剪贴板图片保存为文件 # 由于tkinter剪贴板处理的限制,我们跳过这个功能或使用PIL messagebox.showinfo("提示", "剪贴板功能暂不可用,请使用文件选择功能") except Exception as e: messagebox.showinfo("提示", "剪贴板中没有图片或格式不支持") def clear_all_images(self): """清除所有选择的图片""" # 删除临时文件 for image_path in self.selected_images: if image_path.startswith("temp_clipboard_"): try: os.remove(image_path) except: pass self.selected_images.clear() self.update_image_preview() def update_image_preview(self): """更新图片预览""" # 清除现有预览 for widget in self.image_preview_frame.winfo_children(): widget.destroy() # 添加新的预览 for i, image_path in enumerate(self.selected_images): try: # 创建预览框架 preview_frame = tk.Frame(self.image_preview_frame, bg='white', relief=tk.RAISED, bd=2) preview_frame.pack(side=tk.LEFT, padx=5, pady=5) # 加载图片 with Image.open(image_path) as img: # 调整图片大小 img.thumbnail((100, 100), Image.Resampling.LANCZOS) photo = ImageTk.PhotoImage(img) # 图片标签 img_label = tk.Label(preview_frame, image=photo, bg='white') img_label.image = photo # 保持引用 img_label.pack(padx=5, pady=5) # 删除按钮 remove_btn = tk.Button( preview_frame, text="❌", command=lambda idx=i: self.remove_image(idx), bg='#f56565', fg='white', font=('Segoe UI', 8, 'bold'), relief=tk.FLAT, width=3, height=1 ) remove_btn.pack(pady=(0, 5)) except Exception as e: print(f"加载图片失败: {e}") # 更新画布滚动区域 self.image_preview_frame.update_idletasks() self.canvas.configure(scrollregion=self.canvas.bbox("all")) def remove_image(self, index): """删除指定索引的图片""" if 0 <= index < len(self.selected_images): image_path = self.selected_images[index] # 如果是临时文件,删除它 if image_path.startswith("temp_clipboard_"): try: os.remove(image_path) except: pass self.selected_images.pop(index) self.update_image_preview() def submit_feedback(self): """提交反馈""" # 获取文本反馈 text_feedback = self.feedback_text.get("1.0", tk.END).strip() if text_feedback == "请在此输入您的反馈、建议或问题...": text_feedback = "" # 准备结果 feedback_items = [] # 添加文字反馈 if text_feedback: feedback_items.append({ "type": "text", "content": text_feedback }) # 添加图片反馈 for image_path in self.selected_images: try: # 读取图片并转换为base64 with open(image_path, "rb") as f: image_data = f.read() image_base64 = base64.b64encode(image_data).decode('utf-8') feedback_items.append({ "type": "image", "data": image_base64, "media_type": "image/png" }) except Exception as e: print(f"处理图片失败: {e}") self.result = feedback_items self.root.quit() # 添加窗口销毁,确保窗口自动关闭 self.root.destroy() def cancel(self): """取消对话框""" self.clear_all_images() # 清理临时文件 self.result = [] self.root.quit() # 添加窗口销毁,确保窗口自动关闭 self.root.destroy() def timeout_handler(self): """超时处理""" self.clear_all_images() # 清理临时文件 self.result = None self.root.quit() # 添加窗口销毁,确保窗口自动关闭 self.root.destroy() def show_feedback_dialog(work_summary: str = "", timeout_seconds: int = DIALOG_TIMEOUT): """显示反馈收集对话框""" dialog = FeedbackDialog(work_summary, timeout_seconds) dialog.root.mainloop() return dialog.result @mcp.tool() def collect_feedback(work_summary: str = "", timeout_seconds: int = DIALOG_TIMEOUT) -> list: """ 收集用户反馈的交互式工具 AI可以汇报完成的工作内容,用户可以提供文字和/或图片反馈 Args: work_summary: AI完成的工作内容汇报 timeout_seconds: 对话框超时时间(秒),默认300秒(5分钟) Returns: 包含用户反馈内容的列表,可能包含文本和图片 """ try: result = show_feedback_dialog(work_summary, timeout_seconds) return result or [] except Exception as e: return [{"type": "error", "content": f"收集反馈时出错: {str(e)}"}] @mcp.tool() def pick_image() -> MCPImage: """ 弹出图片选择对话框,让用户选择图片文件或从剪贴板粘贴图片。 用户可以选择本地图片文件,或者先截图到剪贴板然后粘贴。 """ root = tk.Tk() root.withdraw() # 隐藏主窗口 file_types = [ ("图片文件", "*.png *.jpg *.jpeg *.gif *.bmp *.webp"), ("PNG files", "*.png"), ("JPEG files", "*.jpg *.jpeg"), ("所有文件", "*.*") ] file_path = filedialog.askopenfilename( title="选择图片文件", filetypes=file_types ) root.destroy() if file_path: try: with open(file_path, "rb") as f: image_data = f.read() image_base64 = base64.b64encode(image_data).decode('utf-8') return MCPImage( data=image_base64, media_type="image/png" ) except Exception as e: raise Exception(f"读取图片失败: {str(e)}") else: raise Exception("未选择图片文件") @mcp.tool() def get_image_info(image_path: str) -> str: """ 获取指定路径图片的信息(尺寸、格式等) Args: image_path: 图片文件路径 """ try: image_path = Path(image_path) if not image_path.exists(): return f"图片文件不存在: {image_path}" with Image.open(image_path) as img: info = { "path": str(image_path), "format": img.format, "mode": img.mode, "size": img.size, "width": img.width, "height": img.height, } # 尝试获取文件大小 file_size = image_path.stat().st_size info["file_size_bytes"] = file_size info["file_size_mb"] = round(file_size / (1024 * 1024), 2) return f"图片信息: {info}" except Exception as e: return f"获取图片信息失败: {str(e)}" def main(): print("启动 MCP 反馈收集器服务器...") print("所有依赖已加载成功") print("GUI框架 (tkinter) 已就绪") print("MCP服务器正在启动...") print("等待客户端连接...") print() mcp.run() if __name__ == "__main__": main()

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/keizman/mcp-feedforward'

If you have feedback or need assistance with the MCP directory API, please join our Discord server