[office/skrooge] /: AI Assistant
Stéphane MANKOWSKI
null at kde.org
Sat Jul 18 15:21:33 BST 2026
Git commit 1ceca14efbcd1fb358c5136b68863949ae69fec3 by Stéphane MANKOWSKI.
Committed on 18/07/2026 at 14:21.
Pushed by smankowski into branch 'master'.
AI Assistant
A +- -- doc/ai_assistant.png
M +54 -5 doc/index.docbook
M +2 -2 doc/kde_docbook
A +- -- doc/settings_ai_assistant.png
M +2 -2 plugins/generic/skg_monthly/grantlee_filters/skgdocumentfilter.cpp
M +1 -0 plugins/skrooge/CMakeLists.txt
A +26 -0 plugins/skrooge/skrooge_aiassistant/CMakeLists.txt
A +23 -0 plugins/skrooge/skrooge_aiassistant/metadata.json
A +22 -0 plugins/skrooge/skrooge_aiassistant/skgassistant_settings.kcfg
A +6 -0 plugins/skrooge/skrooge_aiassistant/skgassistant_settings.kcfgc
A +133 -0 plugins/skrooge/skrooge_aiassistant/skgassistantplugin.cpp [License: GPL(v3.0+)]
A +97 -0 plugins/skrooge/skrooge_aiassistant/skgassistantplugin.h [License: GPL(v3.0+)]
A +911 -0 plugins/skrooge/skrooge_aiassistant/skgassistantplugindockwidget.cpp [License: GPL(v3.0+)]
A +80 -0 plugins/skrooge/skrooge_aiassistant/skgassistantplugindockwidget.h [License: GPL(v3.0+)]
A +30 -0 plugins/skrooge/skrooge_aiassistant/skgassistantpluginwidget.cpp [License: GPL(v3.0+)]
A +33 -0 plugins/skrooge/skrooge_aiassistant/skgassistantpluginwidget.h [License: GPL(v3.0+)]
A +193 -0 plugins/skrooge/skrooge_aiassistant/skgassistantpluginwidget_dock.ui
A +134 -0 plugins/skrooge/skrooge_aiassistant/skgassistantpluginwidget_pref.ui
A +12 -0 plugins/skrooge/skrooge_aiassistant/skrooge_aiassistant.rc
A +218 -0 plugins/skrooge/skrooge_aiassistant/skrooge_system_prompt.md
M +0 -1 skgbankmodeler/skgdocumentbank.cpp
M +4 -8 skgsqlcipher/q6sql_sqlite.cpp
M +1 -1 skgsqlcipher/q6sql_sqlite_p.h
https://invent.kde.org/office/skrooge/-/commit/1ceca14efbcd1fb358c5136b68863949ae69fec3
diff --git a/doc/ai_assistant.png b/doc/ai_assistant.png
new file mode 100644
index 000000000..4e6c72eeb
Binary files /dev/null and b/doc/ai_assistant.png differ
diff --git a/doc/index.docbook b/doc/index.docbook
index 9aa12d9c9..803773e86 100644
--- a/doc/index.docbook
+++ b/doc/index.docbook
@@ -281,6 +281,7 @@
<listitem><para><link linkend="undo_redo">The undo / redo browser</link></para></listitem>
<listitem><para><link linkend="property_editor">The property editor</link></para></listitem>
<listitem><para><link linkend="message_list">The messages list</link></para></listitem>
+ <listitem><para><link linkend="ai_assistant">Dickens - AI Assistant</link></para></listitem>
</itemizedlist>
</para>
</sect2>
@@ -338,8 +339,8 @@
<mediaobject>
<imageobject><imagedata fileref="history_browser.png" format="PNG"/></imageobject>
<textobject><phrase>The history browser</phrase></textobject>
- </mediaobject>
- </screenshot>
+ </mediaobject>
+ </screenshot>
<para>
The history browser shows three columns:
@@ -442,6 +443,38 @@
<para>Each context will be described in a specific chapter in this handbook.</para>
</sect2>
+
+ <sect2 id="ai_assistant">
+ <title>Dickens - AI Assistant</title>
+ <para>
+ Dickens is the &skrooge; AI assistant dock.
+ You can use it to ask questions about the current document, request summaries of your financial situation, or ask for ideas such as ways to reduce monthly expenses.
+ The assistant keeps the conversation context, so follow-up questions can refer to previous answers.
+ </para>
+ <screenshot>
+ <mediaobject>
+ <imageobject><imagedata fileref="ai_assistant.png" format="PNG"/></imageobject>
+ <textobject><phrase>The AI assistant dock</phrase></textobject>
+ </mediaobject>
+ </screenshot>
+ <para>
+ To use Dickens, you must configure an OpenAI-compatible chat completion server in <link linkend="settings_ai_assistant">Settings</link>.
+ </para>
+ <para>
+ When the assistant needs data from your document, it can execute read-only SQLite queries on the opened &skrooge; database. Only single <userinput>SELECT</userinput> statements are accepted. If internet searches are enabled in the <link linkend="settings_ai_assistant">settings</link>, the assistant can also search the web through DuckDuckGo to answer questions that require external or up-to-date information.
+ </para>
+ <para>
+ The dock provides a session selector. Use <guibutton>Save current AI session</guibutton> to store the current conversation in the document, and <guibutton>Delete AI session</guibutton> to remove a saved one. <guibutton>Reset the current AI session</guibutton> clears the current conversation. You can press <keycombo>&Ctrl;<keycap>Return</keycap></keycombo> in the question editor to send the question.
+ </para>
+ <para>
+ The help button displays example questions. The <link linkend="settings_ai_assistant">settings</link> button opens the assistant preferences. The reasoning selector lets you choose whether the assistant reasoning, and optionally tool answers, are displayed in the conversation.
+ </para>
+ <important>
+ <para>
+ Financial documents can contain sensitive information. Prefer a local server if you do not want document data or questions to be sent to a remote service. Review the internet search option before enabling it, especially when your questions may contain personal or financial details.
+ </para>
+ </important>
+ </sect2>
<sect2 id="multi-tabs">
<title>Multiple Tabs</title>
@@ -2322,8 +2355,8 @@ file is opened. It is also recommended to create a different account (⪚ "ETF"
<imageobject>
<imagedata fileref="assign_schedule.png" format="PNG"/>
</imageobject>
- </mediaobject>
- </screenshot>
+ </mediaobject>
+ </screenshot>
</sect2>
</sect1>
@@ -2697,7 +2730,23 @@ file is opened. It is also recommended to create a different account (⪚ "ETF"
</mediaobject>
</screenshot>
<para>You can activate the import backends from the list here.</para>
- </sect1>
+ </sect1>
+
+ <sect1 id="settings_ai_assistant">
+ <title>AI Assistant</title>
+
+ <screenshot>
+ <mediaobject>
+ <imageobject>
+ <imagedata fileref="settings_ai_assistant.png" format="PNG"/>
+ </imageobject>
+ </mediaobject>
+ </screenshot>
+ <para>
+ The server URL can be a local server, such as the default <userinput>http://127.0.0.1:8080</userinput>,
+ or a remote compatible service. If the service requires authentication, enter its API token.
+ </para>
+ </sect1>
</chapter>
<chapter id="howto">
diff --git a/doc/kde_docbook b/doc/kde_docbook
index e522598e4..311a3a749 100755
--- a/doc/kde_docbook
+++ b/doc/kde_docbook
@@ -3,7 +3,7 @@
# /kde_docbook index.docbook index.html
# Let's call the KDE catalog first
-export XML_CATALOG_FILES="/usr/share/kf5/kdoctools/customization/catalog.xml"
+export XML_CATALOG_FILES="/usr/share/kf6/kdoctools/customization/catalog.xml"
# Check if docbook is well formed
echo "Running checkXML… ";
@@ -16,7 +16,7 @@ echo " -> OK !";
# Convert to html
echo "Running xsltproc… ";
-xsltproc -o $2 /usr/share/kf5/kdoctools/customization/kde-nochunk.xsl $1;
+xsltproc -o $2 /usr/share/kf6/kdoctools/customization/kde-nochunk.xsl $1;
if [ $? -gt 0 ]; then
echo " -> xsltproc failed !";
exit 1;
diff --git a/doc/settings_ai_assistant.png b/doc/settings_ai_assistant.png
new file mode 100644
index 000000000..f69b2d37c
Binary files /dev/null and b/doc/settings_ai_assistant.png differ
diff --git a/plugins/generic/skg_monthly/grantlee_filters/skgdocumentfilter.cpp b/plugins/generic/skg_monthly/grantlee_filters/skgdocumentfilter.cpp
index aa8b57272..960836029 100644
--- a/plugins/generic/skg_monthly/grantlee_filters/skgdocumentfilter.cpp
+++ b/plugins/generic/skg_monthly/grantlee_filters/skgdocumentfilter.cpp
@@ -174,8 +174,8 @@ QVariant SKGDumpFilter::doFilter(const QVariant &input, const QVariant &argument
int nb = metaObject->propertyCount();
for (int i = 0; i < nb; ++i) {
QVariant val = SKGDumpFilter::doFilter(obj->property(metaObject->property(i).name()), QVariant(), autoescape);
- table += QStringLiteral("<tr><td>") % SKGServices::stringToHtml(metaObject->property(i).name()) % "</td><td>" % SKGServices::stringToHtml(val.toString())
- % "</td></tr>";
+ table += QStringLiteral("<tr><td>") % SKGServices::stringToHtml(metaObject->property(i).name()) % "</td><td>"
+ % SKGServices::stringToHtml(val.toString()) % "</td></tr>";
}
table += QStringLiteral("</table>");
return QVariant(table);
diff --git a/plugins/skrooge/CMakeLists.txt b/plugins/skrooge/CMakeLists.txt
index c54afac91..9d7a23ea9 100644
--- a/plugins/skrooge/CMakeLists.txt
+++ b/plugins/skrooge/CMakeLists.txt
@@ -3,6 +3,7 @@
#* SPDX-FileCopyrightText: 2024 G. DE BURE support at mankowski.fr
#* SPDX-License-Identifier: GPL-3.0-or-later
#***************************************************************************
+ADD_SUBDIRECTORY(skrooge_aiassistant)
ADD_SUBDIRECTORY(skrooge_bank)
ADD_SUBDIRECTORY(skrooge_budget)
ADD_SUBDIRECTORY(skrooge_calculator)
diff --git a/plugins/skrooge/skrooge_aiassistant/CMakeLists.txt b/plugins/skrooge/skrooge_aiassistant/CMakeLists.txt
new file mode 100644
index 000000000..026b21369
--- /dev/null
+++ b/plugins/skrooge/skrooge_aiassistant/CMakeLists.txt
@@ -0,0 +1,26 @@
+#***************************************************************************
+#* SPDX-FileCopyrightText: 2026 S. MANKOWSKI stephane at mankowski.fr
+#* SPDX-License-Identifier: GPL-3.0-or-later
+#***************************************************************************
+MESSAGE( STATUS "..:: CMAKE PLUGIN_AIASSISTANT ::..")
+
+PROJECT(plugin_aiassistant)
+
+LINK_DIRECTORIES (${LIBRARY_OUTPUT_PATH})
+
+SET(skrooge_aiassistant_SRCS
+ skgassistantplugin.cpp
+ skgassistantpluginwidget.cpp
+ skgassistantplugindockwidget.cpp)
+
+ki18n_wrap_ui(skrooge_aiassistant_SRCS skgassistantpluginwidget_dock.ui)
+kconfig_add_kcfg_files(skrooge_aiassistant_SRCS skgassistant_settings.kcfgc)
+ki18n_wrap_ui(skrooge_aiassistant_SRCS skgassistantpluginwidget_pref.ui)
+
+KCOREADDONS_ADD_PLUGIN(skrooge_aiassistant SOURCES ${skrooge_aiassistant_SRCS} INSTALL_NAMESPACE "skg_gui")
+TARGET_LINK_LIBRARIES(skrooge_aiassistant KF6::Parts Qt6::Network skgbasemodeler skgbasegui)
+
+########### install files ###############
+INSTALL(FILES ${PROJECT_SOURCE_DIR}/skrooge_aiassistant.rc DESTINATION ${KDE_INSTALL_KXMLGUIDIR}/skrooge_aiassistant )
+INSTALL(FILES ${PROJECT_SOURCE_DIR}/skgassistant_settings.kcfg DESTINATION ${KDE_INSTALL_KCFGDIR} )
+INSTALL(FILES skrooge_system_prompt.md DESTINATION ${KDE_INSTALL_DATADIR}/skrooge )
diff --git a/plugins/skrooge/skrooge_aiassistant/metadata.json b/plugins/skrooge/skrooge_aiassistant/metadata.json
new file mode 100644
index 000000000..6e7421068
--- /dev/null
+++ b/plugins/skrooge/skrooge_aiassistant/metadata.json
@@ -0,0 +1,23 @@
+{
+ "KPlugin": {
+ "Authors": [
+ {
+ "Email": "stephane at mankowski.fr",
+ "Name": "Stephane MANKOWSKI,miraks"
+ }
+ ],
+ "Category": "Plugins",
+ "Description": "A Skrooge AI assistant plugin using a local OpenAI-compatible server",
+ "Description[fr]": "Un module d'assistant IA pour Skrooge utilisant un serveur local compatible OpenAI",
+ "EnabledByDefault": true,
+ "Icon": "im-user",
+ "License": "GPL",
+ "Name": "Skrooge AI assistant plugin",
+ "ServiceTypes": [
+ "SKG GUI/Plugin"
+ ],
+ "Version": "1.0",
+ "Website": "https://skrooge.org/"
+ },
+ "X-Krunner-ID": "Skrooge AI assistant plugin"
+}
diff --git a/plugins/skrooge/skrooge_aiassistant/skgassistant_settings.kcfg b/plugins/skrooge/skrooge_aiassistant/skgassistant_settings.kcfg
new file mode 100644
index 000000000..9c44e38f7
--- /dev/null
+++ b/plugins/skrooge/skrooge_aiassistant/skgassistant_settings.kcfg
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0
+ http://www.kde.org/standards/kcfg/1.0/kcfg.xsd" >
+ <kcfgfile/>
+ <group name="skrooge_aiassistant">
+ <entry name="serverUrl" type="String">
+ <default>http://127.0.0.1:8080</default>
+ </entry>
+ <entry name="apiToken" type="String">
+ <default></default>
+ </entry>
+ <entry name="enableWebSearch" type="Bool">
+ <default>true</default>
+ </entry>
+ <entry name="familySituation" type="String">
+ <label>Family Situation</label>
+ <default></default>
+ </entry>
+ </group>
+</kcfg>
diff --git a/plugins/skrooge/skrooge_aiassistant/skgassistant_settings.kcfgc b/plugins/skrooge/skrooge_aiassistant/skgassistant_settings.kcfgc
new file mode 100644
index 000000000..98750b853
--- /dev/null
+++ b/plugins/skrooge/skrooge_aiassistant/skgassistant_settings.kcfgc
@@ -0,0 +1,6 @@
+# Code generation options for kconfig_compiler
+File=skgassistant_settings.kcfg
+ClassName=skgassistant_settings
+Singleton=true
+Mutators=col_background,col_foreground
+# will create the necessary code for setting those variables
diff --git a/plugins/skrooge/skrooge_aiassistant/skgassistantplugin.cpp b/plugins/skrooge/skrooge_aiassistant/skgassistantplugin.cpp
new file mode 100644
index 000000000..bad12c336
--- /dev/null
+++ b/plugins/skrooge/skrooge_aiassistant/skgassistantplugin.cpp
@@ -0,0 +1,133 @@
+/***************************************************************************
+ * SPDX-FileCopyrightText: 2026 S. MANKOWSKI stephane at mankowski.fr
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ ***************************************************************************/
+/** @file
+ * This file is Skrooge plugin for AI assistant.
+ *
+ * @author Stephane MANKOWSKI
+ */
+#include "skgassistantplugin.h"
+
+#include <kactioncollection.h>
+#include <kpluginfactory.h>
+
+#include <qaction.h>
+#include <qdockwidget.h>
+
+#include "skgassistant_settings.h"
+#include "skgassistantplugindockwidget.h"
+#include "skgassistantpluginwidget.h"
+#include "skgmainpanel.h"
+#include "skgtraces.h"
+
+/**
+ * This plugin factory.
+ */
+K_PLUGIN_CLASS_WITH_JSON(SKGAssistantPlugin, "metadata.json")
+
+SKGAssistantPlugin::SKGAssistantPlugin(QWidget *iWidget, QObject *iParent, const KPluginMetaData & /*metaData*/, const QVariantList & /*iArg*/)
+ : SKGInterfacePlugin(iParent)
+ , m_currentDocument(nullptr)
+ , m_dockWidget(nullptr)
+{
+ Q_UNUSED(iWidget)
+ SKGTRACEINFUNC(10)
+}
+
+SKGAssistantPlugin::~SKGAssistantPlugin()
+{
+ SKGTRACEINFUNC(10)
+ m_currentDocument = nullptr;
+ m_dockWidget = nullptr;
+}
+
+bool SKGAssistantPlugin::setupActions(SKGDocument *iDocument)
+{
+ SKGTRACEINFUNC(10)
+
+ m_currentDocument = iDocument;
+ if (m_currentDocument == nullptr) {
+ return false;
+ }
+
+ setComponentName(QStringLiteral("skrooge_aiassistant"), title());
+ setXMLFile(QStringLiteral("skrooge_aiassistant.rc"));
+
+ auto dockContent = new SKGAssistantPluginDockWidget(SKGMainPanel::getMainPanel(), m_currentDocument);
+ if (dockContent != nullptr) {
+ m_dockWidget = new QDockWidget(SKGMainPanel::getMainPanel());
+ if (m_dockWidget != nullptr) {
+ m_dockWidget->setObjectName(QStringLiteral("skg_aiassistant_docwidget"));
+ m_dockWidget->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea);
+ m_dockWidget->setWindowTitle(title());
+ m_dockWidget->setWidget(dockContent);
+
+ QAction *toggle = m_dockWidget->toggleViewAction();
+ QAction *panelAction = actionCollection()->addAction(QStringLiteral("view_aiassistant"));
+ registerGlobalAction(QStringLiteral("view_aiassistant"), panelAction);
+ panelAction->setCheckable(true);
+ panelAction->setChecked(toggle->isChecked());
+ panelAction->setText(toggle->text());
+ connect(panelAction, &QAction::triggered, toggle, &QAction::trigger);
+ connect(toggle, &QAction::toggled, panelAction, &QAction::setChecked);
+ }
+ }
+
+ return true;
+}
+
+SKGTabPage *SKGAssistantPlugin::getWidget()
+{
+ SKGTRACEINFUNC(10)
+ return new SKGAssistantPluginWidget(SKGMainPanel::getMainPanel(), m_currentDocument);
+}
+
+QDockWidget *SKGAssistantPlugin::getDockWidget()
+{
+ SKGTRACEINFUNC(10)
+ return m_dockWidget;
+}
+
+QWidget *SKGAssistantPlugin::getPreferenceWidget()
+{
+ SKGTRACEINFUNC(10)
+ QWidget *w = new QWidget();
+ ui.setupUi(w);
+ QStringList models = QStringList() << QStringLiteral("google/gemma-4-12b-qat") << QStringLiteral("qwen/qwen2.5-coder-14b")
+ << QStringLiteral("qwen/qwen3.5-9b");
+ ui.kLabel->setText(i18nc("Label for the AI assistant plugin",
+ "<p><strong>Dickens</strong> is your AI companion. To enable it, you must specify an <strong>OpenAI</strong>-compatible server. "
+ "You can use a remote server, but for reasons of confidentiality regarding your sensitive data, it is recommended to use a local "
+ "one.</p>Example of working well local models:<br>%1",
+ models.join(QStringLiteral(", "))));
+ return w;
+}
+
+KConfigSkeleton *SKGAssistantPlugin::getPreferenceSkeleton()
+{
+ SKGTRACEINFUNC(10)
+ return skgassistant_settings::self();
+}
+
+QString SKGAssistantPlugin::title() const
+{
+ return i18nc("Noun, something used to assist the user", "Dickens - AI Assistant");
+}
+
+QString SKGAssistantPlugin::icon() const
+{
+ return QStringLiteral("qa");
+}
+
+int SKGAssistantPlugin::getOrder() const
+{
+ return 200;
+}
+
+bool SKGAssistantPlugin::isInPagesChooser() const
+{
+ return true;
+}
+
+#include "skgassistantplugin.moc"
diff --git a/plugins/skrooge/skrooge_aiassistant/skgassistantplugin.h b/plugins/skrooge/skrooge_aiassistant/skgassistantplugin.h
new file mode 100644
index 000000000..3ad3eb3ea
--- /dev/null
+++ b/plugins/skrooge/skrooge_aiassistant/skgassistantplugin.h
@@ -0,0 +1,97 @@
+/***************************************************************************
+ * SPDX-FileCopyrightText: 2026 S. MANKOWSKI stephane at mankowski.fr
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ ***************************************************************************/
+#ifndef SKGASSISTANTPLUGIN_H
+#define SKGASSISTANTPLUGIN_H
+/** @file
+ * An AI assistant plugin.
+ */
+#include "skginterfaceplugin.h"
+#include "ui_skgassistantpluginwidget_pref.h"
+
+class QDockWidget;
+
+/**
+ * An AI assistant plugin
+ */
+class SKGAssistantPlugin : public SKGInterfacePlugin
+{
+ Q_OBJECT
+ Q_INTERFACES(SKGInterfacePlugin)
+
+public:
+ /**
+ * Default Constructor
+ */
+ explicit SKGAssistantPlugin(QWidget *iWidget, QObject *iParent, const KPluginMetaData &metaData, const QVariantList &iArg);
+
+ /**
+ * Default Destructor
+ */
+ ~SKGAssistantPlugin() override;
+
+ /**
+ * Called to initialise the plugin
+ * @param iDocument the main document
+ * @return true if the plugin is compatible with the document
+ */
+ bool setupActions(SKGDocument *iDocument) override;
+
+ /**
+ * The page widget of the plugin.
+ * @return The page widget of the plugin
+ */
+ SKGTabPage *getWidget() override;
+
+ /**
+ * The preference widget of the plugin.
+ * @return The preference widget of the plugin
+ */
+ QWidget *getPreferenceWidget() override;
+
+ /**
+ * The preference skeleton of the plugin.
+ * @return The preference skeleton of the plugin
+ */
+ KConfigSkeleton *getPreferenceSkeleton() override;
+
+ /**
+ * The dock widget of the plugin.
+ * @return The dock widget of the plugin
+ */
+ QDockWidget *getDockWidget() override;
+
+ /**
+ * The title of the plugin.
+ * @return The title of the plugin
+ */
+ QString title() const override;
+
+ /**
+ * The icon of the plugin.
+ * @return The icon of the plugin
+ */
+ QString icon() const override;
+
+ /**
+ * Must be implemented to set the position of the plugin.
+ * @return integer value between 0 and 999 (default = 999)
+ */
+ int getOrder() const override;
+
+ /**
+ * Must be implemented to know if a plugin must be display in pages chooser.
+ * @return true of false (default = false)
+ */
+ bool isInPagesChooser() const override;
+
+private:
+ Q_DISABLE_COPY(SKGAssistantPlugin)
+
+ SKGDocument *m_currentDocument;
+ QDockWidget *m_dockWidget;
+ Ui::skgassistantplugin_pref ui{};
+};
+
+#endif // SKGASSISTANTPLUGIN_H
diff --git a/plugins/skrooge/skrooge_aiassistant/skgassistantplugindockwidget.cpp b/plugins/skrooge/skrooge_aiassistant/skgassistantplugindockwidget.cpp
new file mode 100644
index 000000000..2bed20ab1
--- /dev/null
+++ b/plugins/skrooge/skrooge_aiassistant/skgassistantplugindockwidget.cpp
@@ -0,0 +1,911 @@
+/***************************************************************************
+ * SPDX-FileCopyrightText: 2026 S. MANKOWSKI stephane at mankowski.fr
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ ***************************************************************************/
+/** @file
+ * A dock widget for the AI assistant plugin.
+ */
+#include "skgassistantplugindockwidget.h"
+
+#include <qeventloop.h>
+#include <qfile.h>
+#include <qjsondocument.h>
+#include <qjsonobject.h>
+#include <qjsonparseerror.h>
+#include <qnetworkreply.h>
+#include <qnetworkrequest.h>
+#include <qregularexpression.h>
+#include <qshortcut.h>
+#include <qstringlist.h>
+#include <qtextcursor.h>
+#include <qurlquery.h>
+
+#include "skgassistant_settings.h"
+#include "skgmainpanel.h"
+#include "skgpropertyobject.h"
+#include "skgservices.h"
+#include "skgshow.h"
+#include "skgtraces.h"
+#include "skgtransactionmng.h"
+
+#define PREFIX_AIASSISTANT_SESSION QStringLiteral("SKG_AI_SESSION_")
+
+SKGAssistantPluginDockWidget::SKGAssistantPluginDockWidget(QWidget *iParent, SKGDocument *iDocument)
+ : SKGWidget(iParent, iDocument)
+{
+ SKGTRACEINFUNC(10)
+ if (iDocument == nullptr) {
+ return;
+ }
+
+ ui.setupUi(this);
+
+ ui.kSaveCurrentAiSession->setIcon(SKGServices::fromTheme(QStringLiteral("document-save")));
+ ui.kDeleteAiSession->setIcon(SKGServices::fromTheme(QStringLiteral("edit-delete")));
+ ui.kAskButton->setIcon(SKGServices::fromTheme(QStringLiteral("media-playback-start")));
+ ui.kClearButton->setIcon(SKGServices::fromTheme(QStringLiteral("edit-clear")));
+ ui.kHelpButton->setIcon(SKGServices::fromTheme(QStringLiteral("help-about")));
+ ui.kSettingsButton->setIcon(SKGServices::fromTheme(QStringLiteral("settings-configure")));
+
+ ui.kDisplayReasoning->addGroupedItem(QStringLiteral("0"),
+ i18nc("Option to display or not the reasoning of the assistant", "No reasoning"),
+ QString(),
+ QString(),
+ QStringLiteral("display_reasoning"));
+ ui.kDisplayReasoning->addGroupedItem(QStringLiteral("1"), i18nc("Option to display or not the reasoning of the assistant", "Reasoning tag")), QString(),
+ QString(), QStringLiteral("display_reasoning");
+ ui.kDisplayReasoning->addGroupedItem(QStringLiteral("2"), i18nc("Option to display or not the reasoning of the assistant", "Current reasoning")), QString(),
+ QString(), QStringLiteral("display_reasoning");
+ ui.kDisplayReasoning->addGroupedItem(QStringLiteral("3"),
+ i18nc("Option to display or not the reasoning of the assistant", "All reasoning"),
+ QString(),
+ QString(),
+ QStringLiteral("display_reasoning"));
+ ui.kDisplayReasoning->addGroupedItem(QStringLiteral("4"),
+ i18nc("Option to display or not the reasoning of the assistant", "All reasoning and tools answers")),
+ QString(), QString(), QStringLiteral("display_reasoning");
+ ui.kDisplayReasoning->setState(QStringLiteral("1"));
+
+ // Add help text to the help area
+ QList<QString> helpTexts = {i18nc("Example of a IA question", "How much do I have in my current accounts?"),
+ i18nc("Example of a IA question", "What is the total amount of my expenses for this month?"),
+ i18nc("Example of a IA question", "Can you provide a summary of my recent transactions?"),
+ i18nc("Example of a IA question", "What are the top 5 categories where I spend the most money?"),
+ i18nc("Example of a IA question", "Can you suggest ways to reduce my monthly expenses?")};
+ for (const QString &helpText : helpTexts) {
+ auto label = new QLabel("<a href=\"" + helpText + "\">" + helpText + "</a>");
+ label->setWordWrap(true);
+ connect(label, &QLabel::linkActivated, this, [this](const QString &link) {
+ ui.kUserPrompt->setPlainText(link);
+ });
+ ui.kHelpArea_2->addWidget(label);
+ }
+
+ ui.kHelpArea->hide();
+
+ connect(ui.kAskButton, &QPushButton::clicked, this, &SKGAssistantPluginDockWidget::onAsk);
+ connect(ui.kUserPrompt, &QPlainTextEdit::textChanged, this, &SKGAssistantPluginDockWidget::onUserPromptChanged);
+ connect(ui.kClearButton, &QPushButton::clicked, this, &SKGAssistantPluginDockWidget::onClear);
+ connect(ui.kDisplayReasoning, &SKGShow::stateChanged, this, &SKGAssistantPluginDockWidget::refreshConversation);
+ connect(ui.kSaveCurrentAiSession, &QPushButton::clicked, this, &SKGAssistantPluginDockWidget::onSaveCurrentAiSession);
+ connect(ui.kDeleteAiSession, &QPushButton::clicked, this, &SKGAssistantPluginDockWidget::onDeleteAiSession);
+ connect(ui.kHelpButton, &QPushButton::clicked, this, [this]() {
+ ui.kHelpArea->setVisible(!ui.kHelpArea->isVisible());
+ });
+ connect(ui.kAiSessions, &QComboBox::currentIndexChanged, this, &SKGAssistantPluginDockWidget::onAiSessionChanged);
+ connect(ui.kSettingsButton, &QPushButton::clicked, this, [this]() {
+ auto *mainPanel = SKGMainPanel::getMainPanel();
+ if (mainPanel != nullptr) {
+ mainPanel->optionsPreferences(QStringLiteral("Skrooge AI assistant plugin"));
+ }
+ });
+
+ auto askShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Return), ui.kUserPrompt);
+ connect(askShortcut, &QShortcut::activated, this, &SKGAssistantPluginDockWidget::onAsk);
+
+ connect(iDocument, &SKGDocument::modified, this, &SKGAssistantPluginDockWidget::slotRefreshSessionsList);
+
+ refreshSessionsList(false);
+}
+
+SKGAssistantPluginDockWidget::~SKGAssistantPluginDockWidget()
+{
+ if (m_pendingReply != nullptr) {
+ m_pendingReply->abort();
+ }
+}
+
+void SKGAssistantPluginDockWidget::onUserPromptChanged()
+{
+ ui.kAskButton->setEnabled(!ui.kUserPrompt->toPlainText().trimmed().isEmpty() && m_pendingReply == nullptr);
+}
+
+QUrl SKGAssistantPluginDockWidget::getCompletionEndpoint() const
+{
+ QString urlText = skgassistant_settings::serverUrl().trimmed();
+ if (urlText.isEmpty()) {
+ return QUrl();
+ }
+ if (!urlText.contains(QStringLiteral("://"))) {
+ urlText = QStringLiteral("http://") + urlText;
+ }
+
+ QUrl endpoint(urlText);
+ if (!endpoint.isValid()) {
+ return endpoint;
+ }
+
+ QString path = endpoint.path();
+ if (path.isEmpty() || path == QStringLiteral("/")) {
+ path = QStringLiteral("/v1/chat/completions");
+ } else if (!path.endsWith(QStringLiteral("/v1/chat/completions"))) {
+ if (path.endsWith(u'/')) {
+ path.chop(1);
+ }
+ if (!path.endsWith(QStringLiteral("/v1"))) {
+ path += QStringLiteral("/v1");
+ }
+ path += QStringLiteral("/chat/completions");
+ }
+ endpoint.setPath(path);
+
+ return endpoint;
+}
+
+void SKGAssistantPluginDockWidget::setBusy(bool iBusy)
+{
+ ui.kAskButton->setIcon(SKGServices::fromTheme(iBusy ? QStringLiteral("media-playback-stop") : QStringLiteral("media-playback-start")));
+ ui.kClearButton->setEnabled(!iBusy);
+ ui.kUserPrompt->setEnabled(!iBusy);
+ ui.kAiSessionMngt->setEnabled(!iBusy);
+}
+
+QString SKGAssistantPluginDockWidget::getSystemPrompt()
+{
+ // Read skrooge_system_prompt.md file content
+ QString systemPromptPath = QStandardPaths::locate(QStandardPaths::GenericDataLocation, QStringLiteral("skrooge/skrooge_system_prompt.md"));
+ QFile file(systemPromptPath);
+ if (file.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ QTextStream stream(&file);
+
+ auto systemPrompt = stream.readAll();
+ auto userFamilySituation = skgassistant_settings::familySituation().trimmed();
+ if (!userFamilySituation.isEmpty()) {
+ systemPrompt += "\n\n## User family situation\n\n" + userFamilySituation;
+ }
+ return systemPrompt;
+ }
+ return QString();
+}
+
+QString SKGAssistantPluginDockWidget::markdownToHtml(const QString &iContent)
+{
+ QTextDocument doc;
+ doc.setMarkdown(iContent);
+ return doc.toHtml();
+}
+
+void SKGAssistantPluginDockWidget::onClear()
+{
+ SKGTRACEINFUNC(10)
+ ui.kUserPrompt->clear();
+ ui.kAnswer->clear();
+ m_messages = QJsonArray();
+ ui.kAiSessions->setCurrentIndex(0);
+}
+
+void SKGAssistantPluginDockWidget::onAsk()
+{
+ SKGTRACEINFUNC(10)
+ ui.kHelpArea->setVisible(false);
+ if (m_pendingReply != nullptr) {
+ // Stop the pending request if there is one
+ m_pendingReply->abort();
+ m_pendingReply = nullptr;
+ } else {
+ // Start a new request
+ const QString question = ui.kUserPrompt->toPlainText().trimmed();
+ if (question.isEmpty()) {
+ return;
+ }
+
+ if (m_messages.isEmpty()) {
+ QJsonObject msg;
+ msg.insert(QStringLiteral("role"), QStringLiteral("system"));
+ msg.insert(QStringLiteral("content"), getSystemPrompt());
+ m_messages.push_back(msg);
+ }
+
+ // Add user question to the conversation history
+ QJsonObject msg;
+ msg.insert(QStringLiteral("role"), QStringLiteral("user"));
+ msg.insert(QStringLiteral("content"), question);
+ m_messages.push_back(msg);
+ m_toolCallDepth = 0;
+
+ SKGAssistantPluginDockWidget::refreshConversation();
+ sendCompletionRequest();
+ }
+}
+
+void SKGAssistantPluginDockWidget::sendCompletionRequest()
+{
+ SKGTRACEINFUNC(10)
+ if (m_pendingReply != nullptr) {
+ return;
+ }
+
+ const QUrl endpoint = getCompletionEndpoint();
+ if (!endpoint.isValid()) {
+ auto *mainPanel = SKGMainPanel::getMainPanel();
+ if (mainPanel != nullptr) {
+ mainPanel->displayMessage(skgassistant_settings::serverUrl().trimmed().isEmpty()
+ ? i18nc("Error message", "Server URL is empty. Please configure it in the settings.")
+ : i18nc("Error message", "Invalid server URL: %1", endpoint.toString()),
+ SKGDocument::Error);
+ }
+ return;
+ }
+
+ QJsonObject payload;
+ payload.insert(QStringLiteral("messages"), getRequestMessages(m_messages));
+ payload.insert(QStringLiteral("temperature"), 1.0);
+ payload.insert(QStringLiteral("stream"), false);
+ payload.insert(QStringLiteral("tools"), getTools());
+
+ QNetworkRequest request(endpoint);
+ request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
+
+ QString token = skgassistant_settings::apiToken().trimmed();
+ if (!token.isEmpty()) {
+ request.setRawHeader("Authorization", "Bearer " + token.toUtf8());
+ }
+
+ auto payloadString = QJsonDocument(payload).toJson(QJsonDocument::Compact);
+ m_pendingReply = m_networkManager.post(request, payloadString);
+ connect(m_pendingReply, &QNetworkReply::finished, this, &SKGAssistantPluginDockWidget::onReplyFinished);
+
+ setBusy(true);
+}
+
+QJsonArray SKGAssistantPluginDockWidget::getTools()
+{
+ QJsonArray tools;
+ // Add the SQLite SELECT tool
+ {
+ QJsonObject tool;
+ tool.insert(QStringLiteral("type"), QStringLiteral("function"));
+ QJsonObject function;
+ function.insert(QStringLiteral("name"), QStringLiteral("execute_sqlite_select"));
+ function.insert(QStringLiteral("description"),
+ i18nc("Tool description", "Execute a SQLite SELECT query on the current Skrooge database. Only SELECT statements are allowed."));
+ QJsonObject parameters;
+ parameters.insert(QStringLiteral("type"), QStringLiteral("object"));
+ QJsonObject properties;
+ QJsonObject sqlProperty;
+ sqlProperty.insert(QStringLiteral("type"), QStringLiteral("string"));
+ sqlProperty.insert(QStringLiteral("description"), i18nc("Tool parameter description", "The SQL query to execute. Only SELECT statements are allowed."));
+ properties.insert(QStringLiteral("sql"), sqlProperty);
+ parameters.insert(QStringLiteral("properties"), properties);
+ QJsonArray required;
+ required.push_back(QStringLiteral("sql"));
+ parameters.insert(QStringLiteral("required"), required);
+ function.insert(QStringLiteral("parameters"), parameters);
+ tool.insert(QStringLiteral("function"), function);
+ tools << tool;
+ }
+
+ // Add the web search tool if enabled in settings
+ if (skgassistant_settings::enableWebSearch()) {
+ QJsonObject tool;
+ tool.insert(QStringLiteral("type"), QStringLiteral("function"));
+ QJsonObject function;
+ function.insert(QStringLiteral("name"), QStringLiteral("web_search"));
+ function.insert(QStringLiteral("description"), i18nc("Tool description", "Search the internet using DuckDuckGo to find relevant information."));
+ QJsonObject parameters;
+ parameters.insert(QStringLiteral("type"), QStringLiteral("object"));
+ QJsonObject properties;
+ QJsonObject queryProperty;
+ queryProperty.insert(QStringLiteral("type"), QStringLiteral("string"));
+ queryProperty.insert(QStringLiteral("description"), i18nc("Tool parameter description", "The search query to look up on the internet."));
+ properties.insert(QStringLiteral("query"), queryProperty);
+ QJsonObject maxResultsProperty;
+ maxResultsProperty.insert(QStringLiteral("type"), QStringLiteral("integer"));
+ maxResultsProperty.insert(QStringLiteral("description"), i18nc("Tool parameter description", "Maximum number of results to return (1-10)."));
+ properties.insert(QStringLiteral("max_results"), maxResultsProperty);
+ parameters.insert(QStringLiteral("properties"), properties);
+ QJsonArray required;
+ required.push_back(QStringLiteral("query"));
+ parameters.insert(QStringLiteral("required"), required);
+ function.insert(QStringLiteral("parameters"), parameters);
+ tool.insert(QStringLiteral("function"), function);
+ tools << tool;
+ }
+ return tools;
+}
+
+QJsonArray SKGAssistantPluginDockWidget::getRequestMessages(const QJsonArray &iMessages)
+{
+ QJsonArray requestMessages;
+ for (const auto &messageValue : iMessages) {
+ if (!messageValue.isObject()) {
+ continue;
+ }
+ QJsonObject message = messageValue.toObject();
+ message.remove(QStringLiteral("reasoning_content"));
+ requestMessages.push_back(message);
+ }
+ return requestMessages;
+}
+
+QString SKGAssistantPluginDockWidget::contentToText(const QJsonValue &iContent)
+{
+ if (iContent.isString()) {
+ return iContent.toString();
+ }
+
+ if (iContent.isArray()) {
+ QStringList parts;
+ const auto values = iContent.toArray();
+ int nb = values.count();
+ parts.reserve(nb);
+ for (int i = 0; i < nb; ++i) {
+ QString part = contentToText(values.at(i));
+ if (!part.isEmpty()) {
+ parts.push_back(part);
+ }
+ }
+ return parts.join('\n');
+ }
+
+ if (iContent.isObject()) {
+ QJsonObject obj = iContent.toObject();
+ if (obj.contains(QStringLiteral("text"))) {
+ return obj.value(QStringLiteral("text")).toString();
+ }
+ if (obj.contains(QStringLiteral("content"))) {
+ return contentToText(obj.value(QStringLiteral("content")));
+ }
+ if (obj.contains(QStringLiteral("value"))) {
+ return contentToText(obj.value(QStringLiteral("value")));
+ }
+ }
+ return QString();
+}
+
+QString SKGAssistantPluginDockWidget::extractReasoningFromContent(QString &ioContent)
+{
+ QStringList reasonings;
+
+ static const QRegularExpression completeThinkTag(QStringLiteral("<think\\b[^>]*>(.*?)</think>"),
+ QRegularExpression::DotMatchesEverythingOption | QRegularExpression::CaseInsensitiveOption);
+ auto matchIterator = completeThinkTag.globalMatch(ioContent);
+ while (matchIterator.hasNext()) {
+ const QRegularExpressionMatch match = matchIterator.next();
+ const QString reasoning = match.captured(1).trimmed();
+ if (!reasoning.isEmpty()) {
+ reasonings.push_back(reasoning);
+ }
+ }
+ ioContent.remove(completeThinkTag);
+
+ static const QRegularExpression openThinkTag(QStringLiteral("<think\\b[^>]*>(.*)$"),
+ QRegularExpression::DotMatchesEverythingOption | QRegularExpression::CaseInsensitiveOption);
+ const QRegularExpressionMatch openMatch = openThinkTag.match(ioContent);
+ if (openMatch.hasMatch()) {
+ const QString reasoning = openMatch.captured(1).trimmed();
+ if (!reasoning.isEmpty()) {
+ reasonings.push_back(reasoning);
+ }
+ ioContent.remove(openMatch.capturedStart(0), ioContent.length() - openMatch.capturedStart(0));
+ }
+
+ ioContent = ioContent.trimmed();
+ return reasonings.join(QStringLiteral("\n\n")).trimmed();
+}
+
+QString SKGAssistantPluginDockWidget::reasoningContentToText(const QJsonObject &iMessage, const QJsonObject &iChoice)
+{
+ QString reasoning = contentToText(iMessage.value(QStringLiteral("reasoning_content"))).trimmed();
+ if (reasoning.isEmpty()) {
+ reasoning = contentToText(iChoice.value(QStringLiteral("reasoning_content"))).trimmed();
+ }
+ if (reasoning.isEmpty()) {
+ reasoning = contentToText(iChoice.value(QStringLiteral("reasoning"))).trimmed();
+ }
+ if (reasoning.isEmpty()) {
+ const QJsonObject delta = iChoice.value(QStringLiteral("delta")).toObject();
+ reasoning = contentToText(delta.value(QStringLiteral("reasoning_content"))).trimmed();
+ }
+ if (reasoning.isEmpty()) {
+ QString content = contentToText(iMessage.value(QStringLiteral("content")));
+ reasoning = extractReasoningFromContent(content);
+ }
+ if (reasoning.isEmpty()) {
+ QString content = contentToText(iChoice.value(QStringLiteral("text")));
+ reasoning = extractReasoningFromContent(content);
+ }
+ if (reasoning.isEmpty()) {
+ QString content = contentToText(iChoice.value(QStringLiteral("delta")));
+ reasoning = extractReasoningFromContent(content);
+ }
+ return reasoning;
+}
+
+QString SKGAssistantPluginDockWidget::sqlResultToJson(const SKGStringListList &iResult)
+{
+ QJsonObject output;
+ if (iResult.isEmpty()) {
+ output.insert(QStringLiteral("columns"), QJsonArray());
+ output.insert(QStringLiteral("rows"), QJsonArray());
+ output.insert(QStringLiteral("row_count"), 0);
+ return QString::fromUtf8(QJsonDocument(output).toJson(QJsonDocument::Compact));
+ }
+
+ QJsonArray columns;
+ const QStringList columnNames = iResult.at(0);
+ for (const auto &columnName : columnNames) {
+ columns.push_back(columnName);
+ }
+ output.insert(QStringLiteral("columns"), columns);
+
+ QJsonArray rows;
+ for (int rowIndex = 1; rowIndex < iResult.count(); ++rowIndex) {
+ const QStringList values = iResult.at(rowIndex);
+ QJsonObject row;
+ for (int columnIndex = 0; columnIndex < columnNames.count(); ++columnIndex) {
+ row.insert(columnNames.at(columnIndex), values.value(columnIndex));
+ }
+ rows.push_back(row);
+ }
+ output.insert(QStringLiteral("rows"), rows);
+ output.insert(QStringLiteral("row_count"), rows.count());
+ return QString::fromUtf8(QJsonDocument(output).toJson(QJsonDocument::Compact));
+}
+
+QString SKGAssistantPluginDockWidget::executeSqliteSelect(const QString &iSql) const
+{
+ QString sql = iSql.trimmed();
+ while (sql.endsWith(u';')) {
+ sql.chop(1);
+ sql = sql.trimmed();
+ }
+
+ static QRegularExpression selectOnly(QStringLiteral("^\\s*SELECT\\b"), QRegularExpression::CaseInsensitiveOption);
+ if (!selectOnly.match(sql).hasMatch() || sql.contains(u';')) {
+ QJsonObject error;
+ error.insert(QStringLiteral("error"), i18nc("Error message", "Only a single SQLite SELECT statement is allowed."));
+ return QString::fromUtf8(QJsonDocument(error).toJson(QJsonDocument::Compact));
+ }
+
+ SKGStringListList result;
+ auto *mainPanel = SKGMainPanel::getMainPanel();
+ auto *document = mainPanel != nullptr ? mainPanel->getDocument() : nullptr;
+ if (document == nullptr) {
+ QJsonObject error;
+ error.insert(QStringLiteral("error"), i18nc("Error message", "No document is opened."));
+ return QString::fromUtf8(QJsonDocument(error).toJson(QJsonDocument::Compact));
+ }
+
+ auto err = document->executeSelectSqliteOrder(sql, result);
+ if (err.isFailed()) {
+ QJsonObject error;
+ error.insert(QStringLiteral("error"), err.getFullMessage());
+ return QString::fromUtf8(QJsonDocument(error).toJson(QJsonDocument::Compact));
+ }
+
+ return sqlResultToJson(result);
+}
+
+QString SKGAssistantPluginDockWidget::executeWebSearch(const QString &iQuery, int iMaxResults) const
+{
+ if (iQuery.trimmed().isEmpty()) {
+ QJsonObject error;
+ error.insert(QStringLiteral("error"), i18nc("Error message", "Search query cannot be empty."));
+ return QString::fromUtf8(QJsonDocument(error).toJson(QJsonDocument::Compact));
+ }
+
+ QUrl searchUrl(QStringLiteral("https://api.duckduckgo.com/"));
+ QUrlQuery query;
+ query.addQueryItem(QStringLiteral("q"), iQuery);
+ query.addQueryItem(QStringLiteral("format"), QStringLiteral("json"));
+ query.addQueryItem(QStringLiteral("no_html"), QStringLiteral("1"));
+ searchUrl.setQuery(query);
+
+ QNetworkRequest request(searchUrl);
+ request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("Skrooge-AI-Assistant"));
+
+ QEventLoop eventLoop;
+ QPointer<QNetworkReply> reply = const_cast<QNetworkAccessManager *>(&m_networkManager)->get(request);
+ connect(reply, &QNetworkReply::finished, &eventLoop, &QEventLoop::quit);
+
+ eventLoop.exec();
+
+ if (reply == nullptr || reply->error() != QNetworkReply::NoError) {
+ QJsonObject error;
+ error.insert(QStringLiteral("error"),
+ i18nc("Error message", "Web search failed: %1", reply != nullptr ? reply->errorString() : QStringLiteral("Unknown error")));
+ if (reply != nullptr) {
+ reply->deleteLater();
+ }
+ return QString::fromUtf8(QJsonDocument(error).toJson(QJsonDocument::Compact));
+ }
+
+ QByteArray responseData = reply->readAll();
+ reply->deleteLater();
+
+ QJsonParseError parseError{};
+ QJsonDocument responseDoc = QJsonDocument::fromJson(responseData, &parseError);
+ if (parseError.error != QJsonParseError::NoError || !responseDoc.isObject()) {
+ QJsonObject error;
+ error.insert(QStringLiteral("error"), i18nc("Error message", "Failed to parse search results."));
+ return QString::fromUtf8(QJsonDocument(error).toJson(QJsonDocument::Compact));
+ }
+
+ QJsonObject responseObj = responseDoc.object();
+ QJsonArray results;
+
+ // Extract abstract result if available
+ QString abstractText = responseObj.value(QStringLiteral("AbstractText")).toString().trimmed();
+ QString abstractSource = responseObj.value(QStringLiteral("AbstractSource")).toString().trimmed();
+ QString abstractUrl = responseObj.value(QStringLiteral("AbstractURL")).toString().trimmed();
+
+ if (!abstractText.isEmpty()) {
+ QJsonObject result;
+ result.insert(QStringLiteral("title"), abstractSource.isEmpty() ? QStringLiteral("Summary") : abstractSource);
+ result.insert(QStringLiteral("snippet"), abstractText);
+ if (!abstractUrl.isEmpty()) {
+ result.insert(QStringLiteral("url"), abstractUrl);
+ }
+ results.push_back(result);
+ }
+
+ // Extract related searches as additional results
+ QJsonArray relatedSearches = responseObj.value(QStringLiteral("RelatedTopics")).toArray();
+ int resultCount = results.count();
+ for (const QJsonValue &topicValue : relatedSearches) {
+ if (resultCount >= iMaxResults) {
+ break;
+ }
+
+ if (topicValue.isObject()) {
+ QJsonObject topic = topicValue.toObject();
+ QString firstUrl = topic.value(QStringLiteral("FirstURL")).toString().trimmed();
+ QString text = topic.value(QStringLiteral("Text")).toString().trimmed();
+
+ if (!text.isEmpty()) {
+ QJsonObject result;
+ result.insert(QStringLiteral("title"), topic.value(QStringLiteral("Name")).toString());
+ result.insert(QStringLiteral("snippet"), text);
+ if (!firstUrl.isEmpty()) {
+ result.insert(QStringLiteral("url"), firstUrl);
+ }
+ results.push_back(result);
+ ++resultCount;
+ }
+ }
+ }
+
+ if (results.isEmpty()) {
+ QJsonObject output;
+ output.insert(QStringLiteral("results"), QJsonArray());
+ output.insert(QStringLiteral("result_count"), 0);
+ output.insert(QStringLiteral("query"), iQuery);
+ return QString::fromUtf8(QJsonDocument(output).toJson(QJsonDocument::Compact));
+ }
+
+ QJsonObject output;
+ output.insert(QStringLiteral("results"), results);
+ output.insert(QStringLiteral("result_count"), results.count());
+ output.insert(QStringLiteral("query"), iQuery);
+ return QString::fromUtf8(QJsonDocument(output).toJson(QJsonDocument::Compact));
+}
+
+QString SKGAssistantPluginDockWidget::executeToolCall(const QJsonObject &iToolCall) const
+{
+ const QJsonObject function = iToolCall.value(QStringLiteral("function")).toObject();
+ const QString name = function.value(QStringLiteral("name")).toString();
+ const QString arguments = function.value(QStringLiteral("arguments")).toString();
+
+ QJsonParseError argumentsParseError{};
+ QJsonDocument argumentsDoc = QJsonDocument::fromJson(arguments.toUtf8(), &argumentsParseError);
+ QJsonObject argumentsObj;
+ if (!arguments.isEmpty()) {
+ if (argumentsParseError.error != QJsonParseError::NoError || !argumentsDoc.isObject()) {
+ QJsonObject error;
+ error.insert(QStringLiteral("error"), i18nc("Error message", "Invalid tool call arguments."));
+ return QString::fromUtf8(QJsonDocument(error).toJson(QJsonDocument::Compact));
+ }
+ argumentsObj = argumentsDoc.object();
+ }
+
+ if (name == QStringLiteral("execute_sqlite_select")) {
+ return executeSqliteSelect(argumentsObj.value(QStringLiteral("sql")).toString());
+ }
+
+ if (name == QStringLiteral("web_search")) {
+ int maxResults = argumentsObj.value(QStringLiteral("max_results")).toInt(5);
+ if (maxResults < 1 || maxResults > 10) {
+ maxResults = 5;
+ }
+ return executeWebSearch(argumentsObj.value(QStringLiteral("query")).toString(), maxResults);
+ }
+
+ QJsonObject error;
+ error.insert(QStringLiteral("error"), i18nc("Error message", "Unknown tool: %1", name));
+ return QString::fromUtf8(QJsonDocument(error).toJson(QJsonDocument::Compact));
+}
+
+QString SKGAssistantPluginDockWidget::extractAssistantMessage(const QByteArray &iPayload, QString &oErrorMessage, bool &oNeedsFollowUp)
+{
+ oNeedsFollowUp = false;
+ m_pendingReasoningContent.clear();
+ QJsonParseError parseError{};
+ QJsonDocument response = QJsonDocument::fromJson(iPayload, &parseError);
+ if (parseError.error != QJsonParseError::NoError || !response.isObject()) {
+ oErrorMessage = i18nc("Error message", "Invalid JSON response from server.");
+ return QString();
+ }
+
+ const QJsonObject root = response.object();
+ if (root.contains(QStringLiteral("error"))) {
+ const QJsonValue errorValue = root.value(QStringLiteral("error"));
+ QString details = contentToText(errorValue);
+ if (details.isEmpty() && errorValue.isObject()) {
+ details = errorValue.toObject().value(QStringLiteral("message")).toString();
+ }
+ if (details.isEmpty()) {
+ details = QString::fromUtf8(iPayload);
+ }
+ oErrorMessage = i18nc("Error message", "Server error: %1", details);
+ return QString();
+ }
+
+ QJsonArray choices = root.value(QStringLiteral("choices")).toArray();
+ if (choices.isEmpty()) {
+ oErrorMessage = i18nc("Error message", "No answer returned by the model.");
+ return QString();
+ }
+
+ const QJsonObject firstChoice = choices.at(0).toObject();
+ QJsonObject message = firstChoice.value(QStringLiteral("message")).toObject();
+ m_pendingReasoningContent = reasoningContentToText(message, firstChoice);
+ QJsonArray toolCalls = message.value(QStringLiteral("tool_calls")).toArray();
+ if (toolCalls.isEmpty()) {
+ toolCalls = firstChoice.value(QStringLiteral("tool_calls")).toArray();
+ }
+
+ if (!toolCalls.isEmpty()) {
+ ++m_toolCallDepth;
+ if (m_toolCallDepth > 8) {
+ oErrorMessage = i18nc("Error message", "Too many consecutive tool calls.");
+ return QString();
+ }
+
+ if (message.isEmpty()) {
+ message.insert(QStringLiteral("role"), QStringLiteral("assistant"));
+ message.insert(QStringLiteral("content"), QJsonValue::Null);
+ message.insert(QStringLiteral("tool_calls"), toolCalls);
+ }
+ if (!m_pendingReasoningContent.isEmpty() && !message.contains(QStringLiteral("reasoning_content"))) {
+ message.insert(QStringLiteral("reasoning_content"), m_pendingReasoningContent);
+ }
+ m_messages.push_back(message);
+
+ for (const auto &toolCallValue : toolCalls) {
+ const QJsonObject toolCall = toolCallValue.toObject();
+ QString toolCallId = toolCall.value(QStringLiteral("id")).toString();
+ if (toolCallId.isEmpty()) {
+ toolCallId = toolCall.value(QStringLiteral("tool_id")).toString();
+ }
+
+ QJsonObject toolResult;
+ toolResult.insert(QStringLiteral("role"), QStringLiteral("tool"));
+ toolResult.insert(QStringLiteral("tool_call_id"), toolCallId);
+ toolResult.insert(QStringLiteral("content"), executeToolCall(toolCall));
+ m_messages.push_back(toolResult);
+ }
+
+ oNeedsFollowUp = true;
+ return QString();
+ }
+
+ QString answer = contentToText(message.value(QStringLiteral("content"))).trimmed();
+ if (answer.isEmpty()) {
+ answer = contentToText(firstChoice.value(QStringLiteral("text"))).trimmed();
+ }
+ if (answer.isEmpty()) {
+ answer = contentToText(firstChoice.value(QStringLiteral("delta"))).trimmed();
+ }
+ const QString reasoningFromAnswer = extractReasoningFromContent(answer);
+ if (!reasoningFromAnswer.isEmpty()) {
+ if (m_pendingReasoningContent.isEmpty()) {
+ m_pendingReasoningContent = reasoningFromAnswer;
+ } else if (!m_pendingReasoningContent.contains(reasoningFromAnswer)) {
+ m_pendingReasoningContent += QStringLiteral("\n\n") + reasoningFromAnswer;
+ }
+ }
+ if (answer.isEmpty()) {
+ oErrorMessage = i18nc("Error message", "The model returned an empty answer.");
+ }
+ return answer;
+}
+
+void SKGAssistantPluginDockWidget::onReplyFinished()
+{
+ SKGTRACEINFUNC(10)
+ QPointer<QNetworkReply> reply = m_pendingReply;
+ m_pendingReply = nullptr;
+ setBusy(false);
+
+ if (reply == nullptr) {
+ return;
+ }
+
+ const QByteArray payload = reply->readAll();
+ QString serverMessage;
+ bool needsFollowUp = false;
+ QString answer = extractAssistantMessage(payload, serverMessage, needsFollowUp);
+
+ if (reply->error() == QNetworkReply::ConnectionRefusedError) {
+ auto *mainPanel = SKGMainPanel::getMainPanel();
+ if (mainPanel != nullptr) {
+ mainPanel->displayMessage(i18nc("Error message", "Connection refused. Are you sure the server is running ?"), SKGDocument::Error);
+ }
+ needsFollowUp = false;
+ } else if (reply->error() != QNetworkReply::NoError) {
+ QString errorMsg = i18nc("Error message", "Network error (%1): %2", reply->error(), reply->errorString());
+ if (!serverMessage.isEmpty()) {
+ errorMsg += QStringLiteral("\n") + serverMessage;
+ } else if (!payload.isEmpty()) {
+ errorMsg += QStringLiteral("\n") + QString::fromUtf8(payload);
+ }
+ auto *mainPanel = SKGMainPanel::getMainPanel();
+ if (mainPanel != nullptr) {
+ mainPanel->displayMessage(errorMsg, SKGDocument::Error);
+ }
+ needsFollowUp = false;
+ } else if (answer.isEmpty() && !serverMessage.isEmpty()) {
+ auto *mainPanel = SKGMainPanel::getMainPanel();
+ if (mainPanel != nullptr) {
+ mainPanel->displayMessage(serverMessage, SKGDocument::Error);
+ }
+ }
+
+ refreshConversation();
+
+ reply->deleteLater();
+
+ if (needsFollowUp) {
+ sendCompletionRequest();
+ return;
+ }
+
+ if (!answer.isEmpty()) {
+ // Add assistant answer to the conversation history
+ QJsonObject msg;
+ msg.insert(QStringLiteral("role"), QStringLiteral("assistant"));
+ msg.insert(QStringLiteral("content"), answer);
+ if (!m_pendingReasoningContent.isEmpty()) {
+ msg.insert(QStringLiteral("reasoning_content"), m_pendingReasoningContent);
+ }
+ m_messages.push_back(msg);
+ refreshConversation();
+ }
+
+ ui.kUserPrompt->clear();
+}
+
+void SKGAssistantPluginDockWidget::refreshConversation() const
+{
+ ui.kSaveCurrentAiSession->setEnabled(!m_messages.isEmpty() && ui.kAiSessions->currentIndex() == 0);
+
+ // Build the answer text from the conversation history
+ auto displayThinking = SKGServices::stringToInt(ui.kDisplayReasoning->getState().replace(QStringLiteral("\""), QStringLiteral("")));
+ QStringList parts;
+ QString lastReasoningContent;
+ for (const QJsonValue &messageValue : m_messages) {
+ if (!messageValue.isObject()) {
+ continue;
+ }
+ QJsonObject messageObj = messageValue.toObject();
+ QString role = messageObj.value(QStringLiteral("role")).toString();
+ QString content = contentToText(messageObj.value(QStringLiteral("content"))).trimmed();
+ QString reasoning = contentToText(messageObj.value(QStringLiteral("reasoning_content"))).trimmed();
+
+ if (role == QStringLiteral("user")) {
+ parts.push_back(i18nc("User question prefix", "<b>You:</b>%1", markdownToHtml(content)));
+ } else if (role == QStringLiteral("assistant") && (!content.isEmpty() || !reasoning.isEmpty())) {
+ QStringList assistantParts;
+ if (!reasoning.isEmpty() && displayThinking > 0) {
+ // Build raisoning
+ lastReasoningContent = displayThinking == 1 ? i18nc("Assistant reasoning prefix", "<b>Reasoning...</b>")
+ : i18nc("Assistant reasoning prefix", "<b>Reasoning:</b>%1", markdownToHtml(reasoning));
+ if (displayThinking >= 3) {
+ // Display all reasoning
+ assistantParts.push_back("<font color='gray' size='2'>" + lastReasoningContent + "</font>");
+ lastReasoningContent.clear();
+ }
+ }
+ if (!content.isEmpty()) {
+ assistantParts.push_back(i18nc("Assistant answer prefix", "<b>AI:</b>%1", markdownToHtml(content)));
+ lastReasoningContent.clear();
+ }
+ if (!assistantParts.isEmpty()) {
+ parts.push_back(assistantParts.join(QStringLiteral("\n\n")));
+ }
+ } else if (role == QStringLiteral("tool") && displayThinking == 4) {
+ parts.push_back("<font color='blue' size='2'>" + i18nc("Tool answer prefix", "<b>Tool:</b>%1", markdownToHtml(content)) + "</font>");
+ }
+ }
+
+ // Add last reasoning content if any
+ if (lastReasoningContent.isEmpty() == false) {
+ parts.push_back("<font color='gray' size='2'>" + lastReasoningContent + "</font>");
+ }
+ ui.kAnswer->setHtml(parts.join(""));
+ ui.kAnswer->moveCursor(QTextCursor::End);
+}
+
+void SKGAssistantPluginDockWidget::onSaveCurrentAiSession()
+{
+ SKGError err;
+ _SKGTRACEINFUNCRC(10, err)
+ auto document = getDocument();
+ SKGBEGINTRANSACTION(*document, i18nc("Noun, name of the user action", "Save AI Session"), err);
+ IFOKDO(err,
+ document->setParameter(PREFIX_AIASSISTANT_SESSION + SKGServices::timeToString(QDateTime::currentDateTime()),
+ QJsonDocument(m_messages).toJson(QJsonDocument::Compact)));
+ refreshSessionsList(true);
+}
+
+void SKGAssistantPluginDockWidget::onDeleteAiSession()
+{
+ SKGError err;
+ _SKGTRACEINFUNCRC(10, err)
+ auto document = getDocument();
+ SKGBEGINTRANSACTION(*document, i18nc("Noun, name of the user action", "Delete AI Session"), err);
+ SKGPropertyObject param(document);
+ IFOKDO(err, param.setName(PREFIX_AIASSISTANT_SESSION + ui.kAiSessions->currentText()));
+ IFOKDO(err, param.load());
+ IFOKDO(err, param.remove());
+ refreshSessionsList();
+}
+
+void SKGAssistantPluginDockWidget::onAiSessionChanged(int iIndex)
+{
+ auto document = getDocument();
+ int index = ui.kAiSessions->currentIndex();
+ if (index <= 0) {
+ onClear();
+ ui.kDeleteAiSession->setEnabled(false);
+ } else {
+ QString json = document->getParameter(PREFIX_AIASSISTANT_SESSION + ui.kAiSessions->currentText());
+ m_messages = QJsonDocument::fromJson(json.toUtf8()).array();
+ refreshConversation();
+ ui.kDeleteAiSession->setEnabled(true);
+ }
+}
+
+void SKGAssistantPluginDockWidget::slotRefreshSessionsList() const
+{
+ refreshSessionsList(false);
+}
+
+void SKGAssistantPluginDockWidget::refreshSessionsList(bool iSelectFirst) const
+{
+ SKGTRACEINFUNC(10)
+ auto document = getDocument();
+ ui.kAiSessions->clear();
+ ui.kAiSessions->addItem(i18nc("AI session list", "New session"));
+ // Get all document parameters and filter those starting with "SKG_AI_SESSION_"
+ auto list = document->getParameters("document", "t_name like '" + PREFIX_AIASSISTANT_SESSION + "%'");
+ std::sort(list.begin(), list.end());
+ std::reverse(list.begin(), list.end());
+ for (const QString &aiSession : list) {
+ ui.kAiSessions->addItem(aiSession.right(aiSession.length() - PREFIX_AIASSISTANT_SESSION.length()));
+ }
+ if (iSelectFirst && ui.kAiSessions->count() > 1) {
+ ui.kAiSessions->setCurrentIndex(1);
+ }
+}
\ No newline at end of file
diff --git a/plugins/skrooge/skrooge_aiassistant/skgassistantplugindockwidget.h b/plugins/skrooge/skrooge_aiassistant/skgassistantplugindockwidget.h
new file mode 100644
index 000000000..ac3c7fe81
--- /dev/null
+++ b/plugins/skrooge/skrooge_aiassistant/skgassistantplugindockwidget.h
@@ -0,0 +1,80 @@
+/***************************************************************************
+ * SPDX-FileCopyrightText: 2026 S. MANKOWSKI stephane at mankowski.fr
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ ***************************************************************************/
+#ifndef SKGASSISTANTPLUGINDOCKWIDGET_H
+#define SKGASSISTANTPLUGINDOCKWIDGET_H
+/** @file
+ * A dock widget for the AI assistant plugin.
+ */
+#include <qjsonarray.h>
+#include <qnetworkaccessmanager.h>
+#include <qpointer.h>
+#include <qurl.h>
+
+#include "skgwidget.h"
+#include "ui_skgassistantpluginwidget_dock.h"
+
+class QJsonValue;
+class QNetworkReply;
+
+/**
+ * A dock widget for the AI assistant plugin.
+ */
+class SKGAssistantPluginDockWidget : public SKGWidget
+{
+ Q_OBJECT
+
+public:
+ /**
+ * Default Constructor
+ * @param iParent the parent widget
+ * @param iDocument the document
+ */
+ explicit SKGAssistantPluginDockWidget(QWidget *iParent, SKGDocument *iDocument);
+
+ /**
+ * Default Destructor
+ */
+ ~SKGAssistantPluginDockWidget() override;
+
+private Q_SLOTS:
+ void onAsk();
+ void onClear();
+ void onReplyFinished();
+ void onUserPromptChanged();
+ void onSaveCurrentAiSession();
+ void onDeleteAiSession();
+ void onAiSessionChanged(int iIndex);
+
+private:
+ Q_DISABLE_COPY(SKGAssistantPluginDockWidget)
+
+ QUrl getCompletionEndpoint() const;
+ void setBusy(bool iBusy);
+ void refreshConversation() const;
+ void refreshSessionsList(bool iSelectFirst = false) const;
+ void slotRefreshSessionsList() const;
+ void sendCompletionRequest();
+ QString extractAssistantMessage(const QByteArray &iPayload, QString &oErrorMessage, bool &oNeedsFollowUp);
+ QString executeToolCall(const QJsonObject &iToolCall) const;
+ QString executeSqliteSelect(const QString &iSql) const;
+ QString executeWebSearch(const QString &iQuery, int iMaxResults) const;
+ static QJsonArray getTools();
+ static QJsonArray getRequestMessages(const QJsonArray &iMessages);
+ static QString sqlResultToJson(const SKGStringListList &iResult);
+ static QString contentToText(const QJsonValue &iContent);
+ static QString extractReasoningFromContent(QString &ioContent);
+ static QString reasoningContentToText(const QJsonObject &iMessage, const QJsonObject &iChoice);
+ static QString getSystemPrompt();
+ static QString markdownToHtml(const QString &iContent);
+
+ Ui::skgassistantplugin_dock ui{};
+ QNetworkAccessManager m_networkManager;
+ QPointer<QNetworkReply> m_pendingReply;
+ QJsonArray m_messages;
+ QString m_pendingReasoningContent;
+ int m_toolCallDepth = 0;
+};
+
+#endif // SKGASSISTANTPLUGINDOCKWIDGET_H
diff --git a/plugins/skrooge/skrooge_aiassistant/skgassistantpluginwidget.cpp b/plugins/skrooge/skrooge_aiassistant/skgassistantpluginwidget.cpp
new file mode 100644
index 000000000..47544189a
--- /dev/null
+++ b/plugins/skrooge/skrooge_aiassistant/skgassistantpluginwidget.cpp
@@ -0,0 +1,30 @@
+/***************************************************************************
+ * SPDX-FileCopyrightText: 2026 S. MANKOWSKI stephane at mankowski.fr
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ ***************************************************************************/
+
+/** @file
+ * A dock widget for the AI assistant plugin.
+ */
+#include "skgassistantpluginwidget.h"
+#include "skgassistantplugindockwidget.h"
+
+SKGAssistantPluginWidget::SKGAssistantPluginWidget(QWidget *iParent, SKGDocument *iDocument)
+ : SKGTabPage(iParent, iDocument)
+{
+ QSizePolicy sizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding);
+ sizePolicy.setHorizontalStretch(0);
+ sizePolicy.setVerticalStretch(0);
+ sizePolicy.setHeightForWidth(this->sizePolicy().hasHeightForWidth());
+ this->setSizePolicy(sizePolicy);
+
+ auto layout = new QHBoxLayout(this);
+ layout->setSpacing(2);
+ layout->setContentsMargins(0, 0, 0, 0);
+ auto t = new SKGAssistantPluginDockWidget(this, iDocument);
+ layout->addWidget(t);
+}
+
+SKGAssistantPluginWidget::~SKGAssistantPluginWidget()
+{
+}
\ No newline at end of file
diff --git a/plugins/skrooge/skrooge_aiassistant/skgassistantpluginwidget.h b/plugins/skrooge/skrooge_aiassistant/skgassistantpluginwidget.h
new file mode 100644
index 000000000..bb4311a28
--- /dev/null
+++ b/plugins/skrooge/skrooge_aiassistant/skgassistantpluginwidget.h
@@ -0,0 +1,33 @@
+/***************************************************************************
+ * SPDX-FileCopyrightText: 2026 S. MANKOWSKI stephane at mankowski.fr
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ ***************************************************************************/
+#ifndef SKGASSISTANTPLUGINWIDGET_H
+#define SKGASSISTANTPLUGINWIDGET_H
+/** @file
+ * A dock widget for the AI assistant plugin.
+ */
+#include "skgtabpage.h"
+
+/**
+ * A skrooge plugin for the AI assistant
+ */
+class SKGAssistantPluginWidget : public SKGTabPage
+{
+ Q_OBJECT
+
+public:
+ /**
+ * Default Constructor
+ * @param iParent the parent widget
+ * @param iDocument the document
+ */
+ explicit SKGAssistantPluginWidget(QWidget *iParent, SKGDocument *iDocument);
+
+ /**
+ * Default Destructor
+ */
+ ~SKGAssistantPluginWidget() override;
+};
+
+#endif // SKGASSISTANTPLUGINWIDGET_H
diff --git a/plugins/skrooge/skrooge_aiassistant/skgassistantpluginwidget_dock.ui b/plugins/skrooge/skrooge_aiassistant/skgassistantpluginwidget_dock.ui
new file mode 100644
index 000000000..a9d593199
--- /dev/null
+++ b/plugins/skrooge/skrooge_aiassistant/skgassistantpluginwidget_dock.ui
@@ -0,0 +1,193 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <author>
+SPDX-FileCopyrightText: 2026 S. MANKOWSKI stephane at mankowski.fr
+SPDX-License-Identifier: GPL-3.0-or-later
+ </author>
+ <class>skgassistantplugin_dock</class>
+ <widget class="QWidget" name="skgassistantplugin_dock">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>395</width>
+ <height>758</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <property name="spacing">
+ <number>2</number>
+ </property>
+ <property name="leftMargin">
+ <number>2</number>
+ </property>
+ <property name="topMargin">
+ <number>2</number>
+ </property>
+ <property name="rightMargin">
+ <number>2</number>
+ </property>
+ <property name="bottomMargin">
+ <number>2</number>
+ </property>
+ <item>
+ <layout class="QHBoxLayout" name="kAiSessionMngt">
+ <item>
+ <widget class="QComboBox" name="kAiSessions">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="toolTip">
+ <string extracomment="Your saved AI sessions"/>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="kSaveCurrentAiSession">
+ <property name="toolTip">
+ <string extracomment="Save current AI session"/>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="kDeleteAiSession"/>
+ </item>
+ <item>
+ <widget class="QPushButton" name="kHelpButton">
+ <property name="toolTip">
+ <string extracomment="Help"/>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="kSettingsButton">
+ <property name="toolTip">
+ <string>Open settings</string>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="QFrame" name="kHelpArea">
+ <layout class="QVBoxLayout" name="kHelpArea_2">
+ <item>
+ <widget class="QLabel" name="kHelpTitle">
+ <property name="text">
+ <string>Here are some examples of questions you can ask your Dickens AI assistant.</string>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="Line" name="line">
+ <property name="orientation">
+ <enum>Qt::Orientation::Horizontal</enum>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QTextEdit" name="kAnswer">
+ <property name="readOnly">
+ <bool>true</bool>
+ </property>
+ <property name="acceptRichText">
+ <bool>false</bool>
+ </property>
+ <property name="placeholderText">
+ <string>How can I help you today ?</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPlainTextEdit" name="kUserPrompt">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Minimum">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="placeholderText">
+ <string>Ask your question</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <layout class="QHBoxLayout" name="buttonsLayout">
+ <item>
+ <widget class="QPushButton" name="kAskButton">
+ <property name="toolTip">
+ <string extracomment="Ask to Dickens"/>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="kClearButton">
+ <property name="toolTip">
+ <string extracomment="Reset the current AI session"/>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="SKGShow" name="kDisplayReasoning">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="horizontalSpacer">
+ <property name="orientation">
+ <enum>Qt::Orientation::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>SKGShow</class>
+ <extends>QToolButton</extends>
+ <header>skgshow.h</header>
+ </customwidget>
+ </customwidgets>
+ <tabstops>
+ <tabstop>kAiSessions</tabstop>
+ <tabstop>kSaveCurrentAiSession</tabstop>
+ <tabstop>kDeleteAiSession</tabstop>
+ <tabstop>kHelpButton</tabstop>
+ <tabstop>kSettingsButton</tabstop>
+ <tabstop>kAnswer</tabstop>
+ <tabstop>kUserPrompt</tabstop>
+ <tabstop>kAskButton</tabstop>
+ <tabstop>kClearButton</tabstop>
+ <tabstop>kDisplayReasoning</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/plugins/skrooge/skrooge_aiassistant/skgassistantpluginwidget_pref.ui b/plugins/skrooge/skrooge_aiassistant/skgassistantpluginwidget_pref.ui
new file mode 100644
index 000000000..01d6ae0df
--- /dev/null
+++ b/plugins/skrooge/skrooge_aiassistant/skgassistantpluginwidget_pref.ui
@@ -0,0 +1,134 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <author>
+SPDX-FileCopyrightText: 2026 S. MANKOWSKI stephane at mankowski.fr
+SPDX-License-Identifier: GPL-3.0-or-later
+ </author>
+ <class>skgassistantplugin_pref</class>
+ <widget class="QWidget" name="skgassistantplugin_pref">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>631</width>
+ <height>693</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="QGroupBox" name="kConnectionGroup">
+ <layout class="QFormLayout" name="formLayout">
+ <item row="1" column="0">
+ <widget class="QLabel" name="kServerUrlLabel">
+ <property name="text">
+ <string>Server URL:</string>
+ </property>
+ <property name="buddy">
+ <cstring>kcfg_serverUrl</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QLineEdit" name="kcfg_serverUrl">
+ <property name="toolTip">
+ <string>Base URL of OpenAI server. The API path /v1/chat/completions is appended automatically.</string>
+ </property>
+ <property name="text">
+ <string notr="true">http://127.0.0.1:8080</string>
+ </property>
+ <property name="placeholderText">
+ <string notr="true">http://127.0.0.1:8080</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="kApiTokenLabel">
+ <property name="text">
+ <string>API Token:</string>
+ </property>
+ <property name="buddy">
+ <cstring>kcfg_apiToken</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="1">
+ <widget class="QLineEdit" name="kcfg_apiToken">
+ <property name="toolTip">
+ <string>API token for authentication (optional). Leave empty if not required.</string>
+ </property>
+ </widget>
+ </item>
+ <item row="4" column="0" colspan="2">
+ <widget class="QCheckBox" name="kcfg_enableWebSearch">
+ <property name="toolTip">
+ <string>Allow the AI assistant to search the internet to provide more accurate and up-to-date information.</string>
+ </property>
+ <property name="text">
+ <string>Enable internet searches</string>
+ </property>
+ </widget>
+ </item>
+ <item row="5" column="0">
+ <widget class="QLabel" name="kFamilySituationLabel">
+ <property name="text">
+ <string>Family Situation:</string>
+ </property>
+ <property name="buddy">
+ <cstring>kcfg_familySituation</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="5" column="1">
+ <widget class="QPlainTextEdit" name="kcfg_familySituation">
+ <property name="maximumSize">
+ <size>
+ <width>16777215</width>
+ <height>200</height>
+ </size>
+ </property>
+ <property name="toolTip">
+ <string>Describe your family situation (e.g., single, married, with children, etc.). This helps the AI assistant provide more personalized recommendations.</string>
+ </property>
+ <property name="placeholderText">
+ <string>You can help Dickens by telling him more about your family situation (number of people, employed, self-employed, or retired, homeowner or renter) and your plans. You can use Markdown syntax.</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0" colspan="2">
+ <widget class="QLabel" name="kLabel">
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="0" colspan="2">
+ <widget class="QLabel" name="label_2">
+ <property name="text">
+ <string><html><head/><body><p>Your AI companion may need internet access. You can allow it to do so, but be mindful of <span style=" text-decoration: underline;">privacy concerns</span>. For your information, the internet searchs are done with <span style=" font-weight:700;">duckduckgo</span>.</p></body></html></string>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer">
+ <property name="orientation">
+ <enum>Qt::Orientation::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/plugins/skrooge/skrooge_aiassistant/skrooge_aiassistant.rc b/plugins/skrooge/skrooge_aiassistant/skrooge_aiassistant.rc
new file mode 100644
index 000000000..195a975d0
--- /dev/null
+++ b/plugins/skrooge/skrooge_aiassistant/skrooge_aiassistant.rc
@@ -0,0 +1,12 @@
+<!DOCTYPE kpartgui SYSTEM "kpartgui.dtd">
+<kpartgui name="skrooge_aiassistant" version="2">
+<MenuBar>
+ <Menu name="view">
+ <Menu name="panels"><text>Panels</text>
+ <Action name="view_aiassistant"/>
+ </Menu>
+ </Menu>
+</MenuBar>
+<ToolBar name="mainToolBar"><text>Main Toolbar</text>
+</ToolBar>
+</kpartgui>
diff --git a/plugins/skrooge/skrooge_aiassistant/skrooge_system_prompt.md b/plugins/skrooge/skrooge_aiassistant/skrooge_system_prompt.md
new file mode 100644
index 000000000..f0581c489
--- /dev/null
+++ b/plugins/skrooge/skrooge_aiassistant/skrooge_system_prompt.md
@@ -0,0 +1,218 @@
+# SYSTEM PROMPT
+
+## Role
+
+You are **Dickens**, the AI assistant embedded in **Skrooge**, a personal finance manager.
+
+Your mission is to help the user:
+
+1. understand and use Skrooge.
+2. analyze their own financial data through the available SQLite read-only tool.
+3. turn query results into clear, useful personal-finance insights.
+
+Answer in the same language as the user whenever possible.
+
+## Scope
+
+Stay strictly within:
+
+- Skrooge usage and features.
+- personal finance analysis based on the user's Skrooge data.
+- general financial information or advice.
+
+For unrelated requests, politely decline and redirect the user to Skrooge or personal finance.
+
+Do not provide regulated financial, tax, legal, or investment advice as a certainty. You may explain what the data shows and suggest that the user verify decisions with a qualified professional when stakes are high.
+
+## Request Routing
+
+Classify each user request before answering.
+
+### 1. Skrooge Support
+
+Use this path when the user asks how to use Skrooge, understand a Skrooge concept, or find a feature.
+
+- Give a concise, practical answer.
+- Prefer step-by-step instructions for UI workflows.
+- If the question needs the user's actual financial data, switch to the data-analysis path and use the SQL tool.
+
+### 2. Financial Data Analysis
+
+Use this path when the user asks about balances, expenses, income, categories, accounts, payees, trends, comparisons, dates, trackers, or transactions.
+
+Required process:
+
+1. Identify the relevant table(s), columns, filters, grouping, and date period.
+2. Build one or more safe SQLite `SELECT` queries.
+3. Call `execute_sqlite_select` for every required query.
+4. Interpret only the returned data.
+5. Present the answer in a human-readable way, with a short explanation of important assumptions.
+
+If required information is missing, ambiguous, or not present in the returned data, say so clearly instead of guessing.
+
+## SQL Safety Rules
+
+The only available database tool is `execute_sqlite_select`.
+
+Always obey these rules:
+
+- Generate only one read-only SQLite `SELECT` statement per tool call.
+- Do not include a trailing semicolon.
+- Never use `INSERT`, `UPDATE`, `DELETE`, `DROP`, `ALTER`, `CREATE`, `REPLACE`, `TRUNCATE`, `VACUUM`, `ATTACH`, `DETACH`, `PRAGMA`, or any write/admin statement.
+- Use only the tables and columns documented in this prompt.
+- Prefer explicit column lists over `SELECT *`.
+- Use aliases that make results easy to interpret.
+- Use `LIMIT` for transaction lists unless the user explicitly asks for all rows.
+- For amounts, remember: positive values are income; negative values are expenses.
+- For expense totals, use `SUM(-f_REALCURRENTAMOUNT)` with `f_REALCURRENTAMOUNT < 0` when the user expects a positive expense number.
+- For income totals, use `SUM(f_REALCURRENTAMOUNT)` with `f_REALCURRENTAMOUNT > 0`.
+- For balances, use account balances from `v_account_display` unless the user specifically asks for transaction-level balances.
+- For date filters, use ISO dates and SQLite date functions when useful.
+
+If the tool returns an error or no relevant rows, answer: "I cannot find that information in your database." Then briefly explain what was attempted.
+
+## Default Assumptions
+
+Apply these defaults unless the user asks otherwise:
+
+- Exclude closed accounts: `t_close = 'N'`.
+- Exclude transfers from transaction analysis: `t_TRANSFER = 'N'`.
+- Include only real transaction amounts from `f_REALCURRENTAMOUNT`.
+- Use the account currency/unit shown by `t_UNIT`; do not convert currencies unless conversion data is explicitly available.
+- When a date range is unclear, ask a clarifying question if the answer would materially change. Otherwise choose a sensible period and state it.
+- For "this month", "last month", "this year", and similar relative periods, use SQLite date functions based on the current system date.
+
+## Data Model
+
+Use only the following tables and columns.
+
+### `v_bank_amount`
+
+Description: banks and their properties, including type, number and current balance.
+
+Columns:
+
+- `t_name` (TEXT): bank name
+- `t_bank_number` (TEXT): bank number
+- `f_CURRENTAMOUNT` (REAL): current bank amount
+
+### `v_account_display`
+
+Description: accounts and their properties, including type, number, bank, and current balance.
+
+Rules:
+
+- Exclude closed accounts unless the user asks for them.
+
+Columns:
+
+- `t_name` (TEXT): account name
+- `t_number` (TEXT): account number
+- `t_agency_number` (TEXT): agency number
+- `t_agency_address` (TEXT): agency address
+- `t_comment` (TEXT): account comment
+- `t_close` (TEXT): closed account flag, `Y` or `N`
+- `t_type` (TEXT): account type: `C` current, `S` savings, `I` investment, `L` loan, `P` pension, `D` credit card, `W` wallet, `O` other
+- `t_BANK` (TEXT): bank name
+- `t_BANK_NUMBER` (TEXT): bank number
+- `f_CURRENTAMOUNT` (REAL): current account amount
+- `t_UNIT` (TEXT): account unit/currency
+
+### `v_suboperation_consolidated`
+
+Description: transactions/suboperations and their properties, including account, category, payee, amount, date, and balance.
+
+Rules:
+
+- Exclude transfers unless the user asks for them.
+
+Columns:
+
+- `t_ACCOUNTTYPE` (TEXT): transaction account type
+- `t_BANK` (TEXT): transaction bank
+- `t_UNITTYPE` (TEXT): unit type
+- `t_REALCOMMENT` (TEXT): suboperation comment
+- `t_REALCATEGORY` (TEXT): category
+- `t_REALREFUND` (TEXT): tracker
+- `d_DATEWEEK` (DATE): year and week, for example `2026-W12`
+- `d_DATEMONTH` (DATE): year and month, for example `2026-01`
+- `d_DATEQUARTER` (DATE): year and quarter, for example `2026-Q2`
+- `d_DATESEMESTER` (DATE): year and semester, for example `2026-S1`
+- `d_DATEYEAR` (DATE): year, for example `2026`
+- `d_date` (DATE): transaction date
+- `t_number` (TEXT): transaction number
+- `t_mode` (TEXT): payment mode
+- `t_comment` (TEXT): transaction comment
+- `t_status` (TEXT): transaction status
+- `t_UNIT` (TEXT): transaction unit/currency
+- `t_PAYEE` (TEXT): payee
+- `t_ACCOUNT` (TEXT): account
+- `t_TYPEACCOUNT` (TEXT): account type
+- `f_REALCURRENTAMOUNT` (REAL): amount; positive means income, negative means expense
+- `t_TRANSFER` (TEXT): transfer flag, `Y` or `N`
+- `t_TOACCOUNT` (TEXT): destination account for transfers
+- `t_REFUND` (TEXT): tracker
+- `f_BALANCE` (REAL): transaction balance
+- `t_formula` (TEXT): formula
+- `i_order` (INTEGER): transaction order
+
+### `v_category_display`
+
+Description: categories
+
+Rules:
+
+- Exclude closed categories unless the user asks for them.
+
+Columns:
+- `id` (INTEGER): id
+- `t_name` (TEXT): name of the category
+- `rd_category_id` (INTEGER): parent category id
+- `t_fullname` (TEXT): full path name of the category
+- `t_bookmarked` (TEXT): bookmarked account flag, `Y` or `N`
+- `t_close` (TEXT): closed account flag, `Y` or `N`
+
+### `v_payee_display`
+
+Description: payees and their properties, including name, contact details, default category, and status.
+
+Rules:
+
+- Exclude closed payees unless the user asks for them.
+
+Columns:
+- `id` (INTEGER): payee id
+- `t_name` (TEXT): name
+- `t_bookmarked` (TEXT): bookmarked payee flag, `Y` or `N`
+- `t_address` (TEXT): payee address or contact info
+- `t_close` (TEXT): closed payee flag, `Y` or `N`
+- `t_CATEGORY` (TEXT): default category associated with the payee
+- `r_category_id` (INTEGER): id of the default category associated with the payee
+
+
+### `v_unit_display`
+
+Description: units and their properties.
+
+Columns:
+- `id` (INTEGER): unit id
+- `t_name` (TEXT): unit name. Example: Euro
+- `t_symbol` (TEXT): unit symbol. Example €
+- `t_country` (TEXT): country of the unit
+- `t_type` (TEXT): unit type: `1` primary currency, `2` secondary currency, `C` currency, `S` financial share, `I` financial index, `O` assets
+- `t_source` (TEXT): internet source for the download of values
+- `t_internet_code` (TEXT): internet code for the download of values
+- `t_bookmarked` (TEXT): bookmarked unit flag, `Y` or `N`
+- `t_UNIT` (TEXT): unit of values of the unit
+- `f_CURRENTAMOUNT` (REAL): last amount of the unit
+- `f_AMOUNTOWNED` (REAL): amount owned of the unit
+
+## Answer Style
+
+- Be concise, precise, and grounded in the returned data.
+- Use tables for ranked lists, comparisons, and grouped totals.
+- Mention filters that materially affect the result, such as excluded transfers, closed accounts, or date ranges.
+- Round monetary values sensibly, usually to two decimals.
+- Do not expose raw JSON or raw SQL unless the user asks for it or it helps explain a technical issue.
+- Do not invent financial figures, categories, dates, payees, account names, or units.
+- If you used multiple queries, synthesize the results instead of listing tool outputs.
diff --git a/skgbankmodeler/skgdocumentbank.cpp b/skgbankmodeler/skgdocumentbank.cpp
index b8bf1b835..45e2a31b6 100644
--- a/skgbankmodeler/skgdocumentbank.cpp
+++ b/skgbankmodeler/skgdocumentbank.cpp
@@ -3004,7 +3004,6 @@ QString SKGDocumentBank::getIconName(const QString &iString) const
}
return SKGDocument::getIconName(iString);
}
-
QString SKGDocumentBank::getDisplay(const QString &iString) const
{
QString output = iString.toLower();
diff --git a/skgsqlcipher/q6sql_sqlite.cpp b/skgsqlcipher/q6sql_sqlite.cpp
index 551d41c21..5a193b4db 100644
--- a/skgsqlcipher/q6sql_sqlite.cpp
+++ b/skgsqlcipher/q6sql_sqlite.cpp
@@ -360,8 +360,7 @@ bool QSQLiteResult::prepare(const QString &query)
#endif
if (res != SQLITE_OK) {
- setLastError(
- qMakeError(d->access(), QCoreApplication::translate("QSQLiteResult", "Unable to execute statement"), QSqlError::StatementError, res));
+ setLastError(qMakeError(d->access(), QCoreApplication::translate("QSQLiteResult", "Unable to execute statement"), QSqlError::StatementError, res));
d->finalize();
return false;
} else if (pzTail && !QString(reinterpret_cast<const QChar *>(pzTail)).trimmed().isEmpty()) {
@@ -413,8 +412,7 @@ bool QSQLiteResult::exec()
int res = sqlite3_reset(d->stmt);
if (res != SQLITE_OK) {
- setLastError(
- qMakeError(d->access(), QCoreApplication::translate("QSQLiteResult", "Unable to reset statement"), QSqlError::StatementError, res));
+ setLastError(qMakeError(d->access(), QCoreApplication::translate("QSQLiteResult", "Unable to reset statement"), QSqlError::StatementError, res));
d->finalize();
return false;
}
@@ -511,10 +509,8 @@ bool QSQLiteResult::exec()
}
}
if (res != SQLITE_OK) {
- setLastError(qMakeError(d->access(),
- QCoreApplication::translate("QSQLiteResult", "Unable to bind parameters"),
- QSqlError::StatementError,
- res));
+ setLastError(
+ qMakeError(d->access(), QCoreApplication::translate("QSQLiteResult", "Unable to bind parameters"), QSqlError::StatementError, res));
d->finalize();
return false;
}
diff --git a/skgsqlcipher/q6sql_sqlite_p.h b/skgsqlcipher/q6sql_sqlite_p.h
index aa4a8cafc..b94cd3a57 100644
--- a/skgsqlcipher/q6sql_sqlite_p.h
+++ b/skgsqlcipher/q6sql_sqlite_p.h
@@ -16,9 +16,9 @@
//
#include "skgsqlcipher_export.h"
-#include <QtSql/qsqldriver.h>
#include <QList>
#include <QStringList>
+#include <QtSql/qsqldriver.h>
struct sqlite3;
More information about the kde-doc-english
mailing list