Universal-AI-Hub / desktop_ui.py
Alibrown's picture
Upload 58 files
5ef3828 verified
Raw History Blame Contribute Delete
37.9 kB
# =============================================================================
# mcp_desktop.py
# Universal MCP Desktop Client
# Copyright 2026 - Volkan KΓΌcΓΌkbudak
# Apache License V. 2 + ESOL 1.1
# Repo: https://github.com/VolkanSah/Universal-MCP-Hub-sandboxed
# =============================================================================
# USAGE:
# pip install PySide6 httpx Pillow PyPDF2 pandas openpyxl
# python mcp_desktop.py
#
# CONNECT:
# 1. Enter HF Token + Hub URL in Settings tab β†’ Save
# 2. Go to Connect tab β†’ Connect
# 3. Use Chat tab β€” select Tool, Provider, Model, upload files
# =============================================================================
# 7 Bugs from Claude AI fixed by Human 14.03.2026 but amazing work from ClaudeAi
import sys
import json
import asyncio
import httpx
import io
import base64
import zipfile
from datetime import datetime
from pathlib import Path
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLineEdit, QTextEdit, QLabel, QTabWidget,
QStatusBar, QComboBox, QSpinBox, QFileDialog
)
from PySide6.QtCore import QThread, Signal, QObject
# Optional file handling imports β€” graceful degradation if missing
try:
from PIL import Image
HAS_PIL = True
except ImportError:
HAS_PIL = False
try:
import PyPDF2
HAS_PDF = True
except ImportError:
HAS_PDF = False
try:
import pandas as pd
HAS_PANDAS = True
except ImportError:
HAS_PANDAS = False
# =============================================================================
# Local config β€” saved to ~/.mcp_desktop.json
# =============================================================================
CONFIG_PATH = Path(__file__).parent / "mcp_desktop.json"
# old CONFIG_PATH = Path.home() / ".mcp_desktop.json"
def load_config() -> dict:
"""Load local config. Returns defaults if not found."""
if CONFIG_PATH.exists():
try:
return json.loads(CONFIG_PATH.read_text())
except Exception:
pass
return {
"hf_token": "",
"hub_url": "",
"default_provider": "",
"default_model": "",
"default_tool": "llm_complete",
"font_size": 14,
"chats": {}, # chat_id β†’ {"title": str, "messages": [...]}
}
def save_config(cfg: dict) -> None:
"""Save config to mcp_desktop.json."""
try:
CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
except Exception as e:
print(f"[save_config] WARNING: Could not save config: {e}")
# =============================================================================
# File Handler β€” extracted from Streamlit app
# Supports: images, text/code, PDF, CSV/XLSX, ZIP
# =============================================================================
def encode_image(image) -> str:
"""Encode PIL image to base64 JPEG string."""
buffered = io.BytesIO()
image.save(buffered, format="JPEG")
return base64.b64encode(buffered.getvalue()).decode("utf-8")
def process_file(file_path: str) -> dict:
"""
Process uploaded file into text or image content.
Returns dict: {"type": "text"|"image"|"error", "content": ...}
Supports: jpg/png, txt/code, pdf, csv/xlsx, zip
"""
path = Path(file_path)
file_type = path.suffix.lower().lstrip(".")
# Images β€” requires Pillow
if file_type in ["jpg", "jpeg", "png"]:
if not HAS_PIL:
return {"type": "error", "content": "Pillow not installed β€” pip install Pillow"}
img = Image.open(file_path).convert("RGB")
return {"type": "image", "content": img, "b64": encode_image(img)}
# Text / code files
code_extensions = ["html", "css", "php", "js", "py", "java", "c", "cpp",
"ts", "go", "rb", "rs", "sh", "sql", "json", "xml", "md"]
if file_type in ["txt"] + code_extensions:
try:
return {"type": "text", "content": path.read_text(encoding="utf-8")}
except Exception as e:
return {"type": "error", "content": str(e)}
# CSV / Excel β€” requires pandas
if file_type in ["csv", "xlsx"]:
if not HAS_PANDAS:
return {"type": "error", "content": "pandas not installed β€” pip install pandas openpyxl"}
try:
df = pd.read_csv(file_path) if file_type == "csv" else pd.read_excel(file_path)
return {"type": "text", "content": df.to_string()}
except Exception as e:
return {"type": "error", "content": str(e)}
# PDF β€” requires PyPDF2
if file_type == "pdf":
if not HAS_PDF:
return {"type": "error", "content": "PyPDF2 not installed β€” pip install PyPDF2"}
try:
reader = PyPDF2.PdfReader(file_path)
content = "".join(
page.extract_text() for page in reader.pages if page.extract_text()
)
return {"type": "text", "content": content}
except Exception as e:
return {"type": "error", "content": str(e)}
# ZIP β€” recursive text extraction
if file_type == "zip":
try:
text_extensions = (
".txt", ".csv", ".py", ".html", ".js", ".css", ".php",
".json", ".xml", ".c", ".cpp", ".java", ".cs", ".rb",
".go", ".ts", ".swift", ".kt", ".rs", ".sh", ".sql", ".md"
)
content = "ZIP Contents:\n"
with zipfile.ZipFile(file_path) as z:
for info in z.infolist():
if info.is_dir():
continue
try:
with z.open(info.filename) as f:
if info.filename.lower().endswith(text_extensions):
content += f"\nπŸ“„ {info.filename}:\n{f.read().decode('utf-8')}\n"
else:
try:
content += f"\nπŸ“„ {info.filename}:\n{f.read().decode('utf-8')}\n"
except UnicodeDecodeError:
content += f"\n⚠️ Binary ignored: {info.filename}\n"
except Exception as e:
content += f"\n❌ Error in {info.filename}: {e}\n"
return {"type": "text", "content": content}
except Exception as e:
return {"type": "error", "content": str(e)}
return {"type": "error", "content": f"Unsupported format: .{file_type}"}
# =============================================================================
# Async Worker β€” all Hub HTTP communication
# =============================================================================
class AsyncWorker(QObject):
result = Signal(str)
error = Signal(str)
log = Signal(str)
tools = Signal(dict)
status = Signal(str)
def __init__(self, hub_url: str, hf_token: str):
super().__init__()
self.hub_url = hub_url.rstrip("/")
self.headers = {"Authorization": f"Bearer {hf_token}"}
def _run(self, coro):
"""Run coroutine in fresh event loop β€” thread safe."""
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
def health_check(self):
"""GET / β€” returns uptime + status."""
async def _do():
try:
async with httpx.AsyncClient() as client:
r = await client.get(f"{self.hub_url}/", headers=self.headers, timeout=10)
data = r.json()
self.status.emit(f"● connected β€” uptime: {data.get('uptime_seconds', '?')}s")
self.log.emit(f"[health] {json.dumps(data)}")
except Exception as e:
self.status.emit("βœ— disconnected")
self.error.emit(f"Health check failed: {e}")
self._run(_do())
def fetch_tools(self):
"""POST /api β†’ list_active_tools β€” returns tools + providers + models."""
async def _do():
try:
async with httpx.AsyncClient() as client:
r = await client.post(
f"{self.hub_url}/api",
headers={**self.headers, "Content-Type": "application/json"},
json={"tool": "list_active_tools", "params": {}},
timeout=15,
)
data = r.json()
self.tools.emit(data)
self.log.emit(f"[tools] {json.dumps(data)}")
except Exception as e:
self.error.emit(f"Fetch tools failed: {e}")
self._run(_do())
def call_tool(self, tool_name: str, prompt: str, provider: str = None, model: str = None):
"""
Generic tool call β€” POST /api with tool_name + params.
Handles all tools: llm_complete, summarize, translate, db_query, etc.
db_query uses 'sql' param instead of 'prompt'.
"""
async def _do():
try:
# db_query needs 'sql' not 'prompt'
if tool_name == "db_query":
params = {"sql": prompt}
else:
params = {
"prompt": prompt,
"provider_name": provider or "",
"model": model or "",
"max_tokens": 1024,
}
async with httpx.AsyncClient() as client:
r = await client.post(
f"{self.hub_url}/api",
headers={**self.headers, "Content-Type": "application/json"},
json={"tool": tool_name, "params": params},
timeout=60,
)
data = r.json()
response = data.get("result", data.get("error", str(data)))
if isinstance(response, (dict, list)):
response = json.dumps(response, indent=2)
self.result.emit(str(response))
self.log.emit(f"[{tool_name}] prompt: {prompt[:60]}...")
except Exception as e:
self.error.emit(f"Tool call failed: {e}")
self._run(_do())
# =============================================================================
# Worker Thread
# =============================================================================
class WorkerThread(QThread):
def __init__(self, fn):
super().__init__()
self.fn = fn
def run(self):
self.fn()
# =============================================================================
# Main Window
# =============================================================================
class MCPDesktop(QMainWindow):
# GitHub dark theme
STYLE = """
QMainWindow, QWidget {{
background-color: #0d1117;
color: #e6edf3;
font-family: 'Consolas', monospace;
font-size: {font_size}px;
}}
QTabWidget::pane {{
border: 1px solid #21262d;
background: #0d1117;
}}
QTabBar::tab {{
background: #161b22;
color: #8b949e;
padding: 8px 16px;
border: 1px solid #21262d;
border-bottom: none;
}}
QTabBar::tab:selected {{
background: #0d1117;
color: #58a6ff;
border-bottom: 2px solid #58a6ff;
}}
QTabBar::tab:hover {{ color: #e6edf3; }}
QLineEdit {{
background: #161b22;
border: 1px solid #21262d;
border-radius: 4px;
padding: 6px 10px;
color: #e6edf3;
}}
QLineEdit:focus {{ border-color: #58a6ff; }}
QTextEdit {{
background: #161b22;
border: 1px solid #21262d;
border-radius: 4px;
padding: 8px;
color: #e6edf3;
font-family: 'Consolas', monospace;
}}
QPushButton {{
background: #21262d;
color: #e6edf3;
border: 1px solid #30363d;
border-radius: 4px;
padding: 6px 14px;
font-weight: bold;
}}
QPushButton:hover {{
background: #30363d;
border-color: #58a6ff;
color: #58a6ff;
}}
QPushButton:pressed {{ background: #161b22; }}
QPushButton#btn_connect {{
background: #238636; border-color: #2ea043; color: #fff;
}}
QPushButton#btn_connect:hover {{ background: #2ea043; }}
QPushButton#btn_send {{
background: #1f6feb; border-color: #388bfd; color: #fff; min-width: 80px;
}}
QPushButton#btn_send:hover {{ background: #388bfd; }}
QPushButton#btn_save {{
background: #6e40c9; border-color: #8957e5; color: #fff;
}}
QPushButton#btn_save:hover {{ background: #8957e5; }}
QPushButton#btn_new_chat {{
background: #0d1117; border-color: #238636; color: #3fb950; padding: 4px 10px;
}}
QPushButton#btn_new_chat:hover {{ background: #238636; color: #fff; }}
QComboBox {{
background: #161b22;
border: 1px solid #21262d;
border-radius: 4px;
padding: 5px 8px;
color: #e6edf3;
}}
QComboBox QAbstractItemView {{
background: #161b22;
border: 1px solid #30363d;
color: #e6edf3;
selection-background-color: #21262d;
}}
QSpinBox {{
background: #161b22;
border: 1px solid #21262d;
border-radius: 4px;
padding: 4px 8px;
color: #e6edf3;
}}
QStatusBar {{
background: #161b22;
color: #8b949e;
border-top: 1px solid #21262d;
font-size: 12px;
}}
"""
def __init__(self):
super().__init__()
self.cfg = load_config()
self._threads = [] # list β€” prevents GC while running
self._file_cache = None # currently loaded file
self._current_chat_id = None
self._tools_loading = False # prevent duplicate tool fetches
self._last_models = [] # Bug 3 fix: cached models for tool filter
self._last_llm_providers = [] # cached providers
self.setWindowTitle("Universal MCP Desktop")
self.setMinimumSize(1000, 720)
self._apply_style()
self._build_ui()
self._set_status("βœ— not connected")
self._load_last_chat()
def _apply_style(self):
self.setStyleSheet(self.STYLE.format(font_size=self.cfg.get("font_size", 14)))
# =========================================================================
# UI Build
# =========================================================================
def _build_ui(self):
central = QWidget()
layout = QVBoxLayout(central)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self._build_header())
self.tabs = QTabWidget()
self.tabs.addTab(self._tab_chat(), "πŸ’¬ Chat")
self.tabs.addTab(self._tab_tools(), "πŸ›  Tools")
self.tabs.addTab(self._tab_connect(), "πŸ”Œ Connect")
self.tabs.addTab(self._tab_settings(), "βš™ Settings")
self.tabs.addTab(self._tab_logs(), "πŸ“‹ Logs")
layout.addWidget(self.tabs)
self.setCentralWidget(central)
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
def _build_header(self) -> QWidget:
"""Header bar: title | Tool | Provider | Model | status"""
header = QWidget()
header.setFixedHeight(48)
header.setStyleSheet("background: #161b22; border-bottom: 1px solid #21262d;")
row = QHBoxLayout(header)
row.setContentsMargins(16, 0, 16, 0)
row.setSpacing(8)
title = QLabel("⬑ Universal MCP Desktop")
title.setStyleSheet("color: #58a6ff; font-size: 15px; font-weight: bold;")
row.addWidget(title)
row.addSpacing(16)
# Tool selector β€” in header, always visible
row.addWidget(self._small_label("Tool:"))
self.tool_select = QComboBox()
self.tool_select.addItem("llm_complete")
self.tool_select.setMinimumWidth(130)
row.addWidget(self.tool_select)
# Provider selector
row.addWidget(self._small_label("Provider:"))
self.provider_select = QComboBox()
self.provider_select.addItem("default")
self.provider_select.setMinimumWidth(120)
row.addWidget(self.provider_select)
# Model selector
row.addWidget(self._small_label("Model:"))
self.model_select = QComboBox()
self.model_select.addItem("default")
self.model_select.setMinimumWidth(200)
row.addWidget(self.model_select)
row.addStretch()
self.status_label = QLabel("βœ— not connected")
self.status_label.setStyleSheet("color: #f85149; font-size: 12px;")
row.addWidget(self.status_label)
return header
# =========================================================================
# Tab: Chat β€” multi-chat + file upload
# =========================================================================
def _tab_chat(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(6)
# Chat selector row
chat_row = QHBoxLayout()
chat_row.addWidget(QLabel("Chat:"))
self.chat_select = QComboBox()
self.chat_select.setMinimumWidth(280)
self.chat_select.currentIndexChanged.connect(self._on_chat_selected)
chat_row.addWidget(self.chat_select)
new_btn = QPushButton("+ New")
new_btn.setObjectName("btn_new_chat")
new_btn.clicked.connect(self._new_chat)
chat_row.addWidget(new_btn)
del_btn = QPushButton("πŸ—‘")
del_btn.setToolTip("Delete current chat")
del_btn.clicked.connect(self._delete_chat)
chat_row.addWidget(del_btn)
chat_row.addStretch()
layout.addLayout(chat_row)
# Chat output
self.chat_output = QTextEdit()
self.chat_output.setReadOnly(True)
self.chat_output.setPlaceholderText(
"Connect in Connect tab first β€” Tool/Provider/Model selectable in header bar above"
)
layout.addWidget(self.chat_output)
# File label β€” shown when file attached, click to remove
self.file_label = QLabel("")
self.file_label.setStyleSheet(
"color: #3fb950; font-size: 11px; padding: 2px 6px;"
"background: #0d1117; border: 1px solid #238636; border-radius: 3px;"
)
self.file_label.hide()
self.file_label.mousePressEvent = lambda e: self._clear_file()
layout.addWidget(self.file_label)
# Input row
input_row = QHBoxLayout()
file_btn = QPushButton("πŸ“Ž")
file_btn.setToolTip("Attach file (image, PDF, text, ZIP, CSV...)")
file_btn.clicked.connect(self._attach_file)
input_row.addWidget(file_btn)
self.chat_input = QLineEdit()
self.chat_input.setPlaceholderText(
"Enter prompt β€” Tool/Provider/Model in header bar..."
)
self.chat_input.returnPressed.connect(self._send_chat)
input_row.addWidget(self.chat_input)
send_btn = QPushButton("Send β–Ά")
send_btn.setObjectName("btn_send")
send_btn.clicked.connect(self._send_chat)
input_row.addWidget(send_btn)
layout.addLayout(input_row)
return tab
# =========================================================================
# Tab: Tools
# =========================================================================
def _tab_tools(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(8)
btn_row = QHBoxLayout()
refresh_btn = QPushButton("↻ Refresh Tools")
refresh_btn.clicked.connect(self._fetch_tools)
btn_row.addWidget(refresh_btn)
btn_row.addStretch()
layout.addLayout(btn_row)
self.tools_output = QTextEdit()
self.tools_output.setReadOnly(True)
self.tools_output.setPlaceholderText("Connect first, then refresh...")
layout.addWidget(self.tools_output)
return tab
# =========================================================================
# Tab: Connect β€” separate from Settings!
# =========================================================================
def _tab_connect(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(12)
layout.addWidget(self._section("Hub URL (edit in Settings tab)"))
self.connect_url_label = QLabel(self.cfg.get("hub_url", "β€” not configured β€”"))
self.connect_url_label.setStyleSheet("color: #58a6ff; padding: 6px 0;")
layout.addWidget(self.connect_url_label)
layout.addWidget(self._section("HF Token"))
token_ok = bool(self.cfg.get("hf_token"))
token_status = QLabel("βœ“ configured" if token_ok else "βœ— not set β€” go to Settings")
token_status.setStyleSheet("color: #3fb950;" if token_ok else "color: #f85149;")
layout.addWidget(token_status)
btn_row = QHBoxLayout()
conn_btn = QPushButton("πŸ”Œ Connect")
conn_btn.setObjectName("btn_connect")
conn_btn.clicked.connect(self._connect)
btn_row.addWidget(conn_btn)
ping_btn = QPushButton("❀ Ping")
ping_btn.clicked.connect(self._health_check)
btn_row.addWidget(ping_btn)
refresh_btn = QPushButton("↻ Refresh Tools")
refresh_btn.clicked.connect(self._fetch_tools)
btn_row.addWidget(refresh_btn)
btn_row.addStretch()
layout.addLayout(btn_row)
layout.addStretch()
info = QTextEdit()
info.setReadOnly(True)
info.setMaximumHeight(80)
info.setPlainText(
"Connect = Health Check + auto-load Tools/Providers/Models into header dropdowns.\n"
"Token is only sent to your own Hub β€” never stored elsewhere."
)
info.setStyleSheet("color: #8b949e; background: #0d1117; border: none; font-size: 12px;")
layout.addWidget(info)
return tab
# =========================================================================
# Tab: Settings β€” ONLY config, no connect!
# =========================================================================
def _tab_settings(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(12)
layout.addWidget(self._section("HuggingFace Token"))
self.token_input = QLineEdit(self.cfg.get("hf_token", ""))
self.token_input.setPlaceholderText("hf_...")
self.token_input.setEchoMode(QLineEdit.Password)
layout.addWidget(self.token_input)
layout.addWidget(self._section("Hub URL"))
self.url_input = QLineEdit(self.cfg.get("hub_url", ""))
self.url_input.setPlaceholderText("https://your-space-name.hf.space")
layout.addWidget(self.url_input)
layout.addWidget(self._section("Default Provider (optional)"))
self.default_provider_input = QLineEdit(self.cfg.get("default_provider", ""))
self.default_provider_input.setPlaceholderText("e.g. gemini β€” leave empty for Hub default")
layout.addWidget(self.default_provider_input)
layout.addWidget(self._section("Default Model (optional)"))
self.default_model_input = QLineEdit(self.cfg.get("default_model", ""))
self.default_model_input.setPlaceholderText("e.g. gemini-2.5-flash β€” leave empty for Hub default")
layout.addWidget(self.default_model_input)
layout.addWidget(self._section("Font Size"))
font_row = QHBoxLayout()
self.font_size_input = QSpinBox()
self.font_size_input.setRange(10, 24)
self.font_size_input.setValue(self.cfg.get("font_size", 14))
font_row.addWidget(self.font_size_input)
font_row.addStretch()
layout.addLayout(font_row)
layout.addStretch()
btn_row = QHBoxLayout()
save_btn = QPushButton("πŸ’Ύ Save Settings")
save_btn.setObjectName("btn_save")
save_btn.clicked.connect(self._save_settings)
btn_row.addWidget(save_btn)
btn_row.addStretch()
layout.addLayout(btn_row)
info = QTextEdit()
info.setReadOnly(True)
info.setMaximumHeight(60)
info.setPlainText("Saved to ~/.mcp_desktop.json β€” go to Connect tab to connect.")
info.setStyleSheet("color: #8b949e; background: #0d1117; border: none; font-size: 12px;")
layout.addWidget(info)
return tab
# =========================================================================
# Tab: Logs
# =========================================================================
def _tab_logs(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(8)
btn_row = QHBoxLayout()
clear_btn = QPushButton("πŸ—‘ Clear")
clear_btn.clicked.connect(lambda: self.log_output.clear())
btn_row.addWidget(clear_btn)
btn_row.addStretch()
layout.addLayout(btn_row)
self.log_output = QTextEdit()
self.log_output.setReadOnly(True)
self.log_output.setPlaceholderText("All requests and responses appear here...")
layout.addWidget(self.log_output)
return tab
# =========================================================================
# Helper Widgets
# =========================================================================
def _section(self, text: str) -> QLabel:
label = QLabel(text)
label.setStyleSheet("color: #8b949e; font-size: 11px; margin-top: 8px;")
return label
def _small_label(self, text: str) -> QLabel:
label = QLabel(text)
label.setStyleSheet("color: #8b949e; font-size: 11px;")
return label
# =========================================================================
# Status + Log
# =========================================================================
def _set_status(self, text: str):
self.status_label.setText(text)
self.status_bar.showMessage(text)
connected = "connected" in text and "not" not in text
color = "#3fb950" if connected else "#f85149"
self.status_label.setStyleSheet(f"color: {color}; font-size: 12px;")
def _log(self, msg: str):
ts = datetime.now().strftime("%H:%M:%S")
self.log_output.append(f"[{ts}] {msg}")
def _make_worker(self) -> AsyncWorker:
return AsyncWorker(
hub_url=self.cfg.get("hub_url", ""),
hf_token=self.cfg.get("hf_token", ""),
)
def _run_in_thread(self, fn):
"""Run in background QThread β€” list keeps all references alive until done."""
t = WorkerThread(fn)
def _cleanup():
try:
self._threads.remove(t)
except ValueError:
pass
t.deleteLater()
t.finished.connect(_cleanup)
t.start()
self._threads.append(t)
# =========================================================================
# Chat Management β€” multi-chat with timestamp IDs
# =========================================================================
def _new_chat(self):
"""Create new chat session β€” ID = timestamp."""
chat_id = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.cfg.setdefault("chats", {})[chat_id] = {"title": f"Chat {chat_id}", "messages": []}
save_config(self.cfg)
self._refresh_chat_dropdown()
idx = self.chat_select.findText(chat_id)
if idx >= 0:
self.chat_select.setCurrentIndex(idx)
self._current_chat_id = chat_id
self.chat_output.clear()
def _delete_chat(self):
"""Delete currently selected chat."""
chat_id = self.chat_select.currentText()
if chat_id and chat_id in self.cfg.get("chats", {}):
del self.cfg["chats"][chat_id]
save_config(self.cfg)
self._refresh_chat_dropdown()
self.chat_output.clear()
def _refresh_chat_dropdown(self):
"""Rebuild dropdown β€” sorted newest first."""
self.chat_select.blockSignals(True)
self.chat_select.clear()
for chat_id in sorted(self.cfg.get("chats", {}).keys(), reverse=True):
self.chat_select.addItem(chat_id)
self.chat_select.blockSignals(False)
if self.chat_select.count() > 0:
self._on_chat_selected(0)
def _on_chat_selected(self, idx: int):
"""Load selected chat into output."""
chat_id = self.chat_select.currentText()
if not chat_id:
return
self._current_chat_id = chat_id
self.chat_output.clear()
for msg in self.cfg.get("chats", {}).get(chat_id, {}).get("messages", []):
self.chat_output.append(msg)
def _load_last_chat(self):
"""Load most recent chat on startup β€” create one if none exist."""
self._refresh_chat_dropdown()
if self.chat_select.count() > 0:
self.chat_select.setCurrentIndex(0)
else:
self._new_chat()
def _save_chat_message(self, msg: str):
"""Persist message to current chat history."""
if not self._current_chat_id:
return
self.cfg.setdefault("chats", {}).setdefault(
self._current_chat_id, {"title": "", "messages": []}
)["messages"].append(msg)
save_config(self.cfg)
# =========================================================================
# File Attachment
# =========================================================================
def _attach_file(self):
"""Open file dialog β€” process and cache file for next send."""
path, _ = QFileDialog.getOpenFileName(
self, "Attach File", "",
"Supported (*.jpg *.jpeg *.png *.txt *.py *.php *.js *.html *.css "
"*.pdf *.csv *.xlsx *.zip *.json *.xml *.md *.sql *.sh *.c *.cpp *.java);;"
"All files (*.*)"
)
if not path:
return
result = process_file(path)
if result["type"] == "error":
self._log(f"File error: {result['content']}")
return
self._file_cache = result
self._file_cache["path"] = path
filename = Path(path).name
self.file_label.setText(f"πŸ“Ž {filename} (click to remove)")
self.file_label.show()
self._log(f"[file] loaded: {filename} ({result['type']})")
def _clear_file(self):
"""Remove attached file."""
self._file_cache = None
self.file_label.hide()
# =========================================================================
# Actions
# =========================================================================
def _save_settings(self):
"""Save settings only β€” no connect action."""
self.cfg["hf_token"] = self.token_input.text().strip()
self.cfg["hub_url"] = self.url_input.text().strip()
self.cfg["default_provider"] = self.default_provider_input.text().strip()
self.cfg["default_model"] = self.default_model_input.text().strip()
self.cfg["font_size"] = self.font_size_input.value()
save_config(self.cfg)
self._apply_style()
self.connect_url_label.setText(self.cfg.get("hub_url", "β€” not configured β€”"))
self._log("Settings saved β€” go to Connect tab to connect.")
def _connect(self):
"""Health check + auto-fetch tools on success."""
if not self.cfg.get("hf_token") or not self.cfg.get("hub_url"):
self._set_status("βœ— configure in Settings first!")
return
self._set_status("… connecting")
self._log(f"Connecting to {self.cfg['hub_url']}...")
w = self._make_worker()
w.status.connect(self._set_status)
w.status.connect(lambda s: self._fetch_tools() if "● connected" in s else None)
w.error.connect(lambda e: self._log(f"ERROR: {e}"))
w.log.connect(self._log)
self._run_in_thread(w.health_check)
def _health_check(self):
"""Ping Hub β€” update status."""
w = self._make_worker()
w.status.connect(self._set_status)
w.status.connect(lambda s: self._fetch_tools() if "● connected" in s else None)
w.error.connect(lambda e: self._log(f"ERROR: {e}"))
w.log.connect(self._log)
self._run_in_thread(w.health_check)
def _fetch_tools(self):
"""Fetch tools + providers + models β€” skip if already loading."""
if self._tools_loading:
return
self._tools_loading = True
w = self._make_worker()
w.tools.connect(self._on_tools)
w.error.connect(lambda e: setattr(self, '_tools_loading', False) or self._log(f"ERROR: {e}"))
w.log.connect(self._log)
self._run_in_thread(w.fetch_tools)
def _on_tools(self, data: dict):
"""Populate tool/provider/model dropdowns from Hub response."""
self._tools_loading = False
result = data.get("result", data)
self.tools_output.setPlainText(
json.dumps({"active_tools": result}, indent=2)
if isinstance(result, list)
else json.dumps(result, indent=2)
)
tools = result.get("active_tools", []) if isinstance(result, dict) else []
try:
self.tool_select.currentTextChanged.disconnect(self._populate_models_for_tool)
except RuntimeError:
pass
self.tool_select.clear()
for t in tools:
self.tool_select.addItem(t)
idx = self.tool_select.findText(self.cfg.get("default_tool", "llm_complete"))
if idx >= 0:
self.tool_select.setCurrentIndex(idx)
self.provider_select.clear()
self.provider_select.addItem("default")
for p in result.get("active_llm_providers", []) if isinstance(result, dict) else []:
self.provider_select.addItem(p)
self._last_models = result.get("available_models", []) if isinstance(result, dict) else []
self._last_llm_providers = result.get("active_llm_providers", []) if isinstance(result, dict) else []
self._populate_models_for_tool(self.tool_select.currentText())
self.tool_select.currentTextChanged.connect(self._populate_models_for_tool)
self._log(f"Tools loaded: {tools}")
_LLM_TOOLS = {"llm_complete", "code_review", "summarize", "translate"}
def _populate_models_for_tool(self, tool_name: str):
"""Filter model dropdown based on selected tool β€” only LLM tools need models."""
self.model_select.clear()
self.model_select.addItem("default")
if tool_name in self._LLM_TOOLS:
for m in getattr(self, "_last_models", []):
self.model_select.addItem(m)
def _send_chat(self):
"""Send prompt to Hub β€” uses Tool/Provider/Model from header dropdowns."""
prompt = self.chat_input.text().strip()
if not prompt:
return
tool_name = self.tool_select.currentText() or "llm_complete"
provider = self.provider_select.currentText()
if provider == "default":
provider = self.cfg.get("default_provider") or None
model = self.model_select.currentText()
if model == "default":
model = self.cfg.get("default_model") or None
full_prompt = prompt
if self._file_cache:
if self._file_cache["type"] == "text":
full_prompt = f"{prompt}\n\n[File Content]\n{self._file_cache['content']}"
self._clear_file()
user_msg = f"\nβ–Ά You [{tool_name}]: {prompt}"
self.chat_output.append(user_msg)
self._save_chat_message(user_msg)
self.chat_input.clear()
self._log(f"β†’ tool: {tool_name}, provider: {provider or 'default'}, model: {model or 'default'}")
w = self._make_worker()
def on_result(r):
msg = f"⬑ Hub: {r}\n"
self.chat_output.append(msg)
self._save_chat_message(msg)
w.result.connect(on_result)
w.error.connect(lambda e: self.chat_output.append(f"βœ— Error: {e}\n"))
w.log.connect(self._log)
self._run_in_thread(lambda: w.call_tool(tool_name, full_prompt, provider, model))
# =============================================================================
# Entry Point
# =============================================================================
if __name__ == "__main__":
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
app = QApplication(sys.argv)
window = MCPDesktop()
window.show()
sys.exit(app.exec())