- aplicação GTK4/Python para servidores MCP - interface moderna com Libadwaita - suporte OpenAI e OpenRouter - configuração múltiplos MCPs - chat em tempo real 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
427 lines
16 KiB
Python
427 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Buddie Chat - Aplicação nativa Linux simples (Tkinter)
|
|
Descomplicar® Crescimento Digital
|
|
https://descomplicar.pt
|
|
"""
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk, scrolledtext, messagebox, simpledialog
|
|
import json
|
|
import os
|
|
import asyncio
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Any
|
|
import subprocess
|
|
import openai
|
|
from datetime import datetime
|
|
|
|
class MCPManager:
|
|
"""Gestor de conexões MCP"""
|
|
|
|
def __init__(self):
|
|
self.connections: Dict[str, Dict] = {}
|
|
self.config_file = Path.home() / '.buddie-chat' / 'config.json'
|
|
self.config_file.parent.mkdir(exist_ok=True)
|
|
|
|
def load_config(self) -> Dict:
|
|
"""Carrega configuração"""
|
|
if self.config_file.exists():
|
|
with open(self.config_file, 'r') as f:
|
|
return json.load(f)
|
|
return {
|
|
'openai_key': '',
|
|
'openrouter_key': '',
|
|
'mcps': [],
|
|
'theme': 'dark'
|
|
}
|
|
|
|
def save_config(self, config: Dict):
|
|
"""Guarda configuração"""
|
|
with open(self.config_file, 'w') as f:
|
|
json.dump(config, f, indent=2)
|
|
|
|
async def test_mcp_connection(self, mcp_config: Dict) -> bool:
|
|
"""Testa conexão MCP"""
|
|
try:
|
|
process = await asyncio.create_subprocess_exec(
|
|
mcp_config['command'],
|
|
*mcp_config.get('args', []),
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE
|
|
)
|
|
|
|
# Timeout de 5 segundos para teste
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(
|
|
process.communicate(b'{"jsonrpc": "2.0", "method": "initialize", "id": 1}\n'),
|
|
timeout=5.0
|
|
)
|
|
return process.returncode == 0
|
|
except asyncio.TimeoutError:
|
|
process.kill()
|
|
return False
|
|
|
|
except Exception as e:
|
|
print(f"Erro ao testar MCP {mcp_config['name']}: {e}")
|
|
return False
|
|
|
|
async def send_chat_message(self, message: str, mcp_id: str, openai_key: str, openrouter_key: str = None) -> Dict:
|
|
"""Processa mensagem de chat"""
|
|
try:
|
|
client = openai.OpenAI(
|
|
api_key=openai_key if not openrouter_key else openrouter_key,
|
|
base_url="https://openrouter.ai/api/v1" if openrouter_key else None
|
|
)
|
|
|
|
model = "anthropic/claude-3.5-sonnet" if openrouter_key else "gpt-4o-mini"
|
|
|
|
response = client.chat.completions.create(
|
|
model=model,
|
|
messages=[{"role": "user", "content": message}],
|
|
max_tokens=2000
|
|
)
|
|
|
|
return {
|
|
'content': response.choices[0].message.content,
|
|
'tool_calls': [],
|
|
'success': True
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
'content': f'Erro: {str(e)}',
|
|
'tool_calls': [],
|
|
'success': False
|
|
}
|
|
|
|
class BuddieChat:
|
|
"""Aplicação principal"""
|
|
|
|
def __init__(self):
|
|
self.mcp_manager = MCPManager()
|
|
self.config = self.mcp_manager.load_config()
|
|
self.active_mcp = None
|
|
self.messages = []
|
|
|
|
self.setup_ui()
|
|
|
|
def setup_ui(self):
|
|
"""Configura interface"""
|
|
self.root = tk.Tk()
|
|
self.root.title("Buddie Chat - MCP Interface")
|
|
self.root.geometry("1200x800")
|
|
|
|
# Style
|
|
style = ttk.Style()
|
|
style.theme_use('clam')
|
|
|
|
# Layout principal
|
|
main_frame = ttk.Frame(self.root)
|
|
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
|
|
# Painel esquerdo (configuração)
|
|
left_frame = ttk.LabelFrame(main_frame, text="Configuração", padding=10)
|
|
left_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10))
|
|
|
|
self.setup_config_panel(left_frame)
|
|
|
|
# Painel direito (chat)
|
|
right_frame = ttk.Frame(main_frame)
|
|
right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
|
|
|
|
self.setup_chat_panel(right_frame)
|
|
|
|
def setup_config_panel(self, parent):
|
|
"""Configura painel de configuração"""
|
|
# API Keys
|
|
api_frame = ttk.LabelFrame(parent, text="Chaves API", padding=5)
|
|
api_frame.pack(fill=tk.X, pady=(0, 10))
|
|
|
|
ttk.Label(api_frame, text="OpenAI Key:").pack(anchor=tk.W)
|
|
self.openai_var = tk.StringVar(value=self.config.get('openai_key', ''))
|
|
openai_entry = ttk.Entry(api_frame, textvariable=self.openai_var, show="*", width=40)
|
|
openai_entry.pack(fill=tk.X, pady=(0, 5))
|
|
openai_entry.bind('<FocusOut>', self.save_config)
|
|
|
|
ttk.Label(api_frame, text="OpenRouter Key:").pack(anchor=tk.W)
|
|
self.openrouter_var = tk.StringVar(value=self.config.get('openrouter_key', ''))
|
|
openrouter_entry = ttk.Entry(api_frame, textvariable=self.openrouter_var, show="*", width=40)
|
|
openrouter_entry.pack(fill=tk.X)
|
|
openrouter_entry.bind('<FocusOut>', self.save_config)
|
|
|
|
# MCPs
|
|
mcp_frame = ttk.LabelFrame(parent, text="Servidores MCP", padding=5)
|
|
mcp_frame.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Botões
|
|
btn_frame = ttk.Frame(mcp_frame)
|
|
btn_frame.pack(fill=tk.X, pady=(0, 5))
|
|
|
|
ttk.Button(btn_frame, text="Adicionar MCP", command=self.add_mcp).pack(side=tk.LEFT, padx=(0, 5))
|
|
ttk.Button(btn_frame, text="Remover", command=self.remove_mcp).pack(side=tk.LEFT)
|
|
|
|
# Lista MCPs
|
|
self.mcp_listbox = tk.Listbox(mcp_frame, height=10)
|
|
self.mcp_listbox.pack(fill=tk.BOTH, expand=True, pady=(0, 5))
|
|
self.mcp_listbox.bind('<<ListboxSelect>>', self.on_mcp_select)
|
|
|
|
# Status
|
|
self.status_label = ttk.Label(mcp_frame, text="Nenhum MCP selecionado")
|
|
self.status_label.pack(anchor=tk.W)
|
|
|
|
self.update_mcp_list()
|
|
|
|
def setup_chat_panel(self, parent):
|
|
"""Configura painel de chat"""
|
|
# Header
|
|
header_frame = ttk.Frame(parent)
|
|
header_frame.pack(fill=tk.X, pady=(0, 10))
|
|
|
|
self.chat_title = ttk.Label(header_frame, text="Buddie Chat", font=('TkDefaultFont', 16, 'bold'))
|
|
self.chat_title.pack(side=tk.LEFT)
|
|
|
|
ttk.Button(header_frame, text="Limpar", command=self.clear_chat).pack(side=tk.RIGHT)
|
|
|
|
# Área de mensagens
|
|
self.chat_area = scrolledtext.ScrolledText(parent, state=tk.DISABLED, wrap=tk.WORD, height=25)
|
|
self.chat_area.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
|
|
|
|
# Tags para formatação
|
|
self.chat_area.tag_config('user', background='#007acc', foreground='white', justify='right')
|
|
self.chat_area.tag_config('assistant', background='#f0f0f0', foreground='black')
|
|
self.chat_area.tag_config('error', background='#ffcccc', foreground='darkred')
|
|
self.chat_area.tag_config('system', foreground='gray', font=('TkDefaultFont', 8))
|
|
|
|
# Input
|
|
input_frame = ttk.Frame(parent)
|
|
input_frame.pack(fill=tk.X)
|
|
|
|
self.message_var = tk.StringVar()
|
|
self.message_entry = ttk.Entry(input_frame, textvariable=self.message_var, font=('TkDefaultFont', 11))
|
|
self.message_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 10))
|
|
self.message_entry.bind('<Return>', self.send_message)
|
|
|
|
self.send_button = ttk.Button(input_frame, text="Enviar", command=self.send_message)
|
|
self.send_button.pack(side=tk.RIGHT)
|
|
|
|
self.show_welcome_message()
|
|
|
|
def show_welcome_message(self):
|
|
"""Mostra mensagem de boas-vindas"""
|
|
self.add_system_message("=== Bem-vindo ao Buddie Chat ===\\n")
|
|
self.add_system_message("Configure suas chaves API e selecione um servidor MCP para começar.\\n")
|
|
self.add_system_message("Descomplicar® Crescimento Digital - https://descomplicar.pt\\n\\n")
|
|
|
|
def add_system_message(self, message):
|
|
"""Adiciona mensagem do sistema"""
|
|
self.chat_area.config(state=tk.NORMAL)
|
|
self.chat_area.insert(tk.END, message, 'system')
|
|
self.chat_area.config(state=tk.DISABLED)
|
|
self.chat_area.see(tk.END)
|
|
|
|
def add_user_message(self, message):
|
|
"""Adiciona mensagem do utilizador"""
|
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
self.chat_area.config(state=tk.NORMAL)
|
|
self.chat_area.insert(tk.END, f"[{timestamp}] Tu: ", 'system')
|
|
self.chat_area.insert(tk.END, f"{message}\\n\\n", 'user')
|
|
self.chat_area.config(state=tk.DISABLED)
|
|
self.chat_area.see(tk.END)
|
|
|
|
def add_assistant_message(self, message, is_error=False):
|
|
"""Adiciona mensagem do assistente"""
|
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
tag = 'error' if is_error else 'assistant'
|
|
name = "Erro" if is_error else "Assistente"
|
|
|
|
self.chat_area.config(state=tk.NORMAL)
|
|
self.chat_area.insert(tk.END, f"[{timestamp}] {name}: ", 'system')
|
|
self.chat_area.insert(tk.END, f"{message}\\n\\n", tag)
|
|
self.chat_area.config(state=tk.DISABLED)
|
|
self.chat_area.see(tk.END)
|
|
|
|
def update_mcp_list(self):
|
|
"""Atualiza lista de MCPs"""
|
|
self.mcp_listbox.delete(0, tk.END)
|
|
for mcp in self.config.get('mcps', []):
|
|
self.mcp_listbox.insert(tk.END, f"{mcp['name']} - {mcp['command']}")
|
|
|
|
def on_mcp_select(self, event):
|
|
"""Seleciona MCP"""
|
|
selection = self.mcp_listbox.curselection()
|
|
if selection:
|
|
index = selection[0]
|
|
self.active_mcp = self.config['mcps'][index]
|
|
self.status_label.config(text=f"MCP ativo: {self.active_mcp['name']}")
|
|
self.chat_title.config(text=f"Chat - {self.active_mcp['name']}")
|
|
|
|
def add_mcp(self):
|
|
"""Adiciona novo MCP"""
|
|
dialog = MCPConfigDialog(self.root, self.add_mcp_callback)
|
|
|
|
def add_mcp_callback(self, mcp_data):
|
|
"""Callback para adicionar MCP"""
|
|
if 'mcps' not in self.config:
|
|
self.config['mcps'] = []
|
|
|
|
mcp_data['id'] = f"mcp_{len(self.config['mcps'])}"
|
|
self.config['mcps'].append(mcp_data)
|
|
self.mcp_manager.save_config(self.config)
|
|
self.update_mcp_list()
|
|
|
|
def remove_mcp(self):
|
|
"""Remove MCP selecionado"""
|
|
selection = self.mcp_listbox.curselection()
|
|
if selection:
|
|
index = selection[0]
|
|
mcp_name = self.config['mcps'][index]['name']
|
|
if messagebox.askyesno("Confirmar", f"Remover MCP '{mcp_name}'?"):
|
|
del self.config['mcps'][index]
|
|
self.mcp_manager.save_config(self.config)
|
|
self.update_mcp_list()
|
|
if self.active_mcp and self.active_mcp['name'] == mcp_name:
|
|
self.active_mcp = None
|
|
self.status_label.config(text="Nenhum MCP selecionado")
|
|
self.chat_title.config(text="Buddie Chat")
|
|
|
|
def save_config(self, event=None):
|
|
"""Guarda configuração"""
|
|
self.config['openai_key'] = self.openai_var.get()
|
|
self.config['openrouter_key'] = self.openrouter_var.get()
|
|
self.mcp_manager.save_config(self.config)
|
|
|
|
def clear_chat(self):
|
|
"""Limpa chat"""
|
|
self.chat_area.config(state=tk.NORMAL)
|
|
self.chat_area.delete(1.0, tk.END)
|
|
self.chat_area.config(state=tk.DISABLED)
|
|
self.show_welcome_message()
|
|
|
|
def send_message(self, event=None):
|
|
"""Envia mensagem"""
|
|
message = self.message_var.get().strip()
|
|
if not message:
|
|
return
|
|
|
|
if not self.active_mcp:
|
|
messagebox.showwarning("Aviso", "Selecione um servidor MCP primeiro.")
|
|
return
|
|
|
|
if not self.config.get('openai_key') and not self.config.get('openrouter_key'):
|
|
messagebox.showwarning("Aviso", "Configure sua chave API primeiro.")
|
|
return
|
|
|
|
self.message_var.set("")
|
|
self.add_user_message(message)
|
|
self.send_button.config(state=tk.DISABLED, text="Processando...")
|
|
|
|
# Processa mensagem em thread separada
|
|
threading.Thread(
|
|
target=self.process_message_thread,
|
|
args=(message,),
|
|
daemon=True
|
|
).start()
|
|
|
|
def process_message_thread(self, message):
|
|
"""Processa mensagem em thread separada"""
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
|
|
try:
|
|
response = loop.run_until_complete(
|
|
self.mcp_manager.send_chat_message(
|
|
message,
|
|
self.active_mcp['id'],
|
|
self.config.get('openai_key', ''),
|
|
self.config.get('openrouter_key', '')
|
|
)
|
|
)
|
|
|
|
self.root.after(0, self.add_assistant_message, response['content'], not response['success'])
|
|
|
|
except Exception as e:
|
|
self.root.after(0, self.add_assistant_message, f"Erro: {str(e)}", True)
|
|
finally:
|
|
self.root.after(0, lambda: self.send_button.config(state=tk.NORMAL, text="Enviar"))
|
|
loop.close()
|
|
|
|
def run(self):
|
|
"""Executa aplicação"""
|
|
self.root.mainloop()
|
|
|
|
class MCPConfigDialog:
|
|
"""Diálogo de configuração MCP"""
|
|
|
|
def __init__(self, parent, callback):
|
|
self.callback = callback
|
|
|
|
self.dialog = tk.Toplevel(parent)
|
|
self.dialog.title("Configurar MCP")
|
|
self.dialog.geometry("500x300")
|
|
self.dialog.transient(parent)
|
|
self.dialog.grab_set()
|
|
|
|
# Centralizar
|
|
self.dialog.geometry("+%d+%d" % (
|
|
parent.winfo_rootx() + 100,
|
|
parent.winfo_rooty() + 100
|
|
))
|
|
|
|
self.setup_dialog()
|
|
|
|
def setup_dialog(self):
|
|
"""Configura diálogo"""
|
|
main_frame = ttk.Frame(self.dialog, padding=20)
|
|
main_frame.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Campos
|
|
ttk.Label(main_frame, text="Nome:").grid(row=0, column=0, sticky=tk.W, pady=(0, 5))
|
|
self.name_var = tk.StringVar()
|
|
ttk.Entry(main_frame, textvariable=self.name_var, width=50).grid(row=0, column=1, pady=(0, 5))
|
|
|
|
ttk.Label(main_frame, text="Comando:").grid(row=1, column=0, sticky=tk.W, pady=(0, 5))
|
|
self.command_var = tk.StringVar()
|
|
ttk.Entry(main_frame, textvariable=self.command_var, width=50).grid(row=1, column=1, pady=(0, 5))
|
|
|
|
ttk.Label(main_frame, text="Argumentos:").grid(row=2, column=0, sticky=tk.W, pady=(0, 5))
|
|
self.args_var = tk.StringVar()
|
|
ttk.Entry(main_frame, textvariable=self.args_var, width=50).grid(row=2, column=1, pady=(0, 5))
|
|
|
|
ttk.Label(main_frame, text="(separados por espaço)", font=('TkDefaultFont', 8)).grid(row=3, column=1, sticky=tk.W, pady=(0, 10))
|
|
|
|
# Botões
|
|
btn_frame = ttk.Frame(main_frame)
|
|
btn_frame.grid(row=4, column=0, columnspan=2, pady=20)
|
|
|
|
ttk.Button(btn_frame, text="Cancelar", command=self.dialog.destroy).pack(side=tk.RIGHT, padx=(10, 0))
|
|
ttk.Button(btn_frame, text="Guardar", command=self.save).pack(side=tk.RIGHT)
|
|
|
|
def save(self):
|
|
"""Guarda configuração"""
|
|
name = self.name_var.get().strip()
|
|
command = self.command_var.get().strip()
|
|
args = self.args_var.get().strip().split() if self.args_var.get().strip() else []
|
|
|
|
if not name or not command:
|
|
messagebox.showwarning("Aviso", "Nome e comando são obrigatórios.")
|
|
return
|
|
|
|
mcp_data = {
|
|
'name': name,
|
|
'command': command,
|
|
'args': args,
|
|
'enabled': True
|
|
}
|
|
|
|
self.callback(mcp_data)
|
|
self.dialog.destroy()
|
|
|
|
def main():
|
|
"""Função principal"""
|
|
app = BuddieChat()
|
|
app.run()
|
|
|
|
if __name__ == '__main__':
|
|
main() |