from __future__ import annotations import sys import os import webbrowser import multiprocessing as mp from PySide6 import QtCore, QtGui, QtWidgets from chatmock.app import create_app from chatmock.cli import cmd_login from chatmock.utils import load_chatgpt_tokens, parse_jwt_claims def run_server( host: str, port: int, reasoning_effort: str = "medium", reasoning_summary: str = "auto", reasoning_compat: str = "think-tags", fast_mode: bool = False, debug_model: str | None = None, expose_reasoning_models: bool = False, default_web_search: bool = False, ) -> None: app = create_app( reasoning_effort=reasoning_effort, reasoning_summary=reasoning_summary, reasoning_compat=reasoning_compat, fast_mode=fast_mode, debug_model=debug_model, expose_reasoning_models=expose_reasoning_models, default_web_search=default_web_search, ) app.run(host=host, port=port, use_reloader=False, threaded=True) class ServerProcess(QtCore.QObject): state_changed = QtCore.Signal(bool) def __init__(self) -> None: super().__init__() self._proc: QtCore.QProcess | None = None self._host = "127.0.0.1" self._port = 8000 self._effort = "medium" self._summary = "auto" self._compat = "think-tags" self._fast_mode = False self._debug_model: str | None = None self._expose_reasoning_models = False self._default_web_search = False def is_running(self) -> bool: return self._proc is not None and self._proc.state() != QtCore.QProcess.NotRunning def start( self, host: str, port: int, effort: str, summary: str, compat: str, fast_mode: bool, debug_model: str | None, expose_reasoning_models: bool, default_web_search: bool, ) -> None: if self.is_running(): return self._host, self._port = host, port self._effort, self._summary = effort, summary self._compat = compat self._fast_mode = fast_mode self._debug_model = debug_model self._expose_reasoning_models = expose_reasoning_models self._default_web_search = default_web_search self._proc = QtCore.QProcess() self._proc.setProcessChannelMode(QtCore.QProcess.MergedChannels) args = [ "--run-server", "--host", host, "--port", str(port), "--effort", effort, "--summary", summary, "--compat", compat, ] if isinstance(debug_model, str) and debug_model.strip(): args.extend(["--debug-model", debug_model.strip()]) if fast_mode: args.append("--fast-mode") if expose_reasoning_models: args.append("--expose-reasoning-models") if default_web_search: args.append("--enable-web-search") self._proc.start(sys.executable, args) self._proc.started.connect(lambda: self.state_changed.emit(True)) def _on_finished(code: int, status: QtCore.QProcess.ExitStatus) -> None: self.state_changed.emit(False) self._proc = None self._proc.finished.connect(_on_finished) def stop(self) -> None: if not self.is_running(): return try: self._proc.kill() self._proc.waitForFinished(3000) except Exception: pass self._proc = None self.state_changed.emit(False) def base_url(self) -> str: return f"http://{self._host}:{self._port}/v1" def resource_path(rel: str) -> str: base = getattr(sys, "_MEIPASS", os.path.abspath(os.path.dirname(__file__))) return os.path.join(base, rel) def find_app_icon() -> QtGui.QIcon: candidates = [ "appicon.icns", "appicon.ico", "appicon.png", "icon.icns", "icon.ico", "icon.png", "ChatMock.icns", "ChatMock.png", ] for name in candidates: p = resource_path(name) if os.path.exists(p): icon = QtGui.QIcon(p) if not icon.isNull(): return icon return QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.SP_DesktopIcon) def is_dark_mode() -> bool: app = QtWidgets.QApplication.instance() pal = app.palette() if app else QtGui.QPalette() bg = pal.window().color() return bg.lightness() < 128 def apply_theme() -> None: dark = is_dark_mode() if dark: bg = "#111827" # slate-900 text = "#e5e7eb" # gray-200 subtext = "#9ca3af" # gray-400 border = "#374151" # slate-700 primary = "#3b82f6" # blue-500 primary_hover = "#2563eb" danger = "#ef4444" # red-500 field_bg = "#0f172a" # slightly lighter (inputs) else: bg = "#ffffff" text = "#0f172a" subtext = "#64748b" border = "#e5e7eb" primary = "#2563eb" primary_hover = "#1d4ed8" danger = "#ef4444" field_bg = "#ffffff" css = f""" QWidget {{ background: {bg}; color: {text}; }} QGroupBox {{ background: {bg}; border: 1px solid {border}; border-radius: 10px; padding: 12px; margin-top: 8px; }} QGroupBox::title {{ subcontrol-origin: margin; subcontrol-position: top left; padding: 2px 6px; color: {text}; font-weight: 600; background: transparent; }} QLabel#subtitle {{ color: {subtext}; }} QLabel {{ background: transparent; }} QLineEdit, QComboBox {{ background: {field_bg}; border: 1px solid {border}; border-radius: 6px; padding: 6px 8px; }} QPushButton {{ border: 1px solid {border}; border-radius: 6px; padding: 6px 12px; background: {bg}; color: {text}; }} QPushButton:hover {{ border-color: {primary}; }} QPushButton[muted="true"] {{ background: transparent; color: {subtext}; border-color: {border}; }} QPushButton[muted="true"]:hover {{ border-color: {primary}; color: {text}; }} QPushButton[primary="true"] {{ background: {primary}; color: #ffffff; border: 1px solid {primary}; }} QPushButton[primary="true"]:hover {{ background: {primary_hover}; border-color: {primary_hover}; }} QPushButton[danger="true"] {{ background: transparent; color: {danger}; border: 1px solid {danger}; }} QPushButton[danger="true"]:hover {{ background: {danger}; color: #ffffff; }} QMenu {{ background: {bg}; border: 1px solid {border}; }} QMenu::item:selected {{ background: {primary}; color: #ffffff; }} """ app = QtWidgets.QApplication.instance() if app: app.setStyleSheet(css) class LoginWorker(QtCore.QThread): finished_with_code = QtCore.Signal(int) def run(self) -> None: try: code = cmd_login(no_browser=False, verbose=False) except Exception: code = 1 self.finished_with_code.emit(code) class MainWindow(QtWidgets.QMainWindow): def __init__(self) -> None: super().__init__() self.setWindowTitle("ChatMock") self.setMinimumSize(620, 420) self._logged_in = False self._server = ServerProcess() self._server.state_changed.connect(self._on_server_state_changed) # Central widget cw = QtWidgets.QWidget() self.setCentralWidget(cw) root = QtWidgets.QVBoxLayout(cw) root.setContentsMargins(16, 16, 16, 12) root.setSpacing(12) # Header header = QtWidgets.QVBoxLayout() self.title = QtWidgets.QLabel("ChatMock") font = self.title.font() font.setPointSize(20) font.setBold(True) self.title.setFont(font) self.status = QtWidgets.QLabel("Welcome to ChatMock") self.status.setObjectName("subtitle") header.addWidget(self.title) header.addWidget(self.status) root.addLayout(header) # Account card acc_box = QtWidgets.QGroupBox("Account") acc_box.setStyleSheet("QGroupBox{font-weight:600;}") acc_layout = QtWidgets.QFormLayout(acc_box) acc_layout.setLabelAlignment(QtCore.Qt.AlignLeft) acc_layout.setFormAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) acc_layout.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow) self.email_value = QtWidgets.QLabel("Not signed in") self.email_value.setWordWrap(True) self.plan_value = QtWidgets.QLabel("-") self.accid_value = QtWidgets.QLabel("-") self.accid_value.setWordWrap(True) acc_layout.addRow("Email", self.email_value) acc_layout.addRow("Plan", self.plan_value) acc_layout.addRow("Account ID", self.accid_value) acc_btns = QtWidgets.QHBoxLayout() self.btn_login = QtWidgets.QPushButton("Log in") self.btn_login.clicked.connect(self._on_login) acc_btns.addWidget(self.btn_login) acc_btns.addStretch(1) acc_layout.addRow(acc_btns) root.addWidget(acc_box) # Server card srv_box = QtWidgets.QGroupBox("Server") srv_layout = QtWidgets.QVBoxLayout(srv_box) form = QtWidgets.QGridLayout() form.setHorizontalSpacing(12) form.setVerticalSpacing(8) form.addWidget(QtWidgets.QLabel("Host"), 0, 0) self.host_edit = QtWidgets.QLineEdit("127.0.0.1") self.host_edit.setClearButtonEnabled(True) self.host_edit.setMinimumWidth(220) self.host_edit.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) form.addWidget(self.host_edit, 0, 1) form.addWidget(QtWidgets.QLabel("Port"), 0, 2) self.port_edit = QtWidgets.QLineEdit("8000") self.port_edit.setValidator(QtGui.QIntValidator(1, 65535, self)) self.port_edit.setMaximumWidth(100) form.addWidget(self.port_edit, 0, 3) form.addWidget(QtWidgets.QLabel("Debug Model"), 1, 0) self.debug_model_edit = QtWidgets.QLineEdit("") self.debug_model_edit.setClearButtonEnabled(True) self.debug_model_edit.setPlaceholderText("Optional override, e.g. gpt-5.4") self.debug_model_edit.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) form.addWidget(self.debug_model_edit, 1, 1, 1, 3) form.setColumnStretch(1, 1) srv_layout.addLayout(form) actions = QtWidgets.QHBoxLayout() self.btn_start = QtWidgets.QPushButton("Start in Background") self.btn_start.setDefault(True) self.btn_start.setProperty("primary", True) self.btn_stop = QtWidgets.QPushButton("Stop") self.btn_stop.setProperty("danger", True) self.btn_open = QtWidgets.QPushButton("Open Base URL") actions.addWidget(self.btn_start) actions.addWidget(self.btn_stop) actions.addWidget(self.btn_open) actions.addStretch(1) srv_layout.addLayout(actions) # Reasoning controls opts = QtWidgets.QGridLayout() opts.setHorizontalSpacing(12) opts.setVerticalSpacing(8) opts.addWidget(QtWidgets.QLabel("Reasoning Effort"), 0, 0) self.effort = QtWidgets.QComboBox() self.effort.addItems(["none", "minimal", "low", "medium", "high", "xhigh"]) self.effort.setCurrentText("medium") self.effort.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents) self.effort.setMinimumContentsLength(7) opts.addWidget(self.effort, 0, 1) opts.addWidget(QtWidgets.QLabel("Reasoning Summary"), 0, 2) self.summary = QtWidgets.QComboBox() self.summary.addItems(["auto", "concise", "detailed", "none"]) self.summary.setCurrentText("auto") self.summary.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents) self.summary.setMinimumContentsLength(8) opts.addWidget(self.summary, 0, 3) opts.addWidget(QtWidgets.QLabel("Reasoning Compat"), 1, 0) self.compat = QtWidgets.QComboBox() self.compat.addItems(["think-tags", "legacy", "o3", "current"]) self.compat.setCurrentText("think-tags") self.compat.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents) opts.addWidget(self.compat, 1, 1) self.expose_reasoning_models = QtWidgets.QCheckBox("Expose reasoning models") opts.addWidget(self.expose_reasoning_models, 1, 2) self.fast_mode = QtWidgets.QCheckBox("Enable fast mode") opts.addWidget(self.fast_mode, 1, 3) self.enable_web_search = QtWidgets.QCheckBox("Enable web search") opts.addWidget(self.enable_web_search, 2, 0) opts.setColumnStretch(1, 1) opts.setColumnStretch(3, 1) srv_layout.addLayout(opts) url_row = QtWidgets.QHBoxLayout() url_row.addWidget(QtWidgets.QLabel("Base URL:")) self.baseurl = QtWidgets.QLabel("(server not running)") self.baseurl.setTextInteractionFlags( QtCore.Qt.TextSelectableByMouse | QtCore.Qt.TextSelectableByKeyboard ) self.baseurl.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) url_row.addWidget(self.baseurl, 1) self.btn_copy = QtWidgets.QPushButton("Copy") url_row.addWidget(self.btn_copy) srv_layout.addLayout(url_row) root.addWidget(srv_box) self.btn_start.clicked.connect(self._start_server) self.btn_stop.clicked.connect(self._stop_server) self.btn_copy.clicked.connect(self._copy_url) self.btn_open.clicked.connect(self._open_base_url) # Tray self.tray = QtWidgets.QSystemTrayIcon(self) icon = find_app_icon() self.setWindowIcon(icon) self.tray.setIcon(icon) tray_menu = QtWidgets.QMenu() act_show = tray_menu.addAction("Show Window") tray_menu.addSeparator() act_quit = tray_menu.addAction("Quit") act_show.triggered.connect(self._show_window) act_quit.triggered.connect(QtWidgets.QApplication.quit) self.tray.setContextMenu(tray_menu) self.tray.show() self._refresh_login_state() self._on_server_state_changed(False) QtWidgets.QApplication.instance().aboutToQuit.connect(self._server.stop) def _refresh_login_state(self) -> None: access_token, account_id, id_token = load_chatgpt_tokens() if access_token and id_token: self.status.setText("Signed in • Ready to serve") self._logged_in = True self.btn_login.setEnabled(True) self.btn_login.setProperty("muted", True) try: self.btn_login.style().unpolish(self.btn_login) self.btn_login.style().polish(self.btn_login) except Exception: pass self.btn_login.setToolTip("You are logged in. Click to re-authenticate.") id_claims = parse_jwt_claims(id_token) or {} access_claims = parse_jwt_claims(access_token) or {} email = id_claims.get("email") or id_claims.get("preferred_username") or "" plan_raw = (access_claims.get("https://api.openai.com/auth") or {}).get("chatgpt_plan_type") or "unknown" plan_map = {"plus": "Plus", "pro": "Pro", "free": "Free", "team": "Team", "enterprise": "Enterprise"} plan = plan_map.get( str(plan_raw).lower(), str(plan_raw).title() if isinstance(plan_raw, str) else "Unknown" ) self.email_value.setText(email) self.plan_value.setText(plan) self.accid_value.setText(account_id or "-") else: self.status.setText("Not signed in • Click Log in") self._logged_in = False self.btn_login.setEnabled(True) self.btn_login.setProperty("muted", False) try: self.btn_login.style().unpolish(self.btn_login) self.btn_login.style().polish(self.btn_login) except Exception: pass self.btn_login.setToolTip("Log in to ChatGPT") self.email_value.setText("Not signed in") self.plan_value.setText("-") self.accid_value.setText("-") self.btn_start.setEnabled(not self._server.is_running() and self._logged_in) def _on_login(self) -> None: self.status.setText("Launching login flow…") self.btn_login.setEnabled(False) self._login_worker = LoginWorker() self._login_worker.finished_with_code.connect(self._after_login) self._login_worker.start() def _after_login(self, code: int) -> None: if code == 0: QtWidgets.QMessageBox.information(self, "Login", "Login successful. You can now start the server.") elif code == 13: QtWidgets.QMessageBox.warning( self, "Login", "Login helper port is in use. Close other instances and try again." ) else: QtWidgets.QMessageBox.critical(self, "Login", "Login failed. Please try again.") self._refresh_login_state() def _start_server(self) -> None: try: host = self.host_edit.text().strip() or "127.0.0.1" port = int(self.port_edit.text().strip() or "8000") except ValueError: QtWidgets.QMessageBox.critical(self, "Port", "Invalid port number.") return effort = self.effort.currentText().strip() summary = self.summary.currentText().strip() compat = self.compat.currentText().strip() fast_mode = self.fast_mode.isChecked() debug_model = self.debug_model_edit.text().strip() or None expose_reasoning_models = self.expose_reasoning_models.isChecked() default_web_search = self.enable_web_search.isChecked() self.status.setText(f"Starting server at http://{host}:{port} …") self.btn_start.setEnabled(False) self._server.start( host, port, effort, summary, compat, fast_mode, debug_model, expose_reasoning_models, default_web_search, ) def _stop_server(self) -> None: self._server.stop() def _on_server_state_changed(self, running: bool) -> None: self.btn_start.setEnabled((not running) and self._logged_in) self.btn_stop.setEnabled(running) self.btn_open.setEnabled(running) self.btn_copy.setEnabled(running) if running: self.status.setText("Serving • Running in background") self.baseurl.setText(self._server.base_url()) self.hide() self.tray.showMessage( "ChatMock", "Server is running in the background", QtWidgets.QSystemTrayIcon.Information, 1500 ) else: self.status.setText("Server stopped") self.baseurl.setText("(server not running)") def _copy_url(self) -> None: url = self.baseurl.text().strip() if url and not url.startswith("("): QtWidgets.QApplication.clipboard().setText(url) def _open_base_url(self) -> None: url = self.baseurl.text().strip() if url and not url.startswith("("): webbrowser.open(url) def _show_window(self) -> None: self.show() self.raise_() self.activateWindow() def main() -> None: mp.freeze_support() if "--run-server" in sys.argv: import argparse p = argparse.ArgumentParser(add_help=False) p.add_argument("--run-server", action="store_true") p.add_argument("--host", default="127.0.0.1") p.add_argument("--port", type=int, default=8000) p.add_argument("--effort", default="medium") p.add_argument("--summary", default="auto") p.add_argument("--compat", default="think-tags") p.add_argument("--fast-mode", action="store_true") p.add_argument("--debug-model") p.add_argument("--expose-reasoning-models", action="store_true") p.add_argument("--enable-web-search", action="store_true") args, _ = p.parse_known_args() run_server( args.host, args.port, args.effort, args.summary, args.compat, args.fast_mode, args.debug_model, args.expose_reasoning_models, args.enable_web_search, ) return app = QtWidgets.QApplication(sys.argv) apply_theme() w = MainWindow() w.show() sys.exit(app.exec()) if __name__ == "__main__": main()