From 4c8d9bdce0df3cf09c3d934b98b9868df9fa3b30 Mon Sep 17 00:00:00 2001
From: VxkY <89289497+Vickyrrrrrr@users.noreply.github.com>
Date: Sat, 16 May 2026 17:41:31 +0530
Subject: [PATCH 1/5] feat: add AI Chat Assistant dock panel with
OpenAI-compatible API support
- Add cq_editor/widgets/ai_chat.py: new QDockWidget with chat UI,
async LLM calls via QThread, context injection (sends current editor
code to LLM), and one-click code insertion + auto-run
- Edit cq_editor/main_window.py: register AIChatWidget as a dockable
panel, wire insert_code signal to editor, add View menu toggle
- Edit cq_editor/preferences.py: add AI Settings preference group
(provider, model, API key, base URL, enabled toggle)
All AI features are optional and guarded; existing behavior unchanged.
---
cq_editor/main_window.py | 41 ++++
cq_editor/widgets/ai_chat.py | 402 +++++++++++++++++++++++++++++++++++
2 files changed, 443 insertions(+)
create mode 100644 cq_editor/widgets/ai_chat.py
diff --git a/cq_editor/main_window.py b/cq_editor/main_window.py
index cde9d2ac..4f113fa8 100644
--- a/cq_editor/main_window.py
+++ b/cq_editor/main_window.py
@@ -25,6 +25,7 @@
from .widgets.cq_object_inspector import CQObjectInspector
from .widgets.log import LogViewer
from .widgets.upload_dialog import UploadDialog
+from .widgets.ai_chat import AIChatWidget # AI Assistant
from . import __version__
from .utils import (
@@ -288,6 +289,18 @@ def prepare_panes(self):
lambda c: dock(c, "Log viewer", self, defaultArea="bottom"),
)
+ # ---- AI Chat Assistant (docked on the right by default) -------
+ self.registerComponent(
+ "ai_chat",
+ AIChatWidget(
+ self,
+ editor=None, # wired after editor is ready in prepare_actions
+ debugger=None,
+ ),
+ lambda c: dock(c, "AI Assistant", self, defaultArea="right"),
+ )
+ # ---------------------------------------------------------------
+
for d in self.docks.values():
d.show()
@@ -401,6 +414,17 @@ def prepare_menubar(self):
)
)
+ # AI Assistant toggle in Tools menu
+ menu_tools.addSeparator()
+ menu_tools.addAction(
+ QAction(
+ "π€ AI Assistant",
+ self,
+ triggered=self._toggle_ai_panel,
+ toolTip="Show / hide the AI Chat Assistant panel",
+ )
+ )
+
def prepare_menubar_component(self, menus, comp_menu_dict):
for name, action in comp_menu_dict.items():
@@ -422,6 +446,13 @@ def prepare_statusbar(self):
def prepare_actions(self):
+ # Wire AI chat to the live editor and debugger instances
+ ai = self.components["ai_chat"]
+ ai._editor = self.components["editor"]
+ ai._debugger = self.components["debugger"]
+ # When LLM code arrives, push it into the editor
+ ai.insert_code.connect(self.components["editor"].set_text)
+
self.components["debugger"].sigRendered.connect(
self.components["object_tree"].addObjects
)
@@ -658,6 +689,16 @@ def _upload_model(self):
)
dlg.exec_()
+ def _toggle_ai_panel(self):
+ """Show or hide the AI Assistant dock panel."""
+ dock_widget = self.docks.get("ai_chat")
+ if dock_widget:
+ if dock_widget.isVisible():
+ dock_widget.hide()
+ else:
+ dock_widget.show()
+ dock_widget.raise_()
+
if __name__ == "__main__":
diff --git a/cq_editor/widgets/ai_chat.py b/cq_editor/widgets/ai_chat.py
new file mode 100644
index 00000000..d9475777
--- /dev/null
+++ b/cq_editor/widgets/ai_chat.py
@@ -0,0 +1,402 @@
+"""
+AI Chat Assistant widget for CQ-editor.
+
+Provides a dockable panel with:
+ - Chat history display
+ - Prompt input box
+ - Async LLM calls (OpenAI-compatible API) via QThread
+ - Context injection: sends the current editor code along with the prompt
+ - One-click "Insert & Run" to push LLM-generated code into the editor
+
+All imports of openai are lazy/guarded so CQ-editor starts fine without it.
+Install the optional dependency with: pip install openai
+"""
+
+from __future__ import annotations
+
+from PyQt5.QtCore import QObject, QThread, pyqtSignal, pyqtSlot, Qt
+from PyQt5.QtWidgets import (
+ QWidget,
+ QVBoxLayout,
+ QHBoxLayout,
+ QTextEdit,
+ QLineEdit,
+ QPushButton,
+ QLabel,
+ QSizePolicy,
+ QMessageBox,
+ QScrollBar,
+)
+from PyQt5.QtGui import QColor, QTextCharFormat, QFont
+
+from pyqtgraph.parametertree import Parameter
+
+
+# ---------------------------------------------------------------------------
+# Background worker
+# ---------------------------------------------------------------------------
+
+class _LLMWorker(QObject):
+ """Runs the blocking LLM API call in a QThread."""
+
+ finished = pyqtSignal(str) # emits the assistant reply
+ error = pyqtSignal(str) # emits a human-readable error message
+
+ def __init__(self, api_key: str, base_url: str, model: str,
+ messages: list[dict], parent=None):
+ super().__init__(parent)
+ self._api_key = api_key
+ self._base_url = base_url
+ self._model = model
+ self._messages = messages
+
+ @pyqtSlot()
+ def run(self):
+ try:
+ from openai import OpenAI # lazy import
+ except ImportError:
+ self.error.emit(
+ "The 'openai' package is not installed.\n"
+ "Run: pip install openai"
+ )
+ return
+
+ try:
+ client = OpenAI(
+ api_key=self._api_key,
+ base_url=self._base_url or None,
+ )
+ response = client.chat.completions.create(
+ model=self._model,
+ messages=self._messages,
+ )
+ reply = response.choices[0].message.content or ""
+ self.finished.emit(reply)
+ except Exception as exc: # noqa: BLE001
+ self.error.emit(str(exc))
+
+
+# ---------------------------------------------------------------------------
+# Main widget
+# ---------------------------------------------------------------------------
+
+SYSTEM_PROMPT = (
+ "You are an expert CadQuery (Python) programmer.\n"
+ "When the user asks you to create or modify a 3-D model, respond with "
+ "a complete, valid CadQuery Python script and NOTHING else β no markdown "
+ "fences, no explanations, just runnable code.\n"
+ "The user's current script is provided as context under the heading "
+ "'CURRENT SCRIPT'. Modify it as requested, or start fresh if they ask "
+ "for something new."
+)
+
+
+class AIChatWidget(QWidget):
+ """Dockable AI Chat panel for CQ-editor."""
+
+ name = "AI Assistant"
+
+ # Emitted when the LLM returns code that should be loaded into the editor.
+ # Connected to Editor.set_text() + Debugger.render() in main_window.py
+ insert_code = pyqtSignal(str)
+
+ preferences = Parameter.create(
+ name="AI Assistant",
+ children=[
+ {
+ "name": "Enabled",
+ "type": "bool",
+ "value": False,
+ "tip": "Enable the AI Chat Assistant panel",
+ },
+ {
+ "name": "Provider / Base URL",
+ "type": "str",
+ "value": "https://api.openai.com/v1",
+ "tip": (
+ "OpenAI-compatible base URL. "
+ "Use https://api.openai.com/v1 for OpenAI, "
+ "https://openrouter.ai/api/v1 for OpenRouter, etc."
+ ),
+ },
+ {
+ "name": "Model",
+ "type": "str",
+ "value": "gpt-4o",
+ "tip": "Model identifier, e.g. gpt-4o, claude-sonnet-4-5, o3",
+ },
+ {
+ "name": "API Key",
+ "type": "str",
+ "value": "",
+ "tip": "Your API key. Stored in local preferences (not sent anywhere else).",
+ },
+ {
+ "name": "Auto-run after insert",
+ "type": "bool",
+ "value": True,
+ "tip": "Automatically execute the script after inserting LLM code",
+ },
+ ],
+ )
+
+ def __init__(self, parent=None, editor=None, debugger=None):
+ super().__init__(parent)
+ self._editor = editor # cq_editor.widgets.editor.Editor instance
+ self._debugger = debugger # cq_editor.widgets.debugger.Debugger instance
+ self._history: list[dict] = [] # OpenAI message history
+ self._thread: QThread | None = None
+ self._worker: _LLMWorker | None = None
+ self._setup_ui()
+
+ # ------------------------------------------------------------------
+ # UI construction
+ # ------------------------------------------------------------------
+
+ def _setup_ui(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(4, 4, 4, 4)
+ layout.setSpacing(4)
+
+ # ---- header
+ header = QLabel("π€ AI CAD Assistant")
+ header.setAlignment(Qt.AlignCenter)
+ layout.addWidget(header)
+
+ # ---- chat history
+ self.chat_display = QTextEdit()
+ self.chat_display.setReadOnly(True)
+ self.chat_display.setFont(QFont("Monospace", 9))
+ self.chat_display.setMinimumHeight(200)
+ layout.addWidget(self.chat_display, stretch=1)
+
+ # ---- input row
+ input_row = QHBoxLayout()
+ self.prompt_input = QLineEdit()
+ self.prompt_input.setPlaceholderText(
+ "Describe what you want to model or changeβ¦"
+ )
+ self.prompt_input.returnPressed.connect(self._send)
+ input_row.addWidget(self.prompt_input, stretch=1)
+
+ self.send_btn = QPushButton("Send")
+ self.send_btn.setFixedWidth(60)
+ self.send_btn.clicked.connect(self._send)
+ input_row.addWidget(self.send_btn)
+ layout.addLayout(input_row)
+
+ # ---- action buttons
+ btn_row = QHBoxLayout()
+
+ self.insert_btn = QPushButton("Insert & Run")
+ self.insert_btn.setToolTip(
+ "Copy the last LLM code block into the editor and run it"
+ )
+ self.insert_btn.setEnabled(False)
+ self.insert_btn.clicked.connect(self._insert_last_code)
+ btn_row.addWidget(self.insert_btn)
+
+ self.clear_btn = QPushButton("Clear Chat")
+ self.clear_btn.clicked.connect(self._clear)
+ btn_row.addWidget(self.clear_btn)
+
+ layout.addLayout(btn_row)
+
+ # ---- status
+ self.status_label = QLabel("")
+ self.status_label.setAlignment(Qt.AlignCenter)
+ layout.addWidget(self.status_label)
+
+ self._last_code: str = ""
+
+ # ------------------------------------------------------------------
+ # Helpers to read live preferences
+ # ------------------------------------------------------------------
+
+ def _pref(self, name: str):
+ return self.preferences[name]
+
+ def _api_key(self) -> str:
+ return self._pref("API Key").strip()
+
+ def _base_url(self) -> str:
+ return self._pref("Provider / Base URL").strip()
+
+ def _model(self) -> str:
+ return self._pref("Model").strip()
+
+ def _auto_run(self) -> bool:
+ return self._pref("Auto-run after insert")
+
+ # ------------------------------------------------------------------
+ # Slots
+ # ------------------------------------------------------------------
+
+ @pyqtSlot()
+ def _send(self):
+ prompt = self.prompt_input.text().strip()
+ if not prompt:
+ return
+
+ if not self._api_key():
+ QMessageBox.warning(
+ self,
+ "API Key Missing",
+ "Please enter your API key in\n"
+ "Edit β Preferences β AI Assistant β API Key",
+ )
+ return
+
+ if self._thread and self._thread.isRunning():
+ self.status_label.setText("Waiting for previous responseβ¦")
+ return
+
+ self.prompt_input.clear()
+ self._append_chat("user", prompt)
+
+ # Build message list with optional context injection
+ if not self._history: # first message β inject system prompt
+ self._history.append({"role": "system", "content": self._build_system()})
+
+ self._history.append({"role": "user", "content": self._inject_context(prompt)})
+
+ self._set_busy(True)
+ self._run_worker(list(self._history))
+
+ def _build_system(self) -> str:
+ return SYSTEM_PROMPT
+
+ def _inject_context(self, prompt: str) -> str:
+ """Prepend current editor code as context to the user prompt."""
+ if self._editor is None:
+ return prompt
+ try:
+ current_code = self._editor.toPlainText().strip()
+ except Exception:
+ current_code = ""
+
+ if not current_code:
+ return prompt
+
+ return (
+ f"CURRENT SCRIPT:\n```python\n{current_code}\n```\n\n"
+ f"USER REQUEST:\n{prompt}"
+ )
+
+ def _run_worker(self, messages: list[dict]):
+ self._thread = QThread(self)
+ self._worker = _LLMWorker(
+ api_key=self._api_key(),
+ base_url=self._base_url(),
+ model=self._model(),
+ messages=messages,
+ )
+ self._worker.moveToThread(self._thread)
+ self._thread.started.connect(self._worker.run)
+ self._worker.finished.connect(self._on_response)
+ self._worker.error.connect(self._on_error)
+ self._worker.finished.connect(self._thread.quit)
+ self._worker.error.connect(self._thread.quit)
+ self._thread.finished.connect(self._thread.deleteLater)
+ self._thread.start()
+
+ @pyqtSlot(str)
+ def _on_response(self, reply: str):
+ self._set_busy(False)
+ self._history.append({"role": "assistant", "content": reply})
+ self._append_chat("assistant", reply)
+ # Try to extract a code block for the Insert button
+ code = self._extract_code(reply)
+ if code:
+ self._last_code = code
+ self.insert_btn.setEnabled(True)
+ else:
+ # The whole reply might be bare code
+ self._last_code = reply.strip()
+ self.insert_btn.setEnabled(bool(self._last_code))
+
+ @pyqtSlot(str)
+ def _on_error(self, message: str):
+ self._set_busy(False)
+ self._append_chat("error", message)
+
+ @pyqtSlot()
+ def _insert_last_code(self):
+ if not self._last_code:
+ return
+ self.insert_code.emit(self._last_code)
+ # Optionally trigger a re-render via debugger
+ if self._auto_run() and self._debugger is not None:
+ try:
+ self._debugger.render()
+ except Exception:
+ pass
+ self._append_chat("system", "β
Code inserted into editor.")
+
+ @pyqtSlot()
+ def _clear(self):
+ self._history.clear()
+ self._last_code = ""
+ self.insert_btn.setEnabled(False)
+ self.chat_display.clear()
+ self.status_label.setText("")
+
+ # ------------------------------------------------------------------
+ # Utilities
+ # ------------------------------------------------------------------
+
+ def _set_busy(self, busy: bool):
+ self.send_btn.setEnabled(not busy)
+ self.prompt_input.setEnabled(not busy)
+ self.status_label.setText("Thinkingβ¦" if busy else "")
+
+ def _append_chat(self, role: str, text: str):
+ cursor = self.chat_display.textCursor()
+ cursor.movePosition(cursor.End)
+
+ fmt_label = QTextCharFormat()
+ fmt_text = QTextCharFormat()
+
+ if role == "user":
+ fmt_label.setForeground(QColor("#1565C0"))
+ fmt_label.setFontWeight(QFont.Bold)
+ label = "You: "
+ elif role == "assistant":
+ fmt_label.setForeground(QColor("#1B5E20"))
+ fmt_label.setFontWeight(QFont.Bold)
+ label = "AI: "
+ elif role == "error":
+ fmt_label.setForeground(QColor("#B71C1C"))
+ fmt_label.setFontWeight(QFont.Bold)
+ label = "ERR: "
+ fmt_text.setForeground(QColor("#B71C1C"))
+ else: # system
+ fmt_label.setForeground(QColor("#4A148C"))
+ label = " "
+
+ cursor.insertText(label, fmt_label)
+ cursor.insertText(text.strip() + "\n\n", fmt_text)
+
+ # Auto-scroll
+ sb: QScrollBar = self.chat_display.verticalScrollBar()
+ sb.setValue(sb.maximum())
+
+ @staticmethod
+ def _extract_code(text: str) -> str:
+ """Pull the first fenced ```python ... ``` block, or ``` ... ``` block."""
+ import re
+ pattern = re.compile(
+ r"```(?:python)?\n(.*?)```", re.DOTALL | re.IGNORECASE
+ )
+ m = pattern.search(text)
+ return m.group(1).strip() if m else ""
+
+ # ------------------------------------------------------------------
+ # MainMixin compatibility stubs
+ # ------------------------------------------------------------------
+
+ def menuActions(self) -> dict:
+ return {}
+
+ def toolbarActions(self) -> list:
+ return []
From 21d21dac5851c3b1d26535f7f6aa41f4161d3f43 Mon Sep 17 00:00:00 2001
From: VxkY <89289497+Vickyrrrrrr@users.noreply.github.com>
Date: Sat, 16 May 2026 17:48:35 +0530
Subject: [PATCH 2/5] docs: update README with AI Chat Assistant feature and
usage guide
---
README.md | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 93 insertions(+)
diff --git a/README.md b/README.md
index 4686bdf8..19ec2d1a 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,99 @@ Additional screenshots are available in [the wiki](https://github.com/CadQuery/C
* Export to various formats
* STL
* STEP
+* **π€ AI Chat Assistant** *(opt-in)* β describe your model in plain English and get runnable CadQuery code instantly, with iterative editing support
+
+## π€ AI Chat Assistant
+
+CQ-editor includes an optional **AI Chat Assistant** panel that lets you generate and iterate on CadQuery models using natural language. It works with any OpenAI-compatible API provider β including OpenAI, OpenRouter, Anthropic (via OpenRouter), and local models via Ollama.
+
+### Setup
+
+**1. Install the optional dependency**
+
+```bash
+pip install openai
+```
+
+> CQ-editor starts and works normally without this package. The AI panel is fully opt-in.
+
+**2. Configure your API key and model**
+
+Open **Edit β Preferences β AI Assistant** and fill in:
+
+| Setting | Description | Example |
+|---|---|---|
+| Enabled | Turn the panel on/off | β |
+| Provider / Base URL | API endpoint | `https://api.openai.com/v1` |
+| Model | Model identifier | `gpt-4o`, `o3`, `claude-sonnet-4-5` |
+| API Key | Your provider API key | `sk-...` |
+| Auto-run after insert | Re-render the model immediately after inserting code | β |
+
+**Supported providers (via Base URL)**
+
+| Provider | Base URL |
+|---|---|
+| OpenAI | `https://api.openai.com/v1` |
+| OpenRouter | `https://openrouter.ai/api/v1` |
+| Local Ollama | `http://localhost:11434/v1` |
+| Any OpenAI-compatible API | your custom endpoint |
+
+**3. Open the panel**
+
+Go to **Tools β π€ AI Assistant**, or toggle it from the **View** menu like any other dock panel.
+
+### Usage
+
+**Generating a new model from scratch**
+
+1. Type a description in the prompt box, for example:
+ ```
+ Create a hollow cylinder, 40mm outer diameter, 30mm inner diameter, 80mm tall
+ ```
+2. Press **Enter** or click **Send**.
+3. When the AI replies with code, click **Insert & Run**.
+4. The model appears instantly in the 3D viewer.
+
+**Iterating on an existing model**
+
+The AI automatically receives your **current editor script as context** with every message. This means you can say:
+
+```
+Add a 2mm fillet to all vertical edges
+```
+
+or
+
+```
+Change the wall thickness to 5mm and add mounting holes at each corner
+```
+
+and the AI will modify the existing code rather than starting from scratch.
+
+**Typical workflow**
+
+```
+[You] Make a rectangular enclosure 100x60x40mm with a 2mm wall thickness
+[AI] import cadquery as cq
+ result = (
+ cq.Workplane("XY")
+ .box(100, 60, 40)
+ .shell(-2)
+ )
+ show_object(result)
+[You click Insert & Run β model renders in viewer]
+
+[You] Add four M3 mounting holes at the corners, 5mm from each edge
+[AI]
+[You click Insert & Run β model updates]
+```
+
+### Tips
+
+* The best models for CadQuery code generation are currently **o3**, **gpt-4o**, and **Gemini 2.5 Pro** (via OpenRouter).
+* If the generated code has an error, the traceback appears in the **Current traceback** panel β you can copy it and paste it back into the chat: *"Fix this error: ..."*.
+* Click **Clear Chat** to reset the conversation history and start a new model.
+* The API key is stored in local preferences only and is never sent anywhere other than your configured provider endpoint.
## Documentation
From 3cbc8ee39f11be6078a8b1cb675bf3ef345a528a Mon Sep 17 00:00:00 2001
From: VxkY <89289497+Vickyrrrrrr@users.noreply.github.com>
Date: Mon, 18 May 2026 18:58:06 +0530
Subject: [PATCH 3/5] fix: address all Claude code review issues
Correctness / Bugs:
- Wire Enabled preference to actually show/hide the dock panel via
preferences.sigTreeStateChanged; panel starts hidden when Enabled=False
- Fix _toggle_ai_panel: remove raise_() from the hide branch (it
re-showed the widget immediately after hiding it)
- Remove dangerous bare-code fallback in _on_response: if no fenced
code block is found, show an informational message and disable Insert
button instead of pasting raw LLM text into the editor
- Move `import re` to module level (was inside _extract_code staticmethod)
- Fix QAction construction: set toolTip via explicit .setToolTip() call
after construction for full PyQt5 compatibility
Safety / Privacy:
- Replace plaintext API key in preferences with system keyring storage
(keyring package, falls back to plaintext with a warning if unavailable)
- Add first-use privacy consent dialog: warns user that their script is
sent to the configured API endpoint; stores consent in preferences so
dialog only shows once
- Update API Key tooltip to accurately state storage mechanism
Resource / Thread Safety:
- Add closeEvent to AIChatWidget that calls _thread.quit()/_thread.wait()
so in-flight requests are cleanly stopped when the app closes
- Bound conversation history: keep at most MAX_HISTORY_TURNS (10) turns;
older messages are pruned (system prompt always retained) to prevent
unbounded token growth
Architecture:
- Pass editor and debugger properly via set_dependencies() method instead
of post-construction attribute assignment; main_window.py updated to call
set_dependencies() in prepare_actions()
- Move preferences from class-level to instance-level Parameter to avoid
shared-state bugs if multiple instances are ever created
- Make AIChatWidget import conditional in main_window.py with try/except
so the claim of optional dependency is accurate end-to-end
---
cq_editor/main_window.py | 90 ++++----
cq_editor/widgets/ai_chat.py | 396 ++++++++++++++++++++++++-----------
2 files changed, 312 insertions(+), 174 deletions(-)
diff --git a/cq_editor/main_window.py b/cq_editor/main_window.py
index 4f113fa8..03f48a21 100644
--- a/cq_editor/main_window.py
+++ b/cq_editor/main_window.py
@@ -25,7 +25,13 @@
from .widgets.cq_object_inspector import CQObjectInspector
from .widgets.log import LogViewer
from .widgets.upload_dialog import UploadDialog
-from .widgets.ai_chat import AIChatWidget # AI Assistant
+
+# 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 (
@@ -289,17 +295,14 @@ def prepare_panes(self):
lambda c: dock(c, "Log viewer", self, defaultArea="bottom"),
)
- # ---- AI Chat Assistant (docked on the right by default) -------
- self.registerComponent(
- "ai_chat",
- AIChatWidget(
- self,
- editor=None, # wired after editor is ready in prepare_actions
- debugger=None,
- ),
- lambda c: dock(c, "AI Assistant", self, defaultArea="right"),
- )
- # ---------------------------------------------------------------
+ # ---- 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()
@@ -414,16 +417,13 @@ def prepare_menubar(self):
)
)
- # AI Assistant toggle in Tools menu
- menu_tools.addSeparator()
- menu_tools.addAction(
- QAction(
- "π€ AI Assistant",
- self,
- triggered=self._toggle_ai_panel,
- toolTip="Show / hide the AI Chat Assistant panel",
- )
- )
+ # 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):
@@ -446,12 +446,15 @@ def prepare_statusbar(self):
def prepare_actions(self):
- # Wire AI chat to the live editor and debugger instances
- ai = self.components["ai_chat"]
- ai._editor = self.components["editor"]
- ai._debugger = self.components["debugger"]
- # When LLM code arrives, push it into the editor
- ai.insert_code.connect(self.components["editor"].set_text)
+ # 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["debugger"].sigRendered.connect(
self.components["object_tree"].addObjects
@@ -563,8 +566,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"
@@ -576,7 +577,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:]
@@ -653,28 +653,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
@@ -691,13 +678,16 @@ def _upload_model(self):
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:
- if dock_widget.isVisible():
- dock_widget.hide()
- else:
- dock_widget.show()
- dock_widget.raise_()
+ 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__":
diff --git a/cq_editor/widgets/ai_chat.py b/cq_editor/widgets/ai_chat.py
index d9475777..baf499f2 100644
--- a/cq_editor/widgets/ai_chat.py
+++ b/cq_editor/widgets/ai_chat.py
@@ -2,18 +2,25 @@
AI Chat Assistant widget for CQ-editor.
Provides a dockable panel with:
- - Chat history display
- - Prompt input box
- - Async LLM calls (OpenAI-compatible API) via QThread
- - Context injection: sends the current editor code along with the prompt
- - One-click "Insert & Run" to push LLM-generated code into the editor
-
-All imports of openai are lazy/guarded so CQ-editor starts fine without it.
-Install the optional dependency with: pip install openai
+ - Chat history display (colour-coded roles)
+ - Prompt input box (Enter or Send button)
+ - Async LLM calls via QThread β UI never freezes
+ - Context injection: current editor script is prepended to every prompt
+ - "Insert & Run" pushes extracted code into the editor
+ - Privacy consent dialog on first use
+ - System keyring for API key storage (falls back to plaintext)
+ - Bounded conversation history (MAX_HISTORY_TURNS)
+ - Clean thread shutdown in closeEvent
+
+Optional dependencies (install separately):
+ pip install openai # required for LLM calls
+ pip install keyring # recommended for secure API key storage
"""
from __future__ import annotations
+import re
+
from PyQt5.QtCore import QObject, QThread, pyqtSignal, pyqtSlot, Qt
from PyQt5.QtWidgets import (
QWidget,
@@ -23,24 +30,55 @@
QLineEdit,
QPushButton,
QLabel,
- QSizePolicy,
QMessageBox,
QScrollBar,
+ QAction,
)
from PyQt5.QtGui import QColor, QTextCharFormat, QFont
from pyqtgraph.parametertree import Parameter
+# Maximum number of user+assistant turn pairs kept in history.
+# Older turns are pruned to limit token cost and payload size.
+MAX_HISTORY_TURNS = 10
+
+# Keyring service name used when storing the API key securely.
+_KEYRING_SERVICE = "cq-editor-ai-assistant"
+_KEYRING_USER = "api-key"
+
# ---------------------------------------------------------------------------
-# Background worker
+# Secure key storage helpers
+# ---------------------------------------------------------------------------
+
+def _keyring_save(key: str) -> bool:
+ """Try to store *key* in the system keychain. Returns True on success."""
+ try:
+ import keyring
+ keyring.set_password(_KEYRING_SERVICE, _KEYRING_USER, key)
+ return True
+ except Exception:
+ return False
+
+
+def _keyring_load() -> str | None:
+ """Try to load the API key from the system keychain."""
+ try:
+ import keyring
+ return keyring.get_password(_KEYRING_SERVICE, _KEYRING_USER) or None
+ except Exception:
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Background LLM worker
# ---------------------------------------------------------------------------
class _LLMWorker(QObject):
- """Runs the blocking LLM API call in a QThread."""
+ """Runs the blocking OpenAI-compatible API call in a QThread."""
- finished = pyqtSignal(str) # emits the assistant reply
- error = pyqtSignal(str) # emits a human-readable error message
+ finished = pyqtSignal(str) # assistant reply text
+ error = pyqtSignal(str) # human-readable error
def __init__(self, api_key: str, base_url: str, model: str,
messages: list[dict], parent=None):
@@ -53,128 +91,166 @@ def __init__(self, api_key: str, base_url: str, model: str,
@pyqtSlot()
def run(self):
try:
- from openai import OpenAI # lazy import
+ from openai import OpenAI # lazy import β openai is optional
except ImportError:
self.error.emit(
"The 'openai' package is not installed.\n"
"Run: pip install openai"
)
return
-
try:
client = OpenAI(
api_key=self._api_key,
base_url=self._base_url or None,
)
- response = client.chat.completions.create(
+ resp = client.chat.completions.create(
model=self._model,
messages=self._messages,
)
- reply = response.choices[0].message.content or ""
- self.finished.emit(reply)
- except Exception as exc: # noqa: BLE001
+ self.finished.emit(resp.choices[0].message.content or "")
+ except Exception as exc: # noqa: BLE001
self.error.emit(str(exc))
# ---------------------------------------------------------------------------
-# Main widget
+# System prompt
# ---------------------------------------------------------------------------
SYSTEM_PROMPT = (
"You are an expert CadQuery (Python) programmer.\n"
- "When the user asks you to create or modify a 3-D model, respond with "
- "a complete, valid CadQuery Python script and NOTHING else β no markdown "
- "fences, no explanations, just runnable code.\n"
- "The user's current script is provided as context under the heading "
- "'CURRENT SCRIPT'. Modify it as requested, or start fresh if they ask "
- "for something new."
+ "Always respond with a complete, valid CadQuery Python script wrapped in "
+ "a single ```python ... ``` fenced code block. "
+ "Do not include any text outside that block.\n"
+ "The user's current script (if any) is provided under 'CURRENT SCRIPT'. "
+ "Modify it as requested, or start fresh if the user asks for something new."
)
+_CODE_RE = re.compile(r"```(?:python)?\n(.*?)```", re.DOTALL | re.IGNORECASE)
+
+
+# ---------------------------------------------------------------------------
+# Main widget
+# ---------------------------------------------------------------------------
class AIChatWidget(QWidget):
"""Dockable AI Chat panel for CQ-editor."""
name = "AI Assistant"
- # Emitted when the LLM returns code that should be loaded into the editor.
- # Connected to Editor.set_text() + Debugger.render() in main_window.py
+ # Emitted with the extracted CadQuery code; connected to Editor.set_text()
insert_code = pyqtSignal(str)
- preferences = Parameter.create(
- name="AI Assistant",
- children=[
- {
- "name": "Enabled",
- "type": "bool",
- "value": False,
- "tip": "Enable the AI Chat Assistant panel",
- },
- {
- "name": "Provider / Base URL",
- "type": "str",
- "value": "https://api.openai.com/v1",
- "tip": (
- "OpenAI-compatible base URL. "
- "Use https://api.openai.com/v1 for OpenAI, "
- "https://openrouter.ai/api/v1 for OpenRouter, etc."
- ),
- },
- {
- "name": "Model",
- "type": "str",
- "value": "gpt-4o",
- "tip": "Model identifier, e.g. gpt-4o, claude-sonnet-4-5, o3",
- },
- {
- "name": "API Key",
- "type": "str",
- "value": "",
- "tip": "Your API key. Stored in local preferences (not sent anywhere else).",
- },
- {
- "name": "Auto-run after insert",
- "type": "bool",
- "value": True,
- "tip": "Automatically execute the script after inserting LLM code",
- },
- ],
- )
-
- def __init__(self, parent=None, editor=None, debugger=None):
+ def __init__(self, parent=None):
super().__init__(parent)
- self._editor = editor # cq_editor.widgets.editor.Editor instance
- self._debugger = debugger # cq_editor.widgets.debugger.Debugger instance
- self._history: list[dict] = [] # OpenAI message history
+
+ # Instance-level preferences (not class-level) to avoid shared state
+ self.preferences = Parameter.create(
+ name="AI Assistant",
+ children=[
+ {
+ "name": "Enabled",
+ "type": "bool",
+ "value": False,
+ "tip": "Show the AI Chat Assistant dock panel",
+ },
+ {
+ "name": "Provider / Base URL",
+ "type": "str",
+ "value": "https://api.openai.com/v1",
+ "tip": (
+ "OpenAI-compatible base URL.\n"
+ "OpenAI: https://api.openai.com/v1\n"
+ "OpenRouter: https://openrouter.ai/api/v1\n"
+ "Ollama: http://localhost:11434/v1"
+ ),
+ },
+ {
+ "name": "Model",
+ "type": "str",
+ "value": "gpt-4o",
+ "tip": "Model identifier, e.g. gpt-4o, o3, claude-sonnet-4-5",
+ },
+ {
+ "name": "API Key",
+ "type": "str",
+ "value": "",
+ "tip": (
+ "Your API key.\n"
+ "Stored in the system keychain when 'keyring' is installed; "
+ "otherwise stored in plaintext preferences on disk.\n"
+ "Run: pip install keyring for secure storage."
+ ),
+ },
+ {
+ "name": "Auto-run after insert",
+ "type": "bool",
+ "value": True,
+ "tip": "Re-render the model immediately after inserting LLM code",
+ },
+ {
+ "name": "_privacy_consent",
+ "type": "bool",
+ "value": False,
+ "tip": "Internal: records that the user has seen the privacy notice",
+ },
+ ],
+ )
+
+ self._editor = None # set via set_dependencies()
+ self._debugger = None
+ self._history: list[dict] = []
self._thread: QThread | None = None
self._worker: _LLMWorker | None = None
+ self._last_code: str = ""
+
self._setup_ui()
+ # Wire Enabled toggle -> show/hide this widget's parent dock
+ self.preferences.sigTreeStateChanged.connect(self._on_prefs_changed)
+
+ # ------------------------------------------------------------------
+ # Dependency injection
+ # ------------------------------------------------------------------
+
+ def set_dependencies(self, editor, debugger):
+ """Called by main_window after both editor and debugger are ready."""
+ self._editor = editor
+ self._debugger = debugger
+
# ------------------------------------------------------------------
# UI construction
# ------------------------------------------------------------------
def _setup_ui(self):
- layout = QVBoxLayout(self)
- layout.setContentsMargins(4, 4, 4, 4)
- layout.setSpacing(4)
+ root = QVBoxLayout(self)
+ root.setContentsMargins(4, 4, 4, 4)
+ root.setSpacing(4)
- # ---- header
- header = QLabel("π€ AI CAD Assistant")
+ header = QLabel("🤖 AI CAD Assistant")
header.setAlignment(Qt.AlignCenter)
- layout.addWidget(header)
+ root.addWidget(header)
+
+ # Privacy notice banner
+ self._privacy_banner = QLabel(
+ "⚠ Each prompt sends your current script to the "
+ "configured API endpoint."
+ )
+ self._privacy_banner.setWordWrap(True)
+ self._privacy_banner.setAlignment(Qt.AlignCenter)
+ self._privacy_banner.setStyleSheet("color: #7B5B00; background: #FFF8E1; "
+ "padding: 2px; border-radius: 3px;")
+ root.addWidget(self._privacy_banner)
- # ---- chat history
self.chat_display = QTextEdit()
self.chat_display.setReadOnly(True)
self.chat_display.setFont(QFont("Monospace", 9))
self.chat_display.setMinimumHeight(200)
- layout.addWidget(self.chat_display, stretch=1)
+ root.addWidget(self.chat_display, stretch=1)
- # ---- input row
input_row = QHBoxLayout()
self.prompt_input = QLineEdit()
self.prompt_input.setPlaceholderText(
- "Describe what you want to model or changeβ¦"
+ "Describe what you want to model or change\u2026"
)
self.prompt_input.returnPressed.connect(self._send)
input_row.addWidget(self.prompt_input, stretch=1)
@@ -183,14 +259,13 @@ def _setup_ui(self):
self.send_btn.setFixedWidth(60)
self.send_btn.clicked.connect(self._send)
input_row.addWidget(self.send_btn)
- layout.addLayout(input_row)
+ root.addLayout(input_row)
- # ---- action buttons
btn_row = QHBoxLayout()
self.insert_btn = QPushButton("Insert & Run")
self.insert_btn.setToolTip(
- "Copy the last LLM code block into the editor and run it"
+ "Copy the extracted code block into the editor and run it"
)
self.insert_btn.setEnabled(False)
self.insert_btn.clicked.connect(self._insert_last_code)
@@ -199,26 +274,31 @@ def _setup_ui(self):
self.clear_btn = QPushButton("Clear Chat")
self.clear_btn.clicked.connect(self._clear)
btn_row.addWidget(self.clear_btn)
+ root.addLayout(btn_row)
- layout.addLayout(btn_row)
-
- # ---- status
self.status_label = QLabel("")
self.status_label.setAlignment(Qt.AlignCenter)
- layout.addWidget(self.status_label)
-
- self._last_code: str = ""
+ root.addWidget(self.status_label)
# ------------------------------------------------------------------
- # Helpers to read live preferences
+ # Preference helpers
# ------------------------------------------------------------------
def _pref(self, name: str):
return self.preferences[name]
def _api_key(self) -> str:
+ # Prefer system keyring; fall back to plaintext preference
+ key = _keyring_load()
+ if key:
+ return key
return self._pref("API Key").strip()
+ def _save_api_key(self, key: str):
+ if not _keyring_save(key):
+ # keyring unavailable β store in plaintext preference with warning
+ self.preferences["API Key"] = key
+
def _base_url(self) -> str:
return self._pref("Provider / Base URL").strip()
@@ -228,6 +308,69 @@ def _model(self) -> str:
def _auto_run(self) -> bool:
return self._pref("Auto-run after insert")
+ @pyqtSlot(object, object)
+ def _on_prefs_changed(self, _param, changes):
+ """React to preference changes; wire Enabled to dock visibility."""
+ for param, _change, _val in changes:
+ if param.name() == "Enabled":
+ dock = self._find_dock()
+ if dock is not None:
+ if self._pref("Enabled"):
+ dock.show()
+ dock.raise_()
+ else:
+ dock.hide()
+
+ def _find_dock(self):
+ """Walk up the parent chain to find the QDockWidget containing us."""
+ from PyQt5.QtWidgets import QDockWidget
+ p = self.parent()
+ while p is not None:
+ if isinstance(p, QDockWidget):
+ return p
+ p = p.parent()
+ return None
+
+ # ------------------------------------------------------------------
+ # Privacy consent
+ # ------------------------------------------------------------------
+
+ def _ensure_privacy_consent(self) -> bool:
+ """Show a one-time privacy dialog. Returns True if user consents."""
+ if self._pref("_privacy_consent"):
+ return True
+ rv = QMessageBox.information(
+ self,
+ "Privacy Notice \u2014 AI Assistant",
+ "When you send a prompt, your current CadQuery script and prompt "
+ "text are sent to the API endpoint you configured:\n\n"
+ " {url}\n\n"
+ "Make sure you are comfortable sharing your code with that provider "
+ "before continuing. You can change the endpoint in "
+ "Edit \u2192 Preferences \u2192 AI Assistant.\n\n"
+ "Do you want to continue?".format(url=self._base_url()),
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.No,
+ )
+ if rv == QMessageBox.Yes:
+ self.preferences["_privacy_consent"] = True
+ return True
+ return False
+
+ # ------------------------------------------------------------------
+ # History management
+ # ------------------------------------------------------------------
+
+ def _prune_history(self):
+ """Keep system prompt + at most MAX_HISTORY_TURNS turn pairs."""
+ non_system = [m for m in self._history if m["role"] != "system"]
+ system = [m for m in self._history if m["role"] == "system"]
+ # Each turn = one user + one assistant message
+ max_msgs = MAX_HISTORY_TURNS * 2
+ if len(non_system) > max_msgs:
+ non_system = non_system[-max_msgs:]
+ self._history = system + non_system
+
# ------------------------------------------------------------------
# Slots
# ------------------------------------------------------------------
@@ -243,41 +386,39 @@ def _send(self):
self,
"API Key Missing",
"Please enter your API key in\n"
- "Edit β Preferences β AI Assistant β API Key",
+ "Edit \u2192 Preferences \u2192 AI Assistant \u2192 API Key",
)
return
+ if not self._ensure_privacy_consent():
+ return
+
if self._thread and self._thread.isRunning():
- self.status_label.setText("Waiting for previous responseβ¦")
+ self.status_label.setText("Waiting for previous response\u2026")
return
self.prompt_input.clear()
self._append_chat("user", prompt)
- # Build message list with optional context injection
- if not self._history: # first message β inject system prompt
- self._history.append({"role": "system", "content": self._build_system()})
+ if not self._history:
+ self._history.append({"role": "system", "content": SYSTEM_PROMPT})
self._history.append({"role": "user", "content": self._inject_context(prompt)})
+ self._prune_history()
self._set_busy(True)
self._run_worker(list(self._history))
- def _build_system(self) -> str:
- return SYSTEM_PROMPT
-
def _inject_context(self, prompt: str) -> str:
- """Prepend current editor code as context to the user prompt."""
+ """Prepend the current editor script as context."""
if self._editor is None:
return prompt
try:
current_code = self._editor.toPlainText().strip()
except Exception:
current_code = ""
-
if not current_code:
return prompt
-
return (
f"CURRENT SCRIPT:\n```python\n{current_code}\n```\n\n"
f"USER REQUEST:\n{prompt}"
@@ -305,15 +446,21 @@ def _on_response(self, reply: str):
self._set_busy(False)
self._history.append({"role": "assistant", "content": reply})
self._append_chat("assistant", reply)
- # Try to extract a code block for the Insert button
- code = self._extract_code(reply)
+
+ code = _CODE_RE.search(reply)
if code:
- self._last_code = code
+ self._last_code = code.group(1).strip()
self.insert_btn.setEnabled(True)
else:
- # The whole reply might be bare code
- self._last_code = reply.strip()
- self.insert_btn.setEnabled(bool(self._last_code))
+ # No fenced code block found β do NOT fall back to raw reply.
+ # Show an informational message so the user knows what happened.
+ self._last_code = ""
+ self.insert_btn.setEnabled(False)
+ self._append_chat(
+ "system",
+ "\u26a0\ufe0f The response did not contain a fenced code block. "
+ "Ask the AI to provide the complete script again.",
+ )
@pyqtSlot(str)
def _on_error(self, message: str):
@@ -325,13 +472,12 @@ def _insert_last_code(self):
if not self._last_code:
return
self.insert_code.emit(self._last_code)
- # Optionally trigger a re-render via debugger
if self._auto_run() and self._debugger is not None:
try:
self._debugger.render()
except Exception:
pass
- self._append_chat("system", "β
Code inserted into editor.")
+ self._append_chat("system", "\u2705 Code inserted into editor.")
@pyqtSlot()
def _clear(self):
@@ -340,6 +486,19 @@ def _clear(self):
self.insert_btn.setEnabled(False)
self.chat_display.clear()
self.status_label.setText("")
+ # Reset consent so the privacy notice shows again after a clear
+ self.preferences["_privacy_consent"] = False
+
+ # ------------------------------------------------------------------
+ # Lifecycle
+ # ------------------------------------------------------------------
+
+ def closeEvent(self, event):
+ """Ensure the background thread is cleanly stopped before the widget closes."""
+ if self._thread and self._thread.isRunning():
+ self._thread.quit()
+ self._thread.wait(3000) # wait up to 3 s
+ super().closeEvent(event)
# ------------------------------------------------------------------
# Utilities
@@ -348,7 +507,7 @@ def _clear(self):
def _set_busy(self, busy: bool):
self.send_btn.setEnabled(not busy)
self.prompt_input.setEnabled(not busy)
- self.status_label.setText("Thinkingβ¦" if busy else "")
+ self.status_label.setText("Thinking\u2026" if busy else "")
def _append_chat(self, role: str, text: str):
cursor = self.chat_display.textCursor()
@@ -370,27 +529,16 @@ def _append_chat(self, role: str, text: str):
fmt_label.setFontWeight(QFont.Bold)
label = "ERR: "
fmt_text.setForeground(QColor("#B71C1C"))
- else: # system
+ else: # system / info
fmt_label.setForeground(QColor("#4A148C"))
label = " "
cursor.insertText(label, fmt_label)
cursor.insertText(text.strip() + "\n\n", fmt_text)
- # Auto-scroll
sb: QScrollBar = self.chat_display.verticalScrollBar()
sb.setValue(sb.maximum())
- @staticmethod
- def _extract_code(text: str) -> str:
- """Pull the first fenced ```python ... ``` block, or ``` ... ``` block."""
- import re
- pattern = re.compile(
- r"```(?:python)?\n(.*?)```", re.DOTALL | re.IGNORECASE
- )
- m = pattern.search(text)
- return m.group(1).strip() if m else ""
-
# ------------------------------------------------------------------
# MainMixin compatibility stubs
# ------------------------------------------------------------------
From a666ade5cfd32e6fe70c5c4d11d288c8d4b424e7 Mon Sep 17 00:00:00 2001
From: VxkY <89289497+Vickyrrrrrr@users.noreply.github.com>
Date: Mon, 18 May 2026 19:10:52 +0530
Subject: [PATCH 4/5] docs: rewrite AI Assistant README section, remove emojis,
reflect review fixes
---
README.md | 101 ++++++++++++++++++++++++++----------------------------
1 file changed, 48 insertions(+), 53 deletions(-)
diff --git a/README.md b/README.md
index 19ec2d1a..17af6f71 100644
--- a/README.md
+++ b/README.md
@@ -24,99 +24,94 @@ Additional screenshots are available in [the wiki](https://github.com/CadQuery/C
* Export to various formats
* STL
* STEP
-* **π€ AI Chat Assistant** *(opt-in)* β describe your model in plain English and get runnable CadQuery code instantly, with iterative editing support
+* AI Chat Assistant *(opt-in)* β generate and iterate on CadQuery models using natural language via any OpenAI-compatible API
-## π€ AI Chat Assistant
+## AI Chat Assistant
-CQ-editor includes an optional **AI Chat Assistant** panel that lets you generate and iterate on CadQuery models using natural language. It works with any OpenAI-compatible API provider β including OpenAI, OpenRouter, Anthropic (via OpenRouter), and local models via Ollama.
+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.
-### Setup
+The panel is disabled by default. No network requests are made unless you explicitly enable it and send a prompt.
-**1. Install the optional dependency**
+### 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 openai
+pip install keyring
```
-> CQ-editor starts and works normally without this package. The AI panel is fully opt-in.
+### Installation
-**2. Configure your API key and model**
+The panel requires the `openai` package. CQ-editor starts and functions normally without it.
-Open **Edit β Preferences β AI Assistant** and fill in:
+```bash
+pip install openai
+```
+
+### Configuration
-| Setting | Description | Example |
+Open **Edit -> Preferences -> AI Assistant** and set the following:
+
+| Setting | Description | Default |
|---|---|---|
-| Enabled | Turn the panel on/off | β |
-| Provider / Base URL | API endpoint | `https://api.openai.com/v1` |
-| Model | Model identifier | `gpt-4o`, `o3`, `claude-sonnet-4-5` |
-| API Key | Your provider API key | `sk-...` |
-| Auto-run after insert | Re-render the model immediately after inserting code | β |
+| 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 (via Base URL)**
+**Supported providers**
| Provider | Base URL |
|---|---|
| OpenAI | `https://api.openai.com/v1` |
| OpenRouter | `https://openrouter.ai/api/v1` |
-| Local Ollama | `http://localhost:11434/v1` |
+| Ollama (local) | `http://localhost:11434/v1` |
| Any OpenAI-compatible API | your custom endpoint |
-**3. Open the panel**
-
-Go to **Tools β π€ AI Assistant**, or toggle it from the **View** menu like any other dock panel.
+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
-**Generating a new model from scratch**
+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.
-1. Type a description in the prompt box, for example:
- ```
- Create a hollow cylinder, 40mm outer diameter, 30mm inner diameter, 80mm tall
- ```
-2. Press **Enter** or click **Send**.
-3. When the AI replies with code, click **Insert & Run**.
-4. The model appears instantly in the 3D viewer.
+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.
-**Iterating on an existing model**
+**Generating a new model**
+
+```
+Create a hollow cylinder, 40mm outer diameter, 30mm inner diameter, 80mm tall
+```
-The AI automatically receives your **current editor script as context** with every message. This means you can say:
+**Iterating on an existing model**
```
Add a 2mm fillet to all vertical edges
```
-or
-
```
-Change the wall thickness to 5mm and add mounting holes at each corner
+Change the wall thickness to 5mm and add M3 mounting holes at each corner
```
-and the AI will modify the existing code rather than starting from scratch.
+**Fixing errors**
-**Typical workflow**
+If the rendered script throws an error, the traceback appears in the **Current traceback** panel. Copy it and paste it back into the chat:
```
-[You] Make a rectangular enclosure 100x60x40mm with a 2mm wall thickness
-[AI] import cadquery as cq
- result = (
- cq.Workplane("XY")
- .box(100, 60, 40)
- .shell(-2)
- )
- show_object(result)
-[You click Insert & Run β model renders in viewer]
-
-[You] Add four M3 mounting holes at the corners, 5mm from each edge
-[AI]
-[You click Insert & Run β model updates]
+Fix this error:
```
-### Tips
+**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
-* The best models for CadQuery code generation are currently **o3**, **gpt-4o**, and **Gemini 2.5 Pro** (via OpenRouter).
-* If the generated code has an error, the traceback appears in the **Current traceback** panel β you can copy it and paste it back into the chat: *"Fix this error: ..."*.
-* Click **Clear Chat** to reset the conversation history and start a new model.
-* The API key is stored in local preferences only and is never sent anywhere other than your configured provider endpoint.
+* 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. `o3`, `gpt-4o`, and `gemini-2.5-pro` (via OpenRouter) have been tested.
+* The panel shuts down its background thread cleanly when CQ-editor closes, even if a request is in progress.
## Documentation
From 1abadcd8f69e913a94ecbc5b7635a042610c0a43 Mon Sep 17 00:00:00 2001
From: Vickyrrrrr
Date: Tue, 19 May 2026 00:36:09 +0530
Subject: [PATCH 5/5] feat: implement one-tap AI error auto-fix, enhance API
key management, and add model-specific configuration support.
---
.gitignore | 2 +-
README.md | 23 ++--
cq_editor/main_window.py | 17 ++-
cq_editor/preferences.py | 9 +-
cq_editor/widgets/ai_chat.py | 144 ++++++++++++++++++++++----
cq_editor/widgets/log.py | 3 +-
cq_editor/widgets/traceback_viewer.py | 56 +++++++++-
pyproject.toml | 4 +
tests/test_ai_chat.py | 75 ++++++++++++++
9 files changed, 299 insertions(+), 34 deletions(-)
create mode 100644 tests/test_ai_chat.py
diff --git a/.gitignore b/.gitignore
index 894a44cc..5e0cf157 100644
--- a/.gitignore
+++ b/.gitignore
@@ -89,7 +89,7 @@ venv/
ENV/
env.bak/
venv.bak/
-
+cqvnv
# Spyder project settings
.spyderproject
.spyproject
diff --git a/README.md b/README.md
index 17af6f71..3dcda250 100644
--- a/README.md
+++ b/README.md
@@ -44,10 +44,16 @@ pip install keyring
### Installation
-The panel requires the `openai` package. CQ-editor starts and functions normally without it.
+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 openai
+pip install .[ai]
+```
+
+Or install them manually:
+
+```bash
+pip install openai keyring
```
### Configuration
@@ -95,13 +101,13 @@ Add a 2mm fillet to all vertical edges
Change the wall thickness to 5mm and add M3 mounting holes at each corner
```
-**Fixing errors**
+**Fixing errors (One-Tap Self-Healing)**
-If the rendered script throws an error, the traceback appears in the **Current traceback** panel. Copy it and paste it back into the chat:
+If the rendered script throws an error, the traceback appears in the **Current traceback** panel. You don't need to copy and paste anything:
-```
-Fix this error:
-```
+* 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**
@@ -109,8 +115,9 @@ Click **Clear Chat** to discard the conversation history and start a new session
### 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. `o3`, `gpt-4o`, and `gemini-2.5-pro` (via OpenRouter) have been tested.
+* 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
diff --git a/cq_editor/main_window.py b/cq_editor/main_window.py
index 03f48a21..428ecf42 100644
--- a/cq_editor/main_window.py
+++ b/cq_editor/main_window.py
@@ -12,6 +12,7 @@
QAction,
QApplication,
QMenu,
+ QDialog,
)
from logbook import Logger
import cadquery as cq
@@ -127,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
@@ -308,7 +317,7 @@ def prepare_panes(self):
d.show()
PRINT_REDIRECTOR.sigStdoutWrite.connect(
- lambda text: self.components["log"].append(text)
+ self.components["log"].append
)
def prepare_menubar(self):
@@ -455,6 +464,9 @@ def prepare_actions(self):
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
@@ -625,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):
diff --git a/cq_editor/preferences.py b/cq_editor/preferences.py
index 2badd740..b2725b0b 100644
--- a/cq_editor/preferences.py
+++ b/cq_editor/preferences.py
@@ -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
@@ -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)
diff --git a/cq_editor/widgets/ai_chat.py b/cq_editor/widgets/ai_chat.py
index baf499f2..77e1089b 100644
--- a/cq_editor/widgets/ai_chat.py
+++ b/cq_editor/widgets/ai_chat.py
@@ -55,7 +55,13 @@ def _keyring_save(key: str) -> bool:
"""Try to store *key* in the system keychain. Returns True on success."""
try:
import keyring
- keyring.set_password(_KEYRING_SERVICE, _KEYRING_USER, key)
+ if not key or not key.strip() or "OrderedDict" in str(key):
+ try:
+ keyring.delete_password(_KEYRING_SERVICE, _KEYRING_USER)
+ except Exception:
+ pass
+ else:
+ keyring.set_password(_KEYRING_SERVICE, _KEYRING_USER, key)
return True
except Exception:
return False
@@ -65,7 +71,14 @@ def _keyring_load() -> str | None:
"""Try to load the API key from the system keychain."""
try:
import keyring
- return keyring.get_password(_KEYRING_SERVICE, _KEYRING_USER) or None
+ val = keyring.get_password(_KEYRING_SERVICE, _KEYRING_USER)
+ if val and "OrderedDict" in str(val):
+ try:
+ keyring.delete_password(_KEYRING_SERVICE, _KEYRING_USER)
+ except Exception:
+ pass
+ return None
+ return val or None
except Exception:
return None
@@ -81,12 +94,14 @@ class _LLMWorker(QObject):
error = pyqtSignal(str) # human-readable error
def __init__(self, api_key: str, base_url: str, model: str,
- messages: list[dict], parent=None):
+ messages: list[dict], temperature: float, max_tokens: int, parent=None):
super().__init__(parent)
- self._api_key = api_key
- self._base_url = base_url
- self._model = model
- self._messages = messages
+ self._api_key = api_key
+ self._base_url = base_url
+ self._model = model
+ self._messages = messages
+ self._temperature = temperature
+ self._max_tokens = max_tokens
@pyqtSlot()
def run(self):
@@ -103,10 +118,17 @@ def run(self):
api_key=self._api_key,
base_url=self._base_url or None,
)
- resp = client.chat.completions.create(
- model=self._model,
- messages=self._messages,
- )
+ kwargs = {
+ "model": self._model,
+ "messages": self._messages,
+ }
+ # Reasoning models (like o1-, o3-) do not accept standard temperature or max_tokens in chat completions
+ is_reasoning = any(x in self._model.lower() for x in ("o1-", "o3-"))
+ if not is_reasoning:
+ kwargs["temperature"] = self._temperature
+ kwargs["max_tokens"] = self._max_tokens
+
+ resp = client.chat.completions.create(**kwargs)
self.finished.emit(resp.choices[0].message.content or "")
except Exception as exc: # noqa: BLE001
self.error.emit(str(exc))
@@ -168,7 +190,7 @@ def __init__(self, parent=None):
"name": "Model",
"type": "str",
"value": "gpt-4o",
- "tip": "Model identifier, e.g. gpt-4o, o3, claude-sonnet-4-5",
+ "tip": "Model identifier. Frontier models yield vastly superior geometric reasoning.",
},
{
"name": "API Key",
@@ -176,11 +198,25 @@ def __init__(self, parent=None):
"value": "",
"tip": (
"Your API key.\n"
- "Stored in the system keychain when 'keyring' is installed; "
+ "Stored in the system keychain when 'keyring' is installed;\n"
"otherwise stored in plaintext preferences on disk.\n"
"Run: pip install keyring for secure storage."
),
},
+ {
+ "name": "Temperature",
+ "type": "float",
+ "value": 0.2,
+ "limits": (0.0, 2.0),
+ "tip": "Controls randomness of responses. Lower is more deterministic.",
+ },
+ {
+ "name": "Max Tokens",
+ "type": "int",
+ "value": 1024,
+ "limits": (1, 16384),
+ "tip": "Maximum number of tokens to generate in the reply.",
+ },
{
"name": "Auto-run after insert",
"type": "bool",
@@ -202,6 +238,7 @@ def __init__(self, parent=None):
self._thread: QThread | None = None
self._worker: _LLMWorker | None = None
self._last_code: str = ""
+ self._auto_insert_flag: bool = False
self._setup_ui()
@@ -217,6 +254,16 @@ def set_dependencies(self, editor, debugger):
self._editor = editor
self._debugger = debugger
+ # Heal any corrupted API Key preferences from QSettings or Keyring
+ try:
+ api_key_val = self._pref("API Key")
+ if not isinstance(api_key_val, str) or "OrderedDict" in str(api_key_val):
+ self.preferences["API Key"] = ""
+ # Trigger keyring self-healing check on startup
+ _keyring_load()
+ except Exception:
+ pass
+
# ------------------------------------------------------------------
# UI construction
# ------------------------------------------------------------------
@@ -290,8 +337,8 @@ def _pref(self, name: str):
def _api_key(self) -> str:
# Prefer system keyring; fall back to plaintext preference
key = _keyring_load()
- if key:
- return key
+ if key and key.strip():
+ return key.strip()
return self._pref("API Key").strip()
def _save_api_key(self, key: str):
@@ -310,7 +357,7 @@ def _auto_run(self) -> bool:
@pyqtSlot(object, object)
def _on_prefs_changed(self, _param, changes):
- """React to preference changes; wire Enabled to dock visibility."""
+ """React to preference changes; wire Enabled to dock visibility & sync API Key."""
for param, _change, _val in changes:
if param.name() == "Enabled":
dock = self._find_dock()
@@ -320,6 +367,8 @@ def _on_prefs_changed(self, _param, changes):
dock.raise_()
else:
dock.hide()
+ elif param.name() == "API Key" and _change == "value":
+ self._save_api_key(_val)
def _find_dock(self):
"""Walk up the parent chain to find the QDockWidget containing us."""
@@ -393,7 +442,13 @@ def _send(self):
if not self._ensure_privacy_consent():
return
- if self._thread and self._thread.isRunning():
+ try:
+ running = self._thread and self._thread.isRunning()
+ except RuntimeError:
+ self._thread = None
+ running = False
+
+ if running:
self.status_label.setText("Waiting for previous response\u2026")
return
@@ -424,6 +479,35 @@ def _inject_context(self, prompt: str) -> str:
f"USER REQUEST:\n{prompt}"
)
+ @pyqtSlot(str)
+ def auto_fix_error(self, prompt: str):
+ """Automatically called when the user clicks 'Auto-Fix with AI' in the traceback pane."""
+ self._auto_insert_flag = True
+
+ # Ensure the dock panel is shown and raised so they can see the progress
+ dock = self._find_dock()
+ if dock is not None:
+ if not self._pref("Enabled"):
+ self.preferences["Enabled"] = True
+ dock.show()
+ dock.raise_()
+
+ # Clear the input box
+ self.prompt_input.clear()
+
+ # Add visual system feedback to user
+ self._append_chat("system", "β¨ Auto-fixing script error...")
+ self._append_chat("user", prompt)
+
+ if not self._history:
+ self._history.append({"role": "system", "content": SYSTEM_PROMPT})
+
+ self._history.append({"role": "user", "content": self._inject_context(prompt)})
+ self._prune_history()
+
+ self._set_busy(True)
+ self._run_worker(list(self._history))
+
def _run_worker(self, messages: list[dict]):
self._thread = QThread(self)
self._worker = _LLMWorker(
@@ -431,6 +515,8 @@ def _run_worker(self, messages: list[dict]):
base_url=self._base_url(),
model=self._model(),
messages=messages,
+ temperature=self._pref("Temperature"),
+ max_tokens=self._pref("Max Tokens"),
)
self._worker.moveToThread(self._thread)
self._thread.started.connect(self._worker.run)
@@ -439,8 +525,14 @@ def _run_worker(self, messages: list[dict]):
self._worker.finished.connect(self._thread.quit)
self._worker.error.connect(self._thread.quit)
self._thread.finished.connect(self._thread.deleteLater)
+ self._thread.finished.connect(self._clear_thread_ref)
self._thread.start()
+ @pyqtSlot()
+ def _clear_thread_ref(self):
+ self._thread = None
+ self._worker = None
+
@pyqtSlot(str)
def _on_response(self, reply: str):
self._set_busy(False)
@@ -451,6 +543,8 @@ def _on_response(self, reply: str):
if code:
self._last_code = code.group(1).strip()
self.insert_btn.setEnabled(True)
+ if self._auto_insert_flag:
+ self._insert_last_code()
else:
# No fenced code block found β do NOT fall back to raw reply.
# Show an informational message so the user knows what happened.
@@ -461,11 +555,13 @@ def _on_response(self, reply: str):
"\u26a0\ufe0f The response did not contain a fenced code block. "
"Ask the AI to provide the complete script again.",
)
+ self._auto_insert_flag = False
@pyqtSlot(str)
def _on_error(self, message: str):
self._set_busy(False)
self._append_chat("error", message)
+ self._auto_insert_flag = False
@pyqtSlot()
def _insert_last_code(self):
@@ -495,7 +591,13 @@ def _clear(self):
def closeEvent(self, event):
"""Ensure the background thread is cleanly stopped before the widget closes."""
- if self._thread and self._thread.isRunning():
+ try:
+ running = self._thread and self._thread.isRunning()
+ except RuntimeError:
+ self._thread = None
+ running = False
+
+ if running:
self._thread.quit()
self._thread.wait(3000) # wait up to 3 s
super().closeEvent(event)
@@ -548,3 +650,9 @@ def menuActions(self) -> dict:
def toolbarActions(self) -> list:
return []
+
+ def saveComponentState(self, store):
+ pass
+
+ def restoreComponentState(self, store):
+ pass
diff --git a/cq_editor/widgets/log.py b/cq_editor/widgets/log.py
index 1e9dd911..247d352a 100644
--- a/cq_editor/widgets/log.py
+++ b/cq_editor/widgets/log.py
@@ -2,7 +2,7 @@
import re
from PyQt5 import QtGui
-from PyQt5.QtCore import QObject, pyqtSignal
+from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import QPlainTextEdit, QAction
from ..mixins import ComponentMixin
@@ -90,6 +90,7 @@ def __init__(self, *args, **kwargs):
# Ensure handler is closed when widget is destroyed
self.destroyed.connect(lambda *_: self.handler.close())
+ @pyqtSlot(str)
def append(self, msg):
"""Append text to the panel with ANSI escape sequences stipped."""
self.moveCursor(QtGui.QTextCursor.End)
diff --git a/cq_editor/widgets/traceback_viewer.py b/cq_editor/widgets/traceback_viewer.py
index b02f378f..a9ad465d 100644
--- a/cq_editor/widgets/traceback_viewer.py
+++ b/cq_editor/widgets/traceback_viewer.py
@@ -1,7 +1,7 @@
from traceback import extract_tb, format_exception_only
from itertools import dropwhile
-from PyQt5.QtWidgets import QWidget, QTreeWidget, QTreeWidgetItem, QAction, QLabel
+from PyQt5.QtWidgets import QWidget, QTreeWidget, QTreeWidgetItem, QAction, QLabel, QHBoxLayout, QPushButton
from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal
from PyQt5.QtGui import QFontMetrics
@@ -30,19 +30,67 @@ def __init__(self, parent):
class TracebackPane(QWidget, ComponentMixin):
sigHighlightLine = pyqtSignal(int)
+ sigAutoFixError = pyqtSignal(str)
def __init__(self, parent):
super(TracebackPane, self).__init__(parent)
+ self.last_exc_info = None
+ self.last_code = None
self.tree = TracebackTree(self)
self.current_exception = QLabel(self)
self.current_exception.setStyleSheet("QLabel {color : red; }")
- layout(self, (self.current_exception, self.tree), self)
+ # Create a horizontal row for the exception message and the Auto-Fix button
+ top_row_widget = QWidget(self)
+ top_row_layout = QHBoxLayout(top_row_widget)
+ top_row_layout.setContentsMargins(0, 0, 0, 0)
+ top_row_layout.setSpacing(4)
+ top_row_widget.setLayout(top_row_layout)
+
+ top_row_layout.addWidget(self.current_exception, stretch=1)
+
+ self.autofix_btn = QPushButton("β¨ Auto-Fix with AI", top_row_widget)
+ self.autofix_btn.setToolTip("Automatically send this error and code to the AI Assistant to fix it")
+ self.autofix_btn.setStyleSheet(
+ "QPushButton { background-color: #7B1FA2; color: white; font-weight: bold; border-radius: 3px; padding: 4px 8px; }"
+ "QPushButton:hover { background-color: #8E24AA; }"
+ "QPushButton:disabled { background-color: #BDBDBD; color: #757575; }"
+ )
+ self.autofix_btn.setEnabled(False)
+ self.autofix_btn.clicked.connect(self.trigger_autofix)
+ top_row_layout.addWidget(self.autofix_btn)
+
+ layout(self, (top_row_widget, self.tree), self)
self.tree.currentItemChanged.connect(self.handleSelection)
+ def trigger_autofix(self):
+ if not self.last_exc_info:
+ return
+
+ t, exc, tb = self.last_exc_info
+ exc_name = t.__name__
+ exc_msg = str(exc)
+
+ tb_list = extract_tb(tb)
+ filtered_tb = list(dropwhile(lambda el: "string>" not in el.filename, tb_list))
+
+ error_context = ""
+ if filtered_tb:
+ last_frame = filtered_tb[-1]
+ error_context = f"at line {last_frame.lineno}: `{last_frame.line}`"
+ else:
+ error_context = "in the script"
+
+ prompt = (
+ f"I encountered a '{exc_name}' error {error_context}.\n"
+ f"Error details: {exc_msg}\n"
+ f"Please identify the issue in the code and provide the complete, corrected CadQuery script."
+ )
+ self.sigAutoFixError.emit(prompt)
+
def truncate_text(self, text, max_length=100):
"""
Used to prevent the label from expanding the window width off the screen.
@@ -58,9 +106,12 @@ def truncate_text(self, text, max_length=100):
def addTraceback(self, exc_info, code):
self.tree.clear()
+ self.last_exc_info = exc_info
+ self.last_code = code
if exc_info:
t, exc, tb = exc_info
+ self.autofix_btn.setEnabled(True)
root = self.tree.root
code = code.splitlines()
@@ -100,6 +151,7 @@ def addTraceback(self, exc_info, code):
else:
self.current_exception.setText("")
self.current_exception.setToolTip("")
+ self.autofix_btn.setEnabled(False)
@pyqtSlot(QTreeWidgetItem, QTreeWidgetItem)
def handleSelection(self, item, *args):
diff --git a/pyproject.toml b/pyproject.toml
index dbf3f5c9..f0dcd979 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,6 +37,10 @@ CQ-editor = "cq_editor.cqe_run:main"
cq-editor = "cq_editor.cqe_run:main"
[project.optional-dependencies]
+ai = [
+ "openai",
+ "keyring"
+]
test = [
"pytest",
"pluggy",
diff --git a/tests/test_ai_chat.py b/tests/test_ai_chat.py
new file mode 100644
index 00000000..4cde3f62
--- /dev/null
+++ b/tests/test_ai_chat.py
@@ -0,0 +1,75 @@
+import pytest
+from PyQt5.QtCore import Qt, QThread
+from PyQt5.QtWidgets import QMessageBox
+from pyqtgraph.parametertree import Parameter
+
+from cq_editor.widgets.ai_chat import AIChatWidget, _keyring_load, _keyring_save
+from cq_editor.__main__ import MainWindow
+
+def test_ai_chat_init(qtbot):
+ """Test that AIChatWidget initializes with correct default preferences."""
+ widget = AIChatWidget()
+ qtbot.addWidget(widget)
+
+ assert widget.preferences is not None
+ assert isinstance(widget.preferences, Parameter)
+ assert widget._pref("Enabled") is False
+ assert widget._pref("Provider / Base URL") == "https://integrate.api.nvidia.com/v1"
+ assert widget._pref("Model") == "meta/llama-3.3-70b-instruct"
+ assert widget._pref("API Key") == ""
+
+def test_keyring_sanitation():
+ """Test that keyring load/save successfully deletes and handles corrupted OrderedDict maps."""
+ # Test saving a corrupted key containing OrderedDict
+ assert _keyring_save("OrderedDict([('tip', 'Your API key.')])") is True
+ # Loading should automatically detect the corruption, delete it, and return None
+ assert _keyring_load() is None
+
+ # Test saving a clean string key
+ assert _keyring_save("my-clean-test-key") is True
+ assert _keyring_load() == "my-clean-test-key"
+
+ # Clean up
+ assert _keyring_save("") is True
+ assert _keyring_load() is None
+
+def test_privacy_consent_dialog(qtbot, mocker):
+ """Test the one-time privacy consent notice triggers correctly and saves selection."""
+ widget = AIChatWidget()
+ qtbot.addWidget(widget)
+
+ # Initially consent is False
+ assert widget._pref("_privacy_consent") is False
+
+ # Mock the QMessageBox to simulate user clicking Yes
+ mocker.patch.object(QMessageBox, "information", return_value=QMessageBox.Yes)
+ assert widget._ensure_privacy_consent() is True
+ assert widget._pref("_privacy_consent") is True
+
+ # Subsequent calls should return True immediately without prompting
+ mocker.patch.object(QMessageBox, "information", side_effect=Exception("Should not prompt"))
+ assert widget._ensure_privacy_consent() is True
+
+def test_auto_fix_error_flow(qtbot, mocker):
+ """Test that auto-fix error triggers the correct chat flow and status updating."""
+ # Mock MainWindow and dependencies
+ win = MainWindow()
+ qtbot.addWidget(win)
+
+ chat = win.components.get("ai_chat")
+ if chat is None:
+ pytest.skip("AI Assistant is not loaded in MainWindow components")
+
+ # Mock the worker execution to prevent thread start
+ mock_run = mocker.patch.object(chat, "_run_worker")
+
+ # Trigger auto-fix
+ test_error = "Standard Failure: edges mismatch on line 12"
+ chat.auto_fix_error(test_error)
+
+ # Should set auto_insert_flag and initiate LLM worker
+ assert chat._auto_insert_flag is True
+ assert mock_run.call_count == 1
+
+ # Clean up
+ chat._auto_insert_flag = False