From bb9b9aef01933ba513b422ea3a1cfea62eb60d03 Mon Sep 17 00:00:00 2001 From: pacer Date: Fri, 4 Sep 2026 20:22:03 +0800 Subject: [PATCH 1/5] feat: add AI text-to-mindmap generation with OrcaRouter support --- CMakeLists.txt | 2 + src/core/AiClient.cpp | 175 +++++++++++++++ src/core/AiClient.h | 45 ++++ src/core/AppSettings.cpp | 32 +++ src/core/AppSettings.h | 12 ++ src/core/FileManager.cpp | 143 ++++++++----- src/core/FileManager.h | 1 + src/core/MainWindow.cpp | 14 ++ src/core/MainWindow.h | 1 + src/core/SettingsDialog.cpp | 64 ++++++ src/core/SettingsDialog.h | 6 + src/ui/AiGenerateDialog.cpp | 159 ++++++++++++++ src/ui/AiGenerateDialog.h | 41 ++++ src/ui/IconFactory.cpp | 69 ++++-- src/ui/MindMapToolBar.cpp | 18 +- src/ui/MindMapToolBar.h | 8 +- tests/CMakeLists.txt | 1 + tests/tst_AiClient.cpp | 63 ++++++ translations/ymind_zh_CN.ts | 411 ++++++++++++++++++++++++++---------- 19 files changed, 1065 insertions(+), 200 deletions(-) create mode 100644 src/core/AiClient.cpp create mode 100644 src/core/AiClient.h create mode 100644 src/ui/AiGenerateDialog.cpp create mode 100644 src/ui/AiGenerateDialog.h create mode 100644 tests/tst_AiClient.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7a86690..51fb10f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,6 +33,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 +76,7 @@ add_library(ymind_lib OBJECT src/layout/LayoutStyle.h # UI – widgets and theming + src/ui/AiGenerateDialog.h src/ui/AiGenerateDialog.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..0869906 --- /dev/null +++ b/src/core/AiClient.cpp @@ -0,0 +1,175 @@ +#include "core/AiClient.h" + +#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); +} diff --git a/src/core/AiClient.h b/src/core/AiClient.h new file mode 100644 index 0000000..3bf4b34 --- /dev/null +++ b/src/core/AiClient.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include + +class QNetworkAccessManager; +class QNetworkReply; + +class AiClient : public QObject { + Q_OBJECT + +public: + // Placeholders for OrcaRouter partnership & OSS program + // TODO: Update these once registered in OrcaRouter's OSS plan + 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://github.com/broccoli-97/xmind"; + static constexpr const char* kProjectTitle = "YMind"; + static constexpr const char* kOrcaPartnerUrl = "https://www.orcarouter.ai/zh-CN/built-with"; + + 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); + +signals: + void started(); + void finished(const QString& markdownOutline); + void errorOccurred(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/core/SettingsDialog.cpp b/src/core/SettingsDialog.cpp index 8aa07a3..2fb30e5 100644 --- a/src/core/SettingsDialog.cpp +++ b/src/core/SettingsDialog.cpp @@ -1,4 +1,5 @@ #include "core/SettingsDialog.h" +#include "core/AiClient.h" #include "core/AppSettings.h" #include "ui/ThemeManager.h" @@ -11,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -80,6 +82,51 @@ SettingsDialog::SettingsDialog(QWidget* parent) : QDialog(parent) { updatesLayout->addRow(m_checkUpdatesCheck); mainLayout->addWidget(updatesGroup); + // AI Service group + auto* aiGroup = new QGroupBox(tr("AI Service")); + auto* aiLayout = new QFormLayout(aiGroup); + + m_aiProviderCombo = new QComboBox; + m_aiProviderCombo->addItem(tr("OrcaRouter (Recommended)"), QStringLiteral("OrcaRouter")); + m_aiProviderCombo->addItem(tr("Custom (OpenAI-compatible)"), QStringLiteral("Custom")); + aiLayout->addRow(tr("Provider:"), m_aiProviderCombo); + + m_aiModelCombo = new QComboBox; + m_aiModelCombo->setEditable(true); + m_aiModelCombo->addItem(QStringLiteral("deepseek/deepseek-chat:free")); + m_aiModelCombo->addItem(QStringLiteral("qwen/qwen-2.5-72b-instruct:free")); + m_aiModelCombo->addItem(QStringLiteral("orcarouter/auto")); + aiLayout->addRow(tr("Model:"), m_aiModelCombo); + + m_aiApiKeyEdit = new QLineEdit; + m_aiApiKeyEdit->setEchoMode(QLineEdit::PasswordEchoOnEdit); + m_aiApiKeyEdit->setPlaceholderText(tr("Enter API Key")); + aiLayout->addRow(tr("API Key:"), m_aiApiKeyEdit); + + m_aiEndpointEdit = new QLineEdit; + m_aiEndpointEdit->setPlaceholderText(QString::fromLatin1(AiClient::kDefaultOrcaEndpoint)); + aiLayout->addRow(tr("Endpoint URL:"), m_aiEndpointEdit); + + auto* getKeyLabel = new QLabel; + getKeyLabel->setOpenExternalLinks(true); + getKeyLabel->setText(QStringLiteral("%2") + .arg(QString::fromLatin1(AiClient::kOrcaPartnerUrl), + tr("Get free OrcaRouter API Key"))); + getKeyLabel->setObjectName("settingsHint"); + aiLayout->addRow("", getKeyLabel); + + auto updateProviderUI = [this]() { + bool isCustom = (m_aiProviderCombo->currentData().toString() == QLatin1String("Custom")); + m_aiEndpointEdit->setEnabled(isCustom); + if (!isCustom) { + m_aiEndpointEdit->setText(QString::fromLatin1(AiClient::kDefaultOrcaEndpoint)); + } + }; + connect(m_aiProviderCombo, QOverload::of(&QComboBox::currentIndexChanged), this, + updateProviderUI); + + mainLayout->addWidget(aiGroup); + mainLayout->addStretch(); // Button box @@ -107,6 +154,18 @@ void SettingsDialog::loadCurrentSettings() { int langIdx = m_languageCombo->findData(s.language()); if (langIdx >= 0) m_languageCombo->setCurrentIndex(langIdx); + + int provIdx = m_aiProviderCombo->findData(s.aiProvider()); + if (provIdx >= 0) + m_aiProviderCombo->setCurrentIndex(provIdx); + else + m_aiProviderCombo->setCurrentIndex(0); + + m_aiModelCombo->setEditText(s.aiModel()); + m_aiApiKeyEdit->setText(s.aiApiKey()); + m_aiEndpointEdit->setText(s.aiCustomEndpoint()); + bool isCustom = (m_aiProviderCombo->currentData().toString() == QLatin1String("Custom")); + m_aiEndpointEdit->setEnabled(isCustom); } void SettingsDialog::onSyncSystemTheme() { @@ -124,6 +183,11 @@ void SettingsDialog::apply() { s.setDefaultFontFamily(m_fontFamilyCombo->currentFont().family()); s.setCheckForUpdatesEnabled(m_checkUpdatesCheck->isChecked()); + s.setAiProvider(m_aiProviderCombo->currentData().toString()); + s.setAiModel(m_aiModelCombo->currentText().trimmed()); + s.setAiApiKey(m_aiApiKeyEdit->text().trimmed()); + s.setAiCustomEndpoint(m_aiEndpointEdit->text().trimmed()); + const QString oldLang = s.language(); const QString newLang = m_languageCombo->currentData().toString(); s.setLanguage(newLang); diff --git a/src/core/SettingsDialog.h b/src/core/SettingsDialog.h index 7988925..1e31da2 100644 --- a/src/core/SettingsDialog.h +++ b/src/core/SettingsDialog.h @@ -7,6 +7,7 @@ class QCheckBox; class QFontComboBox; class QSpinBox; class QPushButton; +class QLineEdit; class SettingsDialog : public QDialog { Q_OBJECT @@ -29,4 +30,9 @@ private slots: QFontComboBox* m_fontFamilyCombo; QSpinBox* m_fontSizeSpin; QCheckBox* m_checkUpdatesCheck; + + QComboBox* m_aiProviderCombo; + QComboBox* m_aiModelCombo; + QLineEdit* m_aiApiKeyEdit; + QLineEdit* m_aiEndpointEdit; }; diff --git a/src/ui/AiGenerateDialog.cpp b/src/ui/AiGenerateDialog.cpp new file mode 100644 index 0000000..6fcb749 --- /dev/null +++ b/src/ui/AiGenerateDialog.cpp @@ -0,0 +1,159 @@ +#include "ui/AiGenerateDialog.h" +#include "core/AiClient.h" +#include "core/AppSettings.h" +#include "core/SettingsDialog.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() { + SettingsDialog 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/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..928a28e 100644 --- a/translations/ymind_zh_CN.ts +++ b/translations/ymind_zh_CN.ts @@ -34,6 +34,124 @@ 关闭 + + 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. + 模型返回的大纲内容为空。 + + + + 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 正在生成思维导图大纲... + + Commands @@ -92,14 +210,14 @@ 保存思维导图 - + Could not export %1: %2 无法导出 %1: %2 - + Exported to %1 已导出到 %1 @@ -115,37 +233,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 +278,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 +347,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 +716,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 +872,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. @@ -750,107 +894,152 @@ The file must be a simple Markdown outline (optional `# Title` followed by an un SettingsDialog - + Settings 设置 - + Appearance 外观 - + Light 浅色 - + Dark 深色 - + Theme: 主题: - + Sync with System Theme 同步系统主题 - + Language: 语言: - + Restart required to apply language change 需要重启应用以使语言更改生效 - + Auto-save 自动保存 - + Enable auto-save 启用自动保存 - + min 分钟 - + Interval: 间隔: - + Editor 编辑器 - + Default font: 默认字体: - + pt - + Default font size: 默认字号: - + Applies to newly created nodes only 仅适用于新创建的节点 - + Updates 更新 - + Check for updates on startup 启动时检查更新 - + + AI Service + AI 服务 + + + + OrcaRouter (Recommended) + OrcaRouter(推荐) + + + + Custom (OpenAI-compatible) + 自定义(OpenAI 兼容) + + + + Provider: + 服务商: + + + + Model: + 模型: + + + + Enter API Key + 输入 API Key + + + + API Key: + API Key: + + + + Endpoint URL: + 接口地址: + + + + Get free OrcaRouter API Key + 获取 OrcaRouter 免费 API Key + + + Restart Required 需要重启 - + The language change will take effect after restarting YMind. Restart now? 语言更改将在重启 YMind 后生效。现在重启吗? From 44dc2856a0ec313b7444ae14d3ef5e4f0780ed76 Mon Sep 17 00:00:00 2001 From: pacer Date: Sat, 5 Sep 2026 12:45:46 +0800 Subject: [PATCH 2/5] refactor: separate AI settings into dedicated dialog - Create standalone AiSettingsDialog (header + implementation) - Remove AI Service group from the main SettingsDialog - Update AiGenerateDialog to open AiSettingsDialog instead - Register AiSettingsDialog in CMakeLists.txt - Update translations: new AiSettingsDialog context with all zh_CN entries --- CMakeLists.txt | 1 + src/core/SettingsDialog.cpp | 64 ----------------- src/core/SettingsDialog.h | 6 -- src/ui/AiGenerateDialog.cpp | 4 +- src/ui/AiSettingsDialog.cpp | 100 ++++++++++++++++++++++++++ src/ui/AiSettingsDialog.h | 22 ++++++ translations/ymind_zh_CN.ts | 140 +++++++++++++++++++----------------- 7 files changed, 199 insertions(+), 138 deletions(-) create mode 100644 src/ui/AiSettingsDialog.cpp create mode 100644 src/ui/AiSettingsDialog.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 51fb10f..32bc3e5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -77,6 +77,7 @@ add_library(ymind_lib OBJECT # 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/SettingsDialog.cpp b/src/core/SettingsDialog.cpp index 2fb30e5..8aa07a3 100644 --- a/src/core/SettingsDialog.cpp +++ b/src/core/SettingsDialog.cpp @@ -1,5 +1,4 @@ #include "core/SettingsDialog.h" -#include "core/AiClient.h" #include "core/AppSettings.h" #include "ui/ThemeManager.h" @@ -12,7 +11,6 @@ #include #include #include -#include #include #include #include @@ -82,51 +80,6 @@ SettingsDialog::SettingsDialog(QWidget* parent) : QDialog(parent) { updatesLayout->addRow(m_checkUpdatesCheck); mainLayout->addWidget(updatesGroup); - // AI Service group - auto* aiGroup = new QGroupBox(tr("AI Service")); - auto* aiLayout = new QFormLayout(aiGroup); - - m_aiProviderCombo = new QComboBox; - m_aiProviderCombo->addItem(tr("OrcaRouter (Recommended)"), QStringLiteral("OrcaRouter")); - m_aiProviderCombo->addItem(tr("Custom (OpenAI-compatible)"), QStringLiteral("Custom")); - aiLayout->addRow(tr("Provider:"), m_aiProviderCombo); - - m_aiModelCombo = new QComboBox; - m_aiModelCombo->setEditable(true); - m_aiModelCombo->addItem(QStringLiteral("deepseek/deepseek-chat:free")); - m_aiModelCombo->addItem(QStringLiteral("qwen/qwen-2.5-72b-instruct:free")); - m_aiModelCombo->addItem(QStringLiteral("orcarouter/auto")); - aiLayout->addRow(tr("Model:"), m_aiModelCombo); - - m_aiApiKeyEdit = new QLineEdit; - m_aiApiKeyEdit->setEchoMode(QLineEdit::PasswordEchoOnEdit); - m_aiApiKeyEdit->setPlaceholderText(tr("Enter API Key")); - aiLayout->addRow(tr("API Key:"), m_aiApiKeyEdit); - - m_aiEndpointEdit = new QLineEdit; - m_aiEndpointEdit->setPlaceholderText(QString::fromLatin1(AiClient::kDefaultOrcaEndpoint)); - aiLayout->addRow(tr("Endpoint URL:"), m_aiEndpointEdit); - - auto* getKeyLabel = new QLabel; - getKeyLabel->setOpenExternalLinks(true); - getKeyLabel->setText(QStringLiteral("%2") - .arg(QString::fromLatin1(AiClient::kOrcaPartnerUrl), - tr("Get free OrcaRouter API Key"))); - getKeyLabel->setObjectName("settingsHint"); - aiLayout->addRow("", getKeyLabel); - - auto updateProviderUI = [this]() { - bool isCustom = (m_aiProviderCombo->currentData().toString() == QLatin1String("Custom")); - m_aiEndpointEdit->setEnabled(isCustom); - if (!isCustom) { - m_aiEndpointEdit->setText(QString::fromLatin1(AiClient::kDefaultOrcaEndpoint)); - } - }; - connect(m_aiProviderCombo, QOverload::of(&QComboBox::currentIndexChanged), this, - updateProviderUI); - - mainLayout->addWidget(aiGroup); - mainLayout->addStretch(); // Button box @@ -154,18 +107,6 @@ void SettingsDialog::loadCurrentSettings() { int langIdx = m_languageCombo->findData(s.language()); if (langIdx >= 0) m_languageCombo->setCurrentIndex(langIdx); - - int provIdx = m_aiProviderCombo->findData(s.aiProvider()); - if (provIdx >= 0) - m_aiProviderCombo->setCurrentIndex(provIdx); - else - m_aiProviderCombo->setCurrentIndex(0); - - m_aiModelCombo->setEditText(s.aiModel()); - m_aiApiKeyEdit->setText(s.aiApiKey()); - m_aiEndpointEdit->setText(s.aiCustomEndpoint()); - bool isCustom = (m_aiProviderCombo->currentData().toString() == QLatin1String("Custom")); - m_aiEndpointEdit->setEnabled(isCustom); } void SettingsDialog::onSyncSystemTheme() { @@ -183,11 +124,6 @@ void SettingsDialog::apply() { s.setDefaultFontFamily(m_fontFamilyCombo->currentFont().family()); s.setCheckForUpdatesEnabled(m_checkUpdatesCheck->isChecked()); - s.setAiProvider(m_aiProviderCombo->currentData().toString()); - s.setAiModel(m_aiModelCombo->currentText().trimmed()); - s.setAiApiKey(m_aiApiKeyEdit->text().trimmed()); - s.setAiCustomEndpoint(m_aiEndpointEdit->text().trimmed()); - const QString oldLang = s.language(); const QString newLang = m_languageCombo->currentData().toString(); s.setLanguage(newLang); diff --git a/src/core/SettingsDialog.h b/src/core/SettingsDialog.h index 1e31da2..7988925 100644 --- a/src/core/SettingsDialog.h +++ b/src/core/SettingsDialog.h @@ -7,7 +7,6 @@ class QCheckBox; class QFontComboBox; class QSpinBox; class QPushButton; -class QLineEdit; class SettingsDialog : public QDialog { Q_OBJECT @@ -30,9 +29,4 @@ private slots: QFontComboBox* m_fontFamilyCombo; QSpinBox* m_fontSizeSpin; QCheckBox* m_checkUpdatesCheck; - - QComboBox* m_aiProviderCombo; - QComboBox* m_aiModelCombo; - QLineEdit* m_aiApiKeyEdit; - QLineEdit* m_aiEndpointEdit; }; diff --git a/src/ui/AiGenerateDialog.cpp b/src/ui/AiGenerateDialog.cpp index 6fcb749..4076df0 100644 --- a/src/ui/AiGenerateDialog.cpp +++ b/src/ui/AiGenerateDialog.cpp @@ -1,7 +1,7 @@ #include "ui/AiGenerateDialog.h" #include "core/AiClient.h" #include "core/AppSettings.h" -#include "core/SettingsDialog.h" +#include "ui/AiSettingsDialog.h" #include #include @@ -92,7 +92,7 @@ void AiGenerateDialog::updateModelInfo() { } void AiGenerateDialog::onOpenSettings() { - SettingsDialog dlg(this); + AiSettingsDialog dlg(this); if (dlg.exec() == QDialog::Accepted) { updateModelInfo(); m_statusLabel->clear(); diff --git a/src/ui/AiSettingsDialog.cpp b/src/ui/AiSettingsDialog.cpp new file mode 100644 index 0000000..f3c94be --- /dev/null +++ b/src/ui/AiSettingsDialog.cpp @@ -0,0 +1,100 @@ +#include "ui/AiSettingsDialog.h" +#include "core/AiClient.h" +#include "core/AppSettings.h" + +#include +#include +#include +#include +#include +#include +#include + +AiSettingsDialog::AiSettingsDialog(QWidget* parent) : QDialog(parent) { + 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); + + m_endpointEdit = new QLineEdit; + m_endpointEdit->setPlaceholderText(QString::fromLatin1(AiClient::kDefaultOrcaEndpoint)); + aiLayout->addRow(tr("Endpoint URL:"), m_endpointEdit); + + auto* getKeyLabel = new QLabel; + getKeyLabel->setOpenExternalLinks(true); + getKeyLabel->setText(QStringLiteral("%2") + .arg(QString::fromLatin1(AiClient::kOrcaPartnerUrl), + tr("Get free OrcaRouter API Key"))); + getKeyLabel->setObjectName("settingsHint"); + aiLayout->addRow("", getKeyLabel); + + auto updateProviderUI = [this]() { + bool isCustom = (m_providerCombo->currentData().toString() == QLatin1String("Custom")); + m_endpointEdit->setEnabled(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); + + loadCurrentSettings(); +} + +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); +} + +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..90c7ebe --- /dev/null +++ b/src/ui/AiSettingsDialog.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +class QComboBox; +class QLineEdit; + +class AiSettingsDialog : public QDialog { + Q_OBJECT + +public: + explicit AiSettingsDialog(QWidget* parent = nullptr); + +private: + void loadCurrentSettings(); + void apply(); + + QComboBox* m_providerCombo; + QComboBox* m_modelCombo; + QLineEdit* m_apiKeyEdit; + QLineEdit* m_endpointEdit; +}; diff --git a/translations/ymind_zh_CN.ts b/translations/ymind_zh_CN.ts index 928a28e..3a4127d 100644 --- a/translations/ymind_zh_CN.ts +++ b/translations/ymind_zh_CN.ts @@ -152,6 +152,59 @@ Would you like to open Settings now? 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: + + + + Endpoint URL: + 接口地址: + + + + Get free OrcaRouter API Key + 获取 OrcaRouter 免费 API Key + + Commands @@ -894,152 +947,107 @@ The file must be a simple Markdown outline (optional `# Title` followed by an un SettingsDialog - + Settings 设置 - + Appearance 外观 - + Light 浅色 - + Dark 深色 - + Theme: 主题: - + Sync with System Theme 同步系统主题 - + Language: 语言: - + Restart required to apply language change 需要重启应用以使语言更改生效 - + Auto-save 自动保存 - + Enable auto-save 启用自动保存 - + min 分钟 - + Interval: 间隔: - + Editor 编辑器 - + Default font: 默认字体: - + pt - + Default font size: 默认字号: - + Applies to newly created nodes only 仅适用于新创建的节点 - + Updates 更新 - + Check for updates on startup 启动时检查更新 - - AI Service - AI 服务 - - - - OrcaRouter (Recommended) - OrcaRouter(推荐) - - - - Custom (OpenAI-compatible) - 自定义(OpenAI 兼容) - - - - Provider: - 服务商: - - - - Model: - 模型: - - - - Enter API Key - 输入 API Key - - - - API Key: - API Key: - - - - Endpoint URL: - 接口地址: - - - - Get free OrcaRouter API Key - 获取 OrcaRouter 免费 API Key - - - + Restart Required 需要重启 - + The language change will take effect after restarting YMind. Restart now? 语言更改将在重启 YMind 后生效。现在重启吗? From d3ac8d00cf900ac00791f2abcec82b503874a694 Mon Sep 17 00:00:00 2001 From: pacer Date: Sat, 5 Sep 2026 13:06:29 +0800 Subject: [PATCH 3/5] chore: configure OrcaRouter referral attribution Replace placeholder URLs with actual OrcaRouter OSS program referral link: - HTTP-Referer header: referral URL for revenue attribution - Partner URL: referral link for user API key signup - Remove TODO comment (registration complete) --- src/core/AiClient.h | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/core/AiClient.h b/src/core/AiClient.h index 3bf4b34..ff11fee 100644 --- a/src/core/AiClient.h +++ b/src/core/AiClient.h @@ -11,13 +11,14 @@ class AiClient : public QObject { Q_OBJECT public: - // Placeholders for OrcaRouter partnership & OSS program - // TODO: Update these once registered in OrcaRouter's OSS plan + // 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://github.com/broccoli-97/xmind"; + 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/zh-CN/built-with"; + static constexpr const char* kOrcaPartnerUrl = + "https://www.orcarouter.ai/ref/ref_dc5e5ce5b8727aef463e"; explicit AiClient(QObject* parent = nullptr); ~AiClient() override; From 2f3a8abb9fcf1ed4680c3175949682e55f63a093 Mon Sep 17 00:00:00 2001 From: pacer Date: Sat, 5 Sep 2026 13:14:21 +0800 Subject: [PATCH 4/5] feat: add one-click OAuth PKCE flow for OrcaRouter API Key Implement OrcaRouter's PKCE authorization flow in AiSettingsDialog: - 'Get API Key from OrcaRouter...' button initiates OAuth flow - Generates PKCE code_verifier (32-byte random) and S256 challenge - Starts ephemeral local TCP server for callback - Opens browser to OrcaRouter auth URL with app_name=YMind and ref code - Handles callback, exchanges code for API Key via /api/v1/auth/keys - Auto-fills API Key field on success AiClient additions: - generateCodeVerifier() / computeCodeChallenge() static helpers - exchangeCodeForKey() with apiKeyReceived / apiKeyError signals - kOrcaAuthBase and kOrcaRefCode constants All 10 new tr() strings translated to Chinese. --- src/core/AiClient.cpp | 55 ++++++++++++ src/core/AiClient.h | 11 +++ src/ui/AiSettingsDialog.cpp | 161 ++++++++++++++++++++++++++++++++++-- src/ui/AiSettingsDialog.h | 16 ++++ translations/ymind_zh_CN.ts | 86 ++++++++++++++----- 5 files changed, 301 insertions(+), 28 deletions(-) diff --git a/src/core/AiClient.cpp b/src/core/AiClient.cpp index 0869906..8665831 100644 --- a/src/core/AiClient.cpp +++ b/src/core/AiClient.cpp @@ -1,11 +1,13 @@ #include "core/AiClient.h" +#include #include #include #include #include #include #include +#include #include AiClient::AiClient(QObject* parent) : QObject(parent), m_nam(new QNetworkAccessManager(this)) {} @@ -173,3 +175,56 @@ void AiClient::onReplyFinished() { 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 index ff11fee..eb88a3a 100644 --- a/src/core/AiClient.h +++ b/src/core/AiClient.h @@ -19,6 +19,8 @@ class AiClient : public QObject { 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; @@ -32,10 +34,19 @@ class AiClient : public QObject { // 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(); diff --git a/src/ui/AiSettingsDialog.cpp b/src/ui/AiSettingsDialog.cpp index f3c94be..4f8bed9 100644 --- a/src/ui/AiSettingsDialog.cpp +++ b/src/ui/AiSettingsDialog.cpp @@ -3,14 +3,22 @@ #include "core/AppSettings.h" #include +#include #include #include #include #include #include +#include +#include +#include +#include +#include +#include #include -AiSettingsDialog::AiSettingsDialog(QWidget* parent) : QDialog(parent) { +AiSettingsDialog::AiSettingsDialog(QWidget* parent) + : QDialog(parent), m_aiClient(new AiClient(this)) { setWindowTitle(tr("AI Settings")); setMinimumWidth(400); @@ -37,21 +45,28 @@ AiSettingsDialog::AiSettingsDialog(QWidget* parent) : QDialog(parent) { 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* getKeyLabel = new QLabel; - getKeyLabel->setOpenExternalLinks(true); - getKeyLabel->setText(QStringLiteral("%2") - .arg(QString::fromLatin1(AiClient::kOrcaPartnerUrl), - tr("Get free OrcaRouter API Key"))); - getKeyLabel->setObjectName("settingsHint"); - aiLayout->addRow("", getKeyLabel); - 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)); } @@ -72,9 +87,136 @@ AiSettingsDialog::AiSettingsDialog(QWidget* parent) : QDialog(parent) { 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(); @@ -89,6 +231,7 @@ void AiSettingsDialog::loadCurrentSettings() { m_endpointEdit->setText(s.aiCustomEndpoint()); bool isCustom = (m_providerCombo->currentData().toString() == QLatin1String("Custom")); m_endpointEdit->setEnabled(isCustom); + m_getKeyBtn->setVisible(!isCustom); } void AiSettingsDialog::apply() { diff --git a/src/ui/AiSettingsDialog.h b/src/ui/AiSettingsDialog.h index 90c7ebe..f4914ed 100644 --- a/src/ui/AiSettingsDialog.h +++ b/src/ui/AiSettingsDialog.h @@ -2,8 +2,12 @@ #include +class AiClient; class QComboBox; +class QLabel; class QLineEdit; +class QPushButton; +class QTcpServer; class AiSettingsDialog : public QDialog { Q_OBJECT @@ -11,12 +15,24 @@ class AiSettingsDialog : public QDialog { 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/translations/ymind_zh_CN.ts b/translations/ymind_zh_CN.ts index 3a4127d..5b7eebb 100644 --- a/translations/ymind_zh_CN.ts +++ b/translations/ymind_zh_CN.ts @@ -37,40 +37,55 @@ 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 @@ -155,54 +170,87 @@ Would you like to open Settings now? 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: 接口地址: - - Get free OrcaRouter API Key - 获取 OrcaRouter 免费 API Key + + 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. + 授权失败:状态无效或缺少授权码。 From 5e119eeda01a1ea484065713f9b9646ceb625201 Mon Sep 17 00:00:00 2001 From: pacer Date: Sat, 5 Sep 2026 13:29:29 +0800 Subject: [PATCH 5/5] fix(build): resolve missing AGL framework linker error on macOS Define WrapOpenGL::WrapOpenGL imported interface target before find_package(Qt6) to prevent Qt from looking for the deprecated/removed AGL framework on modern macOS SDKs. --- CMakeLists.txt | 7 +++++++ translations/ymind_zh_CN.ts | 16 ++++++++-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 32bc3e5..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) ----------------------------------- diff --git a/translations/ymind_zh_CN.ts b/translations/ymind_zh_CN.ts index 5b7eebb..52465c7 100644 --- a/translations/ymind_zh_CN.ts +++ b/translations/ymind_zh_CN.ts @@ -72,17 +72,17 @@ 模型返回的大纲内容为空。
- + 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 为空。 @@ -213,7 +213,7 @@ Would you like to open Settings now? - + Get API Key from OrcaRouter... 从 OrcaRouter 获取 API Key...
@@ -233,22 +233,22 @@ Would you like to open Settings now? 无法启动本地认证服务器。 - + 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. 授权失败:状态无效或缺少授权码。