diff --git a/.gitignore b/.gitignore index dbfa678db..51da52464 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,40 @@ -*.pyc -*.d -generated -build -.vscode +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments .venv/ -*.egg-info -.venv_py39_backup/* \ No newline at end of file +venv/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OpenCode +.opencode/ + +# Test files +*.cif +*.pdb +*.pse diff --git a/AGENTS.md b/AGENTS.md index 04f765ba4..8ec9f6e57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,190 +1,347 @@ -# AGENTS.md - PyMolAI Setup Runbook for Claude Code, Codex, and Cursor +# AGENTS.md - PyMolAI Development Guide -This guide is for coding agents and assistant workflows that need to install and configure PyMolAI for end users. +This guide is for coding agents and developers working on PyMolAI, an AI assistant layer integrated into the PyMOL Qt desktop UI. -## 1) What You Are Setting Up +## 1) Project Overview -PyMolAI is an AI assistant layer integrated into the PyMOL Qt desktop UI. +PyMolAI extends open-source PyMOL with an integrated AI assistant panel for molecular workflows. -Core points: -- OpenRouter key enables AI agent turns. -- OpenBio key is optional and only enables OpenBio gateway tools. -- Internal PyMOL tools (`run_pymol_command`, `capture_viewer_snapshot`) are always available. +**Key Components:** +- `modules/pymol/ai/` — AI runtime, SDK loops, tool execution, API clients +- `modules/pmg_qt/` — Qt desktop UI components (chat panel, dialogs) +- `modules/pymol/` — Core PyMOL Python API +- `layer0-5/`, `layerGraphics/` — C++ native layers (compiled via setup.py) -## 2) Minimal End-User Setup Flow +**Python Version:** Requires Python >=3.9. Full Claude SDK agent path requires Python >=3.10. -1. Install project in a virtual environment. -2. Launch PyMOL GUI. -3. Open `Display -> PyMolAI Settings -> OpenRouter API Key...` and save/test key. -4. For OpenBio access, sign up at https://openbio.tech/ and create an OpenBio API key. -5. Optionally open `Display -> PyMolAI Settings -> OpenBio API Key...` and save/test key. -6. Optionally set the model from `Display -> PyMolAI Settings -> Model`. +--- -## 3) Agent-Safe Setup Scripts +## 2) Build Commands -### macOS prerequisites (Homebrew) - -The C++ extension requires native libraries not bundled in the Python package. -Install them before the pip/uv install step: +### Install (macOS with uv) ```bash +# Prerequisites brew install netcdf glew glm -# libxml2, freetype, and libpng are typically already present; -# install them too if the build fails looking for those headers. -``` - -## macOS -```bash -git clone https://github.com/ravishar313/PyMolAI -cd PyMolAI -# Create venv with uv (Python 3.10+ for full claude-agent-sdk support) +# Create venv and install uv venv .venv source .venv/bin/activate - -# Build and install — include netcdf in PREFIX_PATH alongside homebrew paths PREFIX_PATH=/opt/homebrew:/opt/homebrew/opt/libxml2:/opt/homebrew/opt/netcdf \ uv pip install --python .venv/bin/python --reinstall . -# PyQt5 must be installed explicitly. PyQt6 is also detected by PyMOL's Qt -# loader but has incompatible enum namespacing with this codebase. +# PyQt5 is REQUIRED (PyQt6 has enum incompatibilities) uv pip install --python .venv/bin/python PyQt5 -# Verify -.venv/bin/python -c "import keyring, openai; print('ok: keyring/openai')" -.venv/bin/python -c "import claude_agent_sdk; print('ok: claude-agent-sdk')" -.venv/bin/python -c "from PyQt5 import QtWidgets; print('ok: PyQt5')" +# Install dev dependencies +uv pip install --python .venv/bin/python -e ".[dev]" ``` -## Windows (PowerShell) +### Install (Windows PowerShell) ```powershell -git clone https://github.com/ravishar313/PyMolAI -cd PyMolAI uv venv .venv --python 3.10 .\.venv\Scripts\Activate.ps1 $env:PREFIX_PATH = "C:\path\to\deps" uv pip install --python .venv\Scripts\python.exe --reinstall . uv pip install --python .venv\Scripts\python.exe PyQt5 +``` + +### Verify Installation + +```bash python -c "import keyring, openai; print('ok: keyring/openai')" python -c "import claude_agent_sdk; print('ok: claude-agent-sdk')" python -c "from PyQt5 import QtWidgets; print('ok: PyQt5')" ``` -## 4) Provider/Key Behavior +--- + +## 3) Test Commands + +### Run All Tests + +```bash +cd testing +pytest +``` + +### Run a Single Test File + +```bash +cd testing +pytest tests/api/test_ai_runtime.py +``` + +### Run a Single Test Function -Environment variables: -- `OPENROUTER_API_KEY` -- `ANTHROPIC_AUTH_TOKEN` -- `OPENBIO_API_KEY` -- `OPENBIO_BASE_URL` -- `PYMOL_AI_OPENROUTER_KEY_SOURCE` -- `PYMOL_AI_OPENBIO_KEY_SOURCE` +```bash +cd testing +pytest tests/api/test_ai_runtime.py::TestAiRuntime::test_basic_init -v +``` -Behavior contract: -- Without OpenRouter key (or Anthropic auth token mapping), AI mode is disabled. -- Without OpenBio key, OpenBio tools are not registered, but the app still works. -- Model selector in settings is strict to supported models; `/ai model ` remains flexible. -- Changing model while busy applies to the next turn and should show a user-facing notice. +### Run Tests with PyMOL (legacy method) -Key storage: -- UI save uses `keyring` and system keychain. -- Runtime can load saved keys into process environment at startup. -- Env key takes precedence when explicitly set. +```bash +pymol -cq testing/runall.pml +``` -## 5) Architecture (High-Level) +### Test Configuration -- Runtime bootstraps keys from env/keyring. -- Claude SDK loop maps OpenRouter env and builds tool server. -- Tool registration: - - Always: `run_pymol_command`, `capture_viewer_snapshot` - - Conditional: `openbio_api_*` gateway tools only when OpenBio key is present -- OpenBio API base defaults to `https://api.openbio.tech` unless overridden by `OPENBIO_BASE_URL`. +- `testing/pytest.ini` — pytest configuration +- `testing/conftest.py` — root conftest, extends pymol path +- `testing/tests/api/conftest.py` — API test fixtures (auto-reinitialize) -## 6) Troubleshooting Decision Tree +### Test Structure -## A) "AI disabled" in runtime -- Check `OPENROUTER_API_KEY` (or `ANTHROPIC_AUTH_TOKEN`) exists. -- Check `PYMOL_AI_DISABLE` is not `1`. -- Re-test via OpenRouter key dialog. +``` +testing/ +├── pytest.ini +├── conftest.py +├── testing.py # PyMOLTestCase base class +├── runall.pml # Legacy test runner script +└── tests/ + ├── api/ # API tests (test_*.py) + ├── undo/ # Undo functionality tests + ├── settings/ # Settings tests + ├── performance/ # Performance benchmarks + └── helpers/ # Test utilities +``` -## B) Missing Claude SDK functionality -- Ensure Python is 3.10+. -- Verify `claude-agent-sdk` import succeeds. +--- -## C) Key validation fails in dialogs -- Confirm provider/key pairing (OpenRouter vs OpenBio keys are not interchangeable). -- Check network/proxy restrictions. -- Retry with known-good key. +## 4) Code Style Guidelines -## D) OpenBio errors or blocked calls -- Confirm `OPENBIO_API_KEY` is set. -- Confirm base URL (`OPENBIO_BASE_URL`, if overridden). -- Check firewall/proxy/edge restrictions for `api.openbio.tech`. +### Imports -## E) Keychain unavailable -- Install/configure OS keychain backend for `keyring`. -- If unavailable, use env vars as temporary fallback. +```python +from __future__ import annotations # Always first -## F) macOS build fails: `netcdf.h` not found -- Run: `brew install netcdf` -- Add `/opt/homebrew/opt/netcdf` to `PREFIX_PATH` in the install command. +# Standard library (alphabetical) +import json +import logging +import os +import re +import threading +from typing import Dict, List, Optional, Tuple -## G) macOS build fails: `GL/glew.h` not found -- Run: `brew install glew glm` -- `/opt/homebrew` in `PREFIX_PATH` covers these; ensure it is present. +# Third-party +from PyQt5 import QtCore, QtWidgets -## H) PyMOL crashes on launch: `AttributeError: type object 'Qt' has no attribute '...'` -- Cause: PyQt6 is installed but PyQt5 is not. PyMOL falls back to PyQt6 when PyQt5 - is absent, but the chat UI code uses PyQt5-style flat enum access which is - incompatible with PyQt6's namespaced enums. -- Fix: `uv pip install --python .venv/bin/python PyQt5` -- PyMOL's Qt loader tries PyQt5 first; once installed it will be selected automatically. +# Local imports (relative for same package) +from .message_types import UiEvent, UiRole +from .claude_sdk_loop import ClaudeSdkLoop +``` -## 7) Support Boundaries and Safety +### Type Annotations -- Never log or commit plaintext API keys. -- Never hardcode keys into scripts. -- Prefer UI save flow and keychain storage for end users. -- Use masked key displays for status messaging. +Use modern type hints consistently: -## 8) Quick Diagnostics +```python +from __future__ import annotations +from typing import Dict, List, Optional, Any, Callable -Check Python and AI deps: +def _env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, str(default))) + except Exception: + return default -```bash -python -c "import sys; print(sys.version)" -python -c "import keyring, openai; print('deps ok')" -python -c "import claude_agent_sdk; print('claude sdk ok')" +class AiRuntime: + def __init__(self, cmd) -> None: + self._logger: logging.Logger = logging.getLogger("pymol.ai") + self._plans: List[Dict[str, Any]] = [] ``` -Check environment visibility: +### Naming Conventions + +| Type | Convention | Example | +|------|------------|---------| +| Classes | PascalCase | `AiRuntime`, `ClaudeSdkLoop` | +| Functions | snake_case | `run_pymol_command`, `capture_viewer_snapshot` | +| Private methods | `_leading_underscore` | `_sync_height`, `_import_sdk_symbols` | +| Module-level constants | SCREAMING_SNAKE | `DEFAULT_MODEL`, `SYSTEM_PROMPT_BASE` | +| Internal constants | `_LEADING_SCREAMING` | `_READ_ONLY_PREFIXES`, `_RE_PDB_ID` | + +### Error Handling + +Use custom exceptions with context: + +```python +class ClaudeSdkLoopError(RuntimeError): + def __init__(self, message: str, *, error_class: str = "sdk_error"): + super().__init__(message) + self.error_class = error_class + +# Reraise with context +try: + import claude_agent_sdk +except Exception as exc: + raise ClaudeSdkLoopError( + "Claude Agent SDK is unavailable.", + error_class="sdk_unavailable", + ) from exc +``` + +### Logging + +Use the `pymol.ai` logger namespace: + +```python +self._logger = logging.getLogger("pymol.ai") +self._logger.info("Starting AI turn") +self._logger.error("API call failed: %s", error_msg) +``` + +### Docstrings + +Use triple-quoted docstrings with description: + +```python +def capture_viewer_snapshot(cmd, width: int = 800, height: int = 600) -> dict: + """Capture a PNG snapshot of the current PyMOL viewer. + + Args: + cmd: PyMOL cmd module + width: Image width in pixels + height: Image height in pixels + + Returns: + dict with 'image_data' (base64) and 'error' (if any) + """ +``` + +### Qt/UI Patterns + +Use PyQt5 with flat enum access: + +```python +from pymol.Qt import QtCore, QtGui, QtWidgets +Qt = QtCore.Qt + +# Flat enum style (PyQt5 compatible) +self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) +self.setFrameShape(QtWidgets.QFrame.NoFrame) +``` + +**IMPORTANT:** Never use PyQt6-style namespaced enums like `Qt.ScrollBarPolicy.ScrollBarAlwaysOff`. + +--- + +## 5) Project Structure + +``` +PyMolAI/ +├── modules/ +│ ├── pymol/ +│ │ ├── ai/ # AI integration (runtime, tools, clients) +│ │ ├── wizard/ # PyMOL wizards +│ │ ├── plugins/ # Plugin system +│ │ └── __init__.py # PyMOL entry point +│ ├── pmg_qt/ # Qt GUI components +│ │ ├── assistant_chat_panel.py +│ │ ├── ai_api_key_dialog.py +│ │ └── forms/ # .ui files +│ ├── chempy/ # Chemistry utilities +│ └── pymol2/ # PyMOL2 API +├── testing/ +│ ├── tests/api/ # API tests +│ ├── pytest.ini +│ └── testing.py # PyMOLTestCase base +├── layer0-5/ # C++ layers +├── layerGraphics/ # OpenGL graphics layer +├── data/ # PyMOL data files +├── setup.py # Build configuration +├── pyproject.toml # Package metadata +└── AGENTS.md # This file +``` + +--- + +## 6) API Keys & Environment + +### Required for AI Mode + +- `OPENROUTER_API_KEY` — Enables AI agent turns +- `ANTHROPIC_AUTH_TOKEN` — Alternative to OpenRouter key + +### Optional + +- `OPENBIO_API_KEY` — Enables OpenBio gateway tools +- `OPENBIO_BASE_URL` — Override OpenBio API endpoint + +### Runtime Toggles + +- `PYMOL_AI_DISABLE=1` — Disable AI mode +- `PYMOL_AI_DEFAULT_MODEL` — Set default model +- `PYMOL_AI_LOG_STDOUT` — Log to terminal (default: 1) +- `PYMOL_AI_LOGGER=1` — Use Python logger + +### Key Storage + +- UI dialogs use `keyring` with system keychain +- Environment variables take precedence when explicitly set +- Never log or commit plaintext API keys + +--- + +## 7) Common Tasks + +### Adding a New AI Tool + +1. Define tool in `modules/pymol/ai/tool_execution.py` +2. Register in `ClaudeSdkLoop._build_tool_server()` +3. Add tests in `testing/tests/api/test_ai_tool_execution.py` + +### Adding a New Test + +```python +# In testing/tests/api/test_my_feature.py +import pytest +from pymol import cmd + +class TestMyFeature: + def test_basic_case(self): + cmd.fragment('ala') + result = cmd.get_names('objects') + assert 'ala' in result +``` + +### Debugging AI Runtime ```bash -python - <<'PY' -import os -for k in [ - 'OPENROUTER_API_KEY', - 'ANTHROPIC_AUTH_TOKEN', - 'OPENBIO_API_KEY', - 'OPENBIO_BASE_URL', - 'PYMOL_AI_OPENROUTER_KEY_SOURCE', - 'PYMOL_AI_OPENBIO_KEY_SOURCE', -]: - v = os.getenv(k) - print(k, 'set' if v else 'unset') -PY -``` - -Expected outcomes: -- OpenRouter key set -> AI can run. -- OpenBio key set -> OpenBio gateway tools become available. -- OpenBio key unset -> only OpenBio tools are unavailable; core behavior remains. +# Enable verbose logging +PYMOL_AI_LOG_STDOUT=1 PYMOL_AI_LOGGER=1 pymol + +# Check key status +python -c "from pymol.ai.api_key_store import get_key_status; print(get_key_status())" +``` + +--- + +## 8) Troubleshooting + +| Issue | Cause | Fix | +|-------|-------|-----| +| `netcdf.h` not found | Missing Homebrew dependency | `brew install netcdf` | +| `GL/glew.h` not found | Missing GLEW | `brew install glew glm` | +| Qt enum AttributeError | PyQt6 installed without PyQt5 | `uv pip install PyQt5` | +| claude_agent_sdk import fails | Python < 3.10 | Use Python 3.10+ | +| AI disabled | No API key | Set OPENROUTER_API_KEY | + +--- + +## 9) Support Boundaries + +- **Never** log or commit plaintext API keys +- **Never** hardcode keys into scripts +- Prefer UI save flow and keychain storage +- Use masked key displays (`****abcd`) for status + +--- ## Attribution -- Upstream open-source PyMOL rights and trademark notices remain with Schrodinger, LLC. -- PyMolAI fork-specific integration/packaging documentation is maintained in this fork with explicit attribution in `LICENSE`, `AUTHORS`, and `DEVELOPERS`. -- Maintainer contact: - - Website: https://proteinlanguagemodel.com/ - - X/Twitter: https://x.com/ravishar313 +- Upstream PyMOL: Schrodinger, LLC +- PyMolAI fork maintainer: https://proteinlanguagemodel.com/ +- X/Twitter: [@ravishar313](https://x.com/ravishar313) diff --git a/modules/pmg_qt/ai_zhipuai_api_key_dialog.py b/modules/pmg_qt/ai_zhipuai_api_key_dialog.py new file mode 100644 index 000000000..a0e394f11 --- /dev/null +++ b/modules/pmg_qt/ai_zhipuai_api_key_dialog.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import os + +from pymol.Qt import QtCore, QtWidgets + +from pymol.ai.zhipuai_api_key_store import ( + ApiKeyStoreError, + clear_saved_key_and_loaded_env_if_needed, + get_status, + save_key, + validate_key_live, +) + + +class _KeyValidationWorker(QtCore.QObject): + finished = QtCore.Signal(bool, str) + + def __init__(self, key: str, timeout_sec: float): + super().__init__() + self._key = str(key or "").strip() + self._timeout_sec = float(timeout_sec) + + @QtCore.Slot() + def run(self): + try: + validate_key_live(self._key, timeout_sec=self._timeout_sec) + except Exception as exc: # noqa: BLE001 + self.finished.emit(False, str(exc)) + return + self.finished.emit(True, "API key is valid.") + + +class AiZhipuAiApiKeyDialog(QtWidgets.QDialog): + def __init__(self, parent=None, *, on_changed=None): + super().__init__(parent) + self._on_changed = on_changed + self._worker = None + self._worker_thread = None + + self.setWindowTitle("Z.AI Coding-Plan API Key") + self.setModal(True) + self.resize(520, 180) + + layout = QtWidgets.QVBoxLayout(self) + + self.status_label = QtWidgets.QLabel(self) + self.status_label.setWordWrap(True) + layout.addWidget(self.status_label) + + form = QtWidgets.QFormLayout() + self.key_input = QtWidgets.QLineEdit(self) + self.key_input.setEchoMode(QtWidgets.QLineEdit.PasswordEchoOnEdit) + self.key_input.setPlaceholderText("Enter Z.AI API key") + form.addRow("API Key", self.key_input) + layout.addLayout(form) + + buttons_row = QtWidgets.QHBoxLayout() + self.save_button = QtWidgets.QPushButton("Save", self) + self.clear_button = QtWidgets.QPushButton("Clear", self) + self.test_button = QtWidgets.QPushButton("Test", self) + self.close_button = QtWidgets.QPushButton("Close", self) + buttons_row.addWidget(self.save_button) + buttons_row.addWidget(self.clear_button) + buttons_row.addWidget(self.test_button) + buttons_row.addStretch(1) + buttons_row.addWidget(self.close_button) + layout.addLayout(buttons_row) + + self.save_button.clicked.connect(self._on_save) + self.clear_button.clicked.connect(self._on_clear) + self.test_button.clicked.connect(self._on_test) + self.close_button.clicked.connect(self.accept) + + self._refresh_status() + + def _status_text(self) -> str: + status = get_status() + if status.source == "env" and status.has_key: + source = "environment" + elif status.source == "saved" and status.has_key: + source = "saved keychain" + else: + source = "not set" + + key_text = status.masked_key or "(none)" + keyring_text = "available" if status.keyring_available else "unavailable" + return ("Current source: %s\nCurrent key: %s\nSystem keychain: %s") % ( + source, + key_text, + keyring_text, + ) + + def _refresh_status(self): + self.status_label.setText(self._status_text()) + + def _notify_changed(self): + callback = self._on_changed + if callable(callback): + callback() + + def _on_save(self): + key = str(self.key_input.text() or "").strip() + if not key: + QtWidgets.QMessageBox.warning( + self, "Save API Key", "Please enter a non-empty API key." + ) + return + + try: + save_key(key) + except ApiKeyStoreError as exc: + QtWidgets.QMessageBox.critical(self, "Save API Key", str(exc)) + return + + os.environ["ZHIPUAI_API_KEY"] = key + os.environ["PYMOL_AI_ZHIPUAI_KEY_SOURCE"] = "saved_keyring" + self.key_input.clear() + self._notify_changed() + self._refresh_status() + QtWidgets.QMessageBox.information( + self, "Save API Key", "API key saved to system keychain." + ) + + def _on_clear(self): + confirm = QtWidgets.QMessageBox.question( + self, + "Clear API Key", + "Delete the saved API key from system keychain?", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + if confirm != QtWidgets.QMessageBox.Yes: + return + + try: + env_cleared = clear_saved_key_and_loaded_env_if_needed() + except ApiKeyStoreError as exc: + QtWidgets.QMessageBox.critical(self, "Clear API Key", str(exc)) + return + + self.key_input.clear() + self._notify_changed() + self._refresh_status() + msg = "Saved API key cleared." + if env_cleared: + msg += " Active in-process key was also cleared." + QtWidgets.QMessageBox.information(self, "Clear API Key", msg) + + def _set_testing_state(self, active: bool): + self.save_button.setEnabled(not active) + self.clear_button.setEnabled(not active) + self.test_button.setEnabled(not active) + self.close_button.setEnabled(not active) + + def _on_test(self): + if self._worker_thread is not None: + return + + key = ( + str(self.key_input.text() or "").strip() + or str(os.getenv("ZHIPUAI_API_KEY") or "").strip() + ) + if not key: + QtWidgets.QMessageBox.warning( + self, "Test API Key", "No API key is available to test." + ) + return + + self._set_testing_state(True) + worker = _KeyValidationWorker(key, timeout_sec=10.0) + thread = QtCore.QThread(self) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(self._on_test_finished) + worker.finished.connect(thread.quit) + worker.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + self._worker = worker + self._worker_thread = thread + thread.start() + + @QtCore.Slot(bool, str) + def _on_test_finished(self, ok: bool, message: str): + self._worker = None + self._worker_thread = None + self._set_testing_state(False) + if ok: + QtWidgets.QMessageBox.information( + self, "Test API Key", "API key validation succeeded." + ) + return + text = str(message or "API key validation failed.") + QtWidgets.QMessageBox.warning(self, "Test API Key", text) diff --git a/modules/pmg_qt/pymol_qt_gui.py b/modules/pmg_qt/pymol_qt_gui.py index b1c506261..b52d7f415 100644 --- a/modules/pmg_qt/pymol_qt_gui.py +++ b/modules/pmg_qt/pymol_qt_gui.py @@ -2,7 +2,6 @@ Contains main class for PyMOL QT GUI """ - from collections import defaultdict import os import re @@ -13,10 +12,12 @@ from pymol import save_shortcut from pymol.Qt import QtGui, QtCore, QtWidgets -from pymol.Qt.utils import (getSaveFileNameWithExt, UpdateLock, - MainThreadCaller, - PopupOnException, - ) +from pymol.Qt.utils import ( + getSaveFileNameWithExt, + UpdateLock, + MainThreadCaller, + PopupOnException, +) from pymol.ai.message_types import UiEvent, UiRole from pymol.ai.models import model_menu_entries @@ -34,23 +35,23 @@ class PyMOLQtGUI(QtWidgets.QMainWindow, pymol._gui.PyMOLDesktopGUI): - ''' + """ PyMOL QMainWindow GUI - ''' + """ from pmg_qt.file_dialogs import ( - load_dialog, - load_mae_dialog, - file_fetch_pdb, - file_save_png, - file_save_mpeg, - file_save_map, - file_save_aln, - file_save + load_dialog, + load_mae_dialog, + file_fetch_pdb, + file_save_png, + file_save_mpeg, + file_save_map, + file_save_aln, + file_save, ) _ext_window_visible = True - _initialdir = '' + _initialdir = "" def keyPressEvent(self, ev): args = keymapping.keyPressEventToPyMOLButtonArgs(ev) @@ -74,8 +75,9 @@ def pymolviewport(self, w, h): # maintain aspect ratio if h < 1: if w < 1: - pw.pymol.reshape(int(scale * pw.width()), - int(scale * pw.height()), True) + pw.pymol.reshape( + int(scale * pw.width()), int(scale * pw.height()), True + ) return h = (w * ch) / cw if w < 1: @@ -94,14 +96,19 @@ def get_view(self): def __init__(self): # noqa QtWidgets.QMainWindow.__init__(self) - self.setDockOptions(QtWidgets.QMainWindow.AllowTabbedDocks | - QtWidgets.QMainWindow.AllowNestedDocks) + self.setDockOptions( + QtWidgets.QMainWindow.AllowTabbedDocks + | QtWidgets.QMainWindow.AllowNestedDocks + ) # resize Window before it is shown options = pymol.invocation.options self.resize( - options.win_x + (220 if options.internal_gui else 0) + (340 if options.external_gui else 0), - options.win_y + 18) + options.win_x + + (220 if options.internal_gui else 0) + + (340 if options.external_gui else 0), + options.win_y + 18, + ) # for thread-safe viewport command self.viewportsignal.connect(self.pymolviewport) @@ -161,11 +168,11 @@ def __init__(self): # noqa self._chat_store = AiChatStore() self._start_new_chat_session(title_hint="") - ''' + """ # command completion completer = QtWidgets.QCompleter(cmd.kwhash.keywords, self) self.lineedit.setCompleter(completer) - ''' + """ # overload action for viewport controls self.pymolwidget.installEventFilter(self) @@ -181,27 +188,26 @@ def _addmenu(data, menu): menu.setTearOffEnabled(True) menu.setWindowTitle(menu.title()) # needed for Windows for item in data: - if item[0] == 'separator': + if item[0] == "separator": menu.addSeparator() - elif item[0] == 'menu': - _addmenu(item[2], menu.addMenu(item[1].replace('&', '&&'))) - elif item[0] == 'command': + elif item[0] == "menu": + _addmenu(item[2], menu.addMenu(item[1].replace("&", "&&"))) + elif item[0] == "command": command = item[2] if command is None: - print('warning: skipping', item) + print("warning: skipping", item) else: if isinstance(command, str): command = lambda c=command: cmd.do(c) menu.addAction(item[1], command) - elif item[0] == 'check': + elif item[0] == "check": if len(item) > 4: menu.addAction( - SettingAction(self, cmd, item[2], item[1], - item[3], item[4])) + SettingAction(self, cmd, item[2], item[1], item[3], item[4]) + ) else: - menu.addAction( - SettingAction(self, cmd, item[2], item[1])) - elif item[0] == 'radio': + menu.addAction(SettingAction(self, cmd, item[2], item[1])) + elif item[0] == "radio": label, name, value = item[1:4] try: group, type_, values = actiongroups[item[2]] @@ -210,32 +216,33 @@ def _addmenu(data, menu): type_, values = cmd.get_setting_tuple(name) actiongroups[item[2]] = group, type_, values action = QtWidgets.QAction(label, self) - action.triggered.connect(lambda _=0, args=(name, value): - cmd.set(*args, log=1, quiet=0)) + action.triggered.connect( + lambda _=0, args=(name, value): cmd.set(*args, log=1, quiet=0) + ) - self.setting_callbacks[cmd.setting._get_index( - name)].append( - lambda v, V=value, a=action: a.setChecked(v == V)) + self.setting_callbacks[cmd.setting._get_index(name)].append( + lambda v, V=value, a=action: a.setChecked(v == V) + ) group.addAction(action) menu.addAction(action) action.setCheckable(True) if values[0] == value: action.setChecked(True) - elif item[0] == 'open_recent_menu': - self.open_recent_menu = menu.addMenu('Open Recent...') + elif item[0] == "open_recent_menu": + self.open_recent_menu = menu.addMenu("Open Recent...") else: - print('error:', item) + print("error:", item) # recent files menu self.open_recent_menu = None # for plugins - self.menudict = {'': menubar} + self.menudict = {"": menubar} # menu for _, label, data in self.get_menudata(cmd): - assert _ == 'menu' + assert _ == "menu" menu = menubar.addMenu(label) self.menudict[label] = menu _addmenu(data, menu) @@ -243,41 +250,46 @@ def _addmenu(data, menu): # hack for macOS to hide "Edit > Start Dictation" # https://bugreports.qt.io/browse/QTBUG-43217 if pymol.IS_MACOS: - self.menudict['Edit'].setTitle('Edit_') - QtCore.QTimer.singleShot(10, lambda: - self.menudict['Edit'].setTitle('Edit')) + self.menudict["Edit"].setTitle("Edit_") + QtCore.QTimer.singleShot(10, lambda: self.menudict["Edit"].setTitle("Edit")) # recent files menu if self.open_recent_menu: + @self.open_recent_menu.aboutToShow.connect def _(): self.open_recent_menu.clear() for fname in self.recent_filenames: self.open_recent_menu.addAction( - fname if len(fname) < 128 else '...' + fname[-120:], - lambda fname=fname: self.load_dialog(fname)) + fname if len(fname) < 128 else "..." + fname[-120:], + lambda fname=fname: self.load_dialog(fname), + ) # assistant chat controls - menu = self.menudict['Display'].addSeparator() - menu = self.menudict['Display'].addMenu('PyMolAI') + menu = self.menudict["Display"].addSeparator() + menu = self.menudict["Display"].addMenu("PyMolAI") ext_vis_action = self.ext_window.toggleViewAction() - ext_vis_action.setText('Visible') + ext_vis_action.setText("Visible") menu.addAction(ext_vis_action) - menu.addAction('Focus Input', self.chat_panel.focus_input).setShortcut( - QtGui.QKeySequence('Ctrl+E')) + menu.addAction("Focus Input", self.chat_panel.focus_input).setShortcut( + QtGui.QKeySequence("Ctrl+E") + ) - ai_menu = self.menudict['Display'].addMenu('PyMolAI Settings') - self.ai_reasoning_action = ai_menu.addAction('Show Reasoning') + ai_menu = self.menudict["Display"].addMenu("PyMolAI Settings") + self.ai_reasoning_action = ai_menu.addAction("Show Reasoning") self.ai_reasoning_action.setCheckable(True) - self.ai_debug_action = ai_menu.addAction('Debug Mode') + self.ai_debug_action = ai_menu.addAction("Debug Mode") self.ai_debug_action.setCheckable(True) - self.ai_api_key_action = ai_menu.addAction('OpenRouter API Key...') - self.ai_openbio_api_key_action = ai_menu.addAction('OpenBio API Key...') + self.ai_api_key_action = ai_menu.addAction("OpenRouter API Key...") + self.ai_openbio_api_key_action = ai_menu.addAction("OpenBio API Key...") + self.ai_zhipuai_api_key_action = ai_menu.addAction( + "Z.AI Coding-Plan API Key..." + ) - ai_model_menu = ai_menu.addMenu('Model') + ai_model_menu = ai_menu.addMenu("Model") self.ai_model_action_group = QtWidgets.QActionGroup(self) self.ai_model_action_group.setExclusive(True) self.ai_model_actions = {} @@ -288,34 +300,49 @@ def _(): action.setData(model_id) self.ai_model_action_group.addAction(action) self.ai_model_actions[model_id] = action - action.toggled.connect(lambda checked, m=model_id: checked and self._on_ai_model_selected(m)) + action.toggled.connect( + lambda checked, m=model_id: checked and self._on_ai_model_selected(m) + ) - ai_mode_menu = ai_menu.addMenu('Assistant Mode') + ai_mode_menu = ai_menu.addMenu("Assistant Mode") self.ai_mode_action_group = QtWidgets.QActionGroup(self) self.ai_mode_action_group.setExclusive(True) - self.ai_mode_work_action = ai_mode_menu.addAction('Work') + self.ai_mode_work_action = ai_mode_menu.addAction("Work") self.ai_mode_work_action.setCheckable(True) - self.ai_mode_tutor_action = ai_mode_menu.addAction('Tutor') + self.ai_mode_tutor_action = ai_mode_menu.addAction("Tutor") self.ai_mode_tutor_action.setCheckable(True) self.ai_mode_action_group.addAction(self.ai_mode_work_action) self.ai_mode_action_group.addAction(self.ai_mode_tutor_action) runtime = self.get_ai_runtime(create=True) if runtime is not None: - runtime.set_ui_mode('qt') + runtime.set_ui_mode("qt") self._sync_ai_settings_menu_from_runtime() self._persist_runtime_state_now() self.ai_reasoning_action.toggled.connect(self.set_ai_reasoning_visible) self.ai_debug_action.toggled.connect(self.set_ai_debug_mode) self.ai_api_key_action.triggered.connect(self._open_ai_api_key_dialog) - self.ai_openbio_api_key_action.triggered.connect(self._open_ai_openbio_api_key_dialog) - self.ai_mode_work_action.toggled.connect(lambda checked: checked and self.set_ai_agent_mode('work')) - self.ai_mode_tutor_action.toggled.connect(lambda checked: checked and self.set_ai_agent_mode('tutor')) + self.ai_openbio_api_key_action.triggered.connect( + self._open_ai_openbio_api_key_dialog + ) + self.ai_zhipuai_api_key_action.triggered.connect( + self._open_ai_zhipuai_api_key_dialog + ) + self.ai_mode_work_action.toggled.connect( + lambda checked: checked and self.set_ai_agent_mode("work") + ) + self.ai_mode_tutor_action.toggled.connect( + lambda checked: checked and self.set_ai_agent_mode("tutor") + ) # extra key mappings (MacPyMOL compatible) - QtWidgets.QShortcut(QtGui.QKeySequence('Ctrl+O'), self).activated.connect(self.file_open) - QtWidgets.QShortcut(QtGui.QKeySequence('Ctrl+S'), self).activated.connect(self.session_save) + QtWidgets.QShortcut(QtGui.QKeySequence("Ctrl+O"), self).activated.connect( + self.file_open + ) + QtWidgets.QShortcut(QtGui.QKeySequence("Ctrl+S"), self).activated.connect( + self.session_save + ) # feedback self.feedback_timer = QtCore.QTimer() @@ -324,8 +351,9 @@ def _(): self.feedback_timer.start(100) # legacy plugin system - self.menudict['Plugin'].addAction( - 'Initialize Plugin System', self.initializePlugins) + self.menudict["Plugin"].addAction( + "Initialize Plugin System", self.initializePlugins + ) # focus in command line if options.external_gui: @@ -335,11 +363,11 @@ def _(): # Apply PyMOL stylesheet try: - with open(cmd.exp_path('$PYMOL_DATA/pmg_qt/styles/pymol.sty')) as f: + with open(cmd.exp_path("$PYMOL_DATA/pmg_qt/styles/pymol.sty")) as f: style = f.read() except IOError: - print('Could not read PyMOL stylesheet.') - print('DEBUG: PYMOL_DATA=' + repr(os.getenv('PYMOL_DATA'))) + print("Could not read PyMOL stylesheet.") + print("DEBUG: PYMOL_DATA=" + repr(os.getenv("PYMOL_DATA"))) style = "" if style: @@ -349,9 +377,9 @@ def _(): self.saved_shortcuts = pymol.save_shortcut.load_and_set(self.cmd) def eventFilter(self, watched, event): - ''' + """ Filter out event to do tab-completion instead of move focus - ''' + """ type_ = event.type() if watched is self.pymolwidget: if type_ == QtCore.QEvent.KeyRelease: @@ -364,15 +392,15 @@ def eventFilter(self, watched, event): return False def toggle_ext_window_dockable(self, neverfloat=False): - ''' + """ Backward compatible command hook: toggle assistant chat visibility - ''' + """ self.ext_window.setVisible(not self.ext_window.isVisible()) def toggle_fullscreen(self, toggle=-1): - ''' + """ Full screen - ''' + """ is_fullscreen = self.windowState() == Qt.WindowFullScreen if toggle == -1: @@ -395,10 +423,10 @@ def toggle_fullscreen(self, toggle=-1): @property def initialdir(self): - ''' + """ Be in sync with cd/pwd on the console until the first file has been browsed, then remember the last directory. - ''' + """ return self._initialdir or os.getcwd() @initialdir.setter @@ -410,32 +438,33 @@ def initialdir(self, value): ################## def load_form(self, name, dialog=None): - '''Load a form from pmg_qt/forms/{name}.py''' + """Load a form from pmg_qt/forms/{name}.py""" import importlib + if dialog is None: dialog = QtWidgets.QDialog(self) widget = dialog - elif dialog == 'floating': + elif dialog == "floating": widget = QtWidgets.QWidget(self) else: widget = dialog try: - m = importlib.import_module('.forms.' + name, 'pmg_qt') + m = importlib.import_module(".forms." + name, "pmg_qt") except ImportError as e: if pymol.Qt.DEBUG: - print('load_form import failed (%s)' % (e,)) - uifile = os.path.join(os.path.dirname(__file__), 'forms', '%s.ui' % name) + print("load_form import failed (%s)" % (e,)) + uifile = os.path.join(os.path.dirname(__file__), "forms", "%s.ui" % name) form = pymol.Qt.utils.loadUi(uifile, widget) else: - if hasattr(m, 'Ui_Form'): + if hasattr(m, "Ui_Form"): form = m.Ui_Form() else: form = m.Ui_Dialog() form.setupUi(widget) - if dialog == 'floating': + if dialog == "floating": dialog = QtWidgets.QDockWidget(widget.windowTitle(), self) dialog.setFloating(True) dialog.setWidget(widget) @@ -445,7 +474,7 @@ def load_form(self, name, dialog=None): return form def edit_colors_dialog(self): - form = self.load_form('colors') + form = self.load_form("colors") form.list_colors.setSortingEnabled(True) # populate list with named colors @@ -464,9 +493,10 @@ def load_color(name): # update spinbox from slider spinbox_lock = [False] + def update_spinbox(spinbox, value): if not spinbox_lock[0]: - spinbox.setValue(value / 100.) + spinbox.setValue(value / 100.0) # update sliders and colored frame def update_gui(*args): @@ -478,24 +508,24 @@ def update_gui(*args): form.slider_G.setValue(round(G * 100)) form.slider_B.setValue(round(B * 100)) form.frame_color.setStyleSheet( - "background-color: rgb(%d,%d,%d)" % ( - R * 0xFF, G * 0xFF, B * 0xFF)) + "background-color: rgb(%d,%d,%d)" % (R * 0xFF, G * 0xFF, B * 0xFF) + ) spinbox_lock[0] = False def run(): - name = form.input_name.text() + name = form.input_name.text() R = form.input_R.value() G = form.input_G.value() B = form.input_B.value() - self.cmd.do('set_color %s, [%.2f, %.2f, %.2f]\nrecolor' % - (name, R, G, B)) + self.cmd.do("set_color %s, [%.2f, %.2f, %.2f]\nrecolor" % (name, R, G, B)) # if new color, insert and make current row if not form.list_colors.findItems(name, Qt.MatchExactly): form.list_colors.addItem(name) form.list_colors.setCurrentItem( - form.list_colors.findItems(name, Qt.MatchExactly)[0]) + form.list_colors.findItems(name, Qt.MatchExactly)[0] + ) # hook up events form.slider_R.valueChanged.connect(lambda v: update_spinbox(form.input_R, v)) @@ -534,6 +564,7 @@ def open_props_dialog(self): def edit_pymolrc(self): from . import TextEditor from pymol import plugins + TextEditor.edit_pymolrc(plugins.get_pmgapp()) ################## @@ -541,7 +572,7 @@ def edit_pymolrc(self): ################## def file_open(self): - fnames = getOpenFileNames(self, 'Open file', self.initialdir)[0] + fnames = getOpenFileNames(self, "Open file", self.initialdir)[0] partial = 0 for fname in fnames: if not self.load_dialog(fname, partial=partial): @@ -549,34 +580,32 @@ def file_open(self): partial = 1 def session_save(self): - fname = self.cmd.get('session_file') + fname = self.cmd.get("session_file") fname = self.cmd.as_pathstr(fname) return self.session_save_as(fname) @PopupOnException.decorator - def session_save_as(self, fname=''): + def session_save_as(self, fname=""): formats = [ - 'PyMOL Session File (*.pse *.pze *.pse.gz)', - 'PyMOL Show File (*.psw *.pzw *.psw.gz)', + "PyMOL Session File (*.pse *.pze *.pse.gz)", + "PyMOL Show File (*.psw *.pzw *.psw.gz)", ] if not fname: fname = getSaveFileNameWithExt( - self, - 'Save Session As...', - self.initialdir, - filter=';;'.join(formats)) + self, "Save Session As...", self.initialdir, filter=";;".join(formats) + ) if fname: self.initialdir = os.path.dirname(fname) - self.cmd.save(fname, format='pse', quiet=0) + self.cmd.save(fname, format="pse", quiet=0) self.recent_filenames_add(fname) def render_dialog(self, widget=None): - form = self.load_form('render', widget) + form = self.load_form("render", widget) lock = UpdateLock([ZeroDivisionError]) def get_factor(): units = form.input_units.currentText() - factor = 1.0 if units == 'inch' else 2.54 + factor = 1.0 if units == "inch" else 2.54 return factor / float(form.input_dpi.currentText()) @lock.skipIfCircular @@ -612,9 +641,9 @@ def update_height(*args): def update_aspectratio(checked=True): if checked: try: - form.aspectratio = ( - float(form.input_width.value()) / - float(form.input_height.value())) + form.aspectratio = float(form.input_width.value()) / float( + form.input_height.value() + ) except ZeroDivisionError: form.button_lock.setChecked(False) else: @@ -631,19 +660,21 @@ def run_draw(ray=False): width = form.input_width.value() height = form.input_height.value() if ray: - self.cmd.set('opaque_background', - not form.input_transparent.isChecked()) - self.cmd.do('ray %d, %d, async=1' % (width, height)) + self.cmd.set( + "opaque_background", not form.input_transparent.isChecked() + ) + self.cmd.do("ray %d, %d, async=1" % (width, height)) else: - self.cmd.do('draw %d, %d' % (width, height)) + self.cmd.do("draw %d, %d" % (width, height)) form.stack.setCurrentIndex(1) def run_ray(): run_draw(ray=True) def run_save(): - fname = getSaveFileNameWithExt(self, 'Save As...', self.initialdir, - filter='PNG File (*.png)') + fname = getSaveFileNameWithExt( + self, "Save As...", self.initialdir, filter="PNG File (*.png)" + ) if not fname: return self.initialdir = os.path.dirname(fname) @@ -653,14 +684,18 @@ def run_copy_clipboard(): with PopupOnException(): _copy_image(self.cmd, False, form.input_dpi.currentText()) - dpi = self.cmd.get_setting_int('image_dots_per_inch') + dpi = self.cmd.get_setting_int("image_dots_per_inch") if dpi > 0: form.input_dpi.setEditText(str(dpi)) form.input_dpi.setValidator(QtGui.QIntValidator()) # This connection used to be in the .ui file, but that fails with Qt6 - form.input_units.currentTextChanged.connect(lambda s: form.input_height_units.setSuffix(s)) - form.input_units.currentTextChanged.connect(lambda s: form.input_width_units.setSuffix(s)) + form.input_units.currentTextChanged.connect( + lambda s: form.input_height_units.setSuffix(s) + ) + form.input_units.currentTextChanged.connect( + lambda s: form.input_width_units.setSuffix(s) + ) form.input_units.currentIndexChanged.connect(update_units) form.input_dpi.editTextChanged.connect(update_pixels) @@ -692,100 +727,108 @@ def run_copy_clipboard(): @PopupOnException.decorator def _file_save(self, filter, format): fname = getSaveFileNameWithExt( - self, - 'Save As...', - self.initialdir, - filter=filter) + self, "Save As...", self.initialdir, filter=filter + ) if fname: self.cmd.save(fname, format=format, quiet=0) def file_save_wrl(self): - self._file_save('VRML 2 WRL File (*.wrl)', 'wrl') + self._file_save("VRML 2 WRL File (*.wrl)", "wrl") def file_save_dae(self): - self._file_save('COLLADA File (*.dae)', 'dae') + self._file_save("COLLADA File (*.dae)", "dae") def file_save_pov(self): - self._file_save('POV File (*.pov)', 'pov') + self._file_save("POV File (*.pov)", "pov") def file_save_mpng(self): - self.file_save_mpeg('png') + self.file_save_mpeg("png") def file_save_mov(self): - self.file_save_mpeg('mov') + self.file_save_mpeg("mov") def file_save_stl(self): - self._file_save('STL File (*.stl)', 'stl') + self._file_save("STL File (*.stl)", "stl") def file_save_gltf(self): - self._file_save('GLTF File (*.gltf)', 'gltf') + self._file_save("GLTF File (*.gltf)", "gltf") LOG_FORMATS = [ - 'PyMOL Script (*.pml)', - 'Python Script (*.py *.pym)', - 'All (*)', + "PyMOL Script (*.pml)", + "Python Script (*.py *.pym)", + "All (*)", ] - def log_open(self, fname='', mode='w'): + def log_open(self, fname="", mode="w"): if not fname: - fname = getSaveFileNameWithExt(self, 'Open Logfile...', self.initialdir, - filter=';;'.join(self.LOG_FORMATS)) + fname = getSaveFileNameWithExt( + self, + "Open Logfile...", + self.initialdir, + filter=";;".join(self.LOG_FORMATS), + ) if fname: self.initialdir = os.path.dirname(fname) self.cmd.log_open(fname, mode) def log_append(self): - return self.log_open(mode='a') + return self.log_open(mode="a") def log_resume(self): - fname = getSaveFileNameWithExt(self, 'Open Logfile...', self.initialdir, - filter=';;'.join(self.LOG_FORMATS)) + fname = getSaveFileNameWithExt( + self, "Open Logfile...", self.initialdir, filter=";;".join(self.LOG_FORMATS) + ) if fname: self.initialdir = os.path.dirname(fname) self.cmd.resume(fname) def file_run(self): formats = [ - 'All Runnable (*.pml *.py *.pym)', - 'PyMOL Command Script (*.pml)', - 'PyMOL Command Script (*.txt)', - 'Python Script (*.py *.pym)', - 'Python Script (*.txt)', - 'All Files(*)', + "All Runnable (*.pml *.py *.pym)", + "PyMOL Command Script (*.pml)", + "PyMOL Command Script (*.txt)", + "Python Script (*.py *.pym)", + "Python Script (*.txt)", + "All Files(*)", ] fnames, selectedfilter = getOpenFileNames( - self, 'Open file', self.initialdir, filter=';;'.join(formats)) - is_py = selectedfilter.startswith('Python') + self, "Open file", self.initialdir, filter=";;".join(formats) + ) + is_py = selectedfilter.startswith("Python") with PopupOnException(): for fname in fnames: self.initialdir = os.path.dirname(fname) self.cmd.cd(self.initialdir, quiet=0) # detect: .py, .pym, .pyc, .pyo, .py.txt - if is_py or re.search(r'\.py(|m|c|o|\.txt)$', fname, re.I): + if is_py or re.search(r"\.py(|m|c|o|\.txt)$", fname, re.I): self.cmd.run(fname) else: self.cmd.do("@" + fname) def cd_dialog(self): dname = QFileDialog.getExistingDirectory( - self, "Change Working Directory", self.initialdir) - self.cmd.cd(dname or '.', quiet=0) + self, "Change Working Directory", self.initialdir + ) + self.cmd.cd(dname or ".", quiet=0) def confirm_quit(self): QtWidgets.QApplication.instance().quit() def settings_edit_all_dialog(self): from .advanced_settings_gui import PyMOLAdvancedSettings + if self.advanced_settings_dialog is None: - self.advanced_settings_dialog = PyMOLAdvancedSettings(self, - self.cmd) + self.advanced_settings_dialog = PyMOLAdvancedSettings(self, self.cmd) self.advanced_settings_dialog.show() def shortcut_menu_edit_dialog(self): from .shortcut_menu_gui import PyMOLShortcutMenu + if self.shortcut_menu_filter_dialog is None: - self.shortcut_menu_filter_dialog = PyMOLShortcutMenu(self, self.saved_shortcuts, self.cmd) + self.shortcut_menu_filter_dialog = PyMOLShortcutMenu( + self, self.saved_shortcuts, self.cmd + ) self.shortcut_menu_filter_dialog.show() def scene_panel_menu_dialog(self): @@ -798,22 +841,22 @@ def scene_panel_menu_dialog(self): def show_about(self): msg = [ - 'The PyMOL Molecular Graphics System\n', - 'Version %s' % (self.cmd.get_version()[0]), - u'Copyright (C) Schr\xF6dinger, LLC.', - 'All rights reserved.\n', - 'License information:', + "The PyMOL Molecular Graphics System\n", + "Version %s" % (self.cmd.get_version()[0]), + "Copyright (C) Schr\xf6dinger, LLC.", + "All rights reserved.\n", + "License information:", ] - msg.append('Open-Source Build') + msg.append("Open-Source Build") msg += [ - '', - 'For more information:', - 'https://pymol.org', - 'sales@schrodinger.com', + "", + "For more information:", + "https://pymol.org", + "sales@schrodinger.com", ] - QtWidgets.QMessageBox.about(self, "About PyMOL", '\n'.join(msg)) + QtWidgets.QMessageBox.about(self, "About PyMOL", "\n".join(msg)) ################# # GUI callbacks @@ -844,7 +887,7 @@ def _sync_ai_settings_menu_from_runtime(self): if runtime is None: return - runtime.set_ui_mode('qt') + runtime.set_ui_mode("qt") reasoning = bool(runtime.reasoning_visible) debug_mode = bool(runtime.trace_stream_chunks) mode = str(runtime.current_agent_mode or "work").lower() @@ -857,7 +900,9 @@ def _sync_ai_settings_menu_from_runtime(self): self.ai_debug_action.blockSignals(True) self.ai_debug_action.setChecked(debug_mode) self.ai_debug_action.blockSignals(False) - if hasattr(self, "ai_mode_work_action") and hasattr(self, "ai_mode_tutor_action"): + if hasattr(self, "ai_mode_work_action") and hasattr( + self, "ai_mode_tutor_action" + ): self.ai_mode_work_action.blockSignals(True) self.ai_mode_tutor_action.blockSignals(True) self.ai_mode_work_action.setChecked(mode != "tutor") @@ -929,6 +974,15 @@ def _open_ai_openbio_api_key_dialog(self): ) self.ai_openbio_api_key_dialog.exec_() + def _open_ai_zhipuai_api_key_dialog(self): + from .ai_zhipuai_api_key_dialog import AiZhipuAiApiKeyDialog + + self.ai_zhipuai_api_key_dialog = AiZhipuAiApiKeyDialog( + self, + on_changed=self._on_ai_api_key_changed, + ) + self.ai_zhipuai_api_key_dialog.exec_() + def update_progress(self): return @@ -956,7 +1010,7 @@ def update_feedback(self): if feedback: filtered_feedback = self._filter_internal_feedback_lines(feedback) if filtered_feedback and self._chat_has_user_input: - block = '\n'.join(str(x) for x in filtered_feedback) + block = "\n".join(str(x) for x in filtered_feedback) self.chat_panel.append_feedback_block(block) self._persist_feedback_block(block) @@ -1070,7 +1124,7 @@ def _persist_feedback_block(self, text: str): self._chat_store.append_events(chat_id, [event]) def _save_chat_checkpoint(self, session_path: str): - self.cmd.save(session_path, format='pse', quiet=1) + self.cmd.save(session_path, format="pse", quiet=1) def _list_chat_rows(self, query: str, offset: int, limit: int): return self._chat_store.list_chats(query=query, offset=offset, limit=limit) @@ -1092,7 +1146,9 @@ def _on_history_chat_selected(self, chat_id: str): box.setWindowTitle("Unsaved Work") box.setText("Current chat has unsaved work. What do you want to do?") save_btn = box.addButton("Save now", QtWidgets.QMessageBox.AcceptRole) - discard_btn = box.addButton("Discard and continue", QtWidgets.QMessageBox.DestructiveRole) + discard_btn = box.addButton( + "Discard and continue", QtWidgets.QMessageBox.DestructiveRole + ) cancel_btn = box.addButton("Cancel", QtWidgets.QMessageBox.RejectRole) box.exec_() clicked = box.clickedButton() @@ -1106,14 +1162,16 @@ def _on_history_chat_selected(self, chat_id: str): payload = self._chat_store.load_chat(chat_id) if not payload: - QtWidgets.QMessageBox.warning(self, "Load Chat", "Could not load selected chat.") + QtWidgets.QMessageBox.warning( + self, "Load Chat", "Could not load selected chat." + ) return session_path = str(payload.get("session_path") or "") session_loaded = False if payload.get("session_exists") and session_path: try: - self.cmd.load(session_path, format='pse', quiet=0) + self.cmd.load(session_path, format="pse", quiet=0) session_loaded = True except Exception as exc: # noqa: BLE001 QtWidgets.QMessageBox.warning( @@ -1129,7 +1187,9 @@ def _on_history_chat_selected(self, chat_id: str): ) if not session_loaded: - fallback = self._chat_store.get_last_valid_session_path(exclude_chat_id=chat_id) + fallback = self._chat_store.get_last_valid_session_path( + exclude_chat_id=chat_id + ) if fallback: choice = QtWidgets.QMessageBox.question( self, @@ -1140,7 +1200,7 @@ def _on_history_chat_selected(self, chat_id: str): ) if choice == QtWidgets.QMessageBox.Yes: try: - self.cmd.load(fallback, format='pse', quiet=0) + self.cmd.load(fallback, format="pse", quiet=0) except Exception: pass @@ -1160,19 +1220,33 @@ def _on_history_chat_selected(self, chat_id: str): self._chat_store.set_runtime_state(chat_id, runtime.export_session_state()) self.chat_panel.replace_transcript(events, mode) - self._chat_has_user_input = any(str((e or {}).get("role") or "") == "user" for e in events if isinstance(e, dict)) + self._chat_has_user_input = any( + str((e or {}).get("role") or "") == "user" + for e in events + if isinstance(e, dict) + ) self.feedback_timer.start(0) def _on_chat_new_requested(self): if self._chat_store.count_chats() >= self._chat_store.soft_cap: box = QtWidgets.QMessageBox(self) box.setWindowTitle("History Soft Cap") - box.setText("You have many saved chats. Choose a cleanup action or keep all.") + box.setText( + "You have many saved chats. Choose a cleanup action or keep all." + ) keep_btn = box.addButton("Keep all", QtWidgets.QMessageBox.AcceptRole) - del10_btn = box.addButton("Delete oldest 10", QtWidgets.QMessageBox.DestructiveRole) - del25_btn = box.addButton("Delete oldest 25", QtWidgets.QMessageBox.DestructiveRole) - del50_btn = box.addButton("Delete oldest 50", QtWidgets.QMessageBox.DestructiveRole) - manager_btn = box.addButton("Open manager", QtWidgets.QMessageBox.ActionRole) + del10_btn = box.addButton( + "Delete oldest 10", QtWidgets.QMessageBox.DestructiveRole + ) + del25_btn = box.addButton( + "Delete oldest 25", QtWidgets.QMessageBox.DestructiveRole + ) + del50_btn = box.addButton( + "Delete oldest 50", QtWidgets.QMessageBox.DestructiveRole + ) + manager_btn = box.addButton( + "Open manager", QtWidgets.QMessageBox.ActionRole + ) cancel_btn = box.addButton("Cancel", QtWidgets.QMessageBox.RejectRole) box.exec_() clicked = box.clickedButton() @@ -1193,8 +1267,12 @@ def _on_chat_new_requested(self): box = QtWidgets.QMessageBox(self) box.setWindowTitle("New Chat") box.setText("Start a new chat session:") - keep_scene_btn = box.addButton("Keep current scene, clear chat", QtWidgets.QMessageBox.AcceptRole) - reset_scene_btn = box.addButton("Clear chat + reinitialize scene", QtWidgets.QMessageBox.DestructiveRole) + keep_scene_btn = box.addButton( + "Keep current scene, clear chat", QtWidgets.QMessageBox.AcceptRole + ) + reset_scene_btn = box.addButton( + "Clear chat + reinitialize scene", QtWidgets.QMessageBox.DestructiveRole + ) cancel_btn = box.addButton("Cancel", QtWidgets.QMessageBox.RejectRole) box.exec_() clicked = box.clickedButton() @@ -1216,7 +1294,9 @@ def _on_chat_new_requested(self): self.feedback_timer.start(0) def _open_history_manager(self): - dlg = ChatHistoryManagerDialog(self._list_chat_rows, self._delete_chat_by_id, self) + dlg = ChatHistoryManagerDialog( + self._list_chat_rows, self._delete_chat_by_id, self + ) dlg.exec_() def _delete_chat_by_id(self, chat_id: str) -> bool: @@ -1249,44 +1329,47 @@ def initializePlugins(self): from pymol import plugins from . import mimic_tk - self.menudict['Plugin'].clear() + self.menudict["Plugin"].clear() app = plugins.get_pmgapp() plugins.legacysupport.addPluginManagerMenuItem() # Redirect to Legacy submenu - self.menudict['PluginQt'] = self.menudict['Plugin'] - self.menudict['Plugin'] = self.menudict['PluginQt'].addMenu('Legacy Plugins') - self.menudict['Plugin'].setTearOffEnabled(True) - self.menudict['PluginQt'].addSeparator() + self.menudict["PluginQt"] = self.menudict["Plugin"] + self.menudict["Plugin"] = self.menudict["PluginQt"].addMenu("Legacy Plugins") + self.menudict["Plugin"].setTearOffEnabled(True) + self.menudict["PluginQt"].addSeparator() plugins.HAVE_QT = True plugins.initialize(app) def createlegacypmgapp(self): from . import mimic_pmg_tk as mimic + pmgapp = mimic.PMGApp() pmgapp.menuBar = mimic.PmwMenuBar(self.menudict) return pmgapp def window_cmd(self, action, x, y, w, h): - if action == 0: # hide + if action == 0: # hide self.hide() - elif action == 1: # show + elif action == 1: # show self.show() - elif action == 2: # position + elif action == 2: # position self.move(x, y) - elif action == 3: # size (first two arguments) + elif action == 3: # size (first two arguments) self.resize(x, y) - elif action == 4: # box + elif action == 4: # box self.move(x, y) self.resize(w, h) - elif action == 5: # maximize + elif action == 5: # maximize self.showMaximized() - elif action == 6: # fit - if hasattr(QtGui, 'QWindow') and self.windowHandle().visibility() in ( - QtGui.QWindow.Maximized, QtGui.QWindow.FullScreen): + elif action == 6: # fit + if hasattr(QtGui, "QWindow") and self.windowHandle().visibility() in ( + QtGui.QWindow.Maximized, + QtGui.QWindow.FullScreen, + ): return a = QtWidgets.QApplication.desktop().availableGeometry(self) g = self.geometry() @@ -1301,9 +1384,9 @@ def window_cmd(self, action, x, y, w, h): w - f.width() + g.width(), h - f.height() + g.height(), ) - elif action == 7: # focus + elif action == 7: # focus self.setFocus(Qt.OtherFocusReason) - elif action == 8: # defocus + elif action == 8: # defocus self.clearFocus() @@ -1315,16 +1398,17 @@ def commandoverloaddecorator(func): return func -def SettingAction(parent, cmd, name, label='', true_value=1, false_value=0, - command=None): - ''' +def SettingAction( + parent, cmd, name, label="", true_value=1, false_value=0, command=None +): + """ Menu toggle action for a PyMOL setting parent: parent QObject cmd: PyMOL instance name: setting name label: menu item text - ''' + """ if not label: label = name @@ -1334,37 +1418,38 @@ def SettingAction(parent, cmd, name, label='', true_value=1, false_value=0, if not command: command = lambda: cmd.set( - index, - true_value if action.isChecked() else false_value, - log=1, - quiet=0) + index, true_value if action.isChecked() else false_value, log=1, quiet=0 + ) parent.setting_callbacks[index].append( - lambda v: action.setChecked(v != false_value)) + lambda v: action.setChecked(v != false_value) + ) if type_ in ( - 1, # bool - 2, # int - 3, # float - 5, # color - 6, # str + 1, # bool + 2, # int + 3, # float + 5, # color + 6, # str ): action.setCheckable(True) if values[0] == true_value: action.setChecked(True) else: - print('TODO', type_, name) + print("TODO", type_, name) action.triggered.connect(command) return action + window = None class CommandLineEdit(QtWidgets.QLineEdit): - ''' + """ Line edit widget with instant text insert on drag-enter - ''' + """ + _saved_pos = -1 def dragMoveEvent(self, event): @@ -1402,9 +1487,10 @@ def dragLeaveEvent(self, event): class PyMOLApplication(QtWidgets.QApplication): - ''' + """ Catch drop events on app icon - ''' + """ + # FileOpen event is only activated after the first # application state change, otherwise sys.argv would be # handled by Qt, we don't want that. @@ -1428,11 +1514,11 @@ def handle_file_open_active(self, ev): pymol.cmd.reinitialize() # PyMOL Show - if ev.file().endswith('.psw'): - pymol.cmd.set('presentation') - pymol.cmd.set('internal_gui', 0) - pymol.cmd.set('internal_feedback', 0) - pymol.cmd.full_screen('on') + if ev.file().endswith(".psw"): + pymol.cmd.set("presentation") + pymol.cmd.set("internal_gui", 0) + pymol.cmd.set("internal_feedback", 0) + pymol.cmd.full_screen("on") window.load_dialog(ev.file()) return True @@ -1446,7 +1532,8 @@ def event(self, ev): # like pymol.internal._copy_image def _copy_image(_self=pymol.cmd, quiet=1, dpi=-1): import tempfile - fname = tempfile.mktemp('.png') + + fname = tempfile.mktemp(".png") if not _self.png(fname, prior=1, dpi=dpi): print("no prior image") @@ -1463,38 +1550,41 @@ def _copy_image(_self=pymol.cmd, quiet=1, dpi=-1): def make_pymol_qicon(): - icons_dir = os.path.expandvars('$PYMOL_DATA/pymol/icons') - return QtGui.QIcon(os.path.join(icons_dir, 'icon2.svg')) + icons_dir = os.path.expandvars("$PYMOL_DATA/pymol/icons") + return QtGui.QIcon(os.path.join(icons_dir, "icon2.svg")) def execapp(): - ''' + """ Run PyMOL as a Qt application - ''' + """ global window global pymol # don't let exceptions stop PyMOL import traceback + sys.excepthook = traceback.print_exception # use QT_OPENGL=desktop (auto-detection may fail on Windows) - if hasattr(Qt, 'AA_UseDesktopOpenGL') and pymol.IS_WINDOWS: + if hasattr(Qt, "AA_UseDesktopOpenGL") and pymol.IS_WINDOWS: QtCore.QCoreApplication.setAttribute(Qt.AA_UseDesktopOpenGL) # enable 4K scaling on Windows and Linux - if hasattr(Qt, 'AA_EnableHighDpiScaling') and not any( - v in os.environ - for v in ['QT_SCALE_FACTOR', 'QT_SCREEN_SCALE_FACTORS']): + if hasattr(Qt, "AA_EnableHighDpiScaling") and not any( + v in os.environ for v in ["QT_SCALE_FACTOR", "QT_SCREEN_SCALE_FACTORS"] + ): QtCore.QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling) # fix Windows taskbar icon if pymol.IS_WINDOWS: import ctypes + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( - u'com.schrodinger.pymol') + "com.schrodinger.pymol" + ) - app = PyMOLApplication(['PyMOL']) + app = PyMOLApplication(["PyMOL"]) app.setWindowIcon(make_pymol_qicon()) window = PyMOLQtGUI() @@ -1510,10 +1600,12 @@ def viewport(w=-1, h=-1, _self=None): @commandoverloaddecorator def full_screen(toggle=-1, _self=None): from pymol import viewing as v - toggle = v.toggle_dict[v.toggle_sc.auto_err(str(toggle), 'toggle')] + + toggle = v.toggle_dict[v.toggle_sc.auto_err(str(toggle), "toggle")] window.toggle_fullscreen(toggle) import pymol.gui + pymol.gui.createlegacypmgapp = window.createlegacypmgapp pymol.cmd._copy_image = _copy_image @@ -1526,7 +1618,8 @@ def _call_with_opengl_context_gui_thread(func): # Dispatch to GUI thread and make OpenGL context current before calling func(). pymol.cmd._call_with_opengl_context = lambda func: pymol.cmd._call_in_gui_thread( - lambda: _call_with_opengl_context_gui_thread(func)) + lambda: _call_with_opengl_context_gui_thread(func) + ) window.show() window.raise_() diff --git a/modules/pymol/ai/models.py b/modules/pymol/ai/models.py index 221e1d569..3d655af47 100644 --- a/modules/pymol/ai/models.py +++ b/modules/pymol/ai/models.py @@ -4,17 +4,39 @@ DEFAULT_MODEL = "anthropic/claude-sonnet-4.6" +# Provider prefixes for routing +PROVIDER_OPENROUTER = "openrouter" +PROVIDER_ZHIPUAI = "zhipuai" + SUPPORTED_MODELS: Tuple[Tuple[str, str], ...] = ( + # OpenRouter models ("google/gemini-3.1-pro-preview", "Gemini 3.1 Pro Preview"), ("anthropic/claude-sonnet-4.6", "Claude Sonnet 4.6"), - ("z-ai/glm-5", "GLM-5"), + ("z-ai/glm-5", "GLM-5 (OpenRouter)"), ("minimax/minimax-m2.5", "MiniMax M2.5"), ("moonshotai/kimi-k2.5", "Kimi K2.5"), ("google/gemini-3-flash-preview", "Gemini 3 Flash Preview"), ("anthropic/claude-haiku-4.5", "Claude Haiku 4.5"), - ("openai/gpt-5.2", "GPT-5.2") + ("openai/gpt-5.2", "GPT-5.2"), + # Z.AI Coding-Plan models (direct access) + ("zhipuai/GLM-5", "GLM-5 (Z.AI Coding Plan)"), + ("zhipuai/GLM-5-Turbo", "GLM-5-Turbo (Z.AI Coding Plan)"), + ("zhipuai/GLM-4.7", "GLM-4.7 (Z.AI Coding Plan)"), + ("zhipuai/GLM-4.6", "GLM-4.6 (Z.AI Coding Plan)"), + ("zhipuai/GLM-4.5", "GLM-4.5 (Z.AI Coding Plan)"), + ("zhipuai/GLM-4.5-Air", "GLM-4.5-Air (Z.AI Coding Plan)"), ) +# Z.AI Coding-Plan model ID mapping (prefix -> actual model name) +ZHIPUAI_MODEL_MAP = { + "zhipuai/GLM-5": "GLM-5", + "zhipuai/GLM-5-Turbo": "GLM-5-Turbo", + "zhipuai/GLM-4.7": "GLM-4.7", + "zhipuai/GLM-4.6": "GLM-4.6", + "zhipuai/GLM-4.5": "GLM-4.5", + "zhipuai/GLM-4.5-Air": "GLM-4.5-Air", +} + def supported_model_ids() -> List[str]: return [model_id for model_id, _ in SUPPORTED_MODELS] @@ -34,3 +56,12 @@ def is_supported_model(model_id: str) -> bool: def canonical_default_model() -> str: return DEFAULT_MODEL + +def is_zhipuai_model(model_id: str) -> bool: + candidate = str(model_id or "").strip() + return candidate.startswith("zhipuai/") + + +def get_zhipuai_model_name(model_id: str) -> str: + candidate = str(model_id or "").strip() + return ZHIPUAI_MODEL_MAP.get(candidate, candidate.replace("zhipuai/", "")) diff --git a/modules/pymol/ai/runtime.py b/modules/pymol/ai/runtime.py index b881f1f9a..17a8bdc5d 100644 --- a/modules/pymol/ai/runtime.py +++ b/modules/pymol/ai/runtime.py @@ -7,14 +7,20 @@ import threading import time import uuid -from typing import Dict, List, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple from .claude_sdk_loop import ClaudeSdkLoop from .message_types import UiEvent, UiRole from .openrouter_client import DEFAULT_MODEL -from .models import is_supported_model +from .models import is_supported_model, is_zhipuai_model, get_zhipuai_model_name from .api_key_store import load_saved_key_into_env_if_needed -from .openbio_api_key_store import load_saved_key_into_env_if_needed as load_openbio_saved_key_into_env_if_needed +from .zhipuai_client import ZhipuAIClient +from .openbio_api_key_store import ( + load_saved_key_into_env_if_needed as load_openbio_saved_key_into_env_if_needed, +) +from .zhipuai_api_key_store import ( + load_saved_key_into_env_if_needed as load_zhipuai_saved_key_into_env_if_needed, +) from .openbio_client import execute_openbio_api_gateway_tool from .state_snapshot import build_viewer_state_snapshot from .tool_execution import run_pymol_command @@ -102,10 +108,14 @@ def __init__(self, cmd): self._api_key_source = key_status.source openbio_key_status = load_openbio_saved_key_into_env_if_needed() self._openbio_api_key_source = openbio_key_status.source + zhipuai_key_status = load_zhipuai_saved_key_into_env_if_needed() + self._zhipuai_api_key_source = zhipuai_key_status.source self.history: List[Dict[str, object]] = [] self.model = os.getenv("PYMOL_AI_DEFAULT_MODEL") or DEFAULT_MODEL self.reasoning_visible = _env_int("PYMOL_AI_REASONING_DEFAULT", 1) == 1 - self.agent_mode = self._normalize_agent_mode(os.getenv("PYMOL_AI_AGENT_MODE") or "work") + self.agent_mode = self._normalize_agent_mode( + os.getenv("PYMOL_AI_AGENT_MODE") or "work" + ) self.input_mode = "ai" self.final_answer_enabled = os.getenv("PYMOL_AI_FINAL_ANSWER", "1") != "0" @@ -114,8 +124,12 @@ def __init__(self, cmd): self.long_tool_warn_sec = _env_float("PYMOL_AI_LONG_TOOL_WARN_SEC", 8.0) self.ui_event_batch = max(1, _env_int("PYMOL_AI_UI_EVENT_BATCH", 40)) self.ui_max_events = max(0, _env_int("PYMOL_AI_UI_MAX_EVENTS", 2000)) - self.sdk_max_buffer_size = max(0, _env_int("PYMOL_AI_SDK_MAX_BUFFER_SIZE", 10 * 1024 * 1024)) - self.history_max_messages = max(1, _env_int("PYMOL_AI_HISTORY_MAX_MESSAGES", 80)) + self.sdk_max_buffer_size = max( + 0, _env_int("PYMOL_AI_SDK_MAX_BUFFER_SIZE", 10 * 1024 * 1024) + ) + self.history_max_messages = max( + 1, _env_int("PYMOL_AI_HISTORY_MAX_MESSAGES", 80) + ) self.history_max_chars = max(512, _env_int("PYMOL_AI_HISTORY_MAX_CHARS", 12000)) self.conversation_mode = self._normalize_conversation_mode( os.getenv("PYMOL_AI_CONVERSATION_MODE") or "local_first" @@ -124,7 +138,9 @@ def __init__(self, cmd): self.screenshot_width = _env_int("PYMOL_AI_SCREENSHOT_WIDTH", 1024) self.screenshot_height = _env_int("PYMOL_AI_SCREENSHOT_HEIGHT", 0) - self.screenshot_validate_required = _env_int("PYMOL_AI_SCREENSHOT_VALIDATE_REQUIRED", 1) == 1 + self.screenshot_validate_required = ( + _env_int("PYMOL_AI_SCREENSHOT_VALIDATE_REQUIRED", 1) == 1 + ) self.state_max_selections = _env_int("PYMOL_AI_STATE_MAX_SELECTIONS", 20) self.state_max_objects = _env_int("PYMOL_AI_STATE_MAX_OBJECTS", 30) @@ -141,7 +157,7 @@ def __init__(self, cmd): self._stream_full_text = "" disabled = os.getenv("PYMOL_AI_DISABLE", "").strip() == "1" - self.enabled = bool(self._api_key) and not disabled + self.enabled = bool(self._api_key or self._zhipuai_api_key) and not disabled self._agent_backend = "claude_sdk" self._sdk_session_id: Optional[str] = None @@ -169,12 +185,18 @@ def __init__(self, cmd): @property def _api_key(self) -> str: - return (os.getenv("OPENROUTER_API_KEY") or os.getenv("ANTHROPIC_AUTH_TOKEN") or "").strip() + return ( + os.getenv("OPENROUTER_API_KEY") or os.getenv("ANTHROPIC_AUTH_TOKEN") or "" + ).strip() @property def _openbio_api_key(self) -> str: return (os.getenv("OPENBIO_API_KEY") or "").strip() + @property + def _zhipuai_api_key(self) -> str: + return (os.getenv("ZHIPUAI_API_KEY") or "").strip() + @staticmethod def _normalize_agent_mode(mode: str) -> str: return "tutor" if str(mode or "").strip().lower() == "tutor" else "work" @@ -230,11 +252,14 @@ def set_model(self, model_id: str, emit_notice: bool = True) -> str: self.emit_ui_event( UiEvent( role=UiRole.SYSTEM, - text="Model changed to %s. Change will apply on the next turn." % (self.model,), + text="Model changed to %s. Change will apply on the next turn." + % (self.model,), ) ) else: - self.emit_ui_event(UiEvent(role=UiRole.SYSTEM, text="Model set to %s." % (self.model,))) + self.emit_ui_event( + UiEvent(role=UiRole.SYSTEM, text="Model set to %s." % (self.model,)) + ) return self.model @property @@ -269,7 +294,9 @@ def request_cancel(self) -> bool: busy = bool(self._busy) self._log_ai("cancel requested", busy=busy) if busy: - self.emit_ui_event(UiEvent(role=UiRole.SYSTEM, text="cancellation requested...")) + self.emit_ui_event( + UiEvent(role=UiRole.SYSTEM, text="cancellation requested...") + ) return busy def clear_session(self, emit_notice: bool = True) -> None: @@ -280,7 +307,9 @@ def clear_session(self, emit_notice: bool = True) -> None: self.reset_remote_session_binding(reason="clear_session") self._log_ai("session cleared", emit_notice=emit_notice) if emit_notice: - self.emit_ui_event(UiEvent(role=UiRole.SYSTEM, text="session memory cleared")) + self.emit_ui_event( + UiEvent(role=UiRole.SYSTEM, text="session memory cleared") + ) def reset_remote_session_binding(self, reason: str = "") -> None: prev_session_id = self._sdk_session_id @@ -298,7 +327,7 @@ def reset_remote_session_binding(self, reason: str = "") -> None: def ensure_ai_default_mode(self, emit_notice: bool = False) -> bool: disabled = os.getenv("PYMOL_AI_DISABLE", "").strip() == "1" - has_key = bool(self._api_key) + has_key = bool(self._api_key) or bool(self._zhipuai_api_key) self.input_mode = "ai" self.enabled = has_key and not disabled self._log_ai( @@ -329,7 +358,9 @@ def export_session_state(self) -> Dict[str, object]: }, } - def import_session_state(self, state: Optional[Dict[str, object]], apply_model: bool = False) -> None: + def import_session_state( + self, state: Optional[Dict[str, object]], apply_model: bool = False + ) -> None: payload = dict(state or {}) self._stream_line_buffer = "" self._stream_full_text = "" @@ -339,9 +370,13 @@ def import_session_state(self, state: Optional[Dict[str, object]], apply_model: self._agent_backend = str(payload.get("backend") or "claude_sdk") session_id = str(payload.get("sdk_session_id") or "").strip() self._sdk_session_id = session_id or None - self.conversation_mode = self._normalize_conversation_mode(payload.get("conversation_mode") or self.conversation_mode) + self.conversation_mode = self._normalize_conversation_mode( + payload.get("conversation_mode") or self.conversation_mode + ) query_session_id = str(payload.get("chat_query_session_id") or "").strip() - self._chat_query_session_id = query_session_id or self._new_chat_query_session_id() + self._chat_query_session_id = ( + query_session_id or self._new_chat_query_session_id() + ) history = payload.get("history") or [] if isinstance(history, list): @@ -379,7 +414,9 @@ def import_session_state(self, state: Optional[Dict[str, object]], apply_model: ) def emit_ui_event(self, event: UiEvent) -> None: - if event.role == UiRole.SYSTEM and self._is_internal_system_reminder(event.text): + if event.role == UiRole.SYSTEM and self._is_internal_system_reminder( + event.text + ): return with self._event_lock: @@ -502,7 +539,10 @@ def _handle_cli_control(self, command: str) -> None: self.input_mode = "cli" self._log_ai("cli mode enabled") self.emit_ui_event( - UiEvent(role=UiRole.SYSTEM, text="CLI mode enabled. Commands are executed directly") + UiEvent( + role=UiRole.SYSTEM, + text="CLI mode enabled. Commands are executed directly", + ) ) return @@ -513,26 +553,36 @@ def _handle_cli_control(self, command: str) -> None: return if rest == "help": - self.emit_ui_event(UiEvent(role=UiRole.SYSTEM, text="/cli | /cli off | /cli ")) + self.emit_ui_event( + UiEvent( + role=UiRole.SYSTEM, text="/cli | /cli off | /cli " + ) + ) return self._execute_cli_command(rest) def _enable_ai(self) -> bool: - if not self._api_key: + has_any_key = bool(self._api_key) or bool(self._zhipuai_api_key) + if not has_any_key: self.enabled = False self._log_ai("failed to enable AI: missing API key", level="ERROR") self.emit_ui_event( UiEvent( role=UiRole.ERROR, - text="OPENROUTER_API_KEY (or ANTHROPIC_AUTH_TOKEN) is not set. Export it and retry /ai on", + text="No API key set. Export OPENROUTER_API_KEY, ANTHROPIC_AUTH_TOKEN, or ZHIPUAI_API_KEY and retry /ai on", ) ) return False if os.getenv("PYMOL_AI_DISABLE", "").strip() == "1": self.enabled = False self._log_ai("failed to enable AI: PYMOL_AI_DISABLE=1", level="ERROR") - self.emit_ui_event(UiEvent(role=UiRole.ERROR, text="PYMOL_AI_DISABLE=1 is set. Unset it to enable AI")) + self.emit_ui_event( + UiEvent( + role=UiRole.ERROR, + text="PYMOL_AI_DISABLE=1 is set. Unset it to enable AI", + ) + ) return False self.enabled = True self.input_mode = "ai" @@ -570,7 +620,11 @@ def _handle_ai_control(self, command: str) -> None: if action == "model": if len(parts) < 3: - self.emit_ui_event(UiEvent(role=UiRole.ERROR, text="usage: /ai model ")) + self.emit_ui_event( + UiEvent( + role=UiRole.ERROR, text="usage: /ai model " + ) + ) return self.set_model(parts[2], emit_notice=True) return @@ -579,13 +633,17 @@ def _handle_ai_control(self, command: str) -> None: self.clear_session(emit_notice=True) return - self.emit_ui_event(UiEvent(role=UiRole.ERROR, text="unknown /ai command. Try /ai help")) + self.emit_ui_event( + UiEvent(role=UiRole.ERROR, text="unknown /ai command. Try /ai help") + ) def _start_agent_request(self, prompt: str) -> None: with self._lock: if self._busy: self._log_ai("request skipped because worker is busy", level="WARNING") - self.emit_ui_event(UiEvent(role=UiRole.SYSTEM, text="request already in progress")) + self.emit_ui_event( + UiEvent(role=UiRole.SYSTEM, text="request already in progress") + ) return self._busy = True self._cancel_event.clear() @@ -676,7 +734,11 @@ def _state_summary_for_prompt(self) -> Dict[str, object]: def _build_system_prompt(self) -> str: mode = self._normalize_agent_mode(self.agent_mode) - overlay = SYSTEM_PROMPT_TUTOR_OVERLAY if mode == "tutor" else SYSTEM_PROMPT_WORK_OVERLAY + overlay = ( + SYSTEM_PROMPT_TUTOR_OVERLAY + if mode == "tutor" + else SYSTEM_PROMPT_WORK_OVERLAY + ) return SYSTEM_PROMPT_BASE + "\n" + overlay def _build_turn_prompt(self, prompt: str, *, include_history_context: bool) -> str: @@ -713,18 +775,24 @@ def _on_assistant_chunk(self, chunk: str) -> None: chars=len(piece), preview=piece[:120], ) - self.emit_ui_event(UiEvent(role=UiRole.AI, text=piece, metadata={"stream_chunk": True})) + self.emit_ui_event( + UiEvent(role=UiRole.AI, text=piece, metadata={"stream_chunk": True}) + ) def _on_assistant_message_boundary(self) -> None: if self._cancel_event.is_set(): return if self.trace_stream_chunks: self._log_ai("stream message boundary", level="DEBUG") - self.emit_ui_event(UiEvent(role=UiRole.AI, text="", metadata={"stream_boundary": True})) + self.emit_ui_event( + UiEvent(role=UiRole.AI, text="", metadata={"stream_boundary": True}) + ) def _flush_assistant_chunks(self) -> None: if self._stream_line_buffer.strip(): - self.emit_ui_event(UiEvent(role=UiRole.AI, text=self._stream_line_buffer.strip())) + self.emit_ui_event( + UiEvent(role=UiRole.AI, text=self._stream_line_buffer.strip()) + ) self._stream_line_buffer = "" def _canonicalize_command(self, command: str): @@ -732,8 +800,16 @@ def _canonicalize_command(self, command: str): low = stripped.lower() if low.startswith("load "): arg = stripped[5:].strip() - if _RE_PDB_ID.match(arg) and "." not in arg and "/" not in arg and "\\" not in arg: - return "fetch %s" % (arg,), "translated load %s -> fetch %s" % (arg, arg) + if ( + _RE_PDB_ID.match(arg) + and "." not in arg + and "/" not in arg + and "\\" not in arg + ): + return "fetch %s" % (arg,), "translated load %s -> fetch %s" % ( + arg, + arg, + ) return stripped, None def _is_state_changing_command(self, command: str) -> bool: @@ -784,7 +860,8 @@ def _execute_cli_command(self, command: str) -> None: text="Executed: %s" % (fixed,), ok=result.ok, metadata={ - "tool_call_id": "cli:%s" % (self._normalized_command_key(fixed) or "command",), + "tool_call_id": "cli:%s" + % (self._normalized_command_key(fixed) or "command",), "tool_name": "run_pymol_command", "tool_args": {"command": fixed}, "tool_command": fixed, @@ -823,7 +900,9 @@ def _normalize_sdk_tool_name(raw_name: str) -> Tuple[str, str]: return canonical, canonical return name, name - def _execute_snapshot_tool(self) -> Tuple[Dict[str, object], Optional[str], Dict[str, object]]: + def _execute_snapshot_tool( + self, + ) -> Tuple[Dict[str, object], Optional[str], Dict[str, object]]: capture = self._run_in_gui( lambda: capture_viewer_snapshot( self.cmd, @@ -845,6 +924,193 @@ def _execute_snapshot_tool(self) -> Tuple[Dict[str, object], Optional[str], Dict return payload, image_data_url, state_summary + def _build_zhipuai_tools(self) -> List[Dict[str, object]]: + """Build OpenAI-compatible tool definitions for ZhipuAI.""" + return [ + { + "type": "function", + "function": { + "name": "run_pymol_command", + "description": "Run one or more PyMOL commands in the current session. Prefer newline-separated command blocks for multi-step changes.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "PyMOL command(s) to execute. Multiple commands can be separated by newlines.", + }, + "rationale": { + "type": "string", + "description": "Brief explanation of why this command is being run.", + }, + }, + "required": ["command"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "capture_viewer_snapshot", + "description": "Capture current PyMOL viewport screenshot and compact viewer state summary. Use for internal validation of viewer state.", + "parameters": { + "type": "object", + "properties": { + "purpose": { + "type": "string", + "description": "Why this snapshot is being captured.", + } + }, + "required": ["purpose"], + }, + }, + }, + ] + + def _run_zhipuai_turn( + self, + prompt: str, + model: str, + is_cancelled: Callable[[], bool], + execute_run_command_tool: Callable[[str, Dict[str, object]], Dict[str, object]], + execute_snapshot_tool: Callable[[str, Dict[str, object]], Dict[str, object]], + ) -> Tuple[str, Optional[str], bool]: + """Run a turn using ZhipuAI client directly. + + Args: + prompt: The user prompt with context + model: The ZhipuAI model name (e.g., "GLM-5") + is_cancelled: Callback to check if operation should be cancelled + execute_run_command_tool: Callback to execute PyMOL commands + execute_snapshot_tool: Callback to capture viewer snapshots + + Returns: + Tuple of (assistant_text, error_message, was_cancelled) + """ + if not self._zhipuai_api_key: + return "", "ZHIPUAI_API_KEY is not set. Please set it and retry.", False + + client = ZhipuAIClient(api_key=self._zhipuai_api_key) + tools = self._build_zhipuai_tools() + + messages: List[Dict[str, object]] = [ + {"role": "system", "content": self._build_system_prompt()} + ] + + for entry in self.history[-self.history_max_messages :]: + role = str(entry.get("role") or "").strip() + content = entry.get("content") + if role in ("user", "assistant") and content: + messages.append({"role": role, "content": str(content)}) + + messages.append({"role": "user", "content": prompt}) + + assistant_text = "" + turn_count = 0 + max_turns = self.max_agent_steps + + while turn_count < max_turns: + if is_cancelled(): + return assistant_text, None, True + + turn_count += 1 + self._log_ai( + "zhipuai turn starting", + turn=turn_count, + max_turns=max_turns, + model=model, + ) + + try: + result = client.stream_assistant_turn( + model=model, + messages=messages, + tools=tools, + on_text_chunk=self._on_assistant_chunk, + on_reasoning_chunk=( + lambda t: ( + self.reasoning_visible + and self.emit_ui_event( + UiEvent(role=UiRole.REASONING, text=t) + ) + ) + ) + if self.reasoning_visible + else None, + should_cancel=is_cancelled, + ) + except Exception as exc: + error_msg = str(exc) + self._log_ai("zhipuai turn error", level="ERROR", error=error_msg) + return assistant_text, error_msg, False + + if is_cancelled(): + return assistant_text, None, True + + assistant_text = str(result.get("assistant_text") or "").strip() + tool_calls = result.get("tool_calls") or [] + + if not tool_calls: + self._log_ai( + "zhipuai turn completed", + turns=turn_count, + response_chars=len(assistant_text), + ) + break + + messages.append( + { + "role": "assistant", + "content": assistant_text or "", + "tool_calls": [ + { + "id": tc.tool_call_id, + "type": "function", + "function": { + "name": tc.name, + "arguments": tc.arguments_json, + }, + } + for tc in tool_calls + ], + } + ) + + for tc in tool_calls: + if is_cancelled(): + return assistant_text, None, True + + tool_name = tc.name + tool_args = tc.arguments or {} + tool_call_id = tc.tool_call_id + + self._log_ai( + "zhipuai tool call", tool_name=tool_name, tool_call_id=tool_call_id + ) + + if tool_name == "run_pymol_command": + payload = execute_run_command_tool(tool_call_id, tool_args) + elif tool_name == "capture_viewer_snapshot": + payload = execute_snapshot_tool(tool_call_id, tool_args) + else: + payload = {"ok": False, "error": f"Unknown tool: {tool_name}"} + + messages.append( + { + "role": "tool", + "tool_call_id": tool_call_id, + "name": tool_name, + "content": json.dumps(payload, ensure_ascii=False), + } + ) + + if turn_count >= max_turns: + self._log_ai( + "zhipuai reached max turns", level="WARNING", max_turns=max_turns + ) + + return assistant_text, None, False + def _agent_worker(self, prompt: str) -> None: cancelled = False @@ -856,7 +1122,9 @@ def check_cancel() -> bool: if not is_cancelled(): return False if not cancelled: - self.emit_ui_event(UiEvent(role=UiRole.SYSTEM, text="request cancelled")) + self.emit_ui_event( + UiEvent(role=UiRole.SYSTEM, text="request cancelled") + ) cancelled = True return True @@ -895,16 +1163,22 @@ def maybe_emit_slow_tool_warning(elapsed: float) -> None: self._log_ai("slow tool warning emitted", elapsed="%.3f" % (elapsed,)) slow_tool_notice_emitted = True - def execute_run_command_tool(tool_call_id: str, tool_args: Dict[str, object]) -> Dict[str, object]: + def execute_run_command_tool( + tool_call_id: str, tool_args: Dict[str, object] + ) -> Dict[str, object]: nonlocal pending_validation_required command = str(tool_args.get("command") or "").strip() command, note = self._canonicalize_command(command) if note: self.emit_ui_event(UiEvent(role=UiRole.SYSTEM, text=note)) - self._log_ai("tool run start", tool_call_id=tool_call_id, command=command) + self._log_ai( + "tool run start", tool_call_id=tool_call_id, command=command + ) started = time.monotonic() - exec_result = self._run_in_gui(lambda c=command: run_pymol_command(self.cmd, c)) + exec_result = self._run_in_gui( + lambda c=command: run_pymol_command(self.cmd, c) + ) elapsed = time.monotonic() - started maybe_emit_slow_tool_warning(elapsed) self._log_ai( @@ -916,7 +1190,9 @@ def execute_run_command_tool(tool_call_id: str, tool_args: Dict[str, object]) -> error=exec_result.error or "", ) - self._remember_tool_result(exec_result.command, exec_result.ok, exec_result.error) + self._remember_tool_result( + exec_result.command, exec_result.ok, exec_result.error + ) payload = { "ok": exec_result.ok, "command": exec_result.command, @@ -941,7 +1217,9 @@ def execute_run_command_tool(tool_call_id: str, tool_args: Dict[str, object]) -> msg_content = self._tool_result_content(payload) if len(msg_content) > self.tool_result_max_chars: - msg_content = msg_content[: self.tool_result_max_chars] + "... [truncated]" + msg_content = ( + msg_content[: self.tool_result_max_chars] + "... [truncated]" + ) self._append_history( { "role": "tool", @@ -951,14 +1229,23 @@ def execute_run_command_tool(tool_call_id: str, tool_args: Dict[str, object]) -> } ) - if self._is_state_changing_command(str(payload.get("command") or command)): + if self._is_state_changing_command( + str(payload.get("command") or command) + ): pending_validation_required = True return payload - def execute_snapshot_tool(tool_call_id: str, tool_args: Dict[str, object]) -> Dict[str, object]: - nonlocal pending_validation_required, validation_done_this_turn, snapshot_state_summary - self._log_ai("snapshot tool start", tool_call_id=tool_call_id, args=tool_args) + def execute_snapshot_tool( + tool_call_id: str, tool_args: Dict[str, object] + ) -> Dict[str, object]: + nonlocal \ + pending_validation_required, \ + validation_done_this_turn, \ + snapshot_state_summary + self._log_ai( + "snapshot tool start", tool_call_id=tool_call_id, args=tool_args + ) started = time.monotonic() payload, image_data_url, state_summary = self._execute_snapshot_tool() elapsed = time.monotonic() - started @@ -982,7 +1269,9 @@ def execute_snapshot_tool(tool_call_id: str, tool_args: Dict[str, object]) -> Di if payload["ok"]: metadata["visual_validation"] = "validated: screenshot+state" else: - metadata["visual_validation"] = "validated: state-only (screenshot failed)" + metadata["visual_validation"] = ( + "validated: state-only (screenshot failed)" + ) self.emit_ui_event( UiEvent( @@ -1076,9 +1365,14 @@ def execute_external_tool_result( tool_error_flag: Optional[bool], ) -> None: raw_tool_name = str(tool_name or "").strip() - canonical_name, display_name = self._normalize_sdk_tool_name(raw_tool_name) + canonical_name, display_name = self._normalize_sdk_tool_name( + raw_tool_name + ) normalized = canonical_name or raw_tool_name - if normalized in ("run_pymol_command", "capture_viewer_snapshot") or normalized.startswith("openbio_api_"): + if normalized in ( + "run_pymol_command", + "capture_viewer_snapshot", + ) or normalized.startswith("openbio_api_"): self._log_ai( "ignored mirrored internal mcp tool result", level="DEBUG", @@ -1123,7 +1417,9 @@ def execute_external_tool_result( "tool_name_raw": raw_tool_name or None, "tool_args": args_payload, "tool_command": command if normalized == "Bash" else None, - "tool_result_json": self._tool_result_metadata_payload(payload), + "tool_result_json": self._tool_result_metadata_payload( + payload + ), }, ) ) @@ -1156,16 +1452,19 @@ def run_sdk_turn( on_text_chunk=self._on_assistant_chunk, on_message_boundary=self._on_assistant_message_boundary, on_reasoning_chunk=( - (lambda t: self.reasoning_visible and self.emit_ui_event(UiEvent(role=UiRole.REASONING, text=t))) + lambda t: ( + self.reasoning_visible + and self.emit_ui_event( + UiEvent(role=UiRole.REASONING, text=t) + ) + ) ), on_tool_result=execute_external_tool_result, should_cancel=is_cancelled, run_command_tool=execute_run_command_tool, snapshot_tool=execute_snapshot_tool, openbio_api_tool=( - execute_openbio_api_tool - if self._openbio_api_key - else None + execute_openbio_api_tool if self._openbio_api_key else None ), max_buffer_size=self.sdk_max_buffer_size or None, conversation_mode=self.conversation_mode, @@ -1182,7 +1481,72 @@ def run_sdk_turn( if self.conversation_mode == "resume_only": include_history_context = False - turn_prompt = self._build_turn_prompt(prompt, include_history_context=include_history_context) + turn_prompt = self._build_turn_prompt( + prompt, include_history_context=include_history_context + ) + + if is_zhipuai_model(self.model): + self._log_ai( + "zhipuai turn run", + model=self.model, + zhipuai_model=get_zhipuai_model_name(self.model), + max_turns=self.max_agent_steps, + conversation_mode=self.conversation_mode, + ) + assistant_text, error_msg, was_cancelled = self._run_zhipuai_turn( + prompt=turn_prompt, + model=get_zhipuai_model_name(self.model), + is_cancelled=is_cancelled, + execute_run_command_tool=execute_run_command_tool, + execute_snapshot_tool=execute_snapshot_tool, + ) + + if check_cancel(): + return + + if error_msg: + self._log_ai( + "zhipuai turn failed", + level="ERROR", + error=error_msg, + ) + self.emit_ui_event(UiEvent(role=UiRole.ERROR, text=str(error_msg))) + return + + self._log_ai( + "zhipuai turn completed", + response_chars=len(assistant_text or ""), + ) + + if ( + self.screenshot_validate_required + and pending_validation_required + and not validation_done_this_turn + ): + execute_snapshot_tool( + "auto_capture_viewer_snapshot_1", {"purpose": "auto_validation"} + ) + + if assistant_text: + self._log_ai( + "assistant final text emitted", chars=len(assistant_text) + ) + if not self._stream_had_output: + self.emit_ui_event(UiEvent(role=UiRole.AI, text=assistant_text)) + self._append_history( + {"role": "assistant", "content": assistant_text} + ) + elif self._stream_had_output and self._stream_full_text.strip(): + streamed_text = self._stream_full_text.strip() + self._log_ai( + "assistant final text inferred from streamed chunks", + chars=len(streamed_text), + ) + self._append_history( + {"role": "assistant", "content": streamed_text} + ) + return + self._log_ai( "sdk turn run", include_history_context=include_history_context, @@ -1192,11 +1556,15 @@ def run_sdk_turn( conversation_mode=self.conversation_mode, query_session_id=self._chat_query_session_id, ) - result = run_sdk_turn(turn_prompt, resume_session_id, include_history_context) + result = run_sdk_turn( + turn_prompt, resume_session_id, include_history_context + ) if result.error_class == "resume_invalid" and not check_cancel(): self.reset_remote_session_binding(reason="resume_invalid") - turn_prompt = self._build_turn_prompt(prompt, include_history_context=True) + turn_prompt = self._build_turn_prompt( + prompt, include_history_context=True + ) self._log_ai( "sdk resume invalid; retrying with local history context", conversation_mode=self.conversation_mode, @@ -1227,7 +1595,12 @@ def run_sdk_turn( session_id=result.session_id or self._sdk_session_id or "", query_session_id=self._chat_query_session_id, ) - self._log_ai("sdk turn failed", level="ERROR", error=result.error, error_class=result.error_class or "") + self._log_ai( + "sdk turn failed", + level="ERROR", + error=result.error, + error_class=result.error_class or "", + ) self.emit_ui_event(UiEvent(role=UiRole.ERROR, text=str(result.error))) return @@ -1240,8 +1613,14 @@ def run_sdk_turn( query_session_id=self._chat_query_session_id, ) - if self.screenshot_validate_required and pending_validation_required and not validation_done_this_turn: - execute_snapshot_tool("auto_capture_viewer_snapshot_1", {"purpose": "auto_validation"}) + if ( + self.screenshot_validate_required + and pending_validation_required + and not validation_done_this_turn + ): + execute_snapshot_tool( + "auto_capture_viewer_snapshot_1", {"purpose": "auto_validation"} + ) assistant_text = str(result.assistant_text or "").strip() if assistant_text: @@ -1251,11 +1630,18 @@ def run_sdk_turn( self._append_history({"role": "assistant", "content": assistant_text}) elif self._stream_had_output and self._stream_full_text.strip(): streamed_text = self._stream_full_text.strip() - self._log_ai("assistant final text inferred from streamed chunks", chars=len(streamed_text)) + self._log_ai( + "assistant final text inferred from streamed chunks", + chars=len(streamed_text), + ) self._append_history({"role": "assistant", "content": streamed_text}) elif self.final_answer_enabled: - turns_used = result.num_turns if isinstance(result.num_turns, int) else None - max_turns_hit = turns_used is not None and turns_used >= self.max_agent_steps + turns_used = ( + result.num_turns if isinstance(result.num_turns, int) else None + ) + max_turns_hit = ( + turns_used is not None and turns_used >= self.max_agent_steps + ) if max_turns_hit: self._log_ai( "sdk turn reached iteration cap without final answer", @@ -1273,7 +1659,9 @@ def run_sdk_turn( ) ) else: - self._log_ai("missing final assistant answer from sdk", level="ERROR") + self._log_ai( + "missing final assistant answer from sdk", level="ERROR" + ) self.emit_ui_event( UiEvent( role=UiRole.ERROR, @@ -1282,7 +1670,9 @@ def run_sdk_turn( ) except Exception as exc: # noqa: BLE001 self._log_ai("unexpected runtime exception", level="ERROR", error=exc) - self.emit_ui_event(UiEvent(role=UiRole.ERROR, text="unexpected error: %s" % (exc,))) + self.emit_ui_event( + UiEvent(role=UiRole.ERROR, text="unexpected error: %s" % (exc,)) + ) finally: with self._lock: self._busy = False diff --git a/modules/pymol/ai/zhipuai_api_key_store.py b/modules/pymol/ai/zhipuai_api_key_store.py new file mode 100644 index 000000000..d5f4992c0 --- /dev/null +++ b/modules/pymol/ai/zhipuai_api_key_store.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Literal, Optional, Tuple + +SERVICE_NAME = "pymol.ai" +ACCOUNT_NAME = "zhipuai_api_key" + +_ENV_ZHIPUAI_KEY = "ZHIPUAI_API_KEY" +_ENV_KEY_SOURCE = "PYMOL_AI_ZHIPUAI_KEY_SOURCE" +_ENV_KEY_SOURCE_SAVED = "saved_keyring" + +DEFAULT_ZHIPUAI_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4" +DEFAULT_ZHIPUAI_MODEL = "GLM-5" + + +class ApiKeyStoreError(RuntimeError): + pass + + +class ApiKeyValidationError(ApiKeyStoreError): + pass + + +@dataclass +class ApiKeyStatus: + has_key: bool + source: Literal["env", "saved", "none"] + masked_key: str + keyring_available: bool + + +def _sanitize_error_message(text: str, key: str) -> str: + message = str(text or "").strip() or "Unknown error" + if key: + message = message.replace(key, "***") + return message + + +def _mask_key(key: str) -> str: + raw = str(key or "").strip() + if not raw: + return "" + suffix = raw[-4:] if len(raw) >= 4 else raw + return "****%s" % (suffix,) + + +def _env_key() -> str: + return str(os.getenv(_ENV_ZHIPUAI_KEY) or "").strip() + + +def _load_keyring() -> Tuple[Optional[object], bool]: + try: + import keyring # type: ignore[import-not-found] + except Exception: + return None, False + + try: + backend = keyring.get_keyring() + priority = float(getattr(backend, "priority", 0.0)) + except Exception: + priority = 0.0 + return keyring, bool(priority > 0.0) + + +def _get_saved_key() -> str: + keyring_mod, available = _load_keyring() + if keyring_mod is None or not available: + return "" + try: + value = keyring_mod.get_password(SERVICE_NAME, ACCOUNT_NAME) + except Exception: + return "" + return str(value or "").strip() + + +def _require_keyring() -> object: + keyring_mod, available = _load_keyring() + if keyring_mod is None: + raise ApiKeyStoreError( + "Secure key storage requires the 'keyring' package, but it is unavailable." + ) + if not available: + raise ApiKeyStoreError( + "No system keyring backend is available. Configure an OS keychain and retry." + ) + return keyring_mod + + +def get_status() -> ApiKeyStatus: + env_key = _env_key() + _, keyring_available = _load_keyring() + if env_key: + return ApiKeyStatus( + has_key=True, + source="env", + masked_key=_mask_key(env_key), + keyring_available=keyring_available, + ) + + saved_key = _get_saved_key() + if saved_key: + return ApiKeyStatus( + has_key=True, + source="saved", + masked_key=_mask_key(saved_key), + keyring_available=keyring_available, + ) + + return ApiKeyStatus( + has_key=False, + source="none", + masked_key="", + keyring_available=keyring_available, + ) + + +def save_key(key: str) -> None: + value = str(key or "").strip() + if not value: + raise ApiKeyStoreError("API key cannot be empty.") + + keyring_mod = _require_keyring() + try: + keyring_mod.set_password(SERVICE_NAME, ACCOUNT_NAME, value) + except Exception as exc: # noqa: BLE001 + raise ApiKeyStoreError("Failed to save API key to system keychain.") from exc + + +def clear_saved_key() -> None: + keyring_mod = _require_keyring() + try: + keyring_mod.delete_password(SERVICE_NAME, ACCOUNT_NAME) + except Exception as exc: # noqa: BLE001 + msg = str(exc or "").lower() + if "not found" in msg or "no such password" in msg: + return + raise ApiKeyStoreError("Failed to clear API key from system keychain.") from exc + + +def load_saved_key_into_env_if_needed() -> ApiKeyStatus: + env_key = _env_key() + _, keyring_available = _load_keyring() + if env_key: + return ApiKeyStatus( + has_key=True, + source="env", + masked_key=_mask_key(env_key), + keyring_available=keyring_available, + ) + + saved_key = _get_saved_key() + if not saved_key: + os.environ.pop(_ENV_KEY_SOURCE, None) + return ApiKeyStatus( + has_key=False, + source="none", + masked_key="", + keyring_available=keyring_available, + ) + + os.environ[_ENV_ZHIPUAI_KEY] = saved_key + os.environ[_ENV_KEY_SOURCE] = _ENV_KEY_SOURCE_SAVED + return ApiKeyStatus( + has_key=True, + source="saved", + masked_key=_mask_key(saved_key), + keyring_available=keyring_available, + ) + + +def clear_saved_key_and_loaded_env_if_needed() -> bool: + saved_key = _get_saved_key() + clear_saved_key() + + current = str(os.getenv(_ENV_ZHIPUAI_KEY) or "").strip() + source = str(os.getenv(_ENV_KEY_SOURCE) or "").strip() + if ( + saved_key + and current + and source == _ENV_KEY_SOURCE_SAVED + and current == saved_key + ): + os.environ.pop(_ENV_ZHIPUAI_KEY, None) + os.environ.pop(_ENV_KEY_SOURCE, None) + return True + return False + + +def validate_key_live( + key: str, model: str = DEFAULT_ZHIPUAI_MODEL, timeout_sec: float = 15.0 +) -> None: + """Validate Z.AI API key by making a test API call.""" + value = str(key or "").strip() + if not value: + raise ApiKeyValidationError("API key is empty.") + + model_id = str(model or DEFAULT_ZHIPUAI_MODEL).strip() or DEFAULT_ZHIPUAI_MODEL + client = None + + try: + try: + from openai import OpenAI + except Exception as exc: # noqa: BLE001 + raise ApiKeyValidationError( + "Live validation requires the 'openai' package, which is unavailable." + ) from exc + + base_url = ( + str(os.getenv("ZHIPUAI_BASE_URL") or DEFAULT_ZHIPUAI_BASE_URL).strip() + or DEFAULT_ZHIPUAI_BASE_URL + ) + client = OpenAI( + api_key=value, base_url=base_url, timeout=float(max(0.1, timeout_sec)) + ) + client.chat.completions.create( + model=model_id, + messages=[{"role": "user", "content": "ping"}], + max_tokens=1, + temperature=0.0, + ) + except Exception as exc: # noqa: BLE001 + raise ApiKeyValidationError(_sanitize_error_message(str(exc), value)) from exc + finally: + closer = getattr(client, "close", None) + if callable(closer): + try: + closer() + except Exception: + pass diff --git a/modules/pymol/ai/zhipuai_client.py b/modules/pymol/ai/zhipuai_client.py new file mode 100644 index 000000000..b1fd48ae3 --- /dev/null +++ b/modules/pymol/ai/zhipuai_client.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import asyncio +import json +import os +from typing import Callable, Dict, Iterable, List, Optional + +from .message_types import ToolCall + +DEFAULT_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4" +DEFAULT_MODEL = "GLM-5" + + +class ZhipuAIClientError(RuntimeError): + pass + + +class ChatParseError(ValueError): + pass + + +def _delta_text(delta) -> str: + if not delta: + return "" + + content = getattr(delta, "content", None) + if isinstance(content, str): + return content + + if isinstance(content, list): + chunks = [] + for item in content: + text = getattr(item, "text", None) + if text: + chunks.append(text) + return "".join(chunks) + + return "" + + +def build_multimodal_user_content( + text: str, image_data_url: Optional[str] +) -> List[Dict[str, object]]: + """Build user content with optional image for multimodal requests.""" + parts: List[Dict[str, object]] = [{"type": "text", "text": text}] + if image_data_url: + parts.append( + { + "type": "image_url", + "image_url": {"url": image_data_url}, + } + ) + return parts + + +class ZhipuAIClient: + """Z.AI Coding-Plan API Client using OpenAI Compatible API. + + This client connects to Z.AI's Coding-Plan endpoint which provides + access to GLM models (GLM-5, GLM-4.7, etc.) for coding tasks. + + Endpoint: https://open.bigmodel.cn/api/coding/paas/v4 + """ + + def __init__(self, api_key: str, base_url: Optional[str] = None): + if not api_key: + raise ZhipuAIClientError("ZHIPUAI_API_KEY is required") + self.api_key = api_key + self.base_url = base_url or os.getenv("ZHIPUAI_BASE_URL") or DEFAULT_BASE_URL + + async def _stream_chat_completion( + self, + *, + model: str, + messages: Iterable[Dict[str, object]], + tools: Optional[List[Dict[str, object]]], + on_text_chunk: Callable[[str], None], + on_reasoning_chunk: Optional[Callable[[str], None]] = None, + should_cancel: Optional[Callable[[], bool]] = None, + ) -> Dict[str, object]: + try: + from openai import AsyncOpenAI + except Exception as exc: # noqa: BLE001 + raise ZhipuAIClientError( + "Missing dependency 'openai'. Install with: uv pip install openai" + ) from exc + + client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url) + + req: Dict[str, object] = { + "model": model, + "messages": list(messages), + "temperature": 0.2, + "stream": True, + } + if tools: + req["tools"] = tools + req["parallel_tool_calls"] = False + + try: + stream = await client.chat.completions.create(**req) + except Exception as exc: # noqa: BLE001 + raise ZhipuAIClientError(str(exc)) from exc + + text_chunks: List[str] = [] + reasoning_chunks: List[str] = [] + tool_calls_accum: Dict[int, Dict[str, object]] = {} + + async for event in stream: + if should_cancel and should_cancel(): + break + + choices = getattr(event, "choices", None) or [] + if not choices: + continue + + choice = choices[0] + delta = getattr(choice, "delta", None) + if not delta: + continue + + text = _delta_text(delta) + if text: + text_chunks.append(text) + on_text_chunk(text) + + reasoning = getattr(delta, "reasoning", None) + if isinstance(reasoning, str) and reasoning: + reasoning_chunks.append(reasoning) + if on_reasoning_chunk: + on_reasoning_chunk(reasoning) + + delta_tool_calls = getattr(delta, "tool_calls", None) or [] + for tc in delta_tool_calls: + idx = getattr(tc, "index", None) + if idx is None: + continue + + existing = tool_calls_accum.get( + idx, + { + "id": None, + "type": "function", + "function": {"name": None, "arguments": ""}, + }, + ) + + tc_id = getattr(tc, "id", None) + if tc_id: + existing["id"] = tc_id + + fn = getattr(tc, "function", None) + if fn is not None: + fn_name = getattr(fn, "name", None) + if fn_name: + existing["function"]["name"] = fn_name + + fn_args = getattr(fn, "arguments", None) + if isinstance(fn_args, str) and fn_args: + existing["function"]["arguments"] += fn_args + + tool_calls_accum[idx] = existing + + tool_calls: List[ToolCall] = [] + for idx in sorted(tool_calls_accum.keys()): + call = tool_calls_accum[idx] + fn = call.get("function", {}) + name = str(fn.get("name") or "") + arguments_json = str(fn.get("arguments") or "{}") + try: + arguments = json.loads(arguments_json) + if not isinstance(arguments, dict): + arguments = {"value": arguments} + except Exception: + arguments = {"raw": arguments_json} + + call_id = str(call.get("id") or ("tool_%d" % idx)) + if name: + tool_calls.append( + ToolCall( + tool_call_id=call_id, + name=name, + arguments=arguments, + arguments_json=arguments_json, + ) + ) + + return { + "assistant_text": "".join(text_chunks), + "reasoning": "".join(reasoning_chunks), + "tool_calls": tool_calls, + } + + def stream_assistant_turn( + self, + *, + model: str, + messages: Iterable[Dict[str, object]], + tools: Optional[List[Dict[str, object]]], + on_text_chunk: Callable[[str], None], + on_reasoning_chunk: Optional[Callable[[str], None]] = None, + should_cancel: Optional[Callable[[], bool]] = None, + ) -> Dict[str, object]: + return asyncio.run( + self._stream_chat_completion( + model=model, + messages=messages, + tools=tools, + on_text_chunk=on_text_chunk, + on_reasoning_chunk=on_reasoning_chunk, + should_cancel=should_cancel, + ) + ) + + +def validate_key_live(api_key: str, timeout_sec: float = 10.0) -> None: + """Validate Z.AI API key by making a test API call.""" + value = str(api_key or "").strip() + if not value: + raise ZhipuAIClientError("API key is empty.") + + client = None + try: + try: + from openai import OpenAI + except Exception as exc: # noqa: BLE001 + raise ZhipuAIClientError( + "Live validation requires the 'openai' package, which is unavailable." + ) from exc + + base_url = ( + str(os.getenv("ZHIPUAI_BASE_URL") or DEFAULT_BASE_URL).strip() + or DEFAULT_BASE_URL + ) + client = OpenAI( + api_key=value, base_url=base_url, timeout=float(max(0.1, timeout_sec)) + ) + client.chat.completions.create( + model=DEFAULT_MODEL, + messages=[{"role": "user", "content": "ping"}], + max_tokens=1, + temperature=0.0, + ) + except Exception as exc: # noqa: BLE001 + # Sanitize error message to remove API key + error_msg = str(exc) + if value and value in error_msg: + error_msg = error_msg.replace(value, "***") + raise ZhipuAIClientError(error_msg) from exc + finally: + closer = getattr(client, "close", None) + if callable(closer): + try: + closer() + except Exception: + pass diff --git a/testing/tests/api/test_ai_zhipuai_routing.py b/testing/tests/api/test_ai_zhipuai_routing.py new file mode 100644 index 000000000..2e4869cc4 --- /dev/null +++ b/testing/tests/api/test_ai_zhipuai_routing.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from pymol.ai.models import ( + DEFAULT_MODEL, + canonical_default_model, + is_supported_model, + is_zhipuai_model, + get_zhipuai_model_name, +) + + +class TestZhipuaiModelRouting: + def test_zhipuai_model_detection(self): + assert is_zhipuai_model("zhipuai/GLM-5") is True + assert is_zhipuai_model("zhipuai/GLM-4.5") is True + assert is_zhipuai_model("zhipuai/GLM-4.5-Air") is True + assert is_zhipuai_model("zhipuai/GLM-4.6") is True + assert is_zhipuai_model("zhipuai/GLM-4.7") is True + assert is_zhipuai_model("zhipuai/GLM-5-Turbo") is True + + def test_non_zhipuai_models_not_detected(self): + assert is_zhipuai_model("anthropic/claude-sonnet-4.6") is False + assert is_zhipuai_model("google/gemini-3.1-pro-preview") is False + assert is_zhipuai_model("z-ai/glm-5") is False + assert is_zhipuai_model("openai/gpt-4o-mini") is False + assert is_zhipuai_model("") is False + assert is_zhipuai_model("custom-model") is False + + def test_get_zhipuai_model_name(self): + assert get_zhipuai_model_name("zhipuai/GLM-5") == "GLM-5" + assert get_zhipuai_model_name("zhipuai/GLM-4.5") == "GLM-4.5" + assert get_zhipuai_model_name("zhipuai/GLM-4.5-Air") == "GLM-4.5-Air" + assert get_zhipuai_model_name("zhipuai/GLM-4.6") == "GLM-4.6" + assert get_zhipuai_model_name("zhipuai/GLM-4.7") == "GLM-4.7" + assert get_zhipuai_model_name("zhipuai/GLM-5-Turbo") == "GLM-5-Turbo" + + def test_get_zhipuai_model_name_fallback(self): + assert ( + get_zhipuai_model_name("anthropic/claude-sonnet-4.6") + == "anthropic/claude-sonnet-4.6" + ) + assert get_zhipuai_model_name("unknown-model") == "unknown-model" + assert get_zhipuai_model_name("") == "" + + def test_zhipuai_models_are_supported(self): + assert is_supported_model("zhipuai/GLM-5") is True + assert is_supported_model("zhipuai/GLM-4.5") is True + assert is_supported_model("zhipuai/GLM-4.5-Air") is True + assert is_supported_model("zhipuai/GLM-4.6") is True + assert is_supported_model("zhipuai/GLM-4.7") is True + assert is_supported_model("zhipuai/GLM-5-Turbo") is True