diff --git a/CMakeLists.txt b/CMakeLists.txt index 7a86690..cf7d6bf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,13 @@ endif() # ---- Dependencies ------------------------------------------------------------ +# Workaround for macOS SDK removing AGL.framework which older Qt6 versions attempt to link +if(APPLE AND NOT TARGET WrapOpenGL::WrapOpenGL) + find_package(OpenGL REQUIRED) + add_library(WrapOpenGL::WrapOpenGL INTERFACE IMPORTED) + target_link_libraries(WrapOpenGL::WrapOpenGL INTERFACE OpenGL::GL) +endif() + find_package(Qt6 REQUIRED COMPONENTS Widgets Svg PrintSupport Network LinguistTools) # ---- Library (all sources except main.cpp) ----------------------------------- @@ -33,6 +40,7 @@ find_package(Qt6 REQUIRED COMPONENTS Widgets Svg PrintSupport Network LinguistTo add_library(ymind_lib OBJECT # Core – application infrastructure src/core/AboutDialog.h src/core/AboutDialog.cpp + src/core/AiClient.h src/core/AiClient.cpp src/core/AppSettings.h src/core/AppSettings.cpp src/core/AutoSaveManager.h src/core/AutoSaveManager.cpp src/core/BuiltinTemplateStrings.h @@ -75,6 +83,8 @@ add_library(ymind_lib OBJECT src/layout/LayoutStyle.h # UI – widgets and theming + src/ui/AiGenerateDialog.h src/ui/AiGenerateDialog.cpp + src/ui/AiSettingsDialog.h src/ui/AiSettingsDialog.cpp src/ui/FindBar.h src/ui/FindBar.cpp src/ui/FloatingSearchButton.h src/ui/FloatingSearchButton.cpp src/ui/IconFactory.h src/ui/IconFactory.cpp diff --git a/src/core/AiClient.cpp b/src/core/AiClient.cpp new file mode 100644 index 0000000..8665831 --- /dev/null +++ b/src/core/AiClient.cpp @@ -0,0 +1,230 @@ +#include "core/AiClient.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +AiClient::AiClient(QObject* parent) : QObject(parent), m_nam(new QNetworkAccessManager(this)) {} + +AiClient::~AiClient() { + cancel(); +} + +bool AiClient::isBusy() const { + return m_currentReply != nullptr && m_currentReply->isRunning(); +} + +void AiClient::cancel() { + if (m_currentReply) { + m_currentReply->abort(); + m_currentReply->deleteLater(); + m_currentReply = nullptr; + } +} + +QString AiClient::cleanMarkdownOutline(const QString& rawOutput) { + QString text = rawOutput.trimmed(); + + // Strip leading markdown code fences if wrapped in ``` or ```markdown + if (text.startsWith(QLatin1String("```"))) { + int firstNewline = text.indexOf('\n'); + if (firstNewline != -1) { + text = text.mid(firstNewline + 1); + } + } + if (text.endsWith(QLatin1String("```"))) { + text.chop(3); + } + return text.trimmed(); +} + +void AiClient::generateMindMapOutline(const QString& prompt, const QString& apiKey, + const QString& model, const QString& endpoint) { + if (isBusy()) { + cancel(); + } + + QString actualEndpoint = endpoint.trimmed().isEmpty() + ? QString::fromLatin1(kDefaultOrcaEndpoint) + : endpoint.trimmed(); + if (actualEndpoint.endsWith('/')) { + actualEndpoint.chop(1); + } + QUrl url(actualEndpoint + QStringLiteral("/chat/completions")); + + QString actualModel = + model.trimmed().isEmpty() ? QString::fromLatin1(kDefaultOrcaModel) : model.trimmed(); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + request.setRawHeader("Authorization", "Bearer " + apiKey.trimmed().toUtf8()); + + // Project attribution headers for OrcaRouter (and OpenRouter-compatible gateways) + request.setRawHeader("HTTP-Referer", kProjectReferer); + request.setRawHeader("X-Title", kProjectTitle); + + QJsonObject root; + root["model"] = actualModel; + root["temperature"] = 0.3; + + QJsonArray messages; + + QJsonObject sysMsg; + sysMsg["role"] = "system"; + sysMsg["content"] = + QStringLiteral("You are a specialized mind map outline generator.\n" + "Convert the user's text or topic into a structured Markdown outline.\n" + "Rules:\n" + "1. The first line must be a single top-level heading (# Topic Title) " + "representing the root topic.\n" + "2. Use subsequent headings (##, ###, etc.) or indented list items (- or *) " + "to represent subtopics and branch nodes.\n" + "3. Keep each node text concise and expressive (phrases or key concepts, " + "avoid long paragraphs).\n" + "4. Output ONLY the raw Markdown outline. Do NOT wrap output in ``` code " + "blocks. Do NOT include any intro, commentary, or outro."); + messages.append(sysMsg); + + QJsonObject userMsg; + userMsg["role"] = "user"; + userMsg["content"] = prompt.trimmed(); + messages.append(userMsg); + + root["messages"] = messages; + + QByteArray postData = QJsonDocument(root).toJson(QJsonDocument::Compact); + + emit started(); + + m_currentReply = m_nam->post(request, postData); + connect(m_currentReply, &QNetworkReply::finished, this, &AiClient::onReplyFinished); +} + +void AiClient::onReplyFinished() { + if (!m_currentReply) + return; + + QNetworkReply* reply = m_currentReply; + m_currentReply = nullptr; + reply->deleteLater(); + + if (reply->error() == QNetworkReply::OperationCanceledError) { + // Canceled by user intentionally + return; + } + + int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + QByteArray data = reply->readAll(); + + if (reply->error() != QNetworkReply::NoError) { + if (statusCode == 401) { + emit errorOccurred(tr("Invalid API Key. Please verify your API Key in Settings.")); + return; + } else if (statusCode == 429) { + emit errorOccurred( + tr("Rate limit reached. Please wait a moment or try another model.")); + return; + } + + // Try extracting error message from JSON response + QJsonParseError jsonErr; + QJsonDocument errDoc = QJsonDocument::fromJson(data, &jsonErr); + if (jsonErr.error == QJsonParseError::NoError && errDoc.isObject()) { + QJsonObject errObj = errDoc.object().value("error").toObject(); + QString msg = errObj.value("message").toString(); + if (!msg.isEmpty()) { + emit errorOccurred(tr("API Error (%1): %2").arg(statusCode).arg(msg)); + return; + } + } + + emit errorOccurred( + tr("Network request failed: %1 (HTTP %2)").arg(reply->errorString()).arg(statusCode)); + return; + } + + QJsonParseError jsonErr; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonErr); + if (jsonErr.error != QJsonParseError::NoError || !doc.isObject()) { + emit errorOccurred(tr("Failed to parse API response JSON.")); + return; + } + + QJsonObject resp = doc.object(); + QJsonArray choices = resp.value("choices").toArray(); + if (choices.isEmpty()) { + emit errorOccurred(tr("API returned no choices.")); + return; + } + + QJsonObject firstChoice = choices.first().toObject(); + QJsonObject message = firstChoice.value("message").toObject(); + QString content = message.value("content").toString(); + + QString cleaned = cleanMarkdownOutline(content); + if (cleaned.isEmpty()) { + emit errorOccurred(tr("Model returned an empty outline.")); + return; + } + + emit finished(cleaned); +} + +QByteArray AiClient::generateCodeVerifier() { + // Generate a 32-byte random verifier, base64url-encoded (43 chars) + QByteArray raw(32, '\0'); + QRandomGenerator::global()->fillRange(reinterpret_cast(raw.data()), + raw.size() / sizeof(quint32)); + return raw.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); +} + +QString AiClient::computeCodeChallenge(const QByteArray& verifier) { + QByteArray hash = QCryptographicHash::hash(verifier, QCryptographicHash::Sha256); + return QString::fromLatin1( + hash.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); +} + +void AiClient::exchangeCodeForKey(const QString& code, const QByteArray& codeVerifier) { + QUrl url(QStringLiteral("https://orcarouter.ai/api/v1/auth/keys")); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + + QJsonObject body; + body["code"] = code; + body["code_verifier"] = QString::fromLatin1(codeVerifier); + body["code_challenge_method"] = QStringLiteral("S256"); + + QByteArray postData = QJsonDocument(body).toJson(QJsonDocument::Compact); + QNetworkReply* reply = m_nam->post(request, postData); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + emit apiKeyError(tr("Failed to obtain API Key: %1").arg(reply->errorString())); + return; + } + + QJsonParseError jsonErr; + QJsonDocument doc = QJsonDocument::fromJson(reply->readAll(), &jsonErr); + if (jsonErr.error != QJsonParseError::NoError || !doc.isObject()) { + emit apiKeyError(tr("Invalid response from OrcaRouter auth server.")); + return; + } + + QString key = doc.object().value("key").toString(); + if (key.isEmpty()) { + emit apiKeyError(tr("OrcaRouter returned an empty API Key.")); + return; + } + + emit apiKeyReceived(key); + }); +} diff --git a/src/core/AiClient.h b/src/core/AiClient.h new file mode 100644 index 0000000..eb88a3a --- /dev/null +++ b/src/core/AiClient.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include + +class QNetworkAccessManager; +class QNetworkReply; + +class AiClient : public QObject { + Q_OBJECT + +public: + // OrcaRouter OSS program – attribution & referral + static constexpr const char* kDefaultOrcaEndpoint = "https://api.orcarouter.ai/v1"; + static constexpr const char* kDefaultOrcaModel = "deepseek/deepseek-chat:free"; + static constexpr const char* kProjectReferer = + "https://www.orcarouter.ai/ref/ref_dc5e5ce5b8727aef463e"; + static constexpr const char* kProjectTitle = "YMind"; + static constexpr const char* kOrcaPartnerUrl = + "https://www.orcarouter.ai/ref/ref_dc5e5ce5b8727aef463e"; + static constexpr const char* kOrcaAuthBase = "https://www.orcarouter.ai/auth"; + static constexpr const char* kOrcaRefCode = "ref_dc5e5ce5b8727aef463e"; + + explicit AiClient(QObject* parent = nullptr); + ~AiClient() override; + + bool isBusy() const; + void generateMindMapOutline(const QString& prompt, const QString& apiKey, + const QString& model = QString(), + const QString& endpoint = QString()); + void cancel(); + + // Utility: strips markdown code fences (```markdown ... ```) and excess whitespace + static QString cleanMarkdownOutline(const QString& rawOutput); + + // PKCE helpers for OAuth API key exchange + static QByteArray generateCodeVerifier(); + static QString computeCodeChallenge(const QByteArray& verifier); + + // Exchange authorization code for an API key via OrcaRouter's PKCE endpoint + void exchangeCodeForKey(const QString& code, const QByteArray& codeVerifier); + +signals: + void started(); + void finished(const QString& markdownOutline); + void errorOccurred(const QString& errorMessage); + void apiKeyReceived(const QString& apiKey); + void apiKeyError(const QString& errorMessage); + +private slots: + void onReplyFinished(); + +private: + QNetworkAccessManager* m_nam; + QPointer m_currentReply; +}; diff --git a/src/core/AppSettings.cpp b/src/core/AppSettings.cpp index 77b832a..e593260 100644 --- a/src/core/AppSettings.cpp +++ b/src/core/AppSettings.cpp @@ -112,3 +112,35 @@ QString AppSettings::language() const { void AppSettings::setLanguage(const QString& lang) { m_settings->setValue("appearance/language", lang); } + +QString AppSettings::aiProvider() const { + return m_settings->value("ai/provider", "OrcaRouter").toString(); +} + +void AppSettings::setAiProvider(const QString& provider) { + m_settings->setValue("ai/provider", provider); +} + +QString AppSettings::aiApiKey() const { + return m_settings->value("ai/apiKey", QString()).toString(); +} + +void AppSettings::setAiApiKey(const QString& key) { + m_settings->setValue("ai/apiKey", key); +} + +QString AppSettings::aiModel() const { + return m_settings->value("ai/model", "deepseek/deepseek-chat:free").toString(); +} + +void AppSettings::setAiModel(const QString& model) { + m_settings->setValue("ai/model", model); +} + +QString AppSettings::aiCustomEndpoint() const { + return m_settings->value("ai/customEndpoint", "https://api.orcarouter.ai/v1").toString(); +} + +void AppSettings::setAiCustomEndpoint(const QString& endpoint) { + m_settings->setValue("ai/customEndpoint", endpoint); +} diff --git a/src/core/AppSettings.h b/src/core/AppSettings.h index b112b3f..161a4bd 100644 --- a/src/core/AppSettings.h +++ b/src/core/AppSettings.h @@ -41,6 +41,18 @@ class AppSettings : public QObject { QString language() const; void setLanguage(const QString& lang); + QString aiProvider() const; + void setAiProvider(const QString& provider); + + QString aiApiKey() const; + void setAiApiKey(const QString& key); + + QString aiModel() const; + void setAiModel(const QString& model); + + QString aiCustomEndpoint() const; + void setAiCustomEndpoint(const QString& endpoint); + signals: void themeChanged(AppTheme theme); void autoSaveSettingsChanged(); diff --git a/src/core/FileManager.cpp b/src/core/FileManager.cpp index 411fecd..38ddd1d 100644 --- a/src/core/FileManager.cpp +++ b/src/core/FileManager.cpp @@ -22,9 +22,9 @@ void FileManager::newFile() { } void FileManager::openFile() { - QString filePath = - QFileDialog::getOpenFileName(m_window, tr("Open Mind Map"), QString(), - tr("YMind Files (*.ymind);;JSON Files (*.json);;All Files (*)")); + QString filePath = QFileDialog::getOpenFileName( + m_window, tr("Open Mind Map"), QString(), + tr("YMind Files (*.ymind);;JSON Files (*.json);;All Files (*)")); if (filePath.isEmpty()) return; @@ -91,9 +91,9 @@ void FileManager::saveFile() { } void FileManager::saveFileAs() { - QString filePath = - QFileDialog::getSaveFileName(m_window, tr("Save Mind Map"), QString(), - tr("YMind Files (*.ymind);;JSON Files (*.json);;All Files (*)")); + QString filePath = QFileDialog::getSaveFileName( + m_window, tr("Save Mind Map"), QString(), + tr("YMind Files (*.ymind);;JSON Files (*.json);;All Files (*)")); if (filePath.isEmpty()) return; @@ -118,8 +118,7 @@ void FileManager::saveFileAs() { // Common export helper // --------------------------------------------------------------------------- void FileManager::doExport(const QString& dialogTitle, const QString& filter, - const QString& defaultExt, - std::function exporter, + const QString& defaultExt, std::function exporter, const QString& errorLabel) { QString filePath = QFileDialog::getSaveFileName(m_window, dialogTitle, QString(), filter); if (filePath.isEmpty()) @@ -138,61 +137,59 @@ void FileManager::doExport(const QString& dialogTitle, const QString& filter, } void FileManager::exportAsText() { - doExport(tr("Export as Text"), tr("Text Files (*.txt);;All Files (*)"), ".txt", - [this](const QString& path) { - QFile file(path); - if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) - return false; - file.write(m_tabManager->currentScene()->exportToText().toUtf8()); - file.close(); - return true; - }, - tr("file")); + doExport( + tr("Export as Text"), tr("Text Files (*.txt);;All Files (*)"), ".txt", + [this](const QString& path) { + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) + return false; + file.write(m_tabManager->currentScene()->exportToText().toUtf8()); + file.close(); + return true; + }, + tr("file")); } void FileManager::exportAsMarkdown() { - doExport(tr("Export as Markdown"), tr("Markdown Files (*.md);;All Files (*)"), ".md", - [this](const QString& path) { - QFile file(path); - if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) - return false; - file.write(m_tabManager->currentScene()->exportToMarkdown().toUtf8()); - file.close(); - return true; - }, - tr("file")); + doExport( + tr("Export as Markdown"), tr("Markdown Files (*.md);;All Files (*)"), ".md", + [this](const QString& path) { + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) + return false; + file.write(m_tabManager->currentScene()->exportToMarkdown().toUtf8()); + file.close(); + return true; + }, + tr("file")); } void FileManager::exportAsPng() { - doExport(tr("Export as PNG"), tr("PNG Images (*.png);;All Files (*)"), ".png", - [this](const QString& path) { - return m_tabManager->currentScene()->exportToPng(path); - }, - "PNG"); + doExport( + tr("Export as PNG"), tr("PNG Images (*.png);;All Files (*)"), ".png", + [this](const QString& path) { return m_tabManager->currentScene()->exportToPng(path); }, + "PNG"); } void FileManager::exportAsSvg() { - doExport(tr("Export as SVG"), tr("SVG Files (*.svg);;All Files (*)"), ".svg", - [this](const QString& path) { - return m_tabManager->currentScene()->exportToSvg(path); - }, - "SVG"); + doExport( + tr("Export as SVG"), tr("SVG Files (*.svg);;All Files (*)"), ".svg", + [this](const QString& path) { return m_tabManager->currentScene()->exportToSvg(path); }, + "SVG"); } void FileManager::exportAsPdf() { - doExport(tr("Export as PDF"), tr("PDF Files (*.pdf);;All Files (*)"), ".pdf", - [this](const QString& path) { - return m_tabManager->currentScene()->exportToPdf(path); - }, - "PDF"); + doExport( + tr("Export as PDF"), tr("PDF Files (*.pdf);;All Files (*)"), ".pdf", + [this](const QString& path) { return m_tabManager->currentScene()->exportToPdf(path); }, + "PDF"); } namespace { // Render the first N issues from the strict markdown parser as a single // translated block, with a "...and X more" tail if we truncate. -QString formatMarkdownIssues(const QList& issues, - int maxToShow = 10) { +QString formatMarkdownIssues(const QList& issues, int maxToShow = 10) { QStringList lines; const int n = std::min(issues.size(), maxToShow); for (int i = 0; i < n; ++i) { @@ -207,8 +204,7 @@ QString formatMarkdownIssues(const QList& issues, return lines.join('\n'); } -void showMarkdownImportError(QWidget* window, - const QString& filePath, +void showMarkdownImportError(QWidget* window, const QString& filePath, const MarkdownImportReport& report) { QMessageBox box(window); box.setIcon(QMessageBox::Warning); @@ -227,16 +223,15 @@ void showMarkdownImportError(QWidget* window, } // namespace void FileManager::importFromMarkdown() { - QString filePath = QFileDialog::getOpenFileName( - m_window, tr("Import from Markdown"), QString(), - tr("Markdown Files (*.md *.markdown);;All Files (*)")); + QString filePath = + QFileDialog::getOpenFileName(m_window, tr("Import from Markdown"), QString(), + tr("Markdown Files (*.md *.markdown);;All Files (*)")); if (filePath.isEmpty()) return; QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - QMessageBox::warning(m_window, "YMind", - tr("Could not read file:\n%1").arg(filePath)); + QMessageBox::warning(m_window, "YMind", tr("Could not read file:\n%1").arg(filePath)); return; } QString text = QString::fromUtf8(file.readAll()); @@ -281,3 +276,47 @@ void FileManager::importFromMarkdown() { if (auto* mw = qobject_cast(m_window)) mw->statusBar()->showMessage(tr("Imported from %1").arg(filePath), 3000); } + +void FileManager::importMarkdownContent(const QString& markdown) { + if (markdown.trimmed().isEmpty()) + return; + + int cur = m_tabManager->currentIndex(); + if (cur >= 0 && m_tabManager->isTabEmpty(cur)) { + auto* scene = m_tabManager->currentScene(); + auto* view = m_tabManager->currentView(); + if (!scene->importFromMarkdown(markdown, /*animate=*/true)) { + QMessageBox::warning(m_window, QStringLiteral("YMind"), + tr("Failed to parse generated Markdown into a mind map.")); + return; + } + m_tabManager->setCurrentFilePath(QString()); + auto& tab = m_tabManager->tab(cur); + if (tab.stack) + tab.stack->setCurrentIndex(1); + view->zoomToFit(); + m_tabManager->notifyTabChanged(cur); + } else { + auto* scene = new MindMapScene(m_window); + auto* view = new MindMapView(m_window); + view->setScene(scene); + + if (!scene->importFromMarkdown(markdown, /*animate=*/true)) { + QMessageBox::warning(m_window, QStringLiteral("YMind"), + tr("Failed to parse generated Markdown into a mind map.")); + delete scene; + delete view; + return; + } + + auto* stack = new QStackedWidget(m_window); + stack->addWidget(view); + stack->setCurrentIndex(0); + + m_tabManager->addTab(scene, view, stack, QString()); + m_tabManager->currentView()->zoomToFit(); + } + + if (auto* mw = qobject_cast(m_window)) + mw->statusBar()->showMessage(tr("Mind map generated successfully"), 3000); +} diff --git a/src/core/FileManager.h b/src/core/FileManager.h index 1c82a1f..7931493 100644 --- a/src/core/FileManager.h +++ b/src/core/FileManager.h @@ -21,6 +21,7 @@ class FileManager : public QObject { void exportAsSvg(); void exportAsPdf(); void importFromMarkdown(); + void importMarkdownContent(const QString& markdown); private: // Common export helper: shows save dialog, validates extension, runs exporter, shows status. diff --git a/src/core/MainWindow.cpp b/src/core/MainWindow.cpp index f28ecff..e96a2a3 100644 --- a/src/core/MainWindow.cpp +++ b/src/core/MainWindow.cpp @@ -13,6 +13,7 @@ #include "scene/MindMapScene.h" #include "scene/MindMapView.h" #include "scene/NodeItem.h" +#include "ui/AiGenerateDialog.h" #include "ui/FindBar.h" #include "ui/FloatingSearchButton.h" #include "ui/IconFactory.h" @@ -220,6 +221,8 @@ void MainWindow::setupCentralLayout() { if (m_toggleToolbarAct) m_toggleToolbarAct->setChecked(false); }); + connect(m_toolbar, &MindMapToolBar::aiGenerateRequested, this, + &MainWindow::showAiGenerateDialog); // ---- Content area: splitter with outline + right panel (toolbar + tab pages) ---- m_contentSplitter = new QSplitter(Qt::Horizontal, this); @@ -339,6 +342,10 @@ void MainWindow::setupMenuBar() { auto* importAct = fileMenu->addAction(tr("&Import from Markdown...")); connect(importAct, &QAction::triggered, m_fileManager, &FileManager::importFromMarkdown); + auto* aiGenerateAct = fileMenu->addAction(tr("Generate from &Text (AI)...")); + aiGenerateAct->setShortcut(QKeySequence("Ctrl+Shift+N")); + connect(aiGenerateAct, &QAction::triggered, this, &MainWindow::showAiGenerateDialog); + auto* exportMenu = fileMenu->addMenu(tr("&Export")); exportMenu->addAction(tr("As &Text..."), m_fileManager, &FileManager::exportAsText); exportMenu->addAction(tr("As &Markdown..."), m_fileManager, &FileManager::exportAsMarkdown); @@ -752,6 +759,13 @@ void MainWindow::openAbout() { dlg.exec(); } +void MainWindow::showAiGenerateDialog() { + AiGenerateDialog dlg(this); + connect(&dlg, &AiGenerateDialog::outlineGenerated, this, + [this](const QString& markdown) { m_fileManager->importMarkdownContent(markdown); }); + dlg.exec(); +} + void MainWindow::saveWindowState() { auto* s = m_services->settings; s->setWindowGeometry(saveGeometry()); diff --git a/src/core/MainWindow.h b/src/core/MainWindow.h index 7ca8daf..0897ef1 100644 --- a/src/core/MainWindow.h +++ b/src/core/MainWindow.h @@ -51,6 +51,7 @@ class MainWindow : public QMainWindow { void openSettings(); void openAbout(); + void showAiGenerateDialog(); void saveWindowState(); void restoreWindowState(); void applyTheme(); diff --git a/src/ui/AiGenerateDialog.cpp b/src/ui/AiGenerateDialog.cpp new file mode 100644 index 0000000..4076df0 --- /dev/null +++ b/src/ui/AiGenerateDialog.cpp @@ -0,0 +1,159 @@ +#include "ui/AiGenerateDialog.h" +#include "core/AiClient.h" +#include "core/AppSettings.h" +#include "ui/AiSettingsDialog.h" + +#include +#include +#include +#include +#include +#include +#include + +AiGenerateDialog::AiGenerateDialog(QWidget* parent) + : QDialog(parent), m_aiClient(new AiClient(this)) { + setWindowTitle(tr("Generate Mind Map from Text (AI)")); + resize(540, 420); + setupUI(); + + connect(m_aiClient, &AiClient::started, this, &AiGenerateDialog::onAiStarted); + connect(m_aiClient, &AiClient::finished, this, &AiGenerateDialog::onAiFinished); + connect(m_aiClient, &AiClient::errorOccurred, this, &AiGenerateDialog::onAiError); +} + +AiGenerateDialog::~AiGenerateDialog() = default; + +void AiGenerateDialog::setupUI() { + auto* mainLayout = new QVBoxLayout(this); + mainLayout->setSpacing(12); + + auto* headerLabel = + new QLabel(tr("Enter a topic, notes, or outline to generate a mind map:"), this); + mainLayout->addWidget(headerLabel); + + m_inputText = new QPlainTextEdit(this); + m_inputText->setPlaceholderText( + tr("e.g. Distributed system consensus algorithms\n" + "- Paxos and Raft\n" + "- Leader election\n" + "- Log replication\n\n" + "Or paste any meeting notes, article fragments, or topic ideas here...")); + mainLayout->addWidget(m_inputText, 1); + + // Model info and quick settings bar + auto* infoBar = new QHBoxLayout(); + m_modelInfoLabel = new QLabel(this); + m_modelInfoLabel->setObjectName("settingsHint"); + infoBar->addWidget(m_modelInfoLabel, 1); + + m_settingsBtn = new QPushButton(tr("Settings..."), this); + connect(m_settingsBtn, &QPushButton::clicked, this, &AiGenerateDialog::onOpenSettings); + infoBar->addWidget(m_settingsBtn); + mainLayout->addLayout(infoBar); + + // Progress bar and status + m_progressBar = new QProgressBar(this); + m_progressBar->setRange(0, 0); // marquee + m_progressBar->setVisible(false); + m_progressBar->setFixedHeight(6); + m_progressBar->setTextVisible(false); + mainLayout->addWidget(m_progressBar); + + m_statusLabel = new QLabel(this); + m_statusLabel->setWordWrap(true); + mainLayout->addWidget(m_statusLabel); + + // Action buttons + auto* buttonLayout = new QHBoxLayout(); + buttonLayout->addStretch(); + + m_cancelBtn = new QPushButton(tr("Cancel"), this); + connect(m_cancelBtn, &QPushButton::clicked, this, &AiGenerateDialog::onCancelClicked); + buttonLayout->addWidget(m_cancelBtn); + + m_generateBtn = new QPushButton(tr("Generate"), this); + m_generateBtn->setDefault(true); + connect(m_generateBtn, &QPushButton::clicked, this, &AiGenerateDialog::onGenerateClicked); + buttonLayout->addWidget(m_generateBtn); + + mainLayout->addLayout(buttonLayout); + + updateModelInfo(); +} + +void AiGenerateDialog::updateModelInfo() { + auto& s = AppSettings::instance(); + if (s.aiApiKey().trimmed().isEmpty()) { + m_modelInfoLabel->setText(tr("API Key not set. Click Settings to configure.")); + } else { + m_modelInfoLabel->setText(tr("Provider: %1 | Model: %2").arg(s.aiProvider(), s.aiModel())); + } +} + +void AiGenerateDialog::onOpenSettings() { + AiSettingsDialog dlg(this); + if (dlg.exec() == QDialog::Accepted) { + updateModelInfo(); + m_statusLabel->clear(); + } +} + +void AiGenerateDialog::onGenerateClicked() { + QString prompt = m_inputText->toPlainText().trimmed(); + if (prompt.isEmpty()) { + m_statusLabel->setText(tr("Please enter text or a topic first.")); + m_inputText->setFocus(); + return; + } + + auto& s = AppSettings::instance(); + QString apiKey = s.aiApiKey().trimmed(); + if (apiKey.isEmpty()) { + auto reply = QMessageBox::information( + this, tr("API Key Required"), + tr("Please configure your AI API Key before generating mind maps.\n" + "Would you like to open Settings now?"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + if (reply == QMessageBox::Yes) { + onOpenSettings(); + } + return; + } + + QString endpoint = + (s.aiProvider() == QStringLiteral("Custom")) ? s.aiCustomEndpoint() : QString(); + m_statusLabel->clear(); + m_aiClient->generateMindMapOutline(prompt, apiKey, s.aiModel(), endpoint); +} + +void AiGenerateDialog::onCancelClicked() { + if (m_aiClient->isBusy()) { + m_aiClient->cancel(); + m_inputText->setEnabled(true); + m_generateBtn->setEnabled(true); + m_progressBar->setVisible(false); + m_statusLabel->setText(tr("Generation canceled.")); + } else { + reject(); + } +} + +void AiGenerateDialog::onAiStarted() { + m_inputText->setEnabled(false); + m_generateBtn->setEnabled(false); + m_progressBar->setVisible(true); + m_statusLabel->setText(tr("Generating mind map with AI...")); +} + +void AiGenerateDialog::onAiFinished(const QString& markdownOutline) { + emit outlineGenerated(markdownOutline); + accept(); +} + +void AiGenerateDialog::onAiError(const QString& errorMessage) { + m_inputText->setEnabled(true); + m_generateBtn->setEnabled(true); + m_progressBar->setVisible(false); + m_statusLabel->setText(errorMessage); +} diff --git a/src/ui/AiGenerateDialog.h b/src/ui/AiGenerateDialog.h new file mode 100644 index 0000000..24eed7e --- /dev/null +++ b/src/ui/AiGenerateDialog.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +class AiClient; +class QPlainTextEdit; +class QPushButton; +class QProgressBar; +class QLabel; + +class AiGenerateDialog : public QDialog { + Q_OBJECT + +public: + explicit AiGenerateDialog(QWidget* parent = nullptr); + ~AiGenerateDialog() override; + +signals: + void outlineGenerated(const QString& markdownOutline); + +private slots: + void onGenerateClicked(); + void onCancelClicked(); + void onAiStarted(); + void onAiFinished(const QString& markdownOutline); + void onAiError(const QString& errorMessage); + void onOpenSettings(); + +private: + void setupUI(); + void updateModelInfo(); + + AiClient* m_aiClient; + QPlainTextEdit* m_inputText; + QLabel* m_modelInfoLabel; + QProgressBar* m_progressBar; + QLabel* m_statusLabel; + QPushButton* m_generateBtn; + QPushButton* m_cancelBtn; + QPushButton* m_settingsBtn; +}; diff --git a/src/ui/AiSettingsDialog.cpp b/src/ui/AiSettingsDialog.cpp new file mode 100644 index 0000000..4f8bed9 --- /dev/null +++ b/src/ui/AiSettingsDialog.cpp @@ -0,0 +1,243 @@ +#include "ui/AiSettingsDialog.h" +#include "core/AiClient.h" +#include "core/AppSettings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +AiSettingsDialog::AiSettingsDialog(QWidget* parent) + : QDialog(parent), m_aiClient(new AiClient(this)) { + setWindowTitle(tr("AI Settings")); + setMinimumWidth(400); + + auto* mainLayout = new QVBoxLayout(this); + + // AI Service group + auto* aiGroup = new QGroupBox(tr("AI Service")); + auto* aiLayout = new QFormLayout(aiGroup); + + m_providerCombo = new QComboBox; + m_providerCombo->addItem(tr("OrcaRouter (Recommended)"), QStringLiteral("OrcaRouter")); + m_providerCombo->addItem(tr("Custom (OpenAI-compatible)"), QStringLiteral("Custom")); + aiLayout->addRow(tr("Provider:"), m_providerCombo); + + m_modelCombo = new QComboBox; + m_modelCombo->setEditable(true); + m_modelCombo->addItem(QStringLiteral("deepseek/deepseek-chat:free")); + m_modelCombo->addItem(QStringLiteral("qwen/qwen-2.5-72b-instruct:free")); + m_modelCombo->addItem(QStringLiteral("orcarouter/auto")); + aiLayout->addRow(tr("Model:"), m_modelCombo); + + m_apiKeyEdit = new QLineEdit; + m_apiKeyEdit->setEchoMode(QLineEdit::PasswordEchoOnEdit); + m_apiKeyEdit->setPlaceholderText(tr("Enter API Key")); + aiLayout->addRow(tr("API Key:"), m_apiKeyEdit); + + // One-click get API Key button + auto* keyBtnLayout = new QHBoxLayout(); + m_getKeyBtn = new QPushButton(tr("Get API Key from OrcaRouter...")); + connect(m_getKeyBtn, &QPushButton::clicked, this, &AiSettingsDialog::onGetApiKey); + keyBtnLayout->addWidget(m_getKeyBtn); + keyBtnLayout->addStretch(); + aiLayout->addRow("", keyBtnLayout); + + m_authStatusLabel = new QLabel; + m_authStatusLabel->setObjectName("settingsHint"); + m_authStatusLabel->setWordWrap(true); + m_authStatusLabel->setVisible(false); + aiLayout->addRow(m_authStatusLabel); + + m_endpointEdit = new QLineEdit; + m_endpointEdit->setPlaceholderText(QString::fromLatin1(AiClient::kDefaultOrcaEndpoint)); + aiLayout->addRow(tr("Endpoint URL:"), m_endpointEdit); + + auto updateProviderUI = [this]() { + bool isCustom = (m_providerCombo->currentData().toString() == QLatin1String("Custom")); + m_endpointEdit->setEnabled(isCustom); + m_getKeyBtn->setVisible(!isCustom); + if (!isCustom) { + m_endpointEdit->setText(QString::fromLatin1(AiClient::kDefaultOrcaEndpoint)); + } + }; + connect(m_providerCombo, QOverload::of(&QComboBox::currentIndexChanged), this, + updateProviderUI); + + mainLayout->addWidget(aiGroup); + + mainLayout->addStretch(); + + // Button box + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + connect(buttons, &QDialogButtonBox::accepted, this, [this]() { + apply(); + accept(); + }); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + mainLayout->addWidget(buttons); + + // Connect API key exchange signals + connect(m_aiClient, &AiClient::apiKeyReceived, this, [this](const QString& key) { + m_apiKeyEdit->setText(key); + m_authStatusLabel->setText(tr("API Key obtained successfully!")); + m_authStatusLabel->setVisible(true); + m_getKeyBtn->setEnabled(true); + m_getKeyBtn->setText(tr("Get API Key from OrcaRouter...")); + if (m_callbackServer) { + m_callbackServer->close(); + m_callbackServer->deleteLater(); + m_callbackServer = nullptr; + } + }); + connect(m_aiClient, &AiClient::apiKeyError, this, [this](const QString& err) { + m_authStatusLabel->setText(err); + m_authStatusLabel->setVisible(true); + m_getKeyBtn->setEnabled(true); + m_getKeyBtn->setText(tr("Get API Key from OrcaRouter...")); + if (m_callbackServer) { + m_callbackServer->close(); + m_callbackServer->deleteLater(); + m_callbackServer = nullptr; + } + }); + + loadCurrentSettings(); +} + +void AiSettingsDialog::onGetApiKey() { + // 1. Generate PKCE verifier and challenge + m_codeVerifier = AiClient::generateCodeVerifier(); + QString challenge = AiClient::computeCodeChallenge(m_codeVerifier); + m_oauthState = QUuid::createUuid().toString(QUuid::WithoutBraces); + + // 2. Start local TCP server for callback + if (m_callbackServer) { + m_callbackServer->close(); + m_callbackServer->deleteLater(); + } + m_callbackServer = new QTcpServer(this); + if (!m_callbackServer->listen(QHostAddress::LocalHost, 0)) { + m_authStatusLabel->setText(tr("Failed to start local auth server.")); + m_authStatusLabel->setVisible(true); + return; + } + connect(m_callbackServer, &QTcpServer::newConnection, this, &AiSettingsDialog::onAuthCallback); + + quint16 port = m_callbackServer->serverPort(); + QString callbackUrl = QStringLiteral("http://127.0.0.1:%1/cb").arg(port); + + // 3. Build auth URL + QUrl authUrl(QString::fromLatin1(AiClient::kOrcaAuthBase)); + QUrlQuery query; + query.addQueryItem("callback_url", callbackUrl); + query.addQueryItem("code_challenge", challenge); + query.addQueryItem("code_challenge_method", "S256"); + query.addQueryItem("state", m_oauthState); + query.addQueryItem("app_name", QString::fromLatin1(AiClient::kProjectTitle)); + query.addQueryItem("ref", QString::fromLatin1(AiClient::kOrcaRefCode)); + authUrl.setQuery(query); + + // 4. Open browser and update UI + QDesktopServices::openUrl(authUrl); + m_getKeyBtn->setEnabled(false); + m_getKeyBtn->setText(tr("Waiting for authorization...")); + m_authStatusLabel->setText( + tr("A browser window has been opened. Please authorize YMind on OrcaRouter, " + "then return here.")); + m_authStatusLabel->setVisible(true); +} + +void AiSettingsDialog::onAuthCallback() { + if (!m_callbackServer) + return; + + QTcpSocket* socket = m_callbackServer->nextPendingConnection(); + if (!socket) + return; + + connect(socket, &QTcpSocket::readyRead, this, [this, socket]() { + QByteArray data = socket->readAll(); + + // Parse the HTTP GET request line: "GET /cb?code=...&state=... HTTP/1.1" + QString requestLine = QString::fromUtf8(data).section('\n', 0, 0).trimmed(); + QString path = requestLine.section(' ', 1, 1); // "/cb?code=xxx&state=yyy" + + QUrl requestUrl(QStringLiteral("http://localhost") + path); + QUrlQuery params(requestUrl.query()); + QString code = params.queryItemValue("code"); + QString state = params.queryItemValue("state"); + + // Send response to browser + QByteArray response; + if (!code.isEmpty() && state == m_oauthState) { + response = "HTTP/1.1 200 OK\r\n" + "Content-Type: text/html; charset=utf-8\r\n" + "Connection: close\r\n\r\n" + "" + "

