Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ venv/
ENV/
env.bak/
venv.bak/

cqvnv
# Spyder project settings
.spyderproject
.spyproject
Expand Down
95 changes: 95 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,101 @@ Additional screenshots are available in [the wiki](https://github.com/CadQuery/C
* Export to various formats
* STL
* STEP
* AI Chat Assistant *(opt-in)* — generate and iterate on CadQuery models using natural language via any OpenAI-compatible API

## AI Chat Assistant

An optional dockable panel that lets you describe what you want to model and receive a complete, runnable CadQuery script in response. The current editor script is automatically sent as context with every message, so the model edits your existing code rather than starting from scratch.

The panel is disabled by default. No network requests are made unless you explicitly enable it and send a prompt.

### Privacy

Every prompt you send includes your current editor script and is transmitted to the API endpoint you configure. Before sending the first message, the panel will show a one-time confirmation dialog that lists the endpoint. You can change or review the endpoint at any time in **Edit -> Preferences -> AI Assistant**.

The API key is stored in the system keychain if the `keyring` package is installed. Otherwise it falls back to plaintext in the local preferences file. Install `keyring` for secure storage:

```bash
pip install keyring
```

### Installation

The panel requires the `openai` package and optionally `keyring` for secure key storage. You can install all optional AI Assistant dependencies in one command using the project's standard extras group:

```bash
pip install .[ai]
```

Or install them manually:

```bash
pip install openai keyring
```

### Configuration

Open **Edit -> Preferences -> AI Assistant** and set the following:

| Setting | Description | Default |
|---|---|---|
| Enabled | Show the AI Assistant dock panel | false |
| Provider / Base URL | OpenAI-compatible API endpoint | `https://api.openai.com/v1` |
| Model | Model identifier | `gpt-4o` |
| API Key | Provider API key | *(empty)* |
| Auto-run after insert | Re-render the model after inserting code | true |

**Supported providers**

| Provider | Base URL |
|---|---|
| OpenAI | `https://api.openai.com/v1` |
| OpenRouter | `https://openrouter.ai/api/v1` |
| Ollama (local) | `http://localhost:11434/v1` |
| Any OpenAI-compatible API | your custom endpoint |

Once Enabled is checked, the panel appears as a dock on the right side. It can also be toggled from **Tools -> AI Assistant** or the **View** menu.

### Usage

Type a description in the prompt box and press Enter or click Send. The assistant always replies with a complete `python` fenced code block. Once a response arrives, click **Insert & Run** to load the code into the editor and re-render the model.

If the response contains no code block — for example, the model returned a clarifying question — the Insert button stays disabled and an informational message is shown in the chat.

**Generating a new model**

```
Create a hollow cylinder, 40mm outer diameter, 30mm inner diameter, 80mm tall
```

**Iterating on an existing model**

```
Add a 2mm fillet to all vertical edges
```

```
Change the wall thickness to 5mm and add M3 mounting holes at each corner
```

**Fixing errors (One-Tap Self-Healing)**

If the rendered script throws an error, the traceback appears in the **Current traceback** panel. You don't need to copy and paste anything:

* Simply click the vibrant **✨ Auto-Fix with AI** button at the top of the traceback pane.
* The editor will instantly capture the traceback, line numbers, and crash snippet, open the AI Assistant, and request a fix.
* Once the corrected code is returned, it is **automatically inserted back into the editor and re-rendered in one tap!**

**Resetting the conversation**

Click **Clear Chat** to discard the conversation history and start a new session. The privacy consent is also reset, so the notice will appear again on the next send.

### Notes

* **Model Quality Matters**: For the best results, use high-capability frontier models. Larger models are vastly superior at precise geometric reasoning, spatial awareness, and successfully resolving CadQuery API errors compared to smaller or lightweight local models.
* Conversation history is capped at 10 turn pairs. Older messages are dropped automatically to limit token usage.
* Models that produce consistent fenced code blocks work best.
* The panel shuts down its background thread cleanly when CQ-editor closes, even if a request is in progress.

## Documentation

Expand Down
80 changes: 62 additions & 18 deletions cq_editor/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
QAction,
QApplication,
QMenu,
QDialog,
)
from logbook import Logger
import cadquery as cq
Expand All @@ -26,6 +27,13 @@
from .widgets.log import LogViewer
from .widgets.upload_dialog import UploadDialog

# AI Assistant is optional — CQ-editor works normally without it
try:
from .widgets.ai_chat import AIChatWidget
_AI_AVAILABLE = True
except ImportError:
_AI_AVAILABLE = False

from . import __version__
from .utils import (
dock,
Expand Down Expand Up @@ -120,6 +128,14 @@ def __init__(self, parent=None, filename=None):
self.preferences.sigTreeStateChanged.connect(self.preferencesChanged)

self.restorePreferences()

# Ensure AI Assistant visibility matches preferences on startup
if _AI_AVAILABLE and "ai_chat" in self.components:
dock_widget = self.docks.get("ai_chat")
if dock_widget:
enabled = self.components["ai_chat"]._pref("Enabled")
dock_widget.setVisible(enabled)

self.restoreWindow()

# Handle the event of the editor being hidden or shown
Expand Down Expand Up @@ -288,11 +304,20 @@ def prepare_panes(self):
lambda c: dock(c, "Log viewer", self, defaultArea="bottom"),
)

