Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,21 @@ 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) -----------------------------------

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
Expand Down Expand Up @@ -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
Expand Down
230 changes: 230 additions & 0 deletions src/core/AiClient.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
#include "core/AiClient.h"

#include <QCryptographicHash>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QRandomGenerator>
#include <QUrl>

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<quint32*>(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);
});
}
57 changes: 57 additions & 0 deletions src/core/AiClient.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#pragma once

#include <QObject>
#include <QPointer>
#include <QString>

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<QNetworkReply> m_currentReply;
};
32 changes: 32 additions & 0 deletions src/core/AppSettings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
12 changes: 12 additions & 0 deletions src/core/AppSettings.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading