diff --git a/CMakeLists.txt b/CMakeLists.txt index 881872abe..69974d716 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0) cmake_policy(SET CMP0005 NEW) cmake_policy(SET CMP0048 NEW) # manages project version -project(QtMeshEditor VERSION 2.20.2 LANGUAGES C CXX) +project(QtMeshEditor VERSION 2.21.0 LANGUAGES C CXX) message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}") set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"") diff --git a/docs/index.html b/docs/index.html index 06c15b7d8..eb237d960 100644 --- a/docs/index.html +++ b/docs/index.html @@ -578,10 +578,16 @@

Scene Save & Load

Save your entire scene — multiple meshes with positions, rotations, scales, materials, skeletons, and animations — to a single glTF file. Reopen later to pick up where you left off.

+
+
πŸ’¬
+

AI Chat Assistant

+

Built-in AI chat panel powered by local LLMs. Ask it to create objects, apply materials, move meshes, load files, and control the camera using natural language. Runs entirely on-device.

+
+
πŸ”Œ

MCP Server (AI Agents)

-

Built-in Model Context Protocol server lets AI agents like Claude or Cursor control the editor. 29 tools for materials, meshes, scenes, transforms, and animations via HTTP API.

+

Built-in Model Context Protocol server lets AI agents like Claude or Cursor control the editor. 40 tools for materials, meshes, scenes, transforms, camera, file browsing, and animations via HTTP API.

@@ -853,7 +859,7 @@

Configure Your AI Agent

-

29 Tools Available

+

40 Tools Available

# 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)