[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