[office/skrooge] /: AI Assistant

Stéphane MANKOWSKI <[email protected]>
Newsgroups gmane.comp.kde.cvs,gmane.comp.kde.doc
Message-ID <[email protected]>
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 (&eg; "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 (&eg; "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 [email protected]
 #* 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 [email protected]
+#* 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": "[email protected]",
+                "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 [email protected]
+ * 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 [email protected]
+ * 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 [email protected]
+ * 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 [email protected]
+ * 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 [email protected]
+ * 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 [email protected]
+ * 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 [email protected]
+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 [email protected]
+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>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Your AI companion may need internet access. You can allow it to do so, but be mindful of &lt;span style=&quot; text-decoration: underline;&quot;&gt;privacy concerns&lt;/span&gt;. For your information, the internet searchs are done with &lt;span style=&quot; font-weight:700;&quot;&gt;duckduckgo&lt;/span&gt;.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</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;
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.