# Save and load entire scenes (meshes, transforms, animations)
diff --git a/qml/AIChatPanel.qml b/qml/AIChatPanel.qml
new file mode 100644
index 000000000..3a476806c
--- /dev/null
+++ b/qml/AIChatPanel.qml
@@ -0,0 +1,311 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import AIChatPanel 1.0
+import PropertiesPanel 1.0
+
+Rectangle {
+ id: root
+ color: PropertiesPanelController.panelColor
+
+ // Forward focus to the input field whenever the panel gains active focus
+ // (e.g. when the user clicks anywhere in the dock after returning from
+ // another app window).
+ onActiveFocusChanged: {
+ if (activeFocus)
+ Qt.callLater(() => inputField.forceActiveFocus())
+ }
+
+ // ---- Header bar ----
+ Rectangle {
+ id: header
+ anchors { top: parent.top; left: parent.left; right: parent.right }
+ height: 36
+ color: PropertiesPanelController.headerColor
+
+ RowLayout {
+ anchors { fill: parent; leftMargin: 8; rightMargin: 8 }
+ spacing: 6
+
+ Text {
+ text: "AI Chat"
+ color: PropertiesPanelController.textColor
+ font.pixelSize: 13; font.bold: true
+ Layout.fillWidth: true
+ }
+
+ // Model status dot
+ Rectangle {
+ width: 8; height: 8; radius: 4
+ color: AIChatManager.modelAvailable ? "#44dd44" : "#dd4444"
+ }
+
+ Text {
+ text: AIChatManager.modelAvailable
+ ? AIChatManager.currentModelName
+ : "No model"
+ color: PropertiesPanelController.textColor
+ font.pixelSize: 10
+ elide: Text.ElideMiddle
+ Layout.maximumWidth: 160
+ }
+
+ // Clear button
+ Rectangle {
+ width: 22; height: 22; radius: 3
+ color: clearArea.containsMouse
+ ? Qt.lighter(PropertiesPanelController.panelColor, 1.5)
+ : "transparent"
+
+ Text { anchors.centerIn: parent; text: "β"; color: PropertiesPanelController.textColor; font.pixelSize: 11 }
+ MouseArea {
+ id: clearArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: AIChatManager.clearHistory()
+ }
+ }
+ }
+ }
+
+ // ---- Single selectable conversation area ----
+ Flickable {
+ id: msgFlick
+ anchors {
+ top: header.bottom; left: parent.left; right: parent.right
+ bottom: thinkingRow.top; bottomMargin: 0
+ }
+ clip: true
+ contentWidth: width
+ contentHeight: msgEdit.implicitHeight + 16
+
+ function scrollToBottom() {
+ contentY = Math.max(0, contentHeight - height)
+ }
+
+ TextEdit {
+ id: msgEdit
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 8 }
+ readOnly: true
+ selectByMouse: true
+ textFormat: Text.RichText
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+ color: PropertiesPanelController.textColor
+ font.pixelSize: 12
+ selectionColor: Qt.rgba(0.3, 0.5, 0.8, 0.5)
+ selectedTextColor: PropertiesPanelController.textColor
+ text: root.buildHtml()
+
+ // Intercept Ctrl/Cmd+C to copy plain text instead of RichText HTML.
+ Keys.onPressed: (event) => {
+ if ((event.key === Qt.Key_C) &&
+ (event.modifiers & Qt.ControlModifier)) {
+ if (selectedText.length > 0) {
+ Qt.application.clipboard.text = selectedText
+ event.accepted = true
+ }
+ }
+ }
+
+ // When the user finishes selecting (mouse released with no selection),
+ // return focus to the input field. Use onActiveFocusChanged instead of
+ // onSelectedTextChanged to avoid stealing focus mid-drag.
+ onActiveFocusChanged: {
+ if (!activeFocus && selectedText.length === 0)
+ Qt.callLater(() => inputField.forceActiveFocus())
+ }
+ }
+
+ onContentHeightChanged: Qt.callLater(scrollToBottom)
+
+ Connections {
+ target: AIChatManager
+ function onMessagesChanged() { Qt.callLater(msgFlick.scrollToBottom) }
+ function onStreamingTextChanged(){ Qt.callLater(msgFlick.scrollToBottom) }
+ }
+ }
+
+ // ---- Thinking dots (no tokens yet) ----
+ Row {
+ id: thinkingRow
+ anchors { bottom: inputRow.top; left: parent.left; leftMargin: 12; bottomMargin: 6 }
+ height: visible ? 14 : 0
+ spacing: 4
+ visible: AIChatManager.isGenerating
+
+ Repeater {
+ model: 3
+ Rectangle {
+ width: 6; height: 6; radius: 3
+ color: PropertiesPanelController.accentColor
+ opacity: 0.3
+ SequentialAnimation on opacity {
+ loops: Animation.Infinite
+ NumberAnimation { to: 1.0; duration: 400 }
+ NumberAnimation { to: 0.3; duration: 400 }
+ PauseAnimation { duration: index * 150 }
+ }
+ }
+ }
+ }
+
+ // ---- Input row ----
+ Rectangle {
+ id: inputRow
+ anchors { bottom: parent.bottom; left: parent.left; right: parent.right }
+ height: Math.max(40, Math.min(inputField.implicitHeight, 80) + 16)
+ color: PropertiesPanelController.headerColor
+ border.color: PropertiesPanelController.borderColor
+ border.width: 1
+
+ TextArea {
+ id: inputField
+ anchors { left: parent.left; right: sendBtn.left;
+ verticalCenter: parent.verticalCenter;
+ leftMargin: 8; rightMargin: 6 }
+ height: Math.min(implicitHeight, 80)
+ placeholderText: AIChatManager.modelAvailable
+ ? "Ask AI to do somethingβ¦"
+ : "Load an AI model first (AI β AI Model Settings)"
+ color: PropertiesPanelController.textColor
+ background: null
+ font.pixelSize: 12
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+ enabled: AIChatManager.modelAvailable && !AIChatManager.isGenerating
+ focus: true
+
+ Keys.onReturnPressed: (event) => {
+ if (event.modifiers & Qt.ShiftModifier) {
+ event.accepted = false // shift+enter β newline
+ } else {
+ event.accepted = true
+ doSend()
+ }
+ }
+ }
+
+ Rectangle {
+ id: sendBtn
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter; rightMargin: 8 }
+ width: 32; height: 32; radius: 4
+ color: sendBtnArea.containsMouse
+ ? PropertiesPanelController.accentColor
+ : PropertiesPanelController.buttonColor
+ enabled: AIChatManager.modelAvailable
+
+ Text {
+ anchors.centerIn: parent
+ text: AIChatManager.isGenerating ? "β " : "βΆ"
+ color: PropertiesPanelController.textColor
+ font.pixelSize: 13
+ }
+
+ MouseArea {
+ id: sendBtnArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (AIChatManager.isGenerating)
+ AIChatManager.stopGeneration()
+ else
+ doSend()
+ }
+ }
+ }
+ }
+
+ // ---- Empty state hint ----
+ Column {
+ anchors.centerIn: parent
+ anchors.verticalCenterOffset: -30
+ spacing: 8
+ visible: AIChatManager.messages.length === 0 && !AIChatManager.isGenerating
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "β¦"
+ color: PropertiesPanelController.accentColor
+ font.pixelSize: 28
+ }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "Ask me to control the editor"
+ color: PropertiesPanelController.textColor
+ font.pixelSize: 13; opacity: 0.7
+ }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "\"make the selected mesh twice as large\""
+ color: PropertiesPanelController.textColor
+ font.pixelSize: 11; opacity: 0.45; font.italic: true
+ }
+ }
+
+ // ---- Helpers ----
+
+ function doSend() {
+ var txt = inputField.text.trim()
+ if (txt.length > 0) {
+ AIChatManager.sendMessage(txt)
+ inputField.text = ""
+ }
+ }
+
+ function escHtml(t) {
+ return t.replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/\n/g, "
")
+ }
+
+ function buildHtml() {
+ var html = ""
+ var msgs = AIChatManager.messages
+ for (var i = 0; i < msgs.length; ++i) {
+ var msg = msgs[i]
+ var isTool = msg.isTool
+ var role = msg.role
+ var roleColor, roleLabel
+ if (isTool) { roleColor = "#88cc88"; roleLabel = "β tool" }
+ else if (role === "user"){ roleColor = "#88aacc"; roleLabel = "you" }
+ else { roleColor = "#aaaaaa"; roleLabel = "assistant" }
+
+ // Assistant messages may be structured JSON β render appropriately.
+ var displayText = msg.text
+ if (role === "assistant" && !isTool) {
+ var trimmed = msg.text.trim()
+ if (trimmed.startsWith("{")) {
+ try {
+ var obj = JSON.parse(trimmed)
+ if (obj && obj.command)
+ displayText = "[calling " + obj.command + "]"
+ else if (obj && obj.response)
+ displayText = obj.response // final done message
+ else if (obj && obj.name)
+ displayText = "[calling " + obj.name + "]" // legacy fallback
+ } catch(e) {}
+ }
+ }
+
+ if (i > 0) html += "
"
+ if (role === "user") {
+ html += '
you
'
+ html += escHtml(displayText) + '
'
+ } else {
+ html += '
' + roleLabel + ''
+ html += escHtml(displayText) + "
"
+ }
+ }
+
+ // In-progress streaming text
+ if (AIChatManager.streamingText.length > 0) {
+ if (msgs.length > 0) html += "
"
+ html += '
assistant'
+ html += escHtml(AIChatManager.streamingText)
+ }
+
+ return html
+ }
+}
diff --git a/qml/AISettingsDialog.qml b/qml/AISettingsDialog.qml
index eb20d3f19..6053f3958 100644
--- a/qml/AISettingsDialog.qml
+++ b/qml/AISettingsDialog.qml
@@ -1,7 +1,6 @@
import QtQuick 6.0
import QtQuick.Controls 6.0
import QtQuick.Layouts 6.0
-import QtQuick.Dialogs
import MaterialEditorQML 1.0
import "." as Local
@@ -177,6 +176,11 @@ Dialog {
LLMManager.availableModels.length > 0
onClicked: LLMManager.loadModel(modelCombo.currentText)
}
+ Local.ThemedButton {
+ text: "Load from fileβ¦"
+ enabled: !LLMManager.isLoading
+ onClicked: LLMManager.browseForModelFile()
+ }
Local.ThemedButton {
text: "Refresh"
enabled: !LLMManager.isLoading
@@ -185,7 +189,7 @@ Dialog {
Item { Layout.fillWidth: true }
Local.ThemedButton {
text: "Open Models Folder"
- onClicked: Qt.openUrlExternally("file://" + LLMManager.modelsDirectory)
+ onClicked: Qt.openUrlExternally(LLMManager.modelsDirectoryUrl)
}
}
}
@@ -420,7 +424,7 @@ Dialog {
}
Local.ThemedButton {
text: "Browse..."
- onClicked: folderDialog.open()
+ onClicked: LLMManager.browseForModelsDirectory()
}
}
}
@@ -765,16 +769,6 @@ Dialog {
}
}
- // Folder dialog for models directory
- FolderDialog {
- id: folderDialog
- title: "Select Models Directory"
- currentFolder: "file://" + LLMManager.modelsDirectory
- onAccepted: {
- var path = selectedFolder.toString().replace("file://", "")
- LLMManager.modelsDirectory = path
- }
- }
// Connections for download completion
Connections {
diff --git a/src/AIChatManager.cpp b/src/AIChatManager.cpp
new file mode 100644
index 000000000..0b71cc279
--- /dev/null
+++ b/src/AIChatManager.cpp
@@ -0,0 +1,549 @@
+#include "AIChatManager.h"
+#include "LLMManager.h"
+#include "MCPServer.h"
+#include "SentryReporter.h"
+#include
+#include
+#include
+#include
+#include
+
+AIChatManager* AIChatManager::s_instance = nullptr;
+
+AIChatManager* AIChatManager::instance()
+{
+ if (!s_instance)
+ s_instance = new AIChatManager();
+ return s_instance;
+}
+
+AIChatManager* AIChatManager::qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine)
+{
+ Q_UNUSED(engine); Q_UNUSED(scriptEngine);
+ auto* inst = instance();
+ QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership);
+ return inst;
+}
+
+void AIChatManager::kill()
+{
+ delete s_instance;
+ s_instance = nullptr;
+}
+
+AIChatManager::AIChatManager(QObject* parent) : QObject(parent)
+{
+ auto* llm = LLMManager::instance();
+ connect(llm, &LLMManager::generationProgress, this, &AIChatManager::onGenerationProgress);
+ connect(llm, &LLMManager::generationCompleted, this, &AIChatManager::onGenerationCompleted);
+ connect(llm, &LLMManager::generationError, this, &AIChatManager::onGenerationError);
+ connect(llm, &LLMManager::generationStopped, this, &AIChatManager::onGenerationStopped);
+ connect(llm, &LLMManager::modelLoadedChanged, this, &AIChatManager::modelAvailableChanged);
+ connect(llm, &LLMManager::currentModelNameChanged, this, &AIChatManager::currentModelNameChanged);
+}
+
+bool AIChatManager::modelAvailable() const
+{
+ return LLMManager::instance()->isModelLoaded();
+}
+
+QString AIChatManager::currentModelName() const
+{
+ return LLMManager::instance()->currentModelName();
+}
+
+// ---- public slots ----
+
+void AIChatManager::sendMessage(const QString& text)
+{
+ if (text.trimmed().isEmpty() || m_isGenerating)
+ return;
+
+ SentryReporter::addBreadcrumb("ui.action", "AI Chat: user message");
+
+ m_toolLoopDepth = 0;
+ m_lastToolSignatures.clear();
+ appendMessage("user", text.trimmed());
+ startGeneration(buildSystemPrompt(), buildConversationPrompt());
+}
+
+void AIChatManager::clearHistory()
+{
+ if (m_isGenerating)
+ LLMManager::instance()->stopGeneration();
+ m_messages.clear();
+ m_streamingText.clear();
+ m_toolLoopDepth = 0;
+ m_jsonRetryCount = 0;
+ m_lastToolSignatures.clear();
+ emit messagesChanged();
+ emit streamingTextChanged();
+}
+
+void AIChatManager::stopGeneration()
+{
+ m_stopRequested = true;
+ LLMManager::instance()->stopGeneration();
+}
+
+// ---- helpers ----
+
+// Strip chat-template tokens that some models echo back (<|assistant|> etc.)
+// and truncate at the first hallucinated "User:" continuation.
+static QString cleanGeneratedText(const QString& raw)
+{
+ QString text = raw;
+
+ // Remove <|...|> special tokens (Phi-3, Llama-3, Mistral, etc.)
+ text.remove(QRegularExpression("<\\|[^|>]+\\|>"));
+
+ // Truncate at the first point where the model starts hallucinating the next
+ // user turn β "User:" or "Human:" at the start of a line.
+ static const QRegularExpression nextUserRe(
+ R"(\n(?:User|Human)\s*:)", QRegularExpression::CaseInsensitiveOption);
+ int pos = nextUserRe.match(text).capturedStart();
+ if (pos >= 0)
+ text = text.left(pos);
+
+ // Truncate if the model hallucinates a "RESULT:" continuation (history format)
+ static const QRegularExpression resultRe(
+ R"(\nRESULT\s*:)", QRegularExpression::CaseInsensitiveOption);
+ int rpos = resultRe.match(text).capturedStart();
+ if (rpos >= 0)
+ text = text.left(rpos);
+
+ // Truncate at "" or "EXAMPLE (" β model echoing its own examples
+ static const QRegularExpression afterResultRe(
+ R"(|EXAMPLE\s*\()", QRegularExpression::CaseInsensitiveOption);
+ int apos = afterResultRe.match(text).capturedStart();
+ if (apos >= 0)
+ text = text.left(apos);
+
+ // Strip trailing pipe characters β Qwen and some other models emit "|" as a
+ // stop-token artifact at the end of their output.
+ while (text.endsWith('|') || text.endsWith(" |"))
+ text = text.left(text.lastIndexOf('|')).trimmed();
+
+ return text.trimmed();
+}
+
+// ---- generation callbacks ----
+
+void AIChatManager::onGenerationProgress(const QString& partial, float /*progress*/)
+{
+ m_streamingText = cleanGeneratedText(partial);
+ emit streamingTextChanged();
+}
+
+void AIChatManager::onGenerationCompleted(const QString& fullText)
+{
+ m_streamingText.clear();
+ emit streamingTextChanged();
+
+ executeToolCallsAndContinue(cleanGeneratedText(fullText));
+}
+
+void AIChatManager::onGenerationError(const QString& error)
+{
+ m_streamingText.clear();
+ m_isGenerating = false;
+ emit streamingTextChanged();
+ emit isGeneratingChanged();
+ appendMessage("assistant", QString("Error: %1").arg(error));
+}
+
+void AIChatManager::onGenerationStopped()
+{
+ if (!m_streamingText.isEmpty()) {
+ appendMessage("assistant", m_streamingText);
+ m_streamingText.clear();
+ emit streamingTextChanged();
+ }
+ m_stopRequested = false;
+ m_isGenerating = false;
+ m_toolLoopDepth = 0;
+ m_jsonRetryCount = 0;
+ m_lastToolSignatures.clear();
+ emit isGeneratingChanged();
+}
+
+// ---- private helpers ----
+
+void AIChatManager::appendMessage(const QString& role, const QString& text, bool isTool)
+{
+ QVariantMap msg;
+ msg["role"] = role;
+ msg["text"] = text;
+ msg["isTool"] = isTool;
+ m_messages.append(msg);
+ emit messagesChanged();
+}
+
+void AIChatManager::startGeneration(const QString& sysPrompt, const QString& userPrompt)
+{
+ if (m_stopRequested) {
+ m_stopRequested = false;
+ m_isGenerating = false;
+ m_toolLoopDepth = 0;
+ emit isGeneratingChanged();
+ return;
+ }
+ m_isGenerating = true;
+ emit isGeneratingChanged();
+ // Structured JSON envelope is ~100β150 tokens; allow 500 for generous headroom.
+ // Multi-call stuffing is no longer a risk since we parse a single top-level object.
+ LLMManager::instance()->generateText(sysPrompt, userPrompt, 500);
+}
+
+void AIChatManager::executeToolCallsAndContinue(const QString& assistantText)
+{
+ // ---- Parse structured JSON response ----
+ // The conversation prompt ends with "Assistant: {" so the model may start with
+ // "thought":... (missing the opening brace) or with text + {"thought":...}.
+ // Strategy: try multiple extraction methods to find a valid structured object.
+
+ auto tryParseStructured = [](const QString& json) -> QPair {
+ QJsonParseError err;
+ QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8(), &err);
+ if (err.error == QJsonParseError::NoError && doc.isObject()) {
+ QJsonObject obj = doc.object();
+ if (obj.contains("command") || obj.contains("response"))
+ return {true, doc};
+ }
+ return {false, {}};
+ };
+
+ // Helper: extract the first balanced {...} substring from text
+ auto extractFirstJsonBlock = [](const QString& text, int searchFrom = 0) -> QString {
+ int start = text.indexOf('{', searchFrom);
+ if (start < 0) return {};
+ int depth = 0;
+ for (int i = start; i < text.length(); ++i) {
+ if (text[i] == '{') ++depth;
+ else if (text[i] == '}') {
+ if (--depth == 0) return text.mid(start, i - start + 1);
+ }
+ }
+ return {};
+ };
+
+ QJsonDocument responseDoc;
+ bool isStructured = false;
+
+ // Method 1: parse the whole output directly (model included the opening {)
+ auto [ok1, doc1] = tryParseStructured(assistantText.trimmed());
+ if (ok1) { isStructured = true; responseDoc = doc1; }
+
+ // Method 2: prepend { (model output continues from our "Assistant: {" primer)
+ if (!isStructured) {
+ auto [ok2, doc2] = tryParseStructured('{' + assistantText.trimmed());
+ if (ok2) { isStructured = true; responseDoc = doc2; }
+ }
+
+ // Method 3: extract the first {...} block from anywhere in the output
+ // (model may have outputted text before or after the JSON)
+ if (!isStructured) {
+ QString block = extractFirstJsonBlock(assistantText);
+ if (!block.isEmpty()) {
+ auto [ok3, doc3] = tryParseStructured(block);
+ if (ok3) { isStructured = true; responseDoc = doc3; }
+ }
+ }
+
+ // Method 4: prepend { and extract (missing opening brace + trailing text)
+ if (!isStructured) {
+ QString withBrace = '{' + assistantText.trimmed();
+ QString block = extractFirstJsonBlock(withBrace);
+ if (!block.isEmpty()) {
+ auto [ok4, doc4] = tryParseStructured(block);
+ if (ok4) { isStructured = true; responseDoc = doc4; }
+ }
+ }
+
+ if (!isStructured) {
+ ++m_jsonRetryCount;
+ if (m_jsonRetryCount >= kMaxJsonRetries) {
+ appendMessage("assistant",
+ "I had trouble generating a valid response. Please try again.");
+ m_isGenerating = false;
+ m_toolLoopDepth = 0;
+ m_jsonRetryCount = 0;
+ m_lastToolSignatures.clear();
+ emit isGeneratingChanged();
+ } else {
+ startGeneration(buildSystemPrompt(), buildConversationPrompt());
+ }
+ return;
+ }
+ m_jsonRetryCount = 0;
+
+ QJsonObject resp = responseDoc.object();
+ QString command = resp["command"].toString(); // empty string if null/absent
+ QJsonObject toolArgs = resp["arguments"].toObject();
+ QString userResponse = resp["response"].toString();
+ bool hasCommand = !resp["command"].isNull() && !command.isEmpty();
+
+ // ---- No command β task is done ----
+ if (!hasCommand || m_toolLoopDepth >= kMaxToolLoops) {
+ QString doneText = userResponse.isEmpty() ? "Done." : userResponse;
+ appendMessage("assistant", doneText);
+ m_isGenerating = false;
+ m_toolLoopDepth = 0;
+ m_jsonRetryCount = 0;
+ m_lastToolSignatures.clear();
+ emit isGeneratingChanged();
+ return;
+ }
+
+ // ---- Loop detection ----
+ QString sig = QString::fromUtf8(QJsonDocument(QJsonObject{
+ {"command", command}, {"arguments", toolArgs}
+ }).toJson(QJsonDocument::Compact));
+ QStringList currentSigs = {sig};
+
+ if (!m_lastToolSignatures.isEmpty() && m_lastToolSignatures == currentSigs) {
+ appendMessage("assistant", "Done.");
+ m_isGenerating = false;
+ m_toolLoopDepth = 0;
+ m_jsonRetryCount = 0;
+ m_lastToolSignatures.clear();
+ emit isGeneratingChanged();
+ return;
+ }
+ m_lastToolSignatures = currentSigs;
+
+ // Store compact JSON in history so the model sees its own format in context.
+ // The UI renders messages with a "command" key as "[calling X]".
+ appendMessage("assistant",
+ QString::fromUtf8(responseDoc.toJson(QJsonDocument::Compact)));
+
+ // ---- Execute the tool ----
+ bool anyToolError = false;
+ if (m_mcpServer) {
+ SentryReporter::addBreadcrumb("ai.tool_call", command);
+ QJsonObject result = m_mcpServer->callTool(command, toolArgs);
+
+ QString resultText;
+ QJsonArray content = result["content"].toArray();
+ if (!content.isEmpty())
+ resultText = content.first().toObject()["text"].toString();
+ else
+ resultText = QJsonDocument(result).toJson(QJsonDocument::Compact);
+
+ if (result["isError"].toBool() || resultText.contains("Error:"))
+ anyToolError = true;
+
+ // Truncate result for history β first line is enough context for the model.
+ QString historyText = resultText.trimmed();
+ int nlPos = historyText.indexOf('\n');
+ if (nlPos > 0 && historyText.length() > 120)
+ historyText = historyText.left(nlPos).trimmed();
+
+ appendMessage("tool", QString("[Tool: %1]\n%2").arg(command, historyText), true);
+ }
+
+ ++m_toolLoopDepth;
+
+ // If the model declared remaining=[] (no more steps), force done now.
+ // This prevents the 3B model from replaying the system-prompt example
+ // (e.g. always re-creating a wooden box after every single tool call).
+ QJsonArray remaining = resp["remaining"].toArray();
+ bool noMoreSteps = remaining.isEmpty() && !anyToolError;
+
+ if (noMoreSteps || m_toolLoopDepth >= kMaxToolLoops) {
+ // Task complete (or hard limit).
+ appendMessage("assistant", userResponse.isEmpty() ? "Done." : userResponse);
+ m_isGenerating = false;
+ m_toolLoopDepth = 0;
+ m_jsonRetryCount = 0;
+ m_lastToolSignatures.clear();
+ emit isGeneratingChanged();
+ } else if (anyToolError && m_toolLoopDepth >= 2) {
+ // Two rounds of errors β give up and surface the problem.
+ appendMessage("assistant",
+ "I wasn't able to complete that action. The required resource may not exist. "
+ "Please check that the material/mesh/texture name is correct and try again.");
+ m_isGenerating = false;
+ m_toolLoopDepth = 0;
+ m_jsonRetryCount = 0;
+ m_lastToolSignatures.clear();
+ emit isGeneratingChanged();
+ } else {
+ // Continue β tool succeeded (or first error, allow recovery).
+ startGeneration(buildSystemPrompt(), buildConversationPrompt());
+ }
+}
+
+QString AIChatManager::buildSystemPrompt() const
+{
+ // Only expose the core tool subset to the AI chat β 39 tools overwhelm a 3B model.
+ // The full set is still available via MCP/HTTP for external clients.
+ static const QStringList chatTools = {
+ "create_primitive", "create_material", "modify_material", "apply_material",
+ "transform_mesh", "delete_entity", "get_scene_info", "list_materials",
+ "load_mesh", "export_mesh", "list_textures", "set_texture",
+ "take_screenshot", "list_files", "search_files", "read_file",
+ "camera_control", "get_camera_info"
+ };
+
+ QString tools;
+ if (m_mcpServer) {
+ QJsonArray toolList = m_mcpServer->buildToolsList();
+ for (const QJsonValue& tv : toolList) {
+ QJsonObject t = tv.toObject();
+ QString name = t["name"].toString();
+ if (!chatTools.contains(name))
+ continue; // skip tools not in the AI chat subset
+ QString desc = t["description"].toString();
+ QJsonObject schema = t["inputSchema"].toObject();
+ QJsonObject props = schema["properties"].toObject();
+ QStringList paramLines;
+ for (auto pit = props.begin(); pit != props.end(); ++pit) {
+ QJsonObject pdef = pit.value().toObject();
+ QString pdesc = pdef["description"].toString();
+ paramLines << QString(" %1: %2").arg(pit.key(), pdesc);
+ }
+ tools += QString("- %1: %2\n").arg(name, desc);
+ if (!paramLines.isEmpty())
+ tools += paramLines.join("\n") + "\n";
+ }
+ }
+
+ // Inject current scene + material state so the model can act without a discovery round.
+ QString sceneSection;
+ if (m_mcpServer) {
+ // Objects and their current materials
+ auto extractText = [](const QJsonObject& result) -> QString {
+ QJsonArray content = result["content"].toArray();
+ if (!content.isEmpty())
+ return content.first().toObject()["text"].toString().trimmed();
+ return {};
+ };
+ QString sceneInfo = extractText(m_mcpServer->callTool("get_scene_info", {}));
+ QString matRaw = extractText(m_mcpServer->callTool("list_materials", {}));
+ // Filter material list: skip built-in Ogre materials (BaseWhite, Ogre/*, etc.)
+ // and keep only short user-created names for the scene context.
+ QStringList userMats;
+ static const QStringList sysMatPrefixes = {
+ "Available", "BaseWhite", "Ogre/", "RTSS/", "SdkTrays/",
+ "Debug", "Default", "GUI_", "NormalVisualizer", "BoneWeight",
+ "MeshInfo", "SelectionBox", "Procedural/", "Axes/"
+ };
+ for (const QString& line : matRaw.split('\n')) {
+ QString m = line.trimmed();
+ if (m.isEmpty()) continue;
+ bool isSystem = false;
+ for (const QString& prefix : sysMatPrefixes)
+ if (m.startsWith(prefix)) { isSystem = true; break; }
+ if (!isSystem) userMats << m;
+ }
+ if (userMats.size() > 20) userMats = userMats.mid(0, 20); // cap for prompt size
+ // Recent files from QSettings β include full paths so the model can pass them to load_mesh
+ QSettings settings;
+ QStringList recentFiles = settings.value("RecentFiles/files").toStringList();
+ QStringList recentEntries;
+ for (const QString& path : recentFiles) {
+ QFileInfo fi(path);
+ if (fi.exists())
+ recentEntries << path; // full absolute path
+ }
+ if (recentEntries.size() > 10) recentEntries = recentEntries.mid(0, 10);
+
+ if (!sceneInfo.isEmpty() || !userMats.isEmpty() || !recentEntries.isEmpty()) {
+ sceneSection = "Current scene state:\n";
+ if (!sceneInfo.isEmpty()) sceneSection += sceneInfo + "\n";
+ if (!userMats.isEmpty()) sceneSection += "Available materials: " + userMats.join(", ") + "\n";
+ if (!recentEntries.isEmpty()) {
+ sceneSection += "Recent files (use with load_mesh):\n";
+ for (const QString& path : recentEntries)
+ sceneSection += " " + path + "\n";
+ }
+ sceneSection += "\n";
+ }
+ }
+
+ // Static part first (header + instructions + tool list) so KV cache prefix
+ // stays valid across calls β scene section is dynamic and goes at the end.
+ return QString(
+ "You are an AI assistant controlling QtMeshEditor, a 3D mesh editor.\n\n"
+ "RESPONSE FORMAT β you MUST always reply with a single valid JSON object:\n"
+ "{\n"
+ " \"thought\": \"one sentence, max 10 words\",\n"
+ " \"command\": \"tool_name or null\",\n"
+ " \"arguments\": {\"param\": \"value\"},\n"
+ " \"remaining\": [\"next_step\", \"after_that\"],\n"
+ " \"response\": \"shown to user β only set when command is null\"\n"
+ "}\n\n"
+ "Field rules:\n"
+ "- \"command\": tool to run now, or null when the whole task is finished.\n"
+ "- \"arguments\": params for the command ({} when command is null).\n"
+ "- \"remaining\": tool names you still need to call AFTER this one.\n"
+ "- \"response\": final user-facing message; omit or set null while commands remain.\n\n"
+ "CRITICAL RULES:\n"
+ "1. Output valid JSON only. No text outside the JSON object. One command per response.\n"
+ "2. Check RESULT messages: 'Created X' or 'Applied' means that step is DONE β advance.\n"
+ " Never call the same command twice for the same object.\n"
+ "3. transform_mesh requires \"name\" = exact node name (use get_scene_info if unsure).\n"
+ " Never call create_primitive to recover from a transform error.\n"
+ " POSITION EXAMPLES from origin [0,0,0]:\n"
+ " 'move left 2' β position: [-2, 0, 0]\n"
+ " 'move right 2' β position: [2, 0, 0]\n"
+ " 'move up 3' β position: [0, 3, 0] β Y is up/down\n"
+ " 'on the floor' β position: [0, 0, 0] β Y=0 is ground\n"
+ " 'move forward 1'β position: [0, 0, -1] β Z- is forward/front\n"
+ " 'move back 1' β position: [0, 0, 1] β Z+ is back\n"
+ " UP/DOWN/FLOOR = always Y axis. NEVER use Z for vertical movement.\n"
+ " For relative moves: call get_scene_info first to read current position.\n"
+ "4. Each user message is a SEPARATE task. ONLY do what that message asks.\n"
+ " Do NOT repeat actions from previous messages (e.g. do not re-apply old materials).\n"
+ "5. Use simple names for create_primitive: 'box', 'sphere', 'cylinder', 'cone', 'plane'.\n"
+ " ONLY create what the user asked for. ONE primitive per request. Never create extras.\n"
+ "6. When remaining is empty [], your next response MUST have command=null with a response.\n"
+ " After the last step succeeds, the task is DONE. Do NOT add extra steps.\n"
+ "7. Never use a material not in 'Available materials'. Call create_material first.\n"
+ "8. Never invent tool names or parameter names. Only use tools listed below.\n\n"
+ "Available tools:\n%1\n"
+ "%2"
+ ).arg(tools, sceneSection);
+}
+
+QString AIChatManager::buildConversationPrompt(int maxHistory) const
+{
+ // Caller can pass an explicit window; 0 = use default based on loop depth.
+ if (maxHistory == 0)
+ maxHistory = (m_toolLoopDepth >= kMaxToolLoops - 1) ? 4 : 8;
+ int start = qMax(0, m_messages.size() - maxHistory);
+
+ auto formatMsg = [](const QVariantMap& m) -> QString {
+ QString role = m["role"].toString();
+ QString text = m["text"].toString();
+ bool isTool = m["isTool"].toBool();
+ if (role == "user")
+ return "User: " + text + "\n";
+ if (role == "tool" || isTool)
+ return "RESULT: " + text + "\n";
+ return "Assistant: " + text + "\n";
+ };
+
+ QString conv;
+
+ // Always include the first user message so the model knows the original request,
+ // even if it has scrolled out of the sliding history window.
+ if (start > 0) {
+ for (int i = 0; i < m_messages.size(); ++i) {
+ QVariantMap m = m_messages[i].toMap();
+ if (m["role"].toString() == "user") {
+ conv += formatMsg(m);
+ conv += "...\n"; // indicate intervening history was omitted
+ break;
+ }
+ }
+ }
+
+ for (int i = start; i < m_messages.size(); ++i)
+ conv += formatMsg(m_messages[i].toMap());
+
+ // Prime with "{" so the model continues in structured-JSON mode.
+ conv += "Assistant: {";
+ return conv;
+}
diff --git a/src/AIChatManager.h b/src/AIChatManager.h
new file mode 100644
index 000000000..e1fb7a8d0
--- /dev/null
+++ b/src/AIChatManager.h
@@ -0,0 +1,81 @@
+#ifndef AICHATMANAGER_H
+#define AICHATMANAGER_H
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+class MCPServer;
+
+class AIChatManager : public QObject
+{
+ Q_OBJECT
+ QML_ELEMENT
+ QML_SINGLETON
+
+ Q_PROPERTY(QVariantList messages READ messages NOTIFY messagesChanged)
+ Q_PROPERTY(bool isGenerating READ isGenerating NOTIFY isGeneratingChanged)
+ Q_PROPERTY(bool modelAvailable READ modelAvailable NOTIFY modelAvailableChanged)
+ Q_PROPERTY(QString streamingText READ streamingText NOTIFY streamingTextChanged)
+ Q_PROPERTY(QString currentModelName READ currentModelName NOTIFY currentModelNameChanged)
+
+public:
+ static AIChatManager* instance();
+ static AIChatManager* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine);
+ static void kill();
+
+ QVariantList messages() const { return m_messages; }
+ bool isGenerating() const { return m_isGenerating; }
+ bool modelAvailable() const;
+ QString streamingText() const { return m_streamingText; }
+ QString currentModelName() const;
+
+ Q_INVOKABLE void sendMessage(const QString& text);
+ Q_INVOKABLE void clearHistory();
+ Q_INVOKABLE void stopGeneration();
+
+ // Called by MainWindow after MCPServer is created
+ void setMcpServer(MCPServer* server) { m_mcpServer = server; }
+
+signals:
+ void messagesChanged();
+ void isGeneratingChanged();
+ void modelAvailableChanged();
+ void streamingTextChanged();
+ void currentModelNameChanged();
+
+private slots:
+ void onGenerationProgress(const QString& partial, float progress);
+ void onGenerationCompleted(const QString& fullText);
+ void onGenerationError(const QString& error);
+ void onGenerationStopped();
+
+private:
+ explicit AIChatManager(QObject* parent = nullptr);
+
+ void appendMessage(const QString& role, const QString& text, bool isTool = false);
+ void executeToolCallsAndContinue(const QString& assistantText);
+ QString buildSystemPrompt() const;
+ QString buildConversationPrompt(int maxHistory = 0) const;
+ void startGeneration(const QString& sysPrompt, const QString& userPrompt);
+
+ static AIChatManager* s_instance;
+
+ QVariantList m_messages; // {role, text, isTool}
+ bool m_isGenerating = false;
+ bool m_stopRequested = false; // set by stopGeneration(), checked before follow-up starts
+ QString m_streamingText;
+ QPointer m_mcpServer;
+
+ // Agentic loop state
+ int m_toolLoopDepth = 0;
+ int m_jsonRetryCount = 0;
+ static const int kMaxToolLoops = 10;
+ static const int kMaxJsonRetries = 2; // retry if model outputs malformed JSON
+ QStringList m_lastToolSignatures; // compact JSON of tool calls from previous round
+};
+
+#endif // AICHATMANAGER_H
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 4b1a9438d..3af7de8ef 100755
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -57,6 +57,7 @@ BatchExporter.cpp
MaterialPresetLibrary.cpp
MeshLodController.cpp
MeshValidator.cpp
+AIChatManager.cpp
)
set(HEADER_FILES
@@ -117,6 +118,7 @@ BatchExporter.h
MaterialPresetLibrary.h
MeshLodController.h
MeshValidator.h
+AIChatManager.h
)
set(TEST_SOURCES "")
diff --git a/src/LLMManager.cpp b/src/LLMManager.cpp
index efb72e60f..5270809c6 100644
--- a/src/LLMManager.cpp
+++ b/src/LLMManager.cpp
@@ -3,7 +3,9 @@
#include
#include
#include
+#include
#include
+#include
#include
LLMManager* LLMManager::s_instance = nullptr;
@@ -94,8 +96,8 @@ void LLMManager::populateRecommendedModels()
m_recommendedModels.clear();
// Recommended GGUF models from Hugging Face - ordered by size (smallest first)
+ // Curated to avoid near-duplicates (no Coder variants or older Gemma 2)
- // Gemma 3 models (Google's latest)
m_recommendedModels.append({
"Gemma 3 1B Q4_K_M",
"gemma-3-1b-it-Q4_K_M.gguf",
@@ -105,15 +107,6 @@ void LLMManager::populateRecommendedModels()
false
});
- m_recommendedModels.append({
- "Gemma 2 2B Q4_K_M",
- "gemma-2-2b-it-Q4_K_M.gguf",
- "https://huggingface.co/bartowski/gemma-2-2b-it-GGUF/resolve/main/gemma-2-2b-it-Q4_K_M.gguf",
- "Google's Gemma 2 2B. Fast and efficient.",
- 1800000000, // ~1.8GB
- false
- });
-
m_recommendedModels.append({
"Llama 3.2 3B Q4_K_M",
"Llama-3.2-3B-Instruct-Q4_K_M.gguf",
@@ -127,29 +120,11 @@ void LLMManager::populateRecommendedModels()
"Qwen 2.5 3B Q4_K_M",
"qwen2.5-3b-instruct-q4_k_m.gguf",
"https://huggingface.co/Qwen/Qwen2.5-3B-Instruct-GGUF/resolve/main/qwen2.5-3b-instruct-q4_k_m.gguf",
- "Alibaba's Qwen 2.5 3B. Great for code generation.",
- 2100000000, // ~2.1GB
- false
- });
-
- m_recommendedModels.append({
- "Qwen 2.5 Coder 3B Q4_K_M",
- "qwen2.5-coder-3b-instruct-q4_k_m.gguf",
- "https://huggingface.co/Qwen/Qwen2.5-Coder-3B-Instruct-GGUF/resolve/main/qwen2.5-coder-3b-instruct-q4_k_m.gguf",
- "Qwen 2.5 Coder 3B. Specialized for code tasks.",
+ "Alibaba's Qwen 2.5 3B. Great for structured output.",
2100000000, // ~2.1GB
false
});
- m_recommendedModels.append({
- "Phi-3.5 Mini Q4_K_M",
- "Phi-3.5-mini-instruct-Q4_K_M.gguf",
- "https://huggingface.co/bartowski/Phi-3.5-mini-instruct-GGUF/resolve/main/Phi-3.5-mini-instruct-Q4_K_M.gguf",
- "Microsoft's Phi-3.5 Mini. Compact and capable.",
- 2400000000, // ~2.4GB
- false
- });
-
m_recommendedModels.append({
"Gemma 3 4B Q4_K_M",
"gemma-3-4b-it-Q4_K_M.gguf",
@@ -163,16 +138,7 @@ void LLMManager::populateRecommendedModels()
"Qwen 2.5 7B Q4_K_M",
"qwen2.5-7b-instruct-q4_k_m.gguf",
"https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/main/qwen2.5-7b-instruct-q4_k_m.gguf",
- "Qwen 2.5 7B. Higher quality, requires more VRAM.",
- 4700000000, // ~4.7GB
- false
- });
-
- m_recommendedModels.append({
- "Qwen 2.5 Coder 7B Q4_K_M",
- "qwen2.5-coder-7b-instruct-q4_k_m.gguf",
- "https://huggingface.co/Qwen/Qwen2.5-Coder-7B-Instruct-GGUF/resolve/main/qwen2.5-coder-7b-instruct-q4_k_m.gguf",
- "Qwen 2.5 Coder 7B. Best for code generation.",
+ "Qwen 2.5 7B. Strong instruction following and tool use.",
4700000000, // ~4.7GB
false
});
@@ -181,10 +147,37 @@ void LLMManager::populateRecommendedModels()
"Gemma 3 12B Q4_K_M",
"gemma-3-12b-it-Q4_K_M.gguf",
"https://huggingface.co/bartowski/google_gemma-3-12b-it-GGUF/resolve/main/google_gemma-3-12b-it-Q4_K_M.gguf",
- "Google's Gemma 3 12B. High quality, needs 8GB+ VRAM.",
+ "Google's Gemma 3 12B. High quality, needs 8GB+ RAM.",
8100000000, // ~8.1GB
false
});
+
+ m_recommendedModels.append({
+ "Qwen 2.5 14B Q4_K_M",
+ "qwen2.5-14b-instruct-q4_k_m.gguf",
+ "https://huggingface.co/Qwen/Qwen2.5-14B-Instruct-GGUF/resolve/main/qwen2.5-14b-instruct-q4_k_m.gguf",
+ "Qwen 2.5 14B. Very capable, excellent reasoning.",
+ 9400000000, // ~9.4GB
+ false
+ });
+
+ m_recommendedModels.append({
+ "Gemma 3 27B Q4_K_M",
+ "gemma-3-27b-it-Q4_K_M.gguf",
+ "https://huggingface.co/bartowski/google_gemma-3-27b-it-GGUF/resolve/main/google_gemma-3-27b-it-Q4_K_M.gguf",
+ "Google's Gemma 3 27B. Excellent quality, needs 16GB+ RAM.",
+ 17000000000, // ~17GB
+ false
+ });
+
+ m_recommendedModels.append({
+ "Qwen 2.5 32B Q4_K_M",
+ "qwen2.5-32b-instruct-q4_k_m.gguf",
+ "https://huggingface.co/Qwen/Qwen2.5-32B-Instruct-GGUF/resolve/main/qwen2.5-32b-instruct-q4_k_m.gguf",
+ "Qwen 2.5 32B. Near top-tier quality, needs 20GB+ RAM.",
+ 20000000000, // ~20GB
+ false
+ });
}
bool LLMManager::isModelLoaded() const
@@ -379,6 +372,55 @@ void LLMManager::tryAutoLoadModel()
// LCOV_EXCL_STOP
}
+void LLMManager::loadModelFromPath(const QString &filePath)
+{
+ if (filePath.isEmpty() || !QFileInfo::exists(filePath)) {
+ emit modelLoadError(QString("File not found: %1").arg(filePath));
+ return;
+ }
+
+ // LCOV_EXCL_START β requires a real GGUF model file
+ QFileInfo info(filePath);
+ m_currentModelName = info.completeBaseName();
+ m_isLoading = true;
+ emit isLoadingChanged();
+ emit modelLoadStarted(m_currentModelName);
+
+ QMetaObject::invokeMethod(m_worker, [this, filePath]() {
+ m_worker->setSettings(m_settings);
+ m_worker->loadModel(filePath);
+ }, Qt::QueuedConnection);
+ // LCOV_EXCL_STOP
+}
+
+void LLMManager::setModelsDirectoryFromUrl(const QUrl &url)
+{
+ setModelsDirectory(url.toLocalFile());
+}
+
+void LLMManager::browseForModelsDirectory()
+{
+ QString dir = QFileDialog::getExistingDirectory(
+ nullptr,
+ "Select Models Directory",
+ m_modelsDirectory
+ );
+ if (!dir.isEmpty())
+ setModelsDirectory(dir);
+}
+
+void LLMManager::browseForModelFile()
+{
+ QString file = QFileDialog::getOpenFileName(
+ nullptr,
+ "Load AI Model",
+ m_modelsDirectory,
+ "GGUF models (*.gguf);;Binary models (*.bin);;All files (*)"
+ );
+ if (!file.isEmpty())
+ loadModelFromPath(file);
+}
+
void LLMManager::unloadModel()
{
if (m_worker) {
@@ -402,7 +444,7 @@ void LLMManager::scanForModels()
QFileInfoList files = modelsDir.entryInfoList(filters, QDir::Files);
for (const QFileInfo &file : files) {
- m_availableModels.append(file.baseName());
+ m_availableModels.append(file.completeBaseName());
}
// Update recommended models download status
@@ -415,6 +457,18 @@ void LLMManager::scanForModels()
emit availableModelsChanged();
}
+void LLMManager::generateText(const QString &systemPrompt, const QString &userPrompt, int maxTokensOverride)
+{
+ if (!isModelLoaded()) {
+ emit generationError("No model loaded. Please load a model first.");
+ return;
+ }
+ m_rawTextMode = true;
+ QMetaObject::invokeMethod(m_worker, [this, systemPrompt, userPrompt, maxTokensOverride]() {
+ m_worker->generate(systemPrompt, userPrompt, maxTokensOverride);
+ }, Qt::QueuedConnection);
+}
+
void LLMManager::generateMaterial(const QString &prompt, const QString ¤tMaterial, const QStringList &availableTextures)
{
if (!isModelLoaded()) {
@@ -661,6 +715,15 @@ void LLMManager::onWorkerGenerationProgress(const QString &partialText, float pr
void LLMManager::onWorkerGenerationCompleted(const QString &fullText)
{
+ // Raw text mode (e.g. AI Chat) β skip material cleanup/validation entirely
+ if (m_rawTextMode) {
+ m_rawTextMode = false;
+ m_retryCount = 0;
+ emit generationCompleted(fullText);
+ emit isGeneratingChanged();
+ return;
+ }
+
// Clean up the generated script
QString cleanedScript = cleanupGeneratedScript(fullText);
@@ -703,12 +766,14 @@ void LLMManager::onWorkerGenerationCompleted(const QString &fullText)
void LLMManager::onWorkerGenerationError(const QString &error)
{
+ m_rawTextMode = false;
emit generationError(error);
emit isGeneratingChanged();
}
void LLMManager::onWorkerGenerationStopped()
{
+ m_rawTextMode = false;
m_retryCount = 0;
emit generationStopped();
emit isGeneratingChanged();
diff --git a/src/LLMManager.h b/src/LLMManager.h
index 28795c76f..f3b983592 100644
--- a/src/LLMManager.h
+++ b/src/LLMManager.h
@@ -7,6 +7,7 @@
#include
#include
#include
+#include
#include
#include
#include "LLMWorker.h"
@@ -43,6 +44,7 @@ class LLMManager : public QObject
Q_PROPERTY(bool isLoading READ isLoading NOTIFY isLoadingChanged)
Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged)
Q_PROPERTY(QString modelsDirectory READ modelsDirectory WRITE setModelsDirectory NOTIFY modelsDirectoryChanged)
+ Q_PROPERTY(QUrl modelsDirectoryUrl READ modelsDirectoryUrl NOTIFY modelsDirectoryChanged)
Q_PROPERTY(QVariantList recommendedModels READ getRecommendedModelsInfo NOTIFY availableModelsChanged)
// Settings properties for QML binding
@@ -66,6 +68,7 @@ class LLMManager : public QObject
// Settings
QString modelsDirectory() const { return m_modelsDirectory; }
+ QUrl modelsDirectoryUrl() const { return QUrl::fromLocalFile(m_modelsDirectory); }
void setModelsDirectory(const QString &dir);
LLMSettings getSettings() const;
void setSettings(const LLMSettings &settings);
@@ -92,12 +95,17 @@ class LLMManager : public QObject
public slots:
// Model operations
Q_INVOKABLE void loadModel(const QString &modelName);
+ Q_INVOKABLE void loadModelFromPath(const QString &filePath);
+ Q_INVOKABLE void setModelsDirectoryFromUrl(const QUrl &url);
+ Q_INVOKABLE void browseForModelsDirectory();
+ Q_INVOKABLE void browseForModelFile();
Q_INVOKABLE void unloadModel();
Q_INVOKABLE void scanForModels();
Q_INVOKABLE void tryAutoLoadModel();
// Generation
Q_INVOKABLE void generateMaterial(const QString &prompt, const QString ¤tMaterial = QString(), const QStringList &availableTextures = QStringList());
+ Q_INVOKABLE void generateText(const QString &systemPrompt, const QString &userPrompt, int maxTokensOverride = 0);
Q_INVOKABLE void stopGeneration();
// Settings
@@ -174,6 +182,7 @@ private slots:
LLMSettings m_settings;
bool m_isLoading = false;
bool m_autoLoadModel = false;
+ bool m_rawTextMode = false; // bypass material cleanup/validation when generateText() is active
// Retry logic for invalid scripts
QString m_pendingPrompt;
diff --git a/src/LLMSettingsWidget.cpp b/src/LLMSettingsWidget.cpp
index 14b869ceb..11b1faae7 100644
--- a/src/LLMSettingsWidget.cpp
+++ b/src/LLMSettingsWidget.cpp
@@ -120,9 +120,11 @@ void LLMSettingsWidget::setupModelsTab(QWidget *parent)
QHBoxLayout *buttonLayout = new QHBoxLayout();
m_loadButton = new QPushButton("Load Model", modelGroup);
+ m_loadFromFileButton = new QPushButton("Load from file...", modelGroup);
m_unloadButton = new QPushButton("Unload Model", modelGroup);
m_unloadButton->setEnabled(false);
buttonLayout->addWidget(m_loadButton);
+ buttonLayout->addWidget(m_loadFromFileButton);
buttonLayout->addWidget(m_unloadButton);
buttonLayout->addStretch();
modelLayout->addLayout(buttonLayout);
@@ -143,6 +145,7 @@ void LLMSettingsWidget::setupModelsTab(QWidget *parent)
connect(m_browseButton, &QPushButton::clicked, this, &LLMSettingsWidget::onBrowseDirectoryClicked);
connect(m_refreshButton, &QPushButton::clicked, this, &LLMSettingsWidget::onRefreshModelsClicked);
connect(m_loadButton, &QPushButton::clicked, this, &LLMSettingsWidget::onLoadModelClicked);
+ connect(m_loadFromFileButton, &QPushButton::clicked, this, &LLMSettingsWidget::onLoadFromFileClicked);
connect(m_unloadButton, &QPushButton::clicked, this, &LLMSettingsWidget::onUnloadModelClicked);
}
@@ -397,6 +400,22 @@ void LLMSettingsWidget::onLoadModelClicked()
LLMManager::instance()->loadModel(modelName);
}
+void LLMSettingsWidget::onLoadFromFileClicked()
+{
+ QString file = QFileDialog::getOpenFileName(
+ nullptr,
+ "Load AI Model",
+ LLMManager::instance()->modelsDirectory(),
+ "GGUF models (*.gguf);;Binary models (*.bin);;All files (*)"
+ );
+ if (file.isEmpty())
+ return;
+ m_loadButton->setEnabled(false);
+ m_statusLabel->setText("Loading model...");
+ m_statusLabel->setStyleSheet("color: orange;");
+ LLMManager::instance()->loadModelFromPath(file);
+}
+
void LLMSettingsWidget::onUnloadModelClicked()
{
LLMManager::instance()->unloadModel();
@@ -404,7 +423,9 @@ void LLMSettingsWidget::onUnloadModelClicked()
void LLMSettingsWidget::onBrowseDirectoryClicked()
{
- QString dir = QFileDialog::getExistingDirectory(this, "Select Models Directory",
+ // Use nullptr parent so the dialog is not shown as a sheet attached to
+ // this exec()-modal window β that combination fails silently on macOS.
+ QString dir = QFileDialog::getExistingDirectory(nullptr, "Select Models Directory",
m_directoryEdit->text());
if (!dir.isEmpty()) {
LLMManager::instance()->setModelsDirectory(dir);
diff --git a/src/LLMSettingsWidget.h b/src/LLMSettingsWidget.h
index 5cb3fcdf1..866b5c627 100644
--- a/src/LLMSettingsWidget.h
+++ b/src/LLMSettingsWidget.h
@@ -29,6 +29,7 @@ class LLMSettingsWidget : public QDialog
private slots:
void onLoadModelClicked();
+ void onLoadFromFileClicked();
void onUnloadModelClicked();
void onBrowseDirectoryClicked();
void onRefreshModelsClicked();
@@ -88,6 +89,7 @@ private slots:
// Models tab
QComboBox *m_modelCombo;
QPushButton *m_loadButton;
+ QPushButton *m_loadFromFileButton;
QPushButton *m_unloadButton;
QPushButton *m_refreshButton;
QLineEdit *m_directoryEdit;
diff --git a/src/LLMWorker.cpp b/src/LLMWorker.cpp
index bf2359faa..0a9cbb98e 100644
--- a/src/LLMWorker.cpp
+++ b/src/LLMWorker.cpp
@@ -192,6 +192,7 @@ void LLMWorker::unloadModelInternal()
// This method assumes mutex is already held by caller
// Does NOT emit signals - caller is responsible for that after releasing mutex
cleanupContext();
+ m_prevTokens.clear(); // invalidate KV prefix cache
if (m_model) {
llama_model_free(m_model);
@@ -231,7 +232,7 @@ void LLMWorker::requestStop()
m_stopRequested.store(true);
}
-void LLMWorker::generate(const QString &systemPrompt, const QString &userPrompt)
+void LLMWorker::generate(const QString &systemPrompt, const QString &userPrompt, int maxTokensOverride)
{
#ifdef ENABLE_LOCAL_LLM
if (!isModelLoaded()) {
@@ -285,17 +286,33 @@ void LLMWorker::generate(const QString &systemPrompt, const QString &userPrompt)
return;
}
- // Clear the KV cache
+ // KV-cache prefix reuse: if the new prompt shares a prefix with the previous
+ // call's tokens, only decode the NEW suffix β the shared prefix is already in cache.
+ size_t commonLen = 0;
+ for (size_t i = 0; i < std::min(tokens.size(), m_prevTokens.size()); ++i) {
+ if (tokens[i] == m_prevTokens[i]) ++commonLen;
+ else break;
+ }
+ // Never reuse a prefix that covers the full previous prompt β we need at least
+ // the new suffix tokens to give the model something new to respond to.
+ if (commonLen >= tokens.size()) commonLen = 0;
+
llama_memory_t mem = llama_get_memory(m_ctx);
if (mem) {
- llama_memory_clear(mem, false);
+ if (commonLen == 0) {
+ llama_memory_clear(mem, false); // full reset
+ } else {
+ llama_memory_seq_rm(mem, 0, (llama_pos)commonLen, -1); // trim suffix
+ }
}
+ qDebug() << "LLMWorker: KV prefix reuse" << commonLen << "/" << tokens.size()
+ << "tokens cached";
- // Process prompt in batches (n_batch = 512)
+ // Decode only the tokens not already in cache
const int n_batch = 512;
int n_tokens = static_cast(tokens.size());
- for (int i = 0; i < n_tokens; i += n_batch) {
+ for (int i = static_cast(commonLen); i < n_tokens; i += n_batch) {
if (m_stopRequested.load()) {
m_isGenerating.store(false);
emit generationStopped();
@@ -312,6 +329,9 @@ void LLMWorker::generate(const QString &systemPrompt, const QString &userPrompt)
}
}
+ // Save input tokens so the next call can find the common prefix
+ m_prevTokens = tokens;
+
// Sampling parameters
llama_sampler *sampler = llama_sampler_chain_init(llama_sampler_chain_default_params());
llama_sampler_chain_add(sampler, llama_sampler_init_temp(m_settings.temperature));
@@ -329,7 +349,8 @@ void LLMWorker::generate(const QString &systemPrompt, const QString &userPrompt)
eosToken = llama_vocab_eos(m_vocab);
}
- for (int i = 0; i < m_settings.maxTokens; ++i) {
+ const int effectiveMaxTokens = (maxTokensOverride > 0) ? maxTokensOverride : m_settings.maxTokens;
+ for (int i = 0; i < effectiveMaxTokens; ++i) {
if (m_stopRequested.load()) {
qDebug() << "LLMWorker: Generation stopped by user";
emit generationStopped();
@@ -356,15 +377,17 @@ void LLMWorker::generate(const QString &systemPrompt, const QString &userPrompt)
generatedText += piece;
// Emit progress
- float progress = static_cast(i + 1) / m_settings.maxTokens;
+ float progress = static_cast(i + 1) / effectiveMaxTokens;
emit generationProgress(generatedText, progress);
}
// Prepare next batch
llama_batch nextBatch = llama_batch_get_one(&newToken, 1);
if (llama_decode(m_ctx, nextBatch) != 0) {
- emit generationError("Failed to decode token");
- break;
+ llama_sampler_free(sampler);
+ m_isGenerating.store(false);
+ emit generationError("Failed to decode token (context full β reduce conversation length or increase context size in AI settings)");
+ return;
}
n_cur++;
diff --git a/src/LLMWorker.h b/src/LLMWorker.h
index 97b18b774..9c00a3282 100644
--- a/src/LLMWorker.h
+++ b/src/LLMWorker.h
@@ -44,7 +44,7 @@ class LLMWorker : public QObject
bool isGenerating() const { return m_isGenerating.load(); }
public slots:
- void generate(const QString &systemPrompt, const QString &userPrompt);
+ void generate(const QString &systemPrompt, const QString &userPrompt, int maxTokensOverride = 0);
signals:
void modelLoaded(const QString &modelPath);
@@ -73,6 +73,7 @@ public slots:
llama_model *m_model = nullptr;
llama_context *m_ctx = nullptr;
const llama_vocab *m_vocab = nullptr;
+ std::vector m_prevTokens; // cached input tokens for KV-prefix reuse
bool initializeContext();
void cleanupContext();
diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp
index 52020b28c..98f7ac6aa 100644
--- a/src/MCPServer.cpp
+++ b/src/MCPServer.cpp
@@ -7,6 +7,7 @@
#include "TransformOperator.h"
#include "MeshImporterExporter.h"
#include "OgreWidget.h"
+#include "SpaceCamera.h"
#include "AnimationWidget.h"
#include "NormalVisualizer.h"
#include "MeshInfoOverlay.h"
@@ -470,6 +471,18 @@ QJsonObject MCPServer::callTool(const QString &name, const QJsonObject &args)
toolResult = toolRemoveLods(args);
} else if (name == "get_lod_info") {
toolResult = toolGetLodInfo(args);
+ } else if (name == "list_files") {
+ toolResult = toolListFiles(args);
+ } else if (name == "search_files") {
+ toolResult = toolSearchFiles(args);
+ } else if (name == "read_file") {
+ toolResult = toolReadFile(args);
+ } else if (name == "delete_entity") {
+ toolResult = toolDeleteEntity(args);
+ } else if (name == "camera_control") {
+ toolResult = toolCameraControl(args);
+ } else if (name == "get_camera_info") {
+ toolResult = toolGetCameraInfo(args);
} else {
if (txn) SentryReporter::finishTransaction(txn);
return makeErrorResult(QString("Unknown tool: %1").arg(name));
@@ -574,36 +587,45 @@ QJsonObject MCPServer::toolCreateMaterial(const QJsonObject &args)
Ogre::MaterialPtr mat = Ogre::MaterialManager::getSingleton().create(
name.toStdString(), Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME);
- // Set properties from colors
- QJsonObject colors = args["colors"].toObject();
+ // Accept colors as either top-level params (diffuse, ambient β¦) or
+ // nested under a "colors" object β both formats are valid.
+ auto resolveColor = [&](const QString& key) -> QJsonArray {
+ if (args.contains(key) && args[key].isArray())
+ return args[key].toArray();
+ QJsonObject nested = args["colors"].toObject();
+ if (nested.contains(key) && nested[key].isArray())
+ return nested[key].toArray();
+ return {};
+ };
+ auto resolveNumber = [&](const QString& key, double def) -> double {
+ if (args.contains(key)) return args[key].toDouble(def);
+ return args["colors"].toObject().value(key).toDouble(def);
+ };
+
Ogre::Pass* pass = mat->getTechnique(0)->getPass(0);
- if (colors.contains("ambient")) {
- QJsonArray a = colors["ambient"].toArray();
- pass->setAmbient(a[0].toDouble(0.2), a[1].toDouble(0.2), a[2].toDouble(0.2));
- } else {
+ QJsonArray amb = resolveColor("ambient");
+ if (!amb.isEmpty())
+ pass->setAmbient(amb[0].toDouble(0.2), amb[1].toDouble(0.2), amb[2].toDouble(0.2));
+ else
pass->setAmbient(0.2, 0.2, 0.2);
- }
- if (colors.contains("diffuse")) {
- QJsonArray d = colors["diffuse"].toArray();
- pass->setDiffuse(d[0].toDouble(1.0), d[1].toDouble(1.0), d[2].toDouble(1.0), 1.0);
- }
+ QJsonArray diff = resolveColor("diffuse");
+ if (!diff.isEmpty())
+ pass->setDiffuse(diff[0].toDouble(1.0), diff[1].toDouble(1.0), diff[2].toDouble(1.0), 1.0);
- if (colors.contains("specular")) {
- QJsonArray s = colors["specular"].toArray();
- double shininess = colors.value("shininess").toDouble(32.0);
- pass->setSpecular(s[0].toDouble(0.5), s[1].toDouble(0.5), s[2].toDouble(0.5), 1.0);
- pass->setShininess(shininess);
+ QJsonArray spec = resolveColor("specular");
+ if (!spec.isEmpty()) {
+ pass->setSpecular(spec[0].toDouble(0.5), spec[1].toDouble(0.5), spec[2].toDouble(0.5), 1.0);
+ pass->setShininess(resolveNumber("shininess", 32.0));
} else {
pass->setSpecular(0.5, 0.5, 0.5, 1.0);
pass->setShininess(32.0);
}
- if (colors.contains("emissive")) {
- QJsonArray e = colors["emissive"].toArray();
- pass->setSelfIllumination(e[0].toDouble(), e[1].toDouble(), e[2].toDouble());
- }
+ QJsonArray emis = resolveColor("emissive");
+ if (!emis.isEmpty())
+ pass->setSelfIllumination(emis[0].toDouble(), emis[1].toDouble(), emis[2].toDouble());
try { mat->load(); } catch (...) { /* headless β no GPU context */ }
@@ -736,8 +758,14 @@ QJsonObject MCPServer::toolListMaterials(const QJsonObject &args)
QJsonObject MCPServer::toolApplyMaterial(const QJsonObject &args)
{
+ // Accept common model variations: "material" or "material_name"
QString materialName = args["material"].toString();
+ if (materialName.isEmpty()) materialName = args["material_name"].toString();
+ // Accept "mesh", "mesh_name", or "entity" / "entity_name"
QString meshName = args["mesh"].toString();
+ if (meshName.isEmpty()) meshName = args["mesh_name"].toString();
+ if (meshName.isEmpty()) meshName = args["entity"].toString();
+ if (meshName.isEmpty()) meshName = args["entity_name"].toString();
if (materialName.isEmpty()) {
return makeErrorResult("Error: Material name is required");
@@ -758,9 +786,10 @@ QJsonObject MCPServer::toolApplyMaterial(const QJsonObject &args)
QStringList appliedTo;
if (!meshName.isEmpty()) {
- // Apply to specific entity by name
- QList& entities = mgr->getEntities();
bool found = false;
+
+ // Primary: search by entity name via getEntities()
+ QList& entities = mgr->getEntities();
for (Ogre::Entity* entity : entities) {
if (entity && QString::fromStdString(entity->getName()) == meshName) {
entity->setMaterialName(materialName.toStdString());
@@ -769,8 +798,28 @@ QJsonObject MCPServer::toolApplyMaterial(const QJsonObject &args)
break;
}
}
+
+ // Fallback: look up scene node by name and apply to its attached entity.
+ // Handles cases where the entity name differs from the node name, or
+ // getEntities() returns an incomplete / mis-cast list.
+ if (!found) {
+ Ogre::SceneNode* sn = findSceneNodeByName(meshName);
+ if (sn) {
+ for (int i = 0; i < static_cast(sn->numAttachedObjects()); ++i) {
+ Ogre::MovableObject* obj = sn->getAttachedObject(i);
+ if (obj && obj->getMovableType() == "Entity") {
+ Ogre::Entity* ent = static_cast(obj);
+ ent->setMaterialName(materialName.toStdString());
+ appliedTo << QString::fromStdString(ent->getName());
+ found = true;
+ break;
+ }
+ }
+ }
+ }
+
if (!found) {
- return makeErrorResult(QString("Error: Entity '%1' not found").arg(meshName));
+ return makeErrorResult(QString("Error: Mesh '%1' not found").arg(meshName));
}
} else {
// Apply to selected entities
@@ -1209,7 +1258,11 @@ QJsonObject MCPServer::toolCreatePrimitive(const QJsonObject &args)
return makeErrorResult(QString("Failed to create %1 primitive").arg(type));
}
- return makeSuccessResult(QString("Created %1 primitive '%2'").arg(type).arg(name));
+ // Return the ACTUAL node name β Manager::addSceneNode may append a number
+ // if the requested name was already taken (e.g. "sphere" β "sphere1").
+ // The AI must use this exact name for subsequent apply_material calls.
+ QString actualName = QString::fromStdString(node->getName());
+ return makeSuccessResult(QString("Created %1 primitive '%2'").arg(type).arg(actualName));
}
QJsonObject MCPServer::toolAnimate(const QJsonObject &args)
{
@@ -2179,6 +2232,259 @@ QJsonObject MCPServer::toolGetLodInfo(const QJsonObject &args)
// Helper methods
+QJsonObject MCPServer::toolListFiles(const QJsonObject &args)
+{
+ QString path = args["path"].toString();
+ if (path.isEmpty())
+ path = QDir::homePath();
+
+ QDir dir(path);
+ if (!dir.exists())
+ return makeErrorResult(QString("Error: Directory '%1' does not exist").arg(path));
+
+ QString pattern = args["pattern"].toString();
+ QStringList nameFilters;
+ if (!pattern.isEmpty())
+ nameFilters << pattern;
+
+ QFileInfoList entries = dir.entryInfoList(
+ nameFilters,
+ QDir::AllEntries | QDir::NoDotAndDotDot,
+ QDir::DirsFirst | QDir::Name);
+
+ // Cap at 200 entries to keep response compact
+ const int maxEntries = 200;
+ QStringList lines;
+ lines << QString("Directory: %1").arg(dir.absolutePath());
+ lines << QString("Entries: %1%2").arg(
+ QString::number(qMin(entries.size(), maxEntries)),
+ entries.size() > maxEntries ? QString(" (showing first %1 of %2)").arg(maxEntries).arg(entries.size()) : "");
+ lines << "";
+
+ for (int i = 0; i < qMin(entries.size(), maxEntries); ++i) {
+ const QFileInfo& fi = entries[i];
+ if (fi.isDir()) {
+ lines << QString("[dir] %1/").arg(fi.fileName());
+ } else {
+ // Human-readable size
+ qint64 sz = fi.size();
+ QString sizeStr;
+ if (sz < 1024) sizeStr = QString("%1 B").arg(sz);
+ else if (sz < 1024*1024) sizeStr = QString("%1 KB").arg(sz / 1024);
+ else sizeStr = QString("%1 MB").arg(sz / (1024*1024));
+ lines << QString("[file] %1 (%2)").arg(fi.fileName(), sizeStr);
+ }
+ }
+
+ return makeSuccessResult(lines.join("\n"));
+}
+
+QJsonObject MCPServer::toolSearchFiles(const QJsonObject &args)
+{
+ QString startPath = args["path"].toString();
+ if (startPath.isEmpty())
+ startPath = QDir::homePath();
+
+ QString query = args["query"].toString();
+ if (query.isEmpty())
+ return makeErrorResult("Error: 'query' is required (e.g. '*.fbx', 'wood*', 'model.obj')");
+
+ QDir startDir(startPath);
+ if (!startDir.exists())
+ return makeErrorResult(QString("Error: Directory '%1' does not exist").arg(startPath));
+
+ // Recursive search with depth limit
+ int maxDepth = qBound(1, args["max_depth"].toInt(5), 10);
+ int maxResults = 100;
+ QStringList results;
+
+ std::function searchDir = [&](const QDir& dir, int depth) {
+ if (depth > maxDepth || results.size() >= maxResults)
+ return;
+
+ // Match files against the query pattern
+ QFileInfoList files = dir.entryInfoList(
+ QStringList{query}, QDir::Files, QDir::Name);
+ for (const QFileInfo& fi : files) {
+ if (results.size() >= maxResults) break;
+ qint64 sz = fi.size();
+ QString sizeStr;
+ if (sz < 1024) sizeStr = QString("%1 B").arg(sz);
+ else if (sz < 1024*1024) sizeStr = QString("%1 KB").arg(sz / 1024);
+ else sizeStr = QString("%1 MB").arg(sz / (1024*1024));
+ results << QString("%1 (%2)").arg(fi.absoluteFilePath(), sizeStr);
+ }
+
+ // Recurse into subdirectories
+ QFileInfoList dirs = dir.entryInfoList(
+ QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
+ for (const QFileInfo& di : dirs) {
+ if (results.size() >= maxResults) break;
+ searchDir(QDir(di.absoluteFilePath()), depth + 1);
+ }
+ };
+
+ searchDir(startDir, 1);
+
+ if (results.isEmpty())
+ return makeSuccessResult(QString("No files matching '%1' found in %2 (depth %3)")
+ .arg(query, startDir.absolutePath()).arg(maxDepth));
+
+ QStringList lines;
+ lines << QString("Found %1 file(s) matching '%2' in %3:")
+ .arg(results.size()).arg(query, startDir.absolutePath());
+ lines << "";
+ lines += results;
+ if (results.size() >= maxResults)
+ lines << QString("\n(results capped at %1)").arg(maxResults);
+
+ return makeSuccessResult(lines.join("\n"));
+}
+
+QJsonObject MCPServer::toolReadFile(const QJsonObject &args)
+{
+ QString path = args["path"].toString();
+ if (path.isEmpty())
+ return makeErrorResult("Error: 'path' is required");
+
+ QFileInfo fi(path);
+ if (!fi.exists())
+ return makeErrorResult(QString("Error: File '%1' does not exist").arg(path));
+ if (!fi.isFile())
+ return makeErrorResult(QString("Error: '%1' is not a file").arg(path));
+
+ // Reject binary files by extension
+ static const QStringList binaryExts = {
+ "png", "jpg", "jpeg", "bmp", "tga", "gif", "ico", "tif", "tiff",
+ "mesh", "skeleton", "exe", "dll", "dylib", "so", "o", "a",
+ "zip", "gz", "tar", "rar", "7z",
+ "mp3", "wav", "ogg", "mp4", "avi", "mov",
+ "pdf", "doc", "docx", "xls", "ppt"
+ };
+ if (binaryExts.contains(fi.suffix().toLower()))
+ return makeErrorResult(QString("Error: Cannot read binary file '%1'").arg(fi.fileName()));
+
+ // Size limit: 1 MB
+ if (fi.size() > 1024 * 1024)
+ return makeErrorResult(QString("Error: File too large (%1 MB). Max 1 MB.").arg(fi.size() / (1024*1024)));
+
+ QFile file(path);
+ if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
+ return makeErrorResult(QString("Error: Cannot open '%1': %2").arg(path, file.errorString()));
+
+ int maxLines = qBound(1, args["max_lines"].toInt(100), 500);
+ QStringList lines;
+ QTextStream stream(&file);
+ while (!stream.atEnd() && lines.size() < maxLines)
+ lines << stream.readLine();
+
+ bool truncated = !stream.atEnd();
+ QString header = QString("File: %1 (%2 lines%3)\n---\n").arg(
+ fi.fileName(),
+ QString::number(lines.size()),
+ truncated ? ", truncated" : "");
+
+ return makeSuccessResult(header + lines.join("\n"));
+}
+
+QJsonObject MCPServer::toolDeleteEntity(const QJsonObject &args)
+{
+ QString name = args["name"].toString();
+ if (name.isEmpty())
+ return makeErrorResult("Error: 'name' is required β specify the entity/node name to delete.");
+
+ Ogre::SceneNode* node = findSceneNodeByName(name);
+ if (!node)
+ return makeErrorResult(QString("Error: Node '%1' not found").arg(name));
+
+ // Deselect first (same as UI delete flow)
+ SelectionSet* sel = SelectionSet::getSingleton();
+ if (sel) {
+ sel->removeOne(node);
+ }
+
+ // Destroy the node properly (same as TransformOperator::removeSelected)
+ Manager::getSingleton()->destroySceneNode(node);
+
+ return makeSuccessResult(QString("Deleted '%1' from the scene.").arg(name));
+}
+
+QJsonObject MCPServer::toolGetCameraInfo(const QJsonObject &args)
+{
+ Q_UNUSED(args);
+ auto* top = TransformOperator::getSingleton();
+ OgreWidget* ogreWidget = top ? top->getActiveWidget() : nullptr;
+ // Fallback to any viewport if no active widget yet
+ if (!ogreWidget && m_mainWindow)
+ ogreWidget = m_mainWindow->findChild();
+ if (!ogreWidget || !ogreWidget->getSpaceCamera())
+ return makeErrorResult("Error: No active viewport");
+
+ SpaceCamera* cam = ogreWidget->getSpaceCamera();
+ Ogre::Camera* ogreCam = cam->getCamera();
+ if (!ogreCam)
+ return makeErrorResult("Error: No Ogre camera");
+
+ Ogre::Vector3 pos = ogreCam->getDerivedPosition();
+ Ogre::Vector3 dir = ogreCam->getDerivedDirection();
+ Ogre::Quaternion orient = ogreCam->getDerivedOrientation();
+
+ QStringList lines;
+ lines << QString("Camera position: [%1, %2, %3]").arg(pos.x, 0, 'f', 2).arg(pos.y, 0, 'f', 2).arg(pos.z, 0, 'f', 2);
+ lines << QString("Camera direction: [%1, %2, %3]").arg(dir.x, 0, 'f', 3).arg(dir.y, 0, 'f', 3).arg(dir.z, 0, 'f', 3);
+ lines << QString("Camera orientation: [w=%1, x=%2, y=%3, z=%4]")
+ .arg(orient.w, 0, 'f', 3).arg(orient.x, 0, 'f', 3).arg(orient.y, 0, 'f', 3).arg(orient.z, 0, 'f', 3);
+ lines << QString("Near clip: %1 Far clip: %2").arg(ogreCam->getNearClipDistance()).arg(ogreCam->getFarClipDistance());
+
+ return makeSuccessResult(lines.join("\n"));
+}
+
+QJsonObject MCPServer::toolCameraControl(const QJsonObject &args)
+{
+ auto* top = TransformOperator::getSingleton();
+ OgreWidget* ogreWidget = top ? top->getActiveWidget() : nullptr;
+ // Fallback to any viewport if no active widget yet
+ if (!ogreWidget && m_mainWindow)
+ ogreWidget = m_mainWindow->findChild();
+ if (!ogreWidget || !ogreWidget->getSpaceCamera())
+ return makeErrorResult("Error: No active viewport");
+
+ SpaceCamera* cam = ogreWidget->getSpaceCamera();
+ QStringList actions;
+
+ // Frame selection β zoom to fit selected objects
+ if (args.contains("frame_selection") && args["frame_selection"].toBool()) {
+ cam->frameSelection();
+ actions << "Framed selection";
+ }
+
+ // Set camera position
+ if (args.contains("position")) {
+ Ogre::Vector3 pos = parseVector3(args["position"]);
+ cam->setCameraPosition(pos);
+ actions << QString("Position: [%1, %2, %3]").arg(pos.x).arg(pos.y).arg(pos.z);
+ }
+
+ // Set look-at target
+ if (args.contains("target")) {
+ Ogre::Vector3 target = parseVector3(args["target"]);
+ cam->setTargetPosition(target);
+ actions << QString("Target: [%1, %2, %3]").arg(target.x).arg(target.y).arg(target.z);
+ }
+
+ // Zoom by delta
+ if (args.contains("zoom")) {
+ Ogre::Real delta = args["zoom"].toDouble();
+ cam->zoomByDelta(delta);
+ actions << QString("Zoom: %1").arg(delta);
+ }
+
+ if (actions.isEmpty())
+ return makeErrorResult("Error: No camera action specified. Use position, target, zoom, or frame_selection.");
+
+ return makeSuccessResult("Camera updated:\n" + actions.join("\n"));
+}
+
QJsonArray MCPServer::buildToolsList()
{
QJsonArray tools;
@@ -2200,18 +2506,19 @@ QJsonArray MCPServer::buildToolsList()
QJsonObject inputSchema;
inputSchema["type"] = "object";
QJsonObject properties;
- properties["name"] = QJsonObject{{"type", "string"}, {"description", "Name of the material to create"}};
- properties["script"] = QJsonObject{{"type", "string"}, {"description", "Optional: Full Ogre3D material script"}};
- QJsonObject colors;
- colors["type"] = "object";
- colors["description"] = "Optional: Color values if not providing full script";
- properties["colors"] = colors;
+ properties["name"] = QJsonObject{{"type", "string"}, {"description", "Name of the new material"}};
+ properties["script"] = QJsonObject{{"type", "string"}, {"description", "Optional: full Ogre3D material script (overrides color params)"}};
+ properties["ambient"] = QJsonObject{{"type", "array"}, {"description", "Ambient color [R, G, B] (0.0-1.0)"}};
+ properties["diffuse"] = QJsonObject{{"type", "array"}, {"description", "Diffuse color [R, G, B] (0.0-1.0)"}};
+ properties["specular"] = QJsonObject{{"type", "array"}, {"description", "Specular color [R, G, B] (0.0-1.0)"}};
+ properties["shininess"] = QJsonObject{{"type", "number"}, {"description", "Specular shininess (1-128)"}};
+ properties["emissive"] = QJsonObject{{"type", "array"}, {"description", "Emissive/glow color [R, G, B] (0.0-1.0)"}};
inputSchema["properties"] = properties;
inputSchema["required"] = QJsonArray{"name"};
tools.append(buildToolDefinition(
"create_material",
- "Create a new Ogre3D material. Provide either a full Ogre material script via 'script', or set individual colors (ambient, diffuse, specular, emissive) via 'colors'. The material can then be applied to a mesh with apply_material.",
+ "Create a new Ogre3D material with optional colors. Colors are [R,G,B] arrays (0.0-1.0). Apply the result to a mesh with apply_material.",
inputSchema
));
}
@@ -2318,14 +2625,16 @@ QJsonArray MCPServer::buildToolsList()
QJsonObject inputSchema;
inputSchema["type"] = "object";
QJsonObject properties;
+ properties["name"] = QJsonObject{{"type", "string"}, {"description", "Name of the scene node to transform (required). Use get_scene_info to find exact names."}};
properties["position"] = QJsonObject{{"type", "array"}, {"description", "Position [X, Y, Z]"}};
properties["rotation"] = QJsonObject{{"type", "array"}, {"description", "Rotation in degrees [X, Y, Z]"}};
- properties["scale"] = QJsonObject{{"type", "array"}, {"description", "Scale [X, Y, Z]"}};
+ properties["scale"] = QJsonObject{{"type", "array"}, {"description", "Scale [X, Y, Z]"}};
inputSchema["properties"] = properties;
+ inputSchema["required"] = QJsonArray{"name"};
tools.append(buildToolDefinition(
"transform_mesh",
- "Set the position, rotation, and/or scale of a scene node. Position and scale are [X, Y, Z] arrays. Rotation is in degrees [X, Y, Z]. All parameters are optional β only provided values are applied. Use get_scene_info to find node names.",
+ "Set the position, rotation, and/or scale of a named scene node. 'name' is required β use get_scene_info to find the exact node name. Position and scale are [X, Y, Z] arrays. Rotation is in degrees [X, Y, Z].",
inputSchema
));
}
@@ -2710,6 +3019,84 @@ QJsonArray MCPServer::buildToolsList()
);
}
+ // delete_entity
+ {
+ QJsonObject props;
+ props["name"] = QJsonObject{{"type", "string"}, {"description", "Name of the entity/node to delete from the scene"}};
+ appendTool(
+ "delete_entity",
+ "Permanently delete an entity/node from the scene. Use get_scene_info to find node names. This cannot be undone.",
+ props,
+ QJsonArray{"name"}
+ );
+ }
+
+ // get_camera_info
+ {
+ appendTool(
+ "get_camera_info",
+ "Get the current camera position, direction, and orientation in the 3D viewport.",
+ QJsonObject()
+ );
+ }
+
+ // camera_control
+ {
+ QJsonObject props;
+ props["position"] = QJsonObject{{"type", "array"}, {"description", "Set camera position [X, Y, Z]"}};
+ props["target"] = QJsonObject{{"type", "array"}, {"description", "Set camera look-at target [X, Y, Z]"}};
+ props["zoom"] = QJsonObject{{"type", "number"}, {"description", "Zoom by delta (positive = zoom in, negative = zoom out)"}};
+ props["frame_selection"] = QJsonObject{{"type", "boolean"}, {"description", "Zoom to fit the currently selected objects in view (set to true)"}};
+ appendTool(
+ "camera_control",
+ "Control the 3D viewport camera. Set position, look-at target, zoom, or frame the selection. "
+ "Multiple actions can be combined in one call.",
+ props
+ );
+ }
+
+ // list_files
+ {
+ QJsonObject props;
+ props["path"] = QJsonObject{{"type", "string"}, {"description", "Directory path to list (default: user home directory)"}};
+ props["pattern"] = QJsonObject{{"type", "string"}, {"description", "Glob filter, e.g. '*.fbx' or '*.obj' (default: all files)"}};
+ appendTool(
+ "list_files",
+ "List files and directories at a given path. Use to find mesh files, textures, or other assets on disk. "
+ "Returns file names, sizes, and types (file/dir).",
+ props
+ );
+ }
+
+ // search_files
+ {
+ QJsonObject props;
+ props["path"] = QJsonObject{{"type", "string"}, {"description", "Starting directory for search (default: user home)"}};
+ props["query"] = QJsonObject{{"type", "string"}, {"description", "Glob pattern to match file names, e.g. '*.fbx', 'wood*', '*.obj'"}};
+ props["max_depth"] = QJsonObject{{"type", "integer"}, {"description", "Max directory depth to recurse (default: 5, max: 10)"}};
+ appendTool(
+ "search_files",
+ "Recursively search for files matching a glob pattern. Use to find mesh files, textures, or assets "
+ "anywhere within a directory tree. Returns absolute paths with file sizes.",
+ props,
+ QJsonArray{"query"}
+ );
+ }
+
+ // read_file
+ {
+ QJsonObject props;
+ props["path"] = QJsonObject{{"type", "string"}, {"description", "Absolute path to the file to read"}};
+ props["max_lines"] = QJsonObject{{"type", "integer"}, {"description", "Maximum lines to read (default: 100, max: 500)"}};
+ appendTool(
+ "read_file",
+ "Read the contents of a text file. Useful for viewing material scripts, config files, or scene descriptions. "
+ "Binary files (images, meshes) will be rejected. Max 500 lines.",
+ props,
+ QJsonArray{"path"}
+ );
+ }
+
return tools;
}
diff --git a/src/MCPServer.h b/src/MCPServer.h
index 0e4604933..9ee518d26 100644
--- a/src/MCPServer.h
+++ b/src/MCPServer.h
@@ -54,10 +54,15 @@ class MCPServer : public QObject
bool startHttp(int port = 8080);
/**
- * @brief Call a tool by name with arguments (public API for HTTP)
+ * @brief Call a tool by name with arguments (public API for HTTP and AI Chat)
*/
QJsonObject callTool(const QString &name, const QJsonObject &args);
+ /**
+ * @brief Build the list of available tools (public API for AI Chat)
+ */
+ QJsonArray buildToolsList();
+
/**
* @brief Set the main window reference for accessing editor functionality
*/
@@ -151,6 +156,12 @@ private slots:
QJsonObject toolGenerateAutoLods(const QJsonObject &args);
QJsonObject toolRemoveLods(const QJsonObject &args);
QJsonObject toolGetLodInfo(const QJsonObject &args);
+ QJsonObject toolListFiles(const QJsonObject &args);
+ QJsonObject toolSearchFiles(const QJsonObject &args);
+ QJsonObject toolReadFile(const QJsonObject &args);
+ QJsonObject toolCameraControl(const QJsonObject &args);
+ QJsonObject toolGetCameraInfo(const QJsonObject &args);
+ QJsonObject toolDeleteEntity(const QJsonObject &args);
// Animation
struct NodeAnimation {
@@ -175,7 +186,6 @@ private slots:
static QJsonObject makeErrorResult(const QString &message);
static QJsonObject makeSuccessResult(const QString &message);
bool ensureOgreInitialized();
- QJsonArray buildToolsList();
QJsonObject buildToolDefinition(const QString &name, const QString &description,
const QJsonObject &inputSchema);
diff --git a/src/MCPServer_test.cpp b/src/MCPServer_test.cpp
index 9abd0535c..7efb68823 100644
--- a/src/MCPServer_test.cpp
+++ b/src/MCPServer_test.cpp
@@ -2785,9 +2785,13 @@ TEST_F(MCPServerTest, AllToolNamesAreRecognized)
"list_skeletal_animations", "get_animation_info", "set_animation_length",
"set_animation_time", "add_keyframe", "remove_keyframe",
"play_animation", "toggle_skeleton_debug", "toggle_bone_weights",
- "toggle_normals", "toggle_mesh_info", "merge_animations"
+ "toggle_normals", "toggle_mesh_info", "merge_animations",
+ "save_scene", "open_scene", "validate_mesh",
+ "generate_lods", "generate_auto_lods", "remove_lods", "get_lod_info",
+ "delete_entity", "get_camera_info", "camera_control",
+ "list_files", "search_files", "read_file"
};
- EXPECT_EQ(allTools.size(), 27);
+ EXPECT_EQ(allTools.size(), 40);
for (const QString &tool : allTools) {
QJsonObject result = server->callTool(tool, QJsonObject());
@@ -4149,3 +4153,190 @@ TEST_F(MCPServerProtocolTest, SendErrorSerializesJsonRpcError)
EXPECT_EQ(response["error"].toObject()["code"].toInt(), -32001);
EXPECT_EQ(response["error"].toObject()["message"].toString(), "custom failure");
}
+
+// ---- Filesystem tools ----
+
+TEST_F(MCPServerTest, ListFiles_ValidDirectory)
+{
+ QTemporaryDir tmpDir;
+ ASSERT_TRUE(tmpDir.isValid());
+ // Create a test file
+ QFile f(tmpDir.filePath("test.fbx"));
+ f.open(QIODevice::WriteOnly);
+ f.write("dummy");
+ f.close();
+
+ QJsonObject args;
+ args["path"] = tmpDir.path();
+ QJsonObject result = server->callTool("list_files", args);
+ EXPECT_FALSE(isError(result));
+ EXPECT_TRUE(getResultText(result).contains("test.fbx"));
+}
+
+TEST_F(MCPServerTest, ListFiles_NonexistentDirectory)
+{
+ QJsonObject args;
+ args["path"] = "/nonexistent/path/that/does/not/exist";
+ QJsonObject result = server->callTool("list_files", args);
+ EXPECT_TRUE(isError(result));
+ EXPECT_TRUE(getResultText(result).contains("does not exist"));
+}
+
+TEST_F(MCPServerTest, ListFiles_WithPattern)
+{
+ QTemporaryDir tmpDir;
+ ASSERT_TRUE(tmpDir.isValid());
+ QFile f1(tmpDir.filePath("model.fbx"));
+ f1.open(QIODevice::WriteOnly); f1.write("fbx"); f1.close();
+ QFile f2(tmpDir.filePath("texture.png"));
+ f2.open(QIODevice::WriteOnly); f2.write("png"); f2.close();
+
+ QJsonObject args;
+ args["path"] = tmpDir.path();
+ args["pattern"] = "*.fbx";
+ QJsonObject result = server->callTool("list_files", args);
+ EXPECT_FALSE(isError(result));
+ EXPECT_TRUE(getResultText(result).contains("model.fbx"));
+ EXPECT_FALSE(getResultText(result).contains("texture.png"));
+}
+
+TEST_F(MCPServerTest, SearchFiles_ValidQuery)
+{
+ QTemporaryDir tmpDir;
+ ASSERT_TRUE(tmpDir.isValid());
+ QDir(tmpDir.path()).mkdir("subdir");
+ QFile f(tmpDir.filePath("subdir/deep.obj"));
+ f.open(QIODevice::WriteOnly); f.write("obj"); f.close();
+
+ QJsonObject args;
+ args["path"] = tmpDir.path();
+ args["query"] = "*.obj";
+ QJsonObject result = server->callTool("search_files", args);
+ EXPECT_FALSE(isError(result));
+ EXPECT_TRUE(getResultText(result).contains("deep.obj"));
+}
+
+TEST_F(MCPServerTest, SearchFiles_MissingQuery)
+{
+ QJsonObject args;
+ args["path"] = QDir::tempPath();
+ QJsonObject result = server->callTool("search_files", args);
+ EXPECT_TRUE(isError(result));
+ EXPECT_TRUE(getResultText(result).contains("query"));
+}
+
+TEST_F(MCPServerTest, SearchFiles_NonexistentDirectory)
+{
+ QJsonObject args;
+ args["path"] = "/nonexistent/search/path";
+ args["query"] = "*.fbx";
+ QJsonObject result = server->callTool("search_files", args);
+ EXPECT_TRUE(isError(result));
+}
+
+TEST_F(MCPServerTest, ReadFile_ValidTextFile)
+{
+ QTemporaryDir tmpDir;
+ ASSERT_TRUE(tmpDir.isValid());
+ QFile f(tmpDir.filePath("test.txt"));
+ f.open(QIODevice::WriteOnly);
+ f.write("line1\nline2\nline3\n");
+ f.close();
+
+ QJsonObject args;
+ args["path"] = tmpDir.filePath("test.txt");
+ QJsonObject result = server->callTool("read_file", args);
+ EXPECT_FALSE(isError(result));
+ EXPECT_TRUE(getResultText(result).contains("line1"));
+ EXPECT_TRUE(getResultText(result).contains("line2"));
+}
+
+TEST_F(MCPServerTest, ReadFile_MissingPath)
+{
+ QJsonObject result = server->callTool("read_file", QJsonObject());
+ EXPECT_TRUE(isError(result));
+ EXPECT_TRUE(getResultText(result).contains("required"));
+}
+
+TEST_F(MCPServerTest, ReadFile_NonexistentFile)
+{
+ QJsonObject args;
+ args["path"] = "/nonexistent/file.txt";
+ QJsonObject result = server->callTool("read_file", args);
+ EXPECT_TRUE(isError(result));
+ EXPECT_TRUE(getResultText(result).contains("does not exist"));
+}
+
+TEST_F(MCPServerTest, ReadFile_BinaryFileRejected)
+{
+ QTemporaryDir tmpDir;
+ ASSERT_TRUE(tmpDir.isValid());
+ QFile f(tmpDir.filePath("image.png"));
+ f.open(QIODevice::WriteOnly); f.write("fake png"); f.close();
+
+ QJsonObject args;
+ args["path"] = tmpDir.filePath("image.png");
+ QJsonObject result = server->callTool("read_file", args);
+ EXPECT_TRUE(isError(result));
+ EXPECT_TRUE(getResultText(result).contains("binary"));
+}
+
+TEST_F(MCPServerTest, ReadFile_MaxLinesRespected)
+{
+ QTemporaryDir tmpDir;
+ ASSERT_TRUE(tmpDir.isValid());
+ QFile f(tmpDir.filePath("long.txt"));
+ f.open(QIODevice::WriteOnly);
+ for (int i = 0; i < 50; ++i) f.write(QStringLiteral("line %1\n").arg(i).toUtf8());
+ f.close();
+
+ QJsonObject args;
+ args["path"] = tmpDir.filePath("long.txt");
+ args["max_lines"] = 5;
+ QJsonObject result = server->callTool("read_file", args);
+ EXPECT_FALSE(isError(result));
+ EXPECT_TRUE(getResultText(result).contains("truncated"));
+}
+
+// ---- Delete entity ----
+
+TEST_F(MCPServerTest, DeleteEntity_MissingName)
+{
+ QJsonObject result = server->callTool("delete_entity", QJsonObject());
+ EXPECT_TRUE(isError(result));
+ EXPECT_TRUE(getResultText(result).contains("required"));
+}
+
+TEST_F(MCPServerTest, DeleteEntity_NonexistentEntity)
+{
+ QJsonObject args;
+ args["name"] = "nonexistent_entity_xyz";
+ QJsonObject result = server->callTool("delete_entity", args);
+ EXPECT_TRUE(isError(result));
+ EXPECT_TRUE(getResultText(result).contains("not found"));
+}
+
+// ---- Camera tools ----
+
+TEST_F(MCPServerTest, GetCameraInfo_NoMainWindow)
+{
+ // Server has no mainWindow set β should return error
+ QJsonObject result = server->callTool("get_camera_info", QJsonObject());
+ EXPECT_TRUE(isError(result));
+}
+
+TEST_F(MCPServerTest, CameraControl_NoMainWindow)
+{
+ QJsonObject args;
+ args["zoom"] = 5.0;
+ QJsonObject result = server->callTool("camera_control", args);
+ EXPECT_TRUE(isError(result));
+}
+
+TEST_F(MCPServerTest, CameraControl_NoActionSpecified)
+{
+ // Even with no mainWindow, the error should mention "No camera action"
+ // or "No active viewport" β both are valid error paths
+ QJsonObject result = server->callTool("camera_control", QJsonObject());
+ EXPECT_TRUE(isError(result));
+}
diff --git a/src/TransformOperator.h b/src/TransformOperator.h
index 972ced9cd..28d4763fa 100755
--- a/src/TransformOperator.h
+++ b/src/TransformOperator.h
@@ -79,6 +79,7 @@ public slots:
void setTransformSpace(TransformSpace space);
void toggleTransformSpace();
void setActiveWidget(OgreWidget* ogreWidget);
+ OgreWidget* getActiveWidget() const { return m_pActiveWidget; }
//void setSelectedNode(Ogre::SceneNode* newNode); //TODO it should not exist....
void setSelectedPosition(const Ogre::Vector3& newPosition);
void translateSelected(const Ogre::Vector3& newPosition);
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 3b975642c..e56374f26 100755
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -55,6 +55,7 @@
#include "MeshLodController.h"
#include "MeshValidator.h"
#include "MaterialPresetLibrary.h"
+#include "AIChatManager.h"
#include
#include
#include
@@ -232,6 +233,7 @@ MainWindow::~MainWindow()
MeshLodController::kill();
MeshValidator::kill();
MaterialPresetLibrary::kill();
+ AIChatManager::kill();
// Only destroy Manager if it still exists and belongs to this MainWindow
// (In tests, Manager may be destroyed separately in TearDown)
Manager* manager = Manager::getSingletonPtr();
@@ -334,6 +336,10 @@ void MainWindow::initToolBar()
[](QQmlEngine* engine, QJSEngine*) -> QObject* {
return MaterialPresetLibrary::qmlInstance(engine, nullptr);
});
+ qmlRegisterSingletonType("AIChatPanel", 1, 0, "AIChatManager",
+ [](QQmlEngine* engine, QJSEngine*) -> QObject* {
+ return AIChatManager::qmlInstance(engine, nullptr);
+ });
m_propertiesPanel->setSource(QUrl("qrc:/PropertiesPanel/PropertiesPanel.qml"));
@@ -352,6 +358,36 @@ void MainWindow::initToolBar()
}
}
+ // AI Chat dock
+ {
+ auto* chatWidget = new QQuickWidget();
+ chatWidget->setResizeMode(QQuickWidget::SizeRootObjectToView);
+ chatWidget->setMinimumWidth(280);
+ chatWidget->setMinimumHeight(350);
+ // StrongFocus: a single click inside the dock routes keyboard events into QML
+ // without requiring a prior click in the viewport.
+ chatWidget->setFocusPolicy(Qt::StrongFocus);
+ chatWidget->setSource(QUrl("qrc:/AIChatPanel/AIChatPanel.qml"));
+ m_chatDock = new QDockWidget(tr("AI Chat"), this);
+ m_chatDock->setWidget(chatWidget);
+ m_chatDock->setObjectName("AIChatDock");
+ addDockWidget(Qt::RightDockWidgetArea, m_chatDock);
+ resizeDocks({m_chatDock}, {400}, Qt::Vertical);
+ m_chatDock->hide();
+
+ // When focus lands on the dock container (not the QQuickWidget inside),
+ // forward it to the QQuickWidget. This fixes the macOS issue where
+ // clicking the chat input after switching back from another app requires
+ // two clicks β the first activates the window but focus stays on the dock.
+ connect(qApp, &QApplication::focusChanged, this, [chatWidget, this](QWidget*, QWidget* now) {
+ if (!now || !m_chatDock || !m_chatDock->isVisible()) return;
+ if (now == m_chatDock || (now->parentWidget() && now->parentWidget() == m_chatDock)) {
+ if (now != chatWidget)
+ QTimer::singleShot(0, chatWidget, [chatWidget]() { chatWidget->setFocus(); });
+ }
+ });
+ }
+
// Animation Control dock is created below and auto-shown when animated entity is selected
// PrimitivesWidget (hidden β used by toolbar create menu and Inspector primitive editing)
@@ -393,6 +429,21 @@ void MainWindow::initToolBar()
addPrimitiveButton->setMenu(addPrimitiveMenu);
ui->objectsToolbar->addWidget(addPrimitiveButton);
+ // AI Chat button β star icon is the common AI shorthand
+ auto aiChatButton = new QToolButton(ui->objectsToolbar);
+ aiChatButton->setText("\u2728"); // β¨
+ aiChatButton->setToolTip(tr("Open AI Chat"));
+ QFont aiFont = aiChatButton->font();
+ aiFont.setPixelSize(15);
+ aiChatButton->setFont(aiFont);
+ connect(aiChatButton, &QToolButton::clicked, this, [this]() {
+ if (m_chatDock) {
+ m_chatDock->show();
+ m_chatDock->raise();
+ }
+ });
+ ui->objectsToolbar->addWidget(aiChatButton);
+
connect(pAddCube, SIGNAL(triggered()),m_pPrimitivesWidget,SLOT(createCube()));
connect(pAddSphere, SIGNAL(triggered()),m_pPrimitivesWidget,SLOT(createSphere()));
connect(pAddPlane, SIGNAL(triggered()),m_pPrimitivesWidget,SLOT(createPlane()));
@@ -473,7 +524,15 @@ void MainWindow::initToolBar()
// AI Settings menu
QMenu* aiMenu = menuBar()->addMenu(tr("&AI"));
- QAction* aiSettingsAction = aiMenu->addAction(QIcon(":/icones/ai.png"), tr("AI Model Settings..."));
+ QAction* aiChatAction = aiMenu->addAction(QIcon(":/icones/ai.png"), tr("AI Chat..."));
+ connect(aiChatAction, &QAction::triggered, this, [this]() {
+ if (m_chatDock) {
+ m_chatDock->show();
+ m_chatDock->raise();
+ }
+ });
+ aiMenu->addSeparator();
+ QAction* aiSettingsAction = aiMenu->addAction(tr("AI Model Settings..."));
connect(aiSettingsAction, &QAction::triggered, this, &MainWindow::showAIModelSettings);
QAction* mcpSettingsAction = aiMenu->addAction(tr("MCP Server Settings..."));
@@ -1514,6 +1573,7 @@ bool MainWindow::startMCPServer(int port)
if (!m_mcpServer) {
m_mcpServer = new MCPServer(this);
m_mcpServer->setMainWindow(this);
+ AIChatManager::instance()->setMcpServer(m_mcpServer);
}
bool ok = m_mcpServer->startHttp(port);
@@ -1542,6 +1602,7 @@ void MainWindow::setMCPServer(MCPServer* server)
delete m_mcpServer;
}
m_mcpServer = server;
+ AIChatManager::instance()->setMcpServer(m_mcpServer);
}
void MainWindow::addToRecentFiles(const QString& filePath)
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 9ec1c4b92..346caa152 100755
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -132,6 +132,7 @@ public slots:
ViewCubeController* m_viewCubeController = nullptr;
MCPServer* m_mcpServer = nullptr;
QQuickWidget* m_propertiesPanel = nullptr;
+ QDockWidget* m_chatDock = nullptr;
QMenu* m_recentFilesMenu = nullptr;
void addToRecentFiles(const QString& filePath);
diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc
index 71ff14835..1d8859fd3 100644
--- a/src/qml_resources.qrc
+++ b/src/qml_resources.qrc
@@ -25,4 +25,7 @@
../qml/AnimationControlPanel.qml
+
+ ../qml/AIChatPanel.qml
+
\ No newline at end of file
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 95325b980..75813a8b6 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -68,6 +68,7 @@ if(BUILD_TESTS)
${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPresetLibrary.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshLodController.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshValidator.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/../src/AIChatManager.cpp
)
set(TEST_HEADER_FILES
@@ -128,6 +129,7 @@ if(BUILD_TESTS)
${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPresetLibrary.h
${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshLodController.h
${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshValidator.h
+ ${CMAKE_CURRENT_SOURCE_DIR}/../src/AIChatManager.h
)
# Add Ogre-Procedural sources (matching src/CMakeLists.txt)