# ---- AI Chat Assistant (opt-in; only registered if module loaded) ----
if _AI_AVAILABLE:
self.registerComponent(
"ai_chat",
AIChatWidget(self),
lambda c: dock(c, "AI Assistant", self, defaultArea="right"),
)
# ----------------------------------------------------------------------

for d in self.docks.values():
d.show()

PRINT_REDIRECTOR.sigStdoutWrite.connect(
lambda text: self.components["log"].append(text)
self.components["log"].append
)

def prepare_menubar(self):
Expand Down Expand Up @@ -401,6 +426,14 @@ def prepare_menubar(self):
)
)

# AI Assistant toggle (only if the widget was loaded successfully)
if _AI_AVAILABLE:
menu_tools.addSeparator()
ai_action = QAction("\U0001F916 AI Assistant", self)
ai_action.setToolTip("Show / hide the AI Chat Assistant panel")
ai_action.triggered.connect(self._toggle_ai_panel)
menu_tools.addAction(ai_action)

def prepare_menubar_component(self, menus, comp_menu_dict):

for name, action in comp_menu_dict.items():
Expand All @@ -422,6 +455,19 @@ def prepare_statusbar(self):

def prepare_actions(self):

# Wire AI chat dependencies properly via set_dependencies()
if _AI_AVAILABLE and "ai_chat" in self.components:
self.components["ai_chat"].set_dependencies(
editor=self.components["editor"],
debugger=self.components["debugger"],
)
self.components["ai_chat"].insert_code.connect(
self.components["editor"].set_text
)
self.components["traceback_viewer"].sigAutoFixError.connect(
self.components["ai_chat"].auto_fix_error
)

self.components["debugger"].sigRendered.connect(
self.components["object_tree"].addObjects
)
Expand Down Expand Up @@ -532,8 +578,6 @@ def prepare_console(self):
)

def _examples_dir(self):
# In a PyInstaller bundle examples are extracted alongside the package.
# In development they live next to the cq_editor package directory.
if getattr(sys, "frozen", False):
return Path(sys._MEIPASS) / "examples"
return Path(__file__).parent.parent / "examples"
Expand All @@ -545,7 +589,6 @@ def _populate_examples_menu(self, menu):
return

for path in sorted(examples_dir.glob("*.py")):
# Strip the leading "NN_" numbering prefix for the menu label.
label = path.stem
if len(label) > 3 and label[2] == "_" and label[:2].isdigit():
label = label[3:]
Expand Down Expand Up @@ -594,7 +637,8 @@ def handle_exception(exc_type, exc_value, exc_traceback):
def edit_preferences(self):

prefs = PreferencesWidget(self, self.components)
prefs.exec_()
if prefs.exec_() == QDialog.Accepted:
self.savePreferences()

def about(self):

Expand Down Expand Up @@ -622,28 +666,15 @@ def handle_filename_change(self, fname):
self.setWindowTitle(f"{self.name}: {new_title}")

def update_window_title(self, modified):
"""
Allows updating the window title to show that the document has been modified.
"""
title = self.windowTitle().rstrip("*")
if modified:
title += "*"
self.setWindowTitle(title)

def update_statusbar(self, status_text):
"""
Allow updating the status bar with information.
"""

# Update the statusbar text
self.status_label.setText(status_text)

def _upload_model(self):
"""
Allows the userr to easily upload models to an online service for manufacturing,
analysis, simulation, display, etc.
"""

obj_tree = self.components["object_tree"]
selected = [
item
Expand All @@ -658,6 +689,19 @@ def _upload_model(self):
)
dlg.exec_()

def _toggle_ai_panel(self):
"""Show or hide the AI Assistant dock panel."""
if not _AI_AVAILABLE:
return
dock_widget = self.docks.get("ai_chat")
if dock_widget is None:
return
if dock_widget.isVisible():
dock_widget.hide() # hide only — no raise_() here
else:
dock_widget.show()
dock_widget.raise_() # raise_() only when making visible


if __name__ == "__main__":

Expand Down
9 changes: 7 additions & 2 deletions cq_editor/preferences.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from PyQt5.QtWidgets import QTreeWidget, QTreeWidgetItem, QStackedWidget, QDialog
from PyQt5.QtWidgets import QTreeWidget, QTreeWidgetItem, QStackedWidget, QDialog, QDialogButtonBox
from PyQt5.QtCore import pyqtSlot, Qt

from pyqtgraph.parametertree import ParameterTree
Expand Down Expand Up @@ -45,7 +45,12 @@ def __init__(self, parent, components):
self.add(v.name, v)

self.splitter = splitter((self.preferences_tree, self.stacked), (2, 5))
layout(self, (self.splitter,), self)

self.button_box = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel, self)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)

layout(self, (self.splitter, self.button_box), self)

self.preferences_tree.currentItemChanged.connect(self.handleSelection)

Expand Down
Loading