✓ Authorization successful!

" + "

You can close this window and return to YMind.

" + ""; + } else { + response = "HTTP/1.1 400 Bad Request\r\n" + "Content-Type: text/html; charset=utf-8\r\n" + "Connection: close\r\n\r\n" + "" + "

Authorization failed

" + "

Please try again from YMind.

" + ""; + } + socket->write(response); + socket->flush(); + socket->disconnectFromHost(); + + // Close the server immediately — we only need one callback + m_callbackServer->close(); + + if (!code.isEmpty() && state == m_oauthState) { + m_authStatusLabel->setText(tr("Exchanging authorization code for API Key...")); + m_aiClient->exchangeCodeForKey(code, m_codeVerifier); + } else { + m_authStatusLabel->setText(tr("Authorization failed: invalid state or missing code.")); + m_getKeyBtn->setEnabled(true); + m_getKeyBtn->setText(tr("Get API Key from OrcaRouter...")); + } + }); +} + +void AiSettingsDialog::loadCurrentSettings() { + auto& s = AppSettings::instance(); + + int provIdx = m_providerCombo->findData(s.aiProvider()); + if (provIdx >= 0) + m_providerCombo->setCurrentIndex(provIdx); + else + m_providerCombo->setCurrentIndex(0); + + m_modelCombo->setEditText(s.aiModel()); + m_apiKeyEdit->setText(s.aiApiKey()); + m_endpointEdit->setText(s.aiCustomEndpoint()); + bool isCustom = (m_providerCombo->currentData().toString() == QLatin1String("Custom")); + m_endpointEdit->setEnabled(isCustom); + m_getKeyBtn->setVisible(!isCustom); +} + +void AiSettingsDialog::apply() { + auto& s = AppSettings::instance(); + s.setAiProvider(m_providerCombo->currentData().toString()); + s.setAiModel(m_modelCombo->currentText().trimmed()); + s.setAiApiKey(m_apiKeyEdit->text().trimmed()); + s.setAiCustomEndpoint(m_endpointEdit->text().trimmed()); +} diff --git a/src/ui/AiSettingsDialog.h b/src/ui/AiSettingsDialog.h new file mode 100644 index 0000000..f4914ed --- /dev/null +++ b/src/ui/AiSettingsDialog.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +class AiClient; +class QComboBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QTcpServer; + +class AiSettingsDialog : public QDialog { + Q_OBJECT + +public: + explicit AiSettingsDialog(QWidget* parent = nullptr); + +private slots: + void onGetApiKey(); + void onAuthCallback(); + +private: + void loadCurrentSettings(); + void apply(); + + AiClient* m_aiClient; + QComboBox* m_providerCombo; + QComboBox* m_modelCombo; + QLineEdit* m_apiKeyEdit; + QLineEdit* m_endpointEdit; + QPushButton* m_getKeyBtn; + QLabel* m_authStatusLabel; + + // PKCE OAuth state + QTcpServer* m_callbackServer = nullptr; + QByteArray m_codeVerifier; + QString m_oauthState; +}; diff --git a/src/ui/IconFactory.cpp b/src/ui/IconFactory.cpp index ba8270c..842bd2b 100644 --- a/src/ui/IconFactory.cpp +++ b/src/ui/IconFactory.cpp @@ -117,6 +117,20 @@ QIcon IconFactory::makeToolIcon(const QString& name) { } else if (name == "close-panel") { p.drawLine(10, 10, 22, 22); p.drawLine(22, 10, 10, 22); + } else if (name == "ai-generate") { + p.setBrush(baseColor); + p.setPen(Qt::NoPen); + auto drawStar = [&](qreal cx, qreal cy, qreal r) { + QPainterPath star; + star.moveTo(cx, cy - r); + star.quadTo(cx, cy, cx + r, cy); + star.quadTo(cx, cy, cx, cy + r); + star.quadTo(cx, cy, cx - r, cy); + star.quadTo(cx, cy, cx, cy - r); + p.drawPath(star); + }; + drawStar(14.0, 16.0, 9.0); + drawStar(23.0, 8.0, 5.0); } else if (name == "update" || name == "update-available") { // Rounded-square package with a download arrow — matches the line-art // weight of the other toolbar icons and reads clearly at small sizes. @@ -290,8 +304,8 @@ void drawCurve(QPainter& p, qreal x1, qreal y1, qreal x2, qreal y2, bool horizon // Draw a node = (very subtle drop shadow, light mode only) + tinted fill + // colored border. Border is painted last so it always sits on top of any // curves that pass underneath. -void drawNode(QPainter& p, const QRectF& r, qreal radius, const QColor& accent, - qreal strokeW, bool dark) { +void drawNode(QPainter& p, const QRectF& r, qreal radius, const QColor& accent, qreal strokeW, + bool dark) { if (!dark) { // 1-pixel-Y drop shadow gives just enough lift without looking heavy. p.setPen(Qt::NoPen); @@ -348,25 +362,27 @@ void drawMindMapPreview(QPainter& p, const PreviewPalette& pal) { constexpr qreal radius = 4.0; const QRectF center(42, 26, 36, 18); const QRectF leaves[4] = { - QRectF(84, 4, 28, 12), QRectF(84, 52, 28, 12), - QRectF(4, 4, 28, 12), QRectF(4, 52, 28, 12), + QRectF(84, 4, 28, 12), + QRectF(84, 52, 28, 12), + QRectF(4, 4, 28, 12), + QRectF(4, 52, 28, 12), }; const int leafColorIdx[4] = {1, 2, 3, 4}; p.setBrush(Qt::NoBrush); // Right-side curves - p.setPen(QPen(connectorTone(pal.accent[leafColorIdx[0]], pal.dark), kCurveW, - Qt::SolidLine, Qt::RoundCap)); + p.setPen(QPen(connectorTone(pal.accent[leafColorIdx[0]], pal.dark), kCurveW, Qt::SolidLine, + Qt::RoundCap)); drawCurve(p, 78 - kInset, 35, 84 + kInset, 10, true); - p.setPen(QPen(connectorTone(pal.accent[leafColorIdx[1]], pal.dark), kCurveW, - Qt::SolidLine, Qt::RoundCap)); + p.setPen(QPen(connectorTone(pal.accent[leafColorIdx[1]], pal.dark), kCurveW, Qt::SolidLine, + Qt::RoundCap)); drawCurve(p, 78 - kInset, 35, 84 + kInset, 58, true); // Left-side curves - p.setPen(QPen(connectorTone(pal.accent[leafColorIdx[2]], pal.dark), kCurveW, - Qt::SolidLine, Qt::RoundCap)); + p.setPen(QPen(connectorTone(pal.accent[leafColorIdx[2]], pal.dark), kCurveW, Qt::SolidLine, + Qt::RoundCap)); drawCurve(p, 42 + kInset, 35, 32 - kInset, 10, true); - p.setPen(QPen(connectorTone(pal.accent[leafColorIdx[3]], pal.dark), kCurveW, - Qt::SolidLine, Qt::RoundCap)); + p.setPen(QPen(connectorTone(pal.accent[leafColorIdx[3]], pal.dark), kCurveW, Qt::SolidLine, + Qt::RoundCap)); drawCurve(p, 42 + kInset, 35, 32 - kInset, 58, true); drawNode(p, center, radius, pal.accent[0], kStrokeW, pal.dark); @@ -377,14 +393,16 @@ void drawMindMapPreview(QPainter& p, const PreviewPalette& pal) { void drawOrgChartPreview(QPainter& p, const PreviewPalette& pal) { const QRectF top(42, 4, 36, 14); const QRectF leaves[3] = { - QRectF(8, 46, 28, 14), QRectF(46, 46, 28, 14), QRectF(84, 46, 28, 14), + QRectF(8, 46, 28, 14), + QRectF(46, 46, 28, 14), + QRectF(84, 46, 28, 14), }; const int leafColorIdx[3] = {1, 2, 3}; p.setBrush(Qt::NoBrush); for (int i = 0; i < 3; ++i) { - p.setPen(QPen(connectorTone(pal.accent[leafColorIdx[i]], pal.dark), kCurveW, - Qt::SolidLine, Qt::RoundCap)); + p.setPen(QPen(connectorTone(pal.accent[leafColorIdx[i]], pal.dark), kCurveW, Qt::SolidLine, + Qt::RoundCap)); const qreal targetX = leaves[i].center().x(); drawCurve(p, 60, 18 - kInset, targetX, 46 + kInset, false); } @@ -398,8 +416,10 @@ void drawProjectPlanPreview(QPainter& p, const PreviewPalette& pal) { const QRectF root(4, 26, 28, 14); const QRectF mid[2] = {QRectF(44, 8, 28, 12), QRectF(44, 46, 28, 12)}; const QRectF leaves[4] = { - QRectF(84, 2, 28, 10), QRectF(84, 18, 28, 10), - QRectF(84, 40, 28, 10), QRectF(84, 54, 28, 10), + QRectF(84, 2, 28, 10), + QRectF(84, 18, 28, 10), + QRectF(84, 40, 28, 10), + QRectF(84, 54, 28, 10), }; p.setBrush(Qt::NoBrush); @@ -493,13 +513,17 @@ void drawGenericPreview(QPainter& p, const PreviewPalette& pal) { p.setBrush(Qt::NoBrush); // 4 radiating curves, each colored by a different palette slot for life. const int idx[4] = {1, 2, 3, 4}; - p.setPen(QPen(connectorTone(pal.accent[idx[0]], pal.dark), kCurveW, Qt::SolidLine, Qt::RoundCap)); + p.setPen( + QPen(connectorTone(pal.accent[idx[0]], pal.dark), kCurveW, Qt::SolidLine, Qt::RoundCap)); drawCurve(p, 85 - kInset, 37, 90 + kInset, 14, true); - p.setPen(QPen(connectorTone(pal.accent[idx[1]], pal.dark), kCurveW, Qt::SolidLine, Qt::RoundCap)); + p.setPen( + QPen(connectorTone(pal.accent[idx[1]], pal.dark), kCurveW, Qt::SolidLine, Qt::RoundCap)); drawCurve(p, 85 - kInset, 37, 90 + kInset, 56, true); - p.setPen(QPen(connectorTone(pal.accent[idx[2]], pal.dark), kCurveW, Qt::SolidLine, Qt::RoundCap)); + p.setPen( + QPen(connectorTone(pal.accent[idx[2]], pal.dark), kCurveW, Qt::SolidLine, Qt::RoundCap)); drawCurve(p, 35 + kInset, 37, 18 - kInset, 14, true); - p.setPen(QPen(connectorTone(pal.accent[idx[3]], pal.dark), kCurveW, Qt::SolidLine, Qt::RoundCap)); + p.setPen( + QPen(connectorTone(pal.accent[idx[3]], pal.dark), kCurveW, Qt::SolidLine, Qt::RoundCap)); drawCurve(p, 35 + kInset, 37, 18 - kInset, 56, true); drawNode(p, center, radius, pal.accent[0], kStrokeW, pal.dark); @@ -540,7 +564,6 @@ QPixmap IconFactory::makeTemplatePreview(int index, int width, int height) { static const QString ids[] = {QStringLiteral("builtin.mindmap"), QStringLiteral("builtin.orgchart"), QStringLiteral("builtin.projectplan")}; - const QString id = - (index >= 0 && size_t(index) < std::size(ids)) ? ids[index] : QString(); + const QString id = (index >= 0 && size_t(index) < std::size(ids)) ? ids[index] : QString(); return makeTemplatePreview(id, width, height); } diff --git a/src/ui/MindMapToolBar.cpp b/src/ui/MindMapToolBar.cpp index 2307a60..646af82 100644 --- a/src/ui/MindMapToolBar.cpp +++ b/src/ui/MindMapToolBar.cpp @@ -12,15 +12,9 @@ #include #include -MindMapToolBar::MindMapToolBar(TabManager* tabManager, - FileManager* fileManager, - QAction* undoAct, - QAction* redoAct, - QWidget* parent) - : QWidget(parent), - m_tabManager(tabManager), - m_fileManager(fileManager), - m_undoAct(undoAct), +MindMapToolBar::MindMapToolBar(TabManager* tabManager, FileManager* fileManager, QAction* undoAct, + QAction* redoAct, QWidget* parent) + : QWidget(parent), m_tabManager(tabManager), m_fileManager(fileManager), m_undoAct(undoAct), m_redoAct(redoAct) { setObjectName("inlineToolbar"); buildContent(); @@ -136,6 +130,12 @@ void MindMapToolBar::buildContent() { exportBtn->setMenu(exportMenu); m_layout->addWidget(exportBtn); + addSeparator(); + + auto* aiBtn = + addButton("ai-generate", tr("AI Generate"), tr("Generate mind map from text using AI")); + connect(aiBtn, &QToolButton::clicked, this, &MindMapToolBar::aiGenerateRequested); + m_layout->addStretch(); auto* closeBtn = new QToolButton(this); diff --git a/src/ui/MindMapToolBar.h b/src/ui/MindMapToolBar.h index 793f6cb..9957157 100644 --- a/src/ui/MindMapToolBar.h +++ b/src/ui/MindMapToolBar.h @@ -15,14 +15,12 @@ class MindMapToolBar : public QWidget { Q_OBJECT public: - MindMapToolBar(TabManager* tabManager, - FileManager* fileManager, - QAction* undoAct, - QAction* redoAct, - QWidget* parent = nullptr); + MindMapToolBar(TabManager* tabManager, FileManager* fileManager, QAction* undoAct, + QAction* redoAct, QWidget* parent = nullptr); signals: void closeRequested(); + void aiGenerateRequested(); private: void buildContent(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 895360e..3be96ce 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -25,6 +25,7 @@ add_ymind_test(tst_TemplateRegistry) add_ymind_test(tst_ThemeRegistry) add_ymind_test(tst_LayoutAlgorithmRegistry) add_ymind_test(tst_AppSettings) +add_ymind_test(tst_AiClient) # Tier 3 -- requires QApplication add_ymind_test(tst_MindMapSceneSerialization) diff --git a/tests/tst_AiClient.cpp b/tests/tst_AiClient.cpp new file mode 100644 index 0000000..f7acf83 --- /dev/null +++ b/tests/tst_AiClient.cpp @@ -0,0 +1,63 @@ +#include "core/AiClient.h" +#include "core/AppSettings.h" + +#include +#include + +class tst_AiClient : public QObject { + Q_OBJECT + +private slots: + void initTestCase(); + void cleanMarkdownOutlineFences(); + void cleanMarkdownOutlinePlain(); + void appSettingsAiFields(); + void headerPlaceholdersExist(); +}; + +void tst_AiClient::initTestCase() { + QCoreApplication::setOrganizationName("YMindTest"); + QCoreApplication::setApplicationName("tst_AiClient"); +} + +void tst_AiClient::cleanMarkdownOutlineFences() { + QString raw = "```markdown\n# Root Topic\n## Subtopic A\n- Item 1\n```"; + QString cleaned = AiClient::cleanMarkdownOutline(raw); + QCOMPARE(cleaned, QString("# Root Topic\n## Subtopic A\n- Item 1")); + + QString raw2 = "```\n# Root Topic\n* Leaf\n```"; + QString cleaned2 = AiClient::cleanMarkdownOutline(raw2); + QCOMPARE(cleaned2, QString("# Root Topic\n* Leaf")); +} + +void tst_AiClient::cleanMarkdownOutlinePlain() { + QString raw = "\n\n # Main Idea\n## Sub 1\n## Sub 2 \n\n"; + QString cleaned = AiClient::cleanMarkdownOutline(raw); + QCOMPARE(cleaned, QString("# Main Idea\n## Sub 1\n## Sub 2")); +} + +void tst_AiClient::appSettingsAiFields() { + auto& s = AppSettings::instance(); + s.setAiProvider("Custom"); + QCOMPARE(s.aiProvider(), QString("Custom")); + + s.setAiApiKey("sk-test-key-12345"); + QCOMPARE(s.aiApiKey(), QString("sk-test-key-12345")); + + s.setAiModel("qwen/qwen-2.5-72b-instruct:free"); + QCOMPARE(s.aiModel(), QString("qwen/qwen-2.5-72b-instruct:free")); + + s.setAiCustomEndpoint("https://my-custom-proxy.com/v1"); + QCOMPARE(s.aiCustomEndpoint(), QString("https://my-custom-proxy.com/v1")); +} + +void tst_AiClient::headerPlaceholdersExist() { + QVERIFY(strlen(AiClient::kDefaultOrcaEndpoint) > 0); + QVERIFY(strlen(AiClient::kDefaultOrcaModel) > 0); + QVERIFY(strlen(AiClient::kProjectReferer) > 0); + QVERIFY(strlen(AiClient::kProjectTitle) > 0); + QVERIFY(strlen(AiClient::kOrcaPartnerUrl) > 0); +} + +QTEST_MAIN(tst_AiClient) +#include "tst_AiClient.moc" diff --git a/translations/ymind_zh_CN.ts b/translations/ymind_zh_CN.ts index 4744352..52465c7 100644 --- a/translations/ymind_zh_CN.ts +++ b/translations/ymind_zh_CN.ts @@ -34,6 +34,225 @@ 关闭 + + AiClient + + + Invalid API Key. Please verify your API Key in Settings. + API Key 无效,请在设置中检查您的 API Key。 + + + + Rate limit reached. Please wait a moment or try another model. + 已达到请求速率限制,请稍候重试或更换模型。 + + + + API Error (%1): %2 + API 错误 (%1): %2 + + + + Network request failed: %1 (HTTP %2) + 网络请求失败: %1 (HTTP %2) + + + + Failed to parse API response JSON. + 解析 API 返回数据失败。 + + + + API returned no choices. + API 未返回有效生成内容。 + + + + Model returned an empty outline. + 模型返回的大纲内容为空。 + + + + Failed to obtain API Key: %1 + 获取 API Key 失败: %1 + + + + Invalid response from OrcaRouter auth server. + OrcaRouter 认证服务器返回的响应无效。 + + + + OrcaRouter returned an empty API Key. + OrcaRouter 返回的 API Key 为空。 + + + + AiGenerateDialog + + + Generate Mind Map from Text (AI) + 从文本生成思维导图 (AI) + + + + Enter a topic, notes, or outline to generate a mind map: + 输入主题、笔记或大纲,一键生成思维导图: + + + + e.g. Distributed system consensus algorithms +- Paxos and Raft +- Leader election +- Log replication + +Or paste any meeting notes, article fragments, or topic ideas here... + 例如:分布式系统共识算法 +- Paxos 与 Raft +- Leader 选举 +- 日志复制 + +或直接在此粘贴任何会议记录、文章片段、灵感脑暴等... + + + + Settings... + 设置... + + + + Cancel + 取消 + + + + Generate + 生成导图 + + + + API Key not set. Click Settings to configure. + 未配置 API Key,请点击设置进行配置。 + + + + Provider: %1 | Model: %2 + 服务商: %1 | 模型: %2 + + + + Please enter text or a topic first. + 请先输入文本或主题。 + + + + API Key Required + 需要配置 API Key + + + + Please configure your AI API Key before generating mind maps. +Would you like to open Settings now? + 在生成思维导图之前,请先配置 AI API Key。 +是否现在打开设置? + + + + Generation canceled. + 已取消生成。 + + + + Generating mind map with AI... + AI 正在生成思维导图大纲... + + + + AiSettingsDialog + + + AI Settings + AI 设置 + + + + AI Service + AI 服务 + + + + OrcaRouter (Recommended) + OrcaRouter(推荐) + + + + Custom (OpenAI-compatible) + 自定义(OpenAI 兼容) + + + + Provider: + 服务商: + + + + Model: + 模型: + + + + Enter API Key + 输入 API Key + + + + API Key: + API Key: + + + + + + + Get API Key from OrcaRouter... + 从 OrcaRouter 获取 API Key... + + + + Endpoint URL: + 接口地址: + + + + API Key obtained successfully! + API Key 获取成功! + + + + Failed to start local auth server. + 无法启动本地认证服务器。 + + + + Waiting for authorization... + 等待授权中... + + + + A browser window has been opened. Please authorize YMind on OrcaRouter, then return here. + 已打开浏览器窗口,请在 OrcaRouter 上授权 YMind,完成后返回此处。 + + + + Exchanging authorization code for API Key... + 正在用授权码换取 API Key... + + + + Authorization failed: invalid state or missing code. + 授权失败:状态无效或缺少授权码。 + + Commands @@ -92,14 +311,14 @@ 保存思维导图 - + Could not export %1: %2 无法导出 %1: %2 - + Exported to %1 已导出到 %1 @@ -115,37 +334,37 @@ - + file 文件 - + Export as Markdown 导出为 Markdown - + Markdown Files (*.md);;All Files (*) Markdown 文件 (*.md);;所有文件 (*) - + Export as PNG 导出为 PNG - + PNG Images (*.png);;All Files (*) PNG 图片 (*.png);;所有文件 (*) - + Export as SVG 导出为 SVG - + SVG Files (*.svg);;All Files (*) SVG 文件 (*.svg);;所有文件 (*) @@ -160,27 +379,38 @@ PDF 文件 (*.pdf);;所有文件 (*) - + Import from Markdown 从 Markdown 导入 - + Markdown Files (*.md *.markdown);;All Files (*) Markdown 文件 (*.md *.markdown);;所有文件 (*) - + Could not read file: %1 无法读取文件: %1 - + Imported from %1 已从 %1 导入 + + + + Failed to parse generated Markdown into a mind map. + 无法将生成的 Markdown 解析为思维导图。 + + + + Mind map generated successfully + 思维导图已成功生成 + FindBar @@ -218,249 +448,254 @@ MainWindow - + Te&mplate 模板(&M) - + &Theme 主题(&T) - + Pick a template or open an existing map. 选择模板或打开已有思维导图。 - + Enter: Commit | Esc: Cancel Enter:确认 | Esc:取消 - + Enter: Add Child | Ctrl+Enter: Add Sibling | Del: Delete | F2/Double-click: Edit | Ctrl+L: Auto Layout | Scroll: Zoom | Middle/Right-drag: Pan Enter:添加子节点 | Ctrl+Enter:添加同级节点 | Del:删除 | F2/双击:编辑 | Ctrl+L:自动布局 | 滚轮:缩放 | 中键/右键拖动:平移 - + Toggle Outline Panel 切换大纲面板 - + Toggle Toolbar 切换工具栏 - + &Undo 撤销(&U) - + &Redo 重做(&R) - + Add a child node (Enter) 添加子节点 (Enter) - + Add a sibling node (Ctrl+Enter) 添加同级节点 (Ctrl+Enter) - + Delete selected node (Del) 删除选中节点 (Del) - + &File 文件(&F) - + Find (Ctrl+F) 查找 (Ctrl+F) - + &New 新建(&N) - + New &Tab 新建标签页(&T) - + &Open... 打开(&O)... - + &Save 保存(&S) - + Save &As... 另存为(&A)... - + &Close Tab 关闭标签页(&C) - + &Import from Markdown... 从 Markdown 导入(&I)... - + + Generate from &Text (AI)... + 从文本生成导图 (AI)(&T)... + + + &Export 导出(&E) - + As &Text... 导出为文本(&T)... - + As &Markdown... 导出为 Markdown(&M)... - + As &PNG... 导出为 PNG(&P)... - + As &SVG... 导出为 SVG(&S)... - + As P&DF... 导出为 PDF(&D)... - + E&xit 退出(&x) - + &Edit 编辑(&E) - + &Delete 删除(&D) - + &Find... 查找(&F)... - + &Preferences... 首选项(&P)... - + &View 视图(&V) - + Zoom &In 放大(&I) - + Zoom &Out 缩小(&O) - + &Fit to View 适合视图(&F) - + &Toolbar 工具栏(&T) - + &Outline 大纲(&O) - + &Auto Layout 自动布局(&A) - + Add &Child 添加子节点(&C) - + Add &Sibling 添加同级节点(&S) - + &Help 帮助(&H) - + Check for &Updates... 检查更新(&U)... - + About &YMind... 关于 YMind(&Y)... - + About &Qt... 关于 Qt(&Q)... - + Restore unsaved tabs 恢复未保存的标签页 - + YMind found %1 unsaved tab(s) from a previous session. Restore them now? YMind 发现上次会话中有 %1 个未保存的标签页。 现在恢复吗? - + YMind - Mind Map Editor YMind - 思维导图编辑器 - + Auto-saved 已自动保存 @@ -582,130 +817,140 @@ Restore them now? MindMapToolBar - + Undo 撤销 - + Undo last action (Ctrl+Z) 撤销上一步操作 (Ctrl+Z) - + Redo 重做 - + Redo last action (Ctrl+Y) 重做上一步操作 (Ctrl+Y) - + Add Child 添加子节点 - + Add a child node (Enter) 添加子节点 (Enter) - + Add Sibling 添加同级节点 - + Add a sibling node (Ctrl+Enter) 添加同级节点 (Ctrl+Enter) - + Delete 删除 - + Delete selected node (Del) 删除选中节点 (Del) - + Auto Layout 自动布局 - + Automatically arrange all nodes (Ctrl+L) 自动排列所有节点 (Ctrl+L) - + Zoom In 放大 - + Zoom in (Ctrl++) 放大 (Ctrl++) - + Zoom Out 缩小 - + Zoom out (Ctrl+-) 缩小 (Ctrl+-) - + Fit View 适合视图 - + Fit all nodes in view (Ctrl+0) 将所有节点适合视图 (Ctrl+0) - + Export 导出 - + Export mind map 导出思维导图 - + As Text... 导出为文本... - + As Markdown... 导出为 Markdown... - + As PNG... 导出为 PNG... - + As SVG... 导出为 SVG... - + As PDF... 导出为 PDF... + + + AI Generate + AI 生成 + + + + Generate mind map from text using AI + 使用 AI 将文本转换为思维导图 + Hide Toolbar @@ -728,17 +973,17 @@ Restore them now? QObject - + Line %1: %2 第 %1 行:%2 - + ... and %1 more issue(s). ……另有 %1 个问题。 - + Could not import %1. The file must be a simple Markdown outline (optional `# Title` followed by an unordered list with 2-space indentation). See docs/markdown-import-format.md for the full format.