diff --git a/content/modules/ROOT/nav.adoc b/content/modules/ROOT/nav.adoc
index 9cb5fa58..1c10ff8d 100644
--- a/content/modules/ROOT/nav.adoc
+++ b/content/modules/ROOT/nav.adoc
@@ -38,6 +38,7 @@ include::bpm:partial$nav.adoc[]
* xref:ROOT:add-ons.adoc[]
+
--
+include::ai-tools:partial$nav.adoc[]
include::appsettings:partial$nav.adoc[]
include::audit:partial$nav.adoc[]
include::authorization-server:partial$nav.adoc[]
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/.gitignore b/content/modules/ai-tools/examples/ai-tools-ex1/.gitignore
new file mode 100644
index 00000000..57ec2854
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/.gitignore
@@ -0,0 +1,31 @@
+.jmix
+
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**
+!**/src/test/**
+
+# Remove this line if you want to follow recommendations in src/main/bundles/README.md
+src/main/bundles/
+
+### IntelliJ IDEA ###
+.idea/*
+!.idea/encodings.xml
+*.iws
+*.iml
+*.ipr
+out/
+/target/
+
+### VS Code ###
+.vscode/
+
+# The following files are generated/updated by vaadin-gradle-plugin
+node_modules/
+src/main/frontend/generated/
+pnpmfile.js
+vite.generated.ts
+webpack.generated.js
+
+.DS_Store
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/.idea/encodings.xml b/content/modules/ai-tools/examples/ai-tools-ex1/.idea/encodings.xml
new file mode 100644
index 00000000..da0415a0
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/.idea/encodings.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/build.gradle b/content/modules/ai-tools/examples/ai-tools-ex1/build.gradle
new file mode 100644
index 00000000..7451693b
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/build.gradle
@@ -0,0 +1,62 @@
+plugins {
+ id 'io.jmix' version '3.0.0-M5'
+ id 'java'
+}
+
+apply plugin: 'org.springframework.boot'
+apply plugin: 'com.vaadin'
+
+jmix {
+ bomVersion = '3.0.999-SNAPSHOT'
+}
+
+vaadin {
+ optimizeBundle = false
+}
+
+group = 'com.company'
+version = '0.0.1-SNAPSHOT'
+
+repositories {
+ mavenCentral()
+ maven {
+ url = 'https://nexus.jmix.io/repository/public'
+ }
+}
+
+dependencies {
+ implementation 'io.jmix.core:jmix-core-starter'
+ implementation 'io.jmix.data:jmix-eclipselink-starter'
+ implementation 'io.jmix.security:jmix-security-starter'
+ implementation 'io.jmix.security:jmix-security-flowui-starter'
+ implementation 'io.jmix.security:jmix-security-data-starter'
+ implementation 'io.jmix.localfs:jmix-localfs-starter'
+ implementation 'io.jmix.flowui:jmix-flowui-starter'
+ implementation 'com.vaadin:vaadin-dev'
+ implementation 'io.jmix.flowui:jmix-flowui-data-starter'
+ implementation 'io.jmix.flowui:jmix-flowui-themes'
+ implementation 'io.jmix.datatools:jmix-datatools-starter'
+ implementation 'io.jmix.datatools:jmix-datatools-flowui-starter'
+
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+
+ implementation 'org.hsqldb:hsqldb'
+
+ testImplementation('org.springframework.boot:spring-boot-starter-test') {
+ exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
+ }
+ testImplementation 'io.jmix.flowui:jmix-flowui-test-assist'
+
+ // tag::dependencies[]
+ implementation 'io.jmix.aitools:jmix-aitools-starter'
+ implementation 'io.jmix.aitools:jmix-aitools-flowui-starter'
+ implementation 'io.jmix.aitools:jmix-aitools-flowui-data-starter'
+ // end::dependencies[]
+
+ // Spring AI model API, required to declare custom @Tool methods in the application.
+ implementation 'org.springframework.ai:spring-ai-model'
+}
+
+test {
+ useJUnitPlatform()
+}
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/gradle.properties b/content/modules/ai-tools/examples/ai-tools-ex1/gradle.properties
new file mode 100644
index 00000000..8b4371be
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/gradle.properties
@@ -0,0 +1,2 @@
+hilla.active=false
+org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/gradle/wrapper/gradle-wrapper.jar b/content/modules/ai-tools/examples/ai-tools-ex1/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..1b33c55b
Binary files /dev/null and b/content/modules/ai-tools/examples/ai-tools-ex1/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/gradle/wrapper/gradle-wrapper.properties b/content/modules/ai-tools/examples/ai-tools-ex1/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..5dd3c012
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/gradlew b/content/modules/ai-tools/examples/ai-tools-ex1/gradlew
new file mode 100644
index 00000000..23d15a93
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/gradlew.bat b/content/modules/ai-tools/examples/ai-tools-ex1/gradlew.bat
new file mode 100644
index 00000000..db3a6ac2
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/settings.gradle b/content/modules/ai-tools/examples/ai-tools-ex1/settings.gradle
new file mode 100644
index 00000000..54b1f660
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'aitools-onboarding'
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/frontend/index.html b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/frontend/index.html
new file mode 100644
index 00000000..0701a4b0
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/frontend/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/OnboardingApplication.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/OnboardingApplication.java
new file mode 100644
index 00000000..8807bb5e
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/OnboardingApplication.java
@@ -0,0 +1,63 @@
+package com.company.onboarding;
+
+import com.google.common.base.Strings;
+import com.vaadin.flow.component.page.AppShellConfigurator;
+import com.vaadin.flow.component.dependency.StyleSheet;
+import com.vaadin.flow.component.page.Push;
+import com.vaadin.flow.server.PWA;
+import com.vaadin.flow.theme.Theme;
+import com.vaadin.flow.theme.aura.Aura;
+import com.vaadin.flow.theme.lumo.Lumo;
+import io.jmix.flowui.theme.aura.JmixAura;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.event.ApplicationStartedEvent;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+import org.springframework.context.event.EventListener;
+import org.springframework.core.env.Environment;
+
+import javax.sql.DataSource;
+
+@Push
+@StyleSheet(Aura.STYLESHEET)
+@StyleSheet(JmixAura.STYLESHEET)
+@StyleSheet("themes/onboarding-aura/styles.css")
+@PWA(name = "Onboarding", shortName = "Onboarding")
+@SpringBootApplication
+@StyleSheet(Lumo.UTILITY_STYLESHEET)
+public class OnboardingApplication implements AppShellConfigurator {
+
+ @Autowired
+ private Environment environment;
+
+ public static void main(String[] args) {
+ SpringApplication.run(OnboardingApplication.class, args);
+ }
+
+ @Bean
+ @Primary
+ @ConfigurationProperties("main.datasource")
+ DataSourceProperties dataSourceProperties() {
+ return new DataSourceProperties();
+ }
+
+ @Bean
+ @Primary
+ @ConfigurationProperties("main.datasource.hikari")
+ DataSource dataSource(final DataSourceProperties dataSourceProperties) {
+ return dataSourceProperties.initializeDataSourceBuilder().build();
+ }
+
+ @EventListener
+ public void printApplicationUrl(final ApplicationStartedEvent event) {
+ LoggerFactory.getLogger(OnboardingApplication.class).info("Application started at "
+ + "http://localhost:"
+ + environment.getProperty("local.server.port")
+ + Strings.nullToEmpty(environment.getProperty("server.servlet.context-path")));
+ }
+}
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/ai/OnboardingTools.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/ai/OnboardingTools.java
new file mode 100644
index 00000000..8353372e
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/ai/OnboardingTools.java
@@ -0,0 +1,41 @@
+package com.company.onboarding.ai;
+
+import com.company.onboarding.entity.Step;
+import io.jmix.aitools.tool.AiToolStatusPublisher;
+import io.jmix.aitools.tool.JmixAiTool;
+import io.jmix.core.DataManager;
+import org.springframework.ai.chat.model.ToolContext;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Collectors;
+
+// tag::custom-tool[]
+@Component
+public class OnboardingTools implements JmixAiTool { // <1>
+
+ @Autowired
+ private DataManager dataManager;
+ @Autowired
+ private AiToolStatusPublisher statusPublisher;
+
+ @Tool(name = "getStepCatalog", // <2>
+ description = "Returns the catalog of onboarding steps with their duration in days.")
+ public String getStepCatalog(ToolContext toolContext) { // <3>
+ String message = "Loading the onboarding step catalog";
+ statusPublisher.update(message, toolContext); // <4>
+
+ List steps = dataManager.load(Step.class).all().list(); // <5>
+
+ statusPublisher.complete(message, steps.size() + " steps", toolContext);
+
+ return steps.stream()
+ .sorted(Comparator.comparing(Step::getSortValue))
+ .map(step -> step.getName() + " — " + step.getDuration() + " day(s)")
+ .collect(Collectors.joining("\n"));
+ }
+}
+// end::custom-tool[]
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/ai/SortedEntitiesTool.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/ai/SortedEntitiesTool.java
new file mode 100644
index 00000000..444ebaa5
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/ai/SortedEntitiesTool.java
@@ -0,0 +1,35 @@
+package com.company.onboarding.ai;
+
+import io.jmix.aitools.dataload.introspection.AvailableEntityService;
+import io.jmix.aitools.dataload.introspection.model.EntitySummary;
+import io.jmix.aitools.dataload.tool.DataLoadAiTool;
+import io.jmix.aitools.tool.ToolOverride;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.Comparator;
+import java.util.List;
+
+// tag::tool-override[]
+@Component
+public class SortedEntitiesTool implements DataLoadAiTool { // <1>
+
+ @Autowired
+ private AvailableEntityService availableEntityService;
+
+ @Tool(description = "Returns entities available to the user, ordered by localized name.")
+ @ToolOverride("aitls_getAvailableEntities") // <2>
+ public List getAvailableEntities() {
+ return availableEntityService.getEntitySummaries().stream() // <3>
+ .sorted(Comparator.comparing(this::firstLocalizedName))
+ .toList();
+ }
+
+ private String firstLocalizedName(EntitySummary summary) {
+ return summary.getLocalizedNames().isEmpty()
+ ? summary.getEntityName()
+ : summary.getLocalizedNames().get(0);
+ }
+}
+// end::tool-override[]
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/ai/SupportAssistant.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/ai/SupportAssistant.java
new file mode 100644
index 00000000..eab959de
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/ai/SupportAssistant.java
@@ -0,0 +1,20 @@
+package com.company.onboarding.ai;
+
+import io.jmix.aitools.service.AiAssistantService;
+import org.jspecify.annotations.Nullable;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+// tag::assistant-service[]
+@Component
+public class SupportAssistant {
+
+ @Autowired
+ private AiAssistantService aiAssistantService;
+
+ @Nullable
+ public String ask(String question) {
+ return aiAssistantService.send(question); // <1>
+ }
+}
+// end::assistant-service[]
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/entity/Department.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/entity/Department.java
new file mode 100644
index 00000000..a26e616a
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/entity/Department.java
@@ -0,0 +1,68 @@
+package com.company.onboarding.entity;
+
+import io.jmix.core.entity.annotation.JmixGeneratedValue;
+import io.jmix.core.metamodel.annotation.InstanceName;
+import io.jmix.core.metamodel.annotation.JmixEntity;
+import jakarta.persistence.*;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.UUID;
+
+@JmixEntity
+@Table(name = "DEPARTMENT", indexes = {
+ @Index(name = "IDX_DEPARTMENT_HR_MANAGER", columnList = "HR_MANAGER_ID")
+}, uniqueConstraints = {
+ @UniqueConstraint(name = "IDX_DEPARTMENT_UNQ_NAME", columnNames = {"NAME"})
+})
+@Entity
+public class Department {
+ @JmixGeneratedValue
+ @Column(name = "ID", nullable = false)
+ @Id
+ private UUID id;
+
+ @Column(name = "VERSION", nullable = false)
+ @Version
+ private Integer version;
+
+ @InstanceName
+ @Column(name = "NAME", nullable = false)
+ @NotNull
+ private String name;
+
+ @JoinColumn(name = "HR_MANAGER_ID")
+ @ManyToOne(fetch = FetchType.LAZY)
+ private User hrManager;
+
+ public User getHrManager() {
+ return hrManager;
+ }
+
+ public void setHrManager(User hrManager) {
+ this.hrManager = hrManager;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public Integer getVersion() {
+ return version;
+ }
+
+ public void setVersion(Integer version) {
+ this.version = version;
+ }
+
+ public UUID getId() {
+ return id;
+ }
+
+ public void setId(UUID id) {
+ this.id = id;
+ }
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/entity/OnboardingStatus.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/entity/OnboardingStatus.java
new file mode 100644
index 00000000..64b89a24
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/entity/OnboardingStatus.java
@@ -0,0 +1,33 @@
+package com.company.onboarding.entity;
+
+import io.jmix.core.metamodel.datatype.EnumClass;
+
+import org.springframework.lang.Nullable;
+
+
+public enum OnboardingStatus implements EnumClass {
+
+ NOT_STARTED(10),
+ IN_PROGRESS(20),
+ COMPLETED(30);
+
+ private final Integer id;
+
+ OnboardingStatus(Integer id) {
+ this.id = id;
+ }
+
+ public Integer getId() {
+ return id;
+ }
+
+ @Nullable
+ public static OnboardingStatus fromId(Integer id) {
+ for (OnboardingStatus at : OnboardingStatus.values()) {
+ if (at.getId().equals(id)) {
+ return at;
+ }
+ }
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/entity/Step.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/entity/Step.java
new file mode 100644
index 00000000..89395958
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/entity/Step.java
@@ -0,0 +1,76 @@
+package com.company.onboarding.entity;
+
+import io.jmix.core.entity.annotation.JmixGeneratedValue;
+import io.jmix.core.metamodel.annotation.InstanceName;
+import io.jmix.core.metamodel.annotation.JmixEntity;
+import jakarta.persistence.*;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.UUID;
+
+@JmixEntity
+@Table(name = "STEP")
+@Entity
+public class Step {
+ @JmixGeneratedValue
+ @Column(name = "ID", nullable = false)
+ @Id
+ private UUID id;
+
+ @Column(name = "VERSION", nullable = false)
+ @Version
+ private Integer version;
+
+ @InstanceName
+ @Column(name = "NAME", nullable = false)
+ @NotNull
+ private String name;
+
+ @NotNull
+ @Column(name = "DURATION", nullable = false)
+ private Integer duration;
+
+ @Column(name = "SORT_VALUE", nullable = false)
+ @NotNull
+ private Integer sortValue;
+
+ public Integer getSortValue() {
+ return sortValue;
+ }
+
+ public void setSortValue(Integer sortValue) {
+ this.sortValue = sortValue;
+ }
+
+ public Integer getDuration() {
+ return duration;
+ }
+
+ public void setDuration(Integer duration) {
+ this.duration = duration;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public Integer getVersion() {
+ return version;
+ }
+
+ public void setVersion(Integer version) {
+ this.version = version;
+ }
+
+ public UUID getId() {
+ return id;
+ }
+
+ public void setId(UUID id) {
+ this.id = id;
+ }
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/entity/User.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/entity/User.java
new file mode 100644
index 00000000..4943f09a
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/entity/User.java
@@ -0,0 +1,235 @@
+package com.company.onboarding.entity;
+
+import io.jmix.core.FileRef;
+import io.jmix.core.HasTimeZone;
+import io.jmix.core.annotation.Secret;
+import io.jmix.core.entity.annotation.JmixGeneratedValue;
+import io.jmix.core.entity.annotation.SystemLevel;
+import io.jmix.core.metamodel.annotation.Composition;
+import io.jmix.core.metamodel.annotation.DependsOnProperties;
+import io.jmix.core.metamodel.annotation.InstanceName;
+import io.jmix.core.metamodel.annotation.JmixEntity;
+import io.jmix.security.authentication.JmixUserDetails;
+import jakarta.persistence.*;
+import jakarta.validation.constraints.Email;
+import org.springframework.security.core.GrantedAuthority;
+
+import java.time.LocalDate;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+
+@JmixEntity
+@Entity
+@Table(name = "USER_", indexes = {
+ @Index(name = "IDX_USER__ON_USERNAME", columnList = "USERNAME", unique = true),
+ @Index(name = "IDX_USER__DEPARTMENT", columnList = "DEPARTMENT_ID")
+})
+public class User implements JmixUserDetails, HasTimeZone {
+
+ @Id
+ @Column(name = "ID", nullable = false)
+ @JmixGeneratedValue
+ private UUID id;
+
+ @Version
+ @Column(name = "VERSION", nullable = false)
+ private Integer version;
+
+ @Column(name = "USERNAME", nullable = false)
+ protected String username;
+
+ @Secret
+ @SystemLevel
+ @Column(name = "PASSWORD")
+ protected String password;
+
+ @Column(name = "FIRST_NAME")
+ protected String firstName;
+
+ @Column(name = "LAST_NAME")
+ protected String lastName;
+
+ @Email
+ @Column(name = "EMAIL")
+ protected String email;
+
+ @Column(name = "ACTIVE")
+ protected Boolean active = true;
+
+ @Column(name = "TIME_ZONE_ID")
+ protected String timeZoneId;
+
+ @Column(name = "ONBOARDING_STATUS")
+ private Integer onboardingStatus;
+
+ @JoinColumn(name = "DEPARTMENT_ID")
+ @ManyToOne(fetch = FetchType.LAZY)
+ private Department department;
+
+ @OrderBy("sortValue")
+ @Composition
+ @OneToMany(mappedBy = "user")
+ private List steps;
+
+ @Column(name = "JOINING_DATE")
+ private LocalDate joiningDate;
+
+ @Column(name = "PICTURE", length = 1024)
+ private FileRef picture;
+
+ @Transient
+ protected Collection extends GrantedAuthority> authorities;
+
+ public FileRef getPicture() {
+ return picture;
+ }
+
+ public void setPicture(FileRef picture) {
+ this.picture = picture;
+ }
+
+ public LocalDate getJoiningDate() {
+ return joiningDate;
+ }
+
+ public void setJoiningDate(LocalDate joiningDate) {
+ this.joiningDate = joiningDate;
+ }
+
+ public List getSteps() {
+ return steps;
+ }
+
+ public void setSteps(List steps) {
+ this.steps = steps;
+ }
+
+ public Department getDepartment() {
+ return department;
+ }
+
+ public void setDepartment(Department department) {
+ this.department = department;
+ }
+
+ public OnboardingStatus getOnboardingStatus() {
+ return onboardingStatus == null ? null : OnboardingStatus.fromId(onboardingStatus);
+ }
+
+ public void setOnboardingStatus(OnboardingStatus onboardingStatus) {
+ this.onboardingStatus = onboardingStatus == null ? null : onboardingStatus.getId();
+ }
+
+ public UUID getId() {
+ return id;
+ }
+
+ public void setId(final UUID id) {
+ this.id = id;
+ }
+
+ public Integer getVersion() {
+ return version;
+ }
+
+ public void setVersion(final Integer version) {
+ this.version = version;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ @Override
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(final String username) {
+ this.username = username;
+ }
+
+ public Boolean getActive() {
+ return active;
+ }
+
+ public void setActive(final Boolean active) {
+ this.active = active;
+ }
+
+ public void setPassword(final String password) {
+ this.password = password;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public void setEmail(final String email) {
+ this.email = email;
+ }
+
+ public String getFirstName() {
+ return firstName;
+ }
+
+ public void setFirstName(final String firstName) {
+ this.firstName = firstName;
+ }
+
+ public String getLastName() {
+ return lastName;
+ }
+
+ public void setLastName(final String lastName) {
+ this.lastName = lastName;
+ }
+
+ @Override
+ public Collection extends GrantedAuthority> getAuthorities() {
+ return authorities != null ? authorities : Collections.emptyList();
+ }
+
+ @Override
+ public void setAuthorities(final Collection extends GrantedAuthority> authorities) {
+ this.authorities = authorities;
+ }
+
+ @Override
+ public boolean isAccountNonExpired() {
+ return true;
+ }
+
+ @Override
+ public boolean isAccountNonLocked() {
+ return true;
+ }
+
+ @Override
+ public boolean isCredentialsNonExpired() {
+ return true;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return Boolean.TRUE.equals(active);
+ }
+
+ @InstanceName
+ @DependsOnProperties({"firstName", "lastName", "username"})
+ public String getDisplayName() {
+ return String.format("%s %s [%s]", (firstName != null ? firstName : ""),
+ (lastName != null ? lastName : ""), username).trim();
+ }
+
+ @Override
+ public String getTimeZoneId() {
+ return timeZoneId;
+ }
+
+ public void setTimeZoneId(final String timeZoneId) {
+ this.timeZoneId = timeZoneId;
+ }
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/entity/UserStep.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/entity/UserStep.java
new file mode 100644
index 00000000..5669fe38
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/entity/UserStep.java
@@ -0,0 +1,103 @@
+package com.company.onboarding.entity;
+
+import io.jmix.core.entity.annotation.JmixGeneratedValue;
+import io.jmix.core.metamodel.annotation.JmixEntity;
+import jakarta.persistence.*;
+import jakarta.validation.constraints.NotNull;
+
+import java.time.LocalDate;
+import java.util.UUID;
+
+@JmixEntity
+@Table(name = "USER_STEP", indexes = {
+ @Index(name = "IDX_USER_STEP_USER", columnList = "USER_ID"),
+ @Index(name = "IDX_USER_STEP_STEP", columnList = "STEP_ID")
+})
+@Entity
+public class UserStep {
+ @JmixGeneratedValue
+ @Column(name = "ID", nullable = false)
+ @Id
+ private UUID id;
+
+ @Column(name = "VERSION", nullable = false)
+ @Version
+ private Integer version;
+
+ @JoinColumn(name = "USER_ID", nullable = false)
+ @NotNull
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ private User user;
+
+ @JoinColumn(name = "STEP_ID", nullable = false)
+ @NotNull
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ private Step step;
+
+ @Column(name = "DUE_DATE", nullable = false)
+ @NotNull
+ private LocalDate dueDate;
+
+ @Column(name = "COMPLETED_DATE")
+ private LocalDate completedDate;
+
+ @Column(name = "SORT_VALUE", nullable = false)
+ @NotNull
+ private Integer sortValue;
+
+ public Integer getSortValue() {
+ return sortValue;
+ }
+
+ public void setSortValue(Integer sortValue) {
+ this.sortValue = sortValue;
+ }
+
+ public LocalDate getCompletedDate() {
+ return completedDate;
+ }
+
+ public void setCompletedDate(LocalDate completedDate) {
+ this.completedDate = completedDate;
+ }
+
+ public LocalDate getDueDate() {
+ return dueDate;
+ }
+
+ public void setDueDate(LocalDate dueDate) {
+ this.dueDate = dueDate;
+ }
+
+ public Step getStep() {
+ return step;
+ }
+
+ public void setStep(Step step) {
+ this.step = step;
+ }
+
+ public User getUser() {
+ return user;
+ }
+
+ public void setUser(User user) {
+ this.user = user;
+ }
+
+ public Integer getVersion() {
+ return version;
+ }
+
+ public void setVersion(Integer version) {
+ this.version = version;
+ }
+
+ public UUID getId() {
+ return id;
+ }
+
+ public void setId(UUID id) {
+ this.id = id;
+ }
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/listener/DemoDataInitializer.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/listener/DemoDataInitializer.java
new file mode 100644
index 00000000..f003ee32
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/listener/DemoDataInitializer.java
@@ -0,0 +1,300 @@
+package com.company.onboarding.listener;
+
+import com.company.onboarding.entity.Department;
+import com.company.onboarding.entity.Step;
+import com.company.onboarding.entity.User;
+import com.company.onboarding.entity.UserStep;
+import io.jmix.core.DataManager;
+import io.jmix.core.FileRef;
+import io.jmix.core.FileStorage;
+import io.jmix.core.SaveContext;
+import io.jmix.core.security.Authenticated;
+import io.jmix.security.role.assignment.RoleAssignmentRoleType;
+import io.jmix.securitydata.entity.RoleAssignmentEntity;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.context.event.ApplicationStartedEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+@Component
+public class DemoDataInitializer {
+
+ @Autowired
+ private DataManager dataManager;
+
+ @Autowired
+ private PasswordEncoder passwordEncoder;
+
+ @Autowired
+ private FileStorage fileStorage;
+
+ @EventListener
+ @Authenticated
+ public void onApplicationStarted(ApplicationStartedEvent event) {
+ if (dataManager.load(Step.class).all().maxResults(1).list().size() > 0) {
+ return;
+ }
+ List steps = initSteps();
+ List departments = initDepartments();
+ List users = initUsers(steps, departments);
+ assignRoles(users);
+ }
+
+ private List initSteps() {
+ Step step;
+ ArrayList list = new ArrayList<>();
+
+ step = dataManager.create(Step.class);
+ step.setName("Safety briefing");
+ step.setDuration(1);
+ step.setSortValue(10);
+ list.add(dataManager.save(step));
+
+ step = dataManager.create(Step.class);
+ step.setName("Fill in profile");
+ step.setDuration(1);
+ step.setSortValue(20);
+ list.add(dataManager.save(step));
+
+ step = dataManager.create(Step.class);
+ step.setName("Check all functions");
+ step.setDuration(2);
+ step.setSortValue(30);
+ list.add(dataManager.save(step));
+
+ step = dataManager.create(Step.class);
+ step.setName("Information security training");
+ step.setDuration(3);
+ step.setSortValue(40);
+ list.add(dataManager.save(step));
+
+ step = dataManager.create(Step.class);
+ step.setName("Internal procedures studying");
+ step.setDuration(5);
+ step.setSortValue(50);
+ list.add(dataManager.save(step));
+
+ return list;
+ }
+
+ private List initDepartments() {
+ Department department;
+ List list = new ArrayList<>();
+
+ department = dataManager.create(Department.class);
+ department.setName("Human Resources");
+ list.add(dataManager.save(department));
+
+ department = dataManager.create(Department.class);
+ department.setName("Marketing");
+ list.add(dataManager.save(department));
+
+ department = dataManager.create(Department.class);
+ department.setName("Operations");
+ list.add(dataManager.save(department));
+
+ department = dataManager.create(Department.class);
+ department.setName("Finance");
+ list.add(dataManager.save(department));
+
+ return list;
+ }
+
+ private List initUsers(List steps, List departments) {
+ User user;
+ SaveContext saveContext;
+ List list = new ArrayList<>();
+
+ saveContext = new SaveContext();
+ user = dataManager.create(User.class);
+ user.setUsername("alice");
+ user.setPassword(createPassword());
+ user.setFirstName("Alice");
+ user.setLastName("Brown");
+ user.setDepartment(departments.get(0));
+ user.setJoiningDate(LocalDate.now().minusYears(2).minusWeeks(3));
+ user.setPicture(uploadPicture("com/company/onboarding/demo/" , "alice.png"));
+ saveContext.saving(user);
+ list.add(user);
+ for (Step step : steps) {
+ UserStep userStep = dataManager.create(UserStep.class);
+ userStep.setUser(user);
+ userStep.setStep(step);
+ userStep.setDueDate(user.getJoiningDate().plusDays(step.getDuration()));
+ userStep.setCompletedDate(user.getJoiningDate().plusDays(step.getDuration() - 1));
+ userStep.setSortValue(step.getSortValue());
+ saveContext.saving(userStep);
+ }
+ dataManager.save(saveContext);
+
+ Department marketingDept = departments.get(1);
+ marketingDept.setHrManager(user);
+ dataManager.save(marketingDept);
+
+ saveContext = new SaveContext();
+ user = dataManager.create(User.class);
+ user.setUsername("james");
+ user.setPassword(createPassword());
+ user.setFirstName("James");
+ user.setLastName("Wilson");
+ user.setDepartment(departments.get(0));
+ user.setJoiningDate(LocalDate.now().minusYears(1).minusWeeks(5));
+ user.setPicture(uploadPicture("com/company/onboarding/demo/" , "james.png"));
+ saveContext.saving(user);
+ list.add(user);
+ for (Step step : steps) {
+ UserStep userStep = dataManager.create(UserStep.class);
+ userStep.setUser(user);
+ userStep.setStep(step);
+ userStep.setDueDate(user.getJoiningDate().plusDays(step.getDuration()));
+ userStep.setCompletedDate(user.getJoiningDate().plusDays(step.getDuration() - 1));
+ userStep.setSortValue(step.getSortValue());
+ saveContext.saving(userStep);
+ }
+ dataManager.save(saveContext);
+
+ Department operationsDept = departments.get(2);
+ operationsDept.setHrManager(user);
+ dataManager.save(operationsDept);
+
+ saveContext = new SaveContext();
+ user = dataManager.create(User.class);
+ user.setUsername("mary");
+ user.setPassword(createPassword());
+ user.setFirstName("Mary");
+ user.setLastName("Jones");
+ user.setDepartment(departments.get(1));
+ user.setJoiningDate(LocalDate.now().minusDays(3));
+ user.setPicture(uploadPicture("com/company/onboarding/demo/", "mary.png"));
+ saveContext.saving(user);
+ list.add(user);
+ for (Step step : steps) {
+ UserStep userStep = dataManager.create(UserStep.class);
+ userStep.setUser(user);
+ userStep.setStep(step);
+ userStep.setDueDate(user.getJoiningDate().plusDays(step.getDuration()));
+ userStep.setCompletedDate(null);
+ userStep.setSortValue(step.getSortValue());
+ saveContext.saving(userStep);
+ }
+ dataManager.save(saveContext);
+
+ saveContext = new SaveContext();
+ user = dataManager.create(User.class);
+ user.setUsername("linda");
+ user.setPassword(createPassword());
+ user.setFirstName("Linda");
+ user.setLastName("Evans");
+ user.setDepartment(departments.get(2));
+ user.setJoiningDate(LocalDate.now().minusDays(2));
+ user.setPicture(uploadPicture("com/company/onboarding/demo/", "linda.png"));
+ saveContext.saving(user);
+ list.add(user);
+ for (Step step : steps) {
+ UserStep userStep = dataManager.create(UserStep.class);
+ userStep.setUser(user);
+ userStep.setStep(step);
+ userStep.setDueDate(user.getJoiningDate().plusDays(step.getDuration()));
+ userStep.setCompletedDate(null);
+ userStep.setSortValue(step.getSortValue());
+ saveContext.saving(userStep);
+ }
+ dataManager.save(saveContext);
+
+ saveContext = new SaveContext();
+ user = dataManager.create(User.class);
+ user.setUsername("susan");
+ user.setPassword(createPassword());
+ user.setFirstName("Susan");
+ user.setLastName("Baker");
+ user.setDepartment(departments.get(2));
+ user.setJoiningDate(LocalDate.now());
+ user.setPicture(uploadPicture("com/company/onboarding/demo/", "susan.png"));
+ saveContext.saving(user);
+ list.add(user);
+ for (Step step : steps) {
+ UserStep userStep = dataManager.create(UserStep.class);
+ userStep.setUser(user);
+ userStep.setStep(step);
+ userStep.setDueDate(user.getJoiningDate().plusDays(step.getDuration()));
+ userStep.setCompletedDate(null);
+ userStep.setSortValue(step.getSortValue());
+ saveContext.saving(userStep);
+ }
+ dataManager.save(saveContext);
+
+ saveContext = new SaveContext();
+ user = dataManager.create(User.class);
+ user.setUsername("bob");
+ user.setPassword(createPassword());
+ user.setFirstName("Robert");
+ user.setLastName("Taylor");
+ user.setDepartment(departments.get(2));
+ user.setJoiningDate(LocalDate.now().minusDays(1));
+ user.setPicture(uploadPicture("com/company/onboarding/demo/", "bob.png"));
+ saveContext.saving(user);
+ list.add(user);
+ for (Step step : steps) {
+ UserStep userStep = dataManager.create(UserStep.class);
+ userStep.setUser(user);
+ userStep.setStep(step);
+ userStep.setDueDate(user.getJoiningDate().plusDays(step.getDuration()));
+ userStep.setCompletedDate(userStep.getDueDate().isBefore(LocalDate.now().plusDays(1)) ? userStep.getDueDate() : null);
+ userStep.setSortValue(step.getSortValue());
+ saveContext.saving(userStep);
+ }
+ dataManager.save(saveContext);
+
+ return list;
+ }
+
+ private FileRef uploadPicture(String path, String fileName) {
+ ClassPathResource resource = new ClassPathResource(path + fileName);
+ try (InputStream stream = resource.getInputStream()) {
+ return fileStorage.saveStream(fileName, stream);
+ } catch (IOException e) {
+ throw new RuntimeException("Cannot read resource: " + path + fileName, e);
+ }
+ }
+
+ private String createPassword() {
+ return passwordEncoder.encode("1");
+ }
+
+ private void assignRoles(List users) {
+ for (User user : users) {
+ boolean isHrManager = Arrays.asList("alice", "james").contains(user.getUsername());
+
+ RoleAssignmentEntity roleAssignment;
+
+ roleAssignment = dataManager.create(RoleAssignmentEntity.class);
+ roleAssignment.setUsername(user.getUsername());
+ roleAssignment.setRoleCode("ui-minimal");
+ roleAssignment.setRoleType(RoleAssignmentRoleType.RESOURCE);
+ dataManager.save(roleAssignment);
+
+ roleAssignment = dataManager.create(RoleAssignmentEntity.class);
+ roleAssignment.setUsername(user.getUsername());
+ roleAssignment.setRoleCode(isHrManager ? "hr-manager" : "employee");
+ roleAssignment.setRoleType(RoleAssignmentRoleType.RESOURCE);
+ dataManager.save(roleAssignment);
+
+ if (isHrManager) {
+ roleAssignment = dataManager.create(RoleAssignmentEntity.class);
+ roleAssignment.setUsername(user.getUsername());
+ roleAssignment.setRoleCode("hr-manager-rl");
+ roleAssignment.setRoleType(RoleAssignmentRoleType.ROW_LEVEL);
+ dataManager.save(roleAssignment);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/listener/UserStepEventListener.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/listener/UserStepEventListener.java
new file mode 100644
index 00000000..9f6e8c20
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/listener/UserStepEventListener.java
@@ -0,0 +1,47 @@
+package com.company.onboarding.listener;
+
+import com.company.onboarding.entity.OnboardingStatus;
+import com.company.onboarding.entity.User;
+import com.company.onboarding.entity.UserStep;
+import io.jmix.core.DataManager;
+import io.jmix.core.Id;
+import io.jmix.core.event.EntityChangedEvent;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+
+@Component
+public class UserStepEventListener {
+
+ @Autowired
+ private DataManager dataManager;
+
+ @EventListener
+ public void onUserStepChangedBeforeCommit(final EntityChangedEvent event) {
+ User user;
+ if (event.getType() != EntityChangedEvent.Type.DELETED) {
+ Id userStepId = event.getEntityId();
+ UserStep userStep = dataManager.load(userStepId).one();
+ user = userStep.getUser();
+ } else {
+ Id userId = event.getChanges().getOldReferenceId("user");
+ if (userId == null) {
+ throw new IllegalStateException("Cannot get User from deleted UserStep");
+ }
+ user = dataManager.load(userId).one();
+ }
+
+ long completedCount = user.getSteps().stream()
+ .filter(us -> us.getCompletedDate() != null)
+ .count();
+ if (completedCount == 0) {
+ user.setOnboardingStatus(OnboardingStatus.NOT_STARTED);
+ } else if (completedCount == user.getSteps().size()) {
+ user.setOnboardingStatus(OnboardingStatus.COMPLETED);
+ } else {
+ user.setOnboardingStatus(OnboardingStatus.IN_PROGRESS);
+ }
+
+ dataManager.save(user);
+ }
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/DatabaseUserRepository.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/DatabaseUserRepository.java
new file mode 100644
index 00000000..fc6581b0
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/DatabaseUserRepository.java
@@ -0,0 +1,31 @@
+package com.company.onboarding.security;
+
+import com.company.onboarding.entity.User;
+import io.jmix.securitydata.user.AbstractDatabaseUserRepository;
+import org.springframework.context.annotation.Primary;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.stereotype.Component;
+
+import java.util.Collection;
+
+@Primary
+@Component("UserRepository")
+public class DatabaseUserRepository extends AbstractDatabaseUserRepository {
+
+ @Override
+ protected Class getUserClass() {
+ return User.class;
+ }
+
+ @Override
+ protected void initSystemUser(final User systemUser) {
+ final Collection authorities = getGrantedAuthoritiesBuilder()
+ .addResourceRole(FullAccessRole.CODE)
+ .build();
+ systemUser.setAuthorities(authorities);
+ }
+
+ @Override
+ protected void initAnonymousUser(final User anonymousUser) {
+ }
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/EmployeeRole.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/EmployeeRole.java
new file mode 100644
index 00000000..f5a89592
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/EmployeeRole.java
@@ -0,0 +1,40 @@
+package com.company.onboarding.security;
+
+import com.company.onboarding.entity.Step;
+import com.company.onboarding.entity.User;
+import com.company.onboarding.entity.UserStep;
+import io.jmix.security.model.EntityAttributePolicyAction;
+import io.jmix.security.model.EntityPolicyAction;
+import io.jmix.security.role.annotation.EntityAttributePolicy;
+import io.jmix.security.role.annotation.EntityPolicy;
+import io.jmix.security.role.annotation.ResourceRole;
+import io.jmix.securityflowui.role.annotation.MenuPolicy;
+import io.jmix.securityflowui.role.annotation.ViewPolicy;
+
+@ResourceRole(name = "Employee", code = "employee", scope = "UI")
+public interface EmployeeRole {
+ @MenuPolicy(menuIds = "MyOnboardingView")
+ @ViewPolicy(viewIds = "MyOnboardingView")
+ void screens();
+
+ @EntityAttributePolicy(entityClass = User.class,
+ attributes = "*",
+ action = EntityAttributePolicyAction.VIEW)
+ @EntityPolicy(entityClass = User.class,
+ actions = {EntityPolicyAction.READ, EntityPolicyAction.UPDATE})
+ void user();
+
+ @EntityAttributePolicy(entityClass = UserStep.class,
+ attributes = "*",
+ action = EntityAttributePolicyAction.VIEW)
+ @EntityPolicy(entityClass = UserStep.class,
+ actions = {EntityPolicyAction.READ, EntityPolicyAction.UPDATE})
+ void userStep();
+
+ @EntityAttributePolicy(entityClass = Step.class,
+ attributes = "*",
+ action = EntityAttributePolicyAction.VIEW)
+ @EntityPolicy(entityClass = Step.class,
+ actions = EntityPolicyAction.READ)
+ void step();
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/FullAccessRole.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/FullAccessRole.java
new file mode 100644
index 00000000..21ab0382
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/FullAccessRole.java
@@ -0,0 +1,23 @@
+package com.company.onboarding.security;
+
+import io.jmix.security.model.EntityAttributePolicyAction;
+import io.jmix.security.model.EntityPolicyAction;
+import io.jmix.security.role.annotation.EntityAttributePolicy;
+import io.jmix.security.role.annotation.EntityPolicy;
+import io.jmix.security.role.annotation.ResourceRole;
+import io.jmix.security.role.annotation.SpecificPolicy;
+import io.jmix.securityflowui.role.annotation.MenuPolicy;
+import io.jmix.securityflowui.role.annotation.ViewPolicy;
+
+@ResourceRole(name = "Full Access", code = FullAccessRole.CODE)
+public interface FullAccessRole {
+
+ String CODE = "system-full-access";
+
+ @EntityPolicy(entityName = "*", actions = {EntityPolicyAction.ALL})
+ @EntityAttributePolicy(entityName = "*", attributes = "*", action = EntityAttributePolicyAction.MODIFY)
+ @ViewPolicy(viewIds = "*")
+ @MenuPolicy(menuIds = "*")
+ @SpecificPolicy(resources = "*")
+ void fullAccess();
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/HRManagerRole.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/HRManagerRole.java
new file mode 100644
index 00000000..676ee08a
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/HRManagerRole.java
@@ -0,0 +1,48 @@
+package com.company.onboarding.security;
+
+import com.company.onboarding.entity.Department;
+import com.company.onboarding.entity.Step;
+import com.company.onboarding.entity.User;
+import com.company.onboarding.entity.UserStep;
+import io.jmix.security.model.EntityAttributePolicyAction;
+import io.jmix.security.model.EntityPolicyAction;
+import io.jmix.security.role.annotation.EntityAttributePolicy;
+import io.jmix.security.role.annotation.EntityPolicy;
+import io.jmix.security.role.annotation.ResourceRole;
+import io.jmix.securityflowui.role.annotation.MenuPolicy;
+import io.jmix.securityflowui.role.annotation.ViewPolicy;
+
+@ResourceRole(name = "HR Manager", code = "hr-manager", scope = "UI")
+public interface HRManagerRole {
+ @MenuPolicy(menuIds = "User.list")
+ @ViewPolicy(viewIds = {"User.detail", "User.list"})
+ void screens();
+
+ @EntityAttributePolicy(entityClass = Department.class,
+ attributes = "*",
+ action = EntityAttributePolicyAction.VIEW)
+ @EntityPolicy(entityClass = Department.class,
+ actions = EntityPolicyAction.READ)
+ void department();
+
+ @EntityAttributePolicy(entityClass = Step.class,
+ attributes = "*",
+ action = EntityAttributePolicyAction.VIEW)
+ @EntityPolicy(entityClass = Step.class,
+ actions = EntityPolicyAction.READ)
+ void step();
+
+ @EntityAttributePolicy(entityClass = User.class,
+ attributes = "*",
+ action = EntityAttributePolicyAction.MODIFY)
+ @EntityPolicy(entityClass = User.class,
+ actions = EntityPolicyAction.ALL)
+ void user();
+
+ @EntityAttributePolicy(entityClass = UserStep.class,
+ attributes = "*",
+ action = EntityAttributePolicyAction.MODIFY)
+ @EntityPolicy(entityClass = UserStep.class,
+ actions = EntityPolicyAction.ALL)
+ void userStep();
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/HrManagerRlRole.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/HrManagerRlRole.java
new file mode 100644
index 00000000..cab3dbc1
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/HrManagerRlRole.java
@@ -0,0 +1,21 @@
+package com.company.onboarding.security;
+
+import com.company.onboarding.entity.Department;
+import com.company.onboarding.entity.User;
+import io.jmix.security.role.annotation.JpqlRowLevelPolicy;
+import io.jmix.security.role.annotation.RowLevelRole;
+
+@RowLevelRole(name = "HR manager's departments and users",
+ code = "hr-manager-rl")
+public interface HrManagerRlRole {
+
+ @JpqlRowLevelPolicy(
+ entityClass = Department.class,
+ where = "{E}.hrManager.id = :current_user_id")
+ void department1();
+
+ @JpqlRowLevelPolicy(
+ entityClass = User.class,
+ where = "{E}.department.hrManager.id = :current_user_id")
+ void department();
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/OnboardingSecurityConfiguration.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/OnboardingSecurityConfiguration.java
new file mode 100644
index 00000000..c62d0201
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/OnboardingSecurityConfiguration.java
@@ -0,0 +1,49 @@
+package com.company.onboarding.security;
+
+import io.jmix.core.JmixSecurityFilterChainOrder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.annotation.Order;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.web.SecurityFilterChain;
+
+/**
+ * This configuration complements standard security configurations that come from Jmix modules (security-flowui, oidc,
+ * authserver).
+ *
+ * You can configure custom API endpoints security by defining {@link SecurityFilterChain} beans in this class.
+ * In most cases, custom SecurityFilterChain must be applied first, so the proper
+ * {@link Order} should be defined for the bean. The order value from the
+ * {@link JmixSecurityFilterChainOrder#CUSTOM} is guaranteed to be smaller than any other filter chain
+ * order from Jmix.
+ *
+ * Example:
+ *
+ *
+ * @Bean
+ * @Order(JmixSecurityFilterChainOrder.CUSTOM)
+ * SecurityFilterChain publicFilterChain(HttpSecurity http) throws Exception {
+ * http.securityMatcher("/public/**")
+ * .authorizeHttpRequests(authorize ->
+ * authorize.anyRequest().permitAll()
+ * );
+ * return http.build();
+ * }
+ *
+ *
+ * @see io.jmix.securityflowui.security.FlowuiVaadinWebSecurity
+ */
+@Configuration
+public class OnboardingSecurityConfiguration {
+
+ @Bean
+ @Order(JmixSecurityFilterChainOrder.CUSTOM)
+ SecurityFilterChain publicFilterChain(HttpSecurity http) throws Exception {
+ http.securityMatcher("/public/**")
+ .authorizeHttpRequests(authorize ->
+ authorize.anyRequest().permitAll()
+ );
+
+ return http.build();
+ }
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/UiMinimalRole.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/UiMinimalRole.java
new file mode 100644
index 00000000..7f59786b
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/security/UiMinimalRole.java
@@ -0,0 +1,28 @@
+package com.company.onboarding.security;
+
+import io.jmix.core.entity.KeyValueEntity;
+import io.jmix.security.model.EntityAttributePolicyAction;
+import io.jmix.security.model.EntityPolicyAction;
+import io.jmix.security.model.SecurityScope;
+import io.jmix.security.role.annotation.EntityAttributePolicy;
+import io.jmix.security.role.annotation.EntityPolicy;
+import io.jmix.security.role.annotation.ResourceRole;
+import io.jmix.security.role.annotation.SpecificPolicy;
+import io.jmix.securityflowui.role.annotation.ViewPolicy;
+
+@ResourceRole(name = "UI: minimal access", code = UiMinimalRole.CODE, scope = SecurityScope.UI)
+public interface UiMinimalRole {
+
+ String CODE = "ui-minimal";
+
+ @ViewPolicy(viewIds = "MainView")
+ void main();
+
+ @ViewPolicy(viewIds = "LoginView")
+ @SpecificPolicy(resources = "ui.loginToUi")
+ void login();
+
+ @EntityPolicy(entityClass = KeyValueEntity.class, actions = EntityPolicyAction.READ)
+ @EntityAttributePolicy(entityClass = KeyValueEntity.class, attributes = "*", action = EntityAttributePolicyAction.VIEW)
+ void keyValueEntity();
+}
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/assistant/AssistantView.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/assistant/AssistantView.java
new file mode 100644
index 00000000..c883275f
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/assistant/AssistantView.java
@@ -0,0 +1,13 @@
+package com.company.onboarding.view.assistant;
+
+import com.company.onboarding.view.main.MainView;
+import com.vaadin.flow.router.Route;
+import io.jmix.flowui.view.StandardView;
+import io.jmix.flowui.view.ViewController;
+import io.jmix.flowui.view.ViewDescriptor;
+
+@Route(value = "assistant", layout = MainView.class)
+@ViewController("AssistantView")
+@ViewDescriptor("assistant-view.xml")
+public class AssistantView extends StandardView {
+}
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/department/DepartmentDetailView.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/department/DepartmentDetailView.java
new file mode 100644
index 00000000..207c6b9f
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/department/DepartmentDetailView.java
@@ -0,0 +1,15 @@
+package com.company.onboarding.view.department;
+
+import com.company.onboarding.entity.Department;
+
+import com.company.onboarding.view.main.MainView;
+
+import com.vaadin.flow.router.Route;
+import io.jmix.flowui.view.*;
+
+@Route(value = "departments/:id", layout = MainView.class)
+@ViewController("Department.detail")
+@ViewDescriptor("department-detail-view.xml")
+@EditedEntityContainer("departmentDc")
+public class DepartmentDetailView extends StandardDetailView {
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/department/DepartmentListView.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/department/DepartmentListView.java
new file mode 100644
index 00000000..d5fae7f3
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/department/DepartmentListView.java
@@ -0,0 +1,16 @@
+package com.company.onboarding.view.department;
+
+import com.company.onboarding.entity.Department;
+
+import com.company.onboarding.view.main.MainView;
+
+import com.vaadin.flow.router.Route;
+import io.jmix.flowui.view.*;
+
+@Route(value = "departments", layout = MainView.class)
+@ViewController("Department.list")
+@ViewDescriptor("department-list-view.xml")
+@LookupComponent("departmentsDataGrid")
+@DialogMode(width = "50em", height = "37.5em")
+public class DepartmentListView extends StandardListView {
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/login/LoginView.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/login/LoginView.java
new file mode 100644
index 00000000..fa454c54
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/login/LoginView.java
@@ -0,0 +1,112 @@
+package com.company.onboarding.view.login;
+
+import com.vaadin.flow.component.UI;
+import com.vaadin.flow.component.login.AbstractLogin.LoginEvent;
+import com.vaadin.flow.component.login.LoginI18n;
+import com.vaadin.flow.i18n.LocaleChangeEvent;
+import com.vaadin.flow.i18n.LocaleChangeObserver;
+import com.vaadin.flow.router.Route;
+import com.vaadin.flow.server.VaadinSession;
+import io.jmix.core.MessageTools;
+import io.jmix.core.security.AccessDeniedException;
+import io.jmix.flowui.component.loginform.JmixLoginForm;
+import io.jmix.flowui.kit.component.ComponentUtils;
+import io.jmix.flowui.kit.component.loginform.JmixLoginI18n;
+import io.jmix.flowui.view.*;
+import io.jmix.securityflowui.authentication.AuthDetails;
+import io.jmix.securityflowui.authentication.LoginViewSupport;
+import org.apache.commons.collections4.MapUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.authentication.DisabledException;
+import org.springframework.security.authentication.LockedException;
+
+@Route(value = "login")
+@ViewController("LoginView")
+@ViewDescriptor("login-view.xml")
+public class LoginView extends StandardView implements LocaleChangeObserver {
+
+ private static final Logger log = LoggerFactory.getLogger(LoginView.class);
+
+ @Autowired
+ private LoginViewSupport loginViewSupport;
+
+ @ViewComponent
+ private MessageBundle messageBundle;
+
+ @Autowired
+ private MessageTools messageTools;
+
+ @ViewComponent
+ private JmixLoginForm login;
+
+ @Value("${ui.login.defaultUsername:}")
+ private String defaultUsername;
+
+ @Value("${ui.login.defaultPassword:}")
+ private String defaultPassword;
+
+ @Subscribe
+ public void onInit(final InitEvent event) {
+ initLocales();
+ initDefaultCredentials();
+ }
+
+ protected void initLocales() {
+ ComponentUtils.setItemsMap(login,
+ MapUtils.invertMap(messageTools.getAvailableLocalesMap()));
+
+ login.setSelectedLocale(VaadinSession.getCurrent().getLocale());
+ }
+
+ protected void initDefaultCredentials() {
+ if (StringUtils.isNotBlank(defaultUsername)) {
+ login.setUsername(defaultUsername);
+ }
+
+ if (StringUtils.isNotBlank(defaultPassword)) {
+ login.setPassword(defaultPassword);
+ }
+ }
+
+ @Subscribe("login")
+ public void onLogin(final LoginEvent event) {
+ try {
+ loginViewSupport.authenticate(
+ AuthDetails.of(event.getUsername(), event.getPassword())
+ .withLocale(login.getSelectedLocale())
+ .withRememberMe(login.isRememberMe())
+ );
+ } catch (final BadCredentialsException | DisabledException | LockedException | AccessDeniedException e) {
+ log.warn("Login failed for user '{}': {}", event.getUsername(), e.toString());
+ event.getSource().setError(true);
+ }
+ }
+
+ @Override
+ public void localeChange(final LocaleChangeEvent event) {
+ UI.getCurrent().getPage().setTitle(messageBundle.getMessage("LoginView.title"));
+
+ final JmixLoginI18n loginI18n = JmixLoginI18n.createDefault();
+
+ final JmixLoginI18n.JmixForm form = new JmixLoginI18n.JmixForm();
+ form.setTitle(messageBundle.getMessage("loginForm.headerTitle"));
+ form.setUsername(messageBundle.getMessage("loginForm.username"));
+ form.setPassword(messageBundle.getMessage("loginForm.password"));
+ form.setSubmit(messageBundle.getMessage("loginForm.submit"));
+ form.setForgotPassword(messageBundle.getMessage("loginForm.forgotPassword"));
+ form.setRememberMe(messageBundle.getMessage("loginForm.rememberMe"));
+ loginI18n.setForm(form);
+
+ final LoginI18n.ErrorMessage errorMessage = new LoginI18n.ErrorMessage();
+ errorMessage.setTitle(messageBundle.getMessage("loginForm.errorTitle"));
+ errorMessage.setMessage(messageBundle.getMessage("loginForm.badCredentials"));
+ loginI18n.setErrorMessage(errorMessage);
+
+ login.setI18n(loginI18n);
+ }
+}
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/main/MainView.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/main/MainView.java
new file mode 100644
index 00000000..3a5809ed
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/main/MainView.java
@@ -0,0 +1,118 @@
+package com.company.onboarding.view.main;
+
+import com.company.onboarding.entity.User;
+import com.google.common.base.Strings;
+import com.vaadin.flow.component.Component;
+import com.vaadin.flow.component.avatar.Avatar;
+import com.vaadin.flow.component.avatar.AvatarVariant;
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.component.html.Span;
+import com.vaadin.flow.router.Route;
+import io.jmix.core.Messages;
+import io.jmix.core.usersubstitution.CurrentUserSubstitution;
+import io.jmix.flowui.UiComponents;
+import io.jmix.flowui.app.main.StandardMainView;
+import io.jmix.flowui.view.Install;
+import io.jmix.flowui.view.ViewController;
+import io.jmix.flowui.view.ViewDescriptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.core.userdetails.UserDetails;
+
+@Route("")
+@ViewController("MainView")
+@ViewDescriptor("main-view.xml")
+public class MainView extends StandardMainView {
+
+ @Autowired
+ private Messages messages;
+ @Autowired
+ private UiComponents uiComponents;
+ @Autowired
+ private CurrentUserSubstitution currentUserSubstitution;
+
+ @Install(to = "userMenu", subject = "buttonRenderer")
+ private Component userMenuButtonRenderer(final UserDetails userDetails) {
+ if (!(userDetails instanceof User user)) {
+ return null;
+ }
+
+ String userName = generateUserName(user);
+
+ Div content = uiComponents.create(Div.class);
+ content.setClassName("user-menu-button-content");
+
+ Avatar avatar = createAvatar(userName);
+
+ Span name = uiComponents.create(Span.class);
+ name.setText(userName);
+ name.setClassName("user-menu-text");
+
+ content.add(avatar, name);
+
+ if (isSubstituted(user)) {
+ Span subtext = uiComponents.create(Span.class);
+ subtext.setText(messages.getMessage("userMenu.substituted"));
+ subtext.setClassName("user-menu-subtext");
+
+ content.add(subtext);
+ }
+
+ return content;
+ }
+
+ @Install(to = "userMenu", subject = "headerRenderer")
+ private Component userMenuHeaderRenderer(final UserDetails userDetails) {
+ if (!(userDetails instanceof User user)) {
+ return null;
+ }
+
+ Div content = uiComponents.create(Div.class);
+ content.setClassName("user-menu-header-content");
+
+ String name = generateUserName(user);
+
+ Avatar avatar = createAvatar(name);
+ avatar.addThemeVariants(AvatarVariant.LUMO_LARGE);
+
+ Span text = uiComponents.create(Span.class);
+ text.setText(name);
+ text.setClassName("user-menu-text");
+
+ content.add(avatar, text);
+
+ if (name.equals(user.getUsername())) {
+ text.addClassName("user-menu-text-subtext");
+ } else {
+ Span subtext = uiComponents.create(Span.class);
+ subtext.setText(user.getUsername());
+ subtext.setClassName("user-menu-subtext");
+
+ content.add(subtext);
+ }
+
+ return content;
+ }
+
+ private Avatar createAvatar(String fullName) {
+ Avatar avatar = uiComponents.create(Avatar.class);
+ avatar.setName(fullName);
+ avatar.getElement().setAttribute("tabindex", "-1");
+ avatar.setClassName("user-menu-avatar");
+
+ return avatar;
+ }
+
+ private String generateUserName(User user) {
+ String userName = String.format("%s %s",
+ Strings.nullToEmpty(user.getFirstName()),
+ Strings.nullToEmpty(user.getLastName()))
+ .trim();
+
+ return userName.isEmpty() ? user.getUsername() : userName;
+ }
+
+ private boolean isSubstituted(User user) {
+ UserDetails authenticatedUser = currentUserSubstitution.getAuthenticatedUser();
+ return user != null && !authenticatedUser.getUsername().equals(user.getUsername());
+ }
+}
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/myonboarding/MyOnboardingView.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/myonboarding/MyOnboardingView.java
new file mode 100644
index 00000000..c7ba3093
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/myonboarding/MyOnboardingView.java
@@ -0,0 +1,123 @@
+package com.company.onboarding.view.myonboarding;
+
+
+import com.company.onboarding.entity.User;
+import com.company.onboarding.entity.UserStep;
+import com.company.onboarding.view.main.MainView;
+import com.vaadin.flow.component.ClickEvent;
+import com.vaadin.flow.component.checkbox.Checkbox;
+import com.vaadin.flow.component.html.Span;
+import com.vaadin.flow.data.renderer.ComponentRenderer;
+import com.vaadin.flow.data.renderer.Renderer;
+import com.vaadin.flow.router.Route;
+import io.jmix.core.security.CurrentAuthentication;
+import io.jmix.flowui.UiComponents;
+import io.jmix.flowui.kit.component.button.JmixButton;
+import io.jmix.flowui.model.CollectionContainer;
+import io.jmix.flowui.model.CollectionLoader;
+import io.jmix.flowui.model.DataContext;
+import io.jmix.flowui.model.InstanceContainer;
+import io.jmix.flowui.view.*;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import java.time.DayOfWeek;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.List;
+
+@Route(value = "MyOnboardingView", layout = MainView.class)
+@ViewController("MyOnboardingView")
+@ViewDescriptor("my-onboarding-view.xml")
+public class MyOnboardingView extends StandardView {
+
+ @Autowired
+ private CurrentAuthentication currentAuthentication;
+
+ @ViewComponent
+ private CollectionLoader userStepsDl;
+
+ @Autowired
+ private UiComponents uiComponents;
+
+ @ViewComponent
+ private CollectionContainer userStepsDc;
+
+ @ViewComponent
+ private Span completedStepsLabel;
+
+ @ViewComponent
+ private Span overdueStepsLabel;
+
+ @ViewComponent
+ private Span totalStepsLabel;
+
+ @ViewComponent
+ private DataContext dataContext;
+
+ @Supply(to = "userStepsDataGrid.completed", subject = "renderer")
+ private Renderer userStepsDataGridCompletedRenderer() {
+ return new ComponentRenderer<>(userStep -> {
+ Checkbox checkbox = uiComponents.create(Checkbox.class);
+ checkbox.setValue(userStep.getCompletedDate() != null);
+ checkbox.addValueChangeListener(e -> {
+ if (userStep.getCompletedDate() == null) {
+ userStep.setCompletedDate(LocalDate.now());
+ } else {
+ userStep.setCompletedDate(null);
+ }
+ });
+ return checkbox;
+ });
+ }
+
+ private void updateLabels() {
+ totalStepsLabel.setText("Total steps: " + userStepsDc.getItems().size());
+
+ long completedCount = userStepsDc.getItems().stream()
+ .filter(us -> us.getCompletedDate() != null)
+ .count();
+ completedStepsLabel.setText("Completed steps: " + completedCount);
+
+ long overdueCount = userStepsDc.getItems().stream()
+ .filter(us -> isOverdue(us))
+ .count();
+ overdueStepsLabel.setText("Overdue steps: " + overdueCount);
+ }
+
+ private boolean isOverdue(UserStep us) {
+ return us.getCompletedDate() == null
+ && us.getDueDate() != null
+ && us.getDueDate().isBefore(LocalDate.now());
+ }
+
+ @Subscribe
+ public void onBeforeShow(final BeforeShowEvent event) {
+ final User user = (User) currentAuthentication.getUser();
+ userStepsDl.setParameter("user", user);
+ userStepsDl.load();
+
+ updateLabels();
+ }
+
+ @Subscribe(id = "userStepsDc", target = Target.DATA_CONTAINER)
+ public void onUserStepsDcItemPropertyChange(final InstanceContainer.ItemPropertyChangeEvent event) {
+ updateLabels();
+ }
+
+ @Subscribe("saveButton")
+ public void onSaveButtonClick(final ClickEvent event) {
+ dataContext.save();
+ close(StandardOutcome.SAVE);
+ }
+
+ @Subscribe("discardButton")
+ public void onDiscardButtonClick(final ClickEvent event) {
+ close(StandardOutcome.DISCARD);
+ }
+
+ @Install(to = "userStepsDataGrid.dueDate", subject = "partNameGenerator")
+ private String userStepsDataGridDueDatePartNameGenerator(final UserStep userStep) {
+ return isOverdue(userStep) ? "overdue-step" : null;
+ }
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/step/StepDetailView.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/step/StepDetailView.java
new file mode 100644
index 00000000..febdfb7e
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/step/StepDetailView.java
@@ -0,0 +1,15 @@
+package com.company.onboarding.view.step;
+
+import com.company.onboarding.entity.Step;
+
+import com.company.onboarding.view.main.MainView;
+
+import com.vaadin.flow.router.Route;
+import io.jmix.flowui.view.*;
+
+@Route(value = "steps/:id", layout = MainView.class)
+@ViewController("Step.detail")
+@ViewDescriptor("step-detail-view.xml")
+@EditedEntityContainer("stepDc")
+public class StepDetailView extends StandardDetailView {
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/step/StepListView.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/step/StepListView.java
new file mode 100644
index 00000000..5b8ad952
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/step/StepListView.java
@@ -0,0 +1,16 @@
+package com.company.onboarding.view.step;
+
+import com.company.onboarding.entity.Step;
+
+import com.company.onboarding.view.main.MainView;
+
+import com.vaadin.flow.router.Route;
+import io.jmix.flowui.view.*;
+
+@Route(value = "steps", layout = MainView.class)
+@ViewController("Step.list")
+@ViewDescriptor("step-list-view.xml")
+@LookupComponent("stepsDataGrid")
+@DialogMode(width = "50em", height = "37.5em")
+public class StepListView extends StandardListView {
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/user/UserDetailView.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/user/UserDetailView.java
new file mode 100644
index 00000000..cc3c6537
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/user/UserDetailView.java
@@ -0,0 +1,174 @@
+package com.company.onboarding.view.user;
+
+import com.company.onboarding.entity.OnboardingStatus;
+import com.company.onboarding.entity.Step;
+import com.company.onboarding.entity.User;
+import com.company.onboarding.entity.UserStep;
+import com.company.onboarding.view.main.MainView;
+import com.vaadin.flow.component.ClickEvent;
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.checkbox.Checkbox;
+import com.vaadin.flow.component.combobox.ComboBox;
+import com.vaadin.flow.component.textfield.PasswordField;
+import com.vaadin.flow.data.renderer.ComponentRenderer;
+import com.vaadin.flow.data.renderer.Renderer;
+import com.vaadin.flow.router.Route;
+import io.jmix.core.DataManager;
+import io.jmix.core.EntityStates;
+import io.jmix.flowui.Notifications;
+import io.jmix.flowui.UiComponents;
+import io.jmix.flowui.component.textfield.TypedTextField;
+import io.jmix.flowui.model.CollectionContainer;
+import io.jmix.flowui.model.CollectionPropertyContainer;
+import io.jmix.flowui.model.DataContext;
+import io.jmix.flowui.model.InstanceContainer;
+import io.jmix.flowui.view.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Objects;
+import java.util.TimeZone;
+
+@Route(value = "users/:id", layout = MainView.class)
+@ViewController("User.detail")
+@ViewDescriptor("user-detail-view.xml")
+@EditedEntityContainer("userDc")
+public class UserDetailView extends StandardDetailView {
+ @Autowired
+ private DataManager dataManager;
+
+ @Autowired
+ private Notifications notifications;
+
+ @ViewComponent
+ private DataContext dataContext;
+
+ @ViewComponent
+ private CollectionPropertyContainer stepsDc;
+
+ @ViewComponent
+ private TypedTextField usernameField;
+ @ViewComponent
+ private PasswordField passwordField;
+ @ViewComponent
+ private PasswordField confirmPasswordField;
+ @ViewComponent
+ private ComboBox timeZoneField;
+
+ @Autowired
+ private EntityStates entityStates;
+ @ViewComponent
+ private MessageBundle messageBundle;
+ @Autowired
+ private PasswordEncoder passwordEncoder;
+ @Autowired
+ private UiComponents uiComponents;
+
+ @Subscribe
+ public void onInit(final InitEvent event) {
+ timeZoneField.setItems(List.of(TimeZone.getAvailableIDs()));
+ }
+
+ @Subscribe
+ public void onInitEntity(final InitEntityEvent event) {
+ usernameField.setReadOnly(false);
+ passwordField.setVisible(true);
+ confirmPasswordField.setVisible(true);
+
+ User user = event.getEntity();
+ user.setOnboardingStatus(OnboardingStatus.NOT_STARTED);
+ }
+
+ @Subscribe
+ public void onReady(final ReadyEvent event) {
+ if (entityStates.isNew(getEditedEntity())) {
+ usernameField.focus();
+ }
+ }
+
+ @Subscribe
+ public void onValidation(final ValidationEvent event) {
+ if (entityStates.isNew(getEditedEntity())
+ && !Objects.equals(passwordField.getValue(), confirmPasswordField.getValue())) {
+ event.getErrors().add(messageBundle.getMessage("passwordsDoNotMatch"));
+ }
+ }
+
+ @Subscribe
+ protected void onBeforeSave(final BeforeSaveEvent event) {
+ if (entityStates.isNew(getEditedEntity())) {
+ getEditedEntity().setPassword(passwordEncoder.encode(passwordField.getValue()));
+ }
+ }
+
+ @Subscribe("generateButton")
+ public void onGenerateButtonClick(final ClickEvent event) {
+ User user = getEditedEntity();
+
+ if (user.getJoiningDate() == null) {
+ notifications.create("Cannot generate steps for user without 'Joining date'")
+ .show();
+ return;
+ }
+
+ List steps = dataManager.load(Step.class)
+ .query("select s from Step s order by s.sortValue asc")
+ .list();
+
+ for (Step step : steps) {
+ if (stepsDc.getItems().stream().noneMatch(userStep ->
+ userStep.getStep().equals(step))) {
+ UserStep userStep = dataContext.create(UserStep.class);
+ userStep.setUser(user);
+ userStep.setStep(step);
+ userStep.setDueDate(user.getJoiningDate().plusDays(step.getDuration()));
+ userStep.setSortValue(step.getSortValue());
+ stepsDc.getMutableItems().add(userStep);
+ }
+ }
+ }
+
+ @Supply(to = "stepsDataGrid.completed", subject = "renderer")
+ private Renderer stepsDataGridCompletedRenderer() {
+ return new ComponentRenderer<>(userStep -> {
+ Checkbox checkbox = uiComponents.create(Checkbox.class);
+ checkbox.setValue(userStep.getCompletedDate() != null);
+ checkbox.addValueChangeListener(e -> {
+ if (userStep.getCompletedDate() == null) {
+ userStep.setCompletedDate(LocalDate.now());
+ } else {
+ userStep.setCompletedDate(null);
+ }
+ });
+ return checkbox;
+ });
+ }
+
+ @Subscribe(id = "stepsDc", target = Target.DATA_CONTAINER)
+ public void onStepsDcCollectionChange(final CollectionContainer.CollectionChangeEvent event) {
+ updateOnboardingStatus();
+ }
+
+ @Subscribe(id = "stepsDc", target = Target.DATA_CONTAINER)
+ public void onStepsDcItemPropertyChange(final InstanceContainer.ItemPropertyChangeEvent event) {
+ updateOnboardingStatus();
+ }
+
+ private void updateOnboardingStatus() {
+ User user = getEditedEntity();
+
+ long completedCount = user.getSteps() == null ? 0 :
+ user.getSteps().stream()
+ .filter(us -> us.getCompletedDate() != null)
+ .count();
+ if (completedCount == 0) {
+ user.setOnboardingStatus(OnboardingStatus.NOT_STARTED);
+ } else if (completedCount == user.getSteps().size()) {
+ user.setOnboardingStatus(OnboardingStatus.COMPLETED);
+ } else {
+ user.setOnboardingStatus(OnboardingStatus.IN_PROGRESS);
+ }
+ }
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/user/UserListView.java b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/user/UserListView.java
new file mode 100644
index 00000000..ab5034bc
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/java/com/company/onboarding/view/user/UserListView.java
@@ -0,0 +1,55 @@
+package com.company.onboarding.view.user;
+
+import com.company.onboarding.entity.User;
+import com.company.onboarding.view.main.MainView;
+import com.vaadin.flow.component.html.Image;
+import com.vaadin.flow.data.renderer.ComponentRenderer;
+import com.vaadin.flow.data.renderer.Renderer;
+import com.vaadin.flow.router.Route;
+import com.vaadin.flow.server.streams.DownloadHandler;
+import com.vaadin.flow.server.streams.DownloadResponse;
+import com.vaadin.flow.server.streams.InputStreamDownloadHandler;
+import io.jmix.core.FileRef;
+import io.jmix.core.FileStorage;
+import io.jmix.flowui.UiComponents;
+import io.jmix.flowui.view.*;
+import org.springframework.beans.factory.annotation.Autowired;
+
+@Route(value = "users", layout = MainView.class)
+@ViewController("User.list")
+@ViewDescriptor("user-list-view.xml")
+@LookupComponent("usersDataGrid")
+@DialogMode(width = "50em", height = "37.5em")
+public class UserListView extends StandardListView {
+
+ @Autowired
+ private UiComponents uiComponents;
+
+ @Autowired
+ private FileStorage fileStorage;
+
+ @Supply(to = "usersDataGrid.picture", subject = "renderer")
+ private Renderer usersDataGridPictureRenderer() {
+ return new ComponentRenderer<>(user -> {
+ FileRef fileRef = user.getPicture();
+ if (fileRef != null) {
+ Image image = uiComponents.create(Image.class);
+ image.setWidth("30px");
+ image.setHeight("30px");
+ InputStreamDownloadHandler handler = DownloadHandler.fromInputStream(event ->
+ new DownloadResponse(
+ fileStorage.openStream(fileRef),
+ fileRef.getFileName(),
+ fileRef.getContentType(),
+ -1
+ ));
+ image.setSrc(handler);
+ image.setClassName("user-picture");
+
+ return image;
+ } else {
+ return null;
+ }
+ });
+ }
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/icons/icon.png b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/icons/icon.png
new file mode 100644
index 00000000..678fa53e
Binary files /dev/null and b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/icons/icon.png differ
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/public/images/logo.png b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/public/images/logo.png
new file mode 100644
index 00000000..678fa53e
Binary files /dev/null and b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/public/images/logo.png differ
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-aura/onboarding.css b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-aura/onboarding.css
new file mode 100644
index 00000000..dadaed59
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-aura/onboarding.css
@@ -0,0 +1,9 @@
+/* Define your styles here */
+
+.user-picture {
+ object-fit: contain;
+}
+
+vaadin-grid.onboarding-steps::part(overdue-step) {
+ color: red;
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-aura/styles.css b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-aura/styles.css
new file mode 100644
index 00000000..d065e2b0
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-aura/styles.css
@@ -0,0 +1,4 @@
+@import url('view/main-view.css');
+@import url('view/main-view-top-menu.css');
+@import url('view/login-view.css');
+@import url('onboarding.css');
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-aura/view/login-view.css b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-aura/view/login-view.css
new file mode 100644
index 00000000..cbab2bef
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-aura/view/login-view.css
@@ -0,0 +1,9 @@
+/* Predefined styles for the LoginView */
+
+.jmix-login-main-layout jmix-login-form::part(form) {
+ --vaadin-login-form-border-radius: var(--vaadin-radius-l);
+ --vaadin-login-form-background: var(--aura-surface-color);
+
+ box-shadow: var(--aura-shadow-s);
+ border: 1px solid var(--vaadin-border-color);
+}
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-aura/view/main-view-top-menu.css b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-aura/view/main-view-top-menu.css
new file mode 100644
index 00000000..99083773
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-aura/view/main-view-top-menu.css
@@ -0,0 +1,57 @@
+/* Predefined styles for the MainView with a HorizontalMenu */
+
+.jmix-main-view-top-menu-app-layout {
+ --vaadin-app-layout-navbar-padding-bottom: 0px;
+}
+
+vaadin-app-layout.jmix-main-view-top-menu-app-layout::part(navbar) {
+ min-height: 0;
+ border-bottom: 0;
+}
+
+.jmix-main-view-top-menu-navigation-bar-box {
+ padding: 0;
+ width: 100%;
+}
+
+.jmix-main-view-top-menu-view-header-box {
+ width: 100%;
+ border-top: 1px solid var(--vaadin-border-color-secondary)
+}
+
+.jmix-main-view-top-menu-navigation {
+ display: flex;
+ flex-grow: 1;
+ overflow: auto;
+}
+
+.jmix-main-view-top-menu-header {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+ gap: var(--vaadin-gap-m);
+}
+
+.jmix-main-view-top-menu-logo-container {
+ display: flex;
+ margin: 0 var(--vaadin-padding-m);
+}
+
+.jmix-main-view-top-menu-logo {
+ --logo-size: round(var(--aura-base-size) * 2.5 * 1px, 1px);
+ width: var(--logo-size);
+ height: var(--logo-size);
+}
+
+.jmix-main-view-top-menu-user-box {
+ align-self: flex-end;
+ align-items: center;
+ margin: 0 var(--vaadin-padding-m);
+ max-width: 20em;
+}
+
+.jmix-main-view-top-menu-view-title {
+ font-size: var(--aura-font-size-xl);
+ margin: var(--vaadin-padding-s) var(--vaadin-padding-m);
+}
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-aura/view/main-view.css b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-aura/view/main-view.css
new file mode 100644
index 00000000..a41eece1
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-aura/view/main-view.css
@@ -0,0 +1,274 @@
+/* Predefined styles for the MainView */
+
+:where(:root),
+:where(:host) {
+ --vaadin-app-layout-drawer-width: 256px;
+}
+
+vaadin-app-layout.jmix-main-view-app-layout:not([primary-section='navbar']) {
+ --vaadin-app-layout-navbar-padding-top: var(--aura-app-layout-inset);
+ --vaadin-app-layout-navbar-padding-inline-start: var(--aura-app-layout-inset);
+ --vaadin-app-layout-navbar-padding-inline-end: var(--aura-app-layout-inset);
+ --vaadin-app-layout-navbar-padding-bottom: 0px;
+
+ transition: padding-bottom var(--vaadin-app-layout-transition-duration),
+ padding-inline-start var(--vaadin-app-layout-transition-duration),
+ padding-inline-end var(--vaadin-app-layout-transition-duration);
+}
+
+vaadin-app-layout.jmix-main-view-app-layout[drawer-opened]:not([primary-section='navbar']) {
+ --vaadin-app-layout-navbar-padding-inline-start: 0px;
+}
+
+vaadin-app-layout.jmix-main-view-app-layout:not([primary-section='navbar'])::part(navbar) {
+ transition: inset-inline-start var(--vaadin-app-layout-transition-duration),
+ padding var(--vaadin-app-layout-transition-duration);
+}
+
+vaadin-app-layout.jmix-main-view-app-layout[has-navbar]:not([primary-section='navbar']) > :nth-child(1 of :not([slot])):nth-last-child(1 of :not([slot])) {
+ border-top-width: 0;
+ border-start-start-radius: 0;
+ border-start-end-radius: 0;
+}
+
+vaadin-app-layout.jmix-main-view-app-layout[has-navbar][has-drawer][drawer-opened]:not([overlay]):not([primary-section='navbar']) > :nth-child(1 of :not([slot])):nth-last-child(1 of :not([slot])) {
+ border-start-start-radius: 0;
+}
+
+vaadin-app-layout.jmix-main-view-app-layout:not([primary-section='navbar']) .jmix-main-view-header {
+ background: linear-gradient(var(--aura-surface-color), var(--aura-surface-color)), var(--aura-app-background);
+ background-clip: padding-box;
+ background-origin: border-box;
+
+ border: var(--aura-app-layout-border-width) solid var(--vaadin-border-color-secondary);
+ border-top-width: min(var(--aura-app-layout-inset), var(--aura-app-layout-border-width));
+ border-inline-width: min(var(--aura-app-layout-inset), var(--aura-app-layout-border-width));
+ border-bottom-width: 1px;
+ border-start-start-radius: var(--_app-layout-radius);
+ border-start-end-radius: var(--_app-layout-radius);
+
+ padding: var(--vaadin-padding-s) var(--vaadin-padding-m);
+}
+
+vaadin-app-layout.jmix-main-view-app-layout:not([primary-section='navbar'])::part(navbar-bottom) {
+ border-top: 1px solid var(--vaadin-border-color-secondary);
+}
+
+vaadin-app-layout.jmix-main-view-app-layout[has-drawer][drawer-opened]:not([overlay]):not([primary-section='navbar']) > .jmix-main-view-header {
+ border-inline-start-width: var(--aura-app-layout-border-width);
+}
+
+.jmix-main-view-header {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+ gap: var(--vaadin-gap-m);
+}
+
+.jmix-main-view-title {
+ font-size: var(--aura-font-size-xl);
+}
+
+.jmix-main-view-section {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ max-height: 100%;
+ min-height: 100%;
+}
+
+.jmix-main-view-application-title {
+ display: flex;
+ align-items: center;
+ margin: 0;
+ padding: var(--vaadin-padding-m);
+ font-size: var(--aura-font-size-l);
+}
+
+.jmix-main-view-application-title-base-link {
+ color: var(--vaadin-text-color);
+ text-decoration: none;
+}
+
+.jmix-main-view-navigation {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ overflow: auto;
+}
+
+.jmix-main-view-footer {
+ display: flex;
+ align-items: center;
+ gap: var(--vaadin-gap-m);
+ padding: var(--vaadin-padding-m);
+ padding-bottom: max(0px, var(--aura-app-layout-inset) - var(--vaadin-padding-m));
+ border-top: 1px solid var(--vaadin-border-color-secondary);
+}
+
+.jmix-main-view-footer .jmix-user-indicator {
+ flex-grow: 1;
+}
+
+/* User Menu */
+
+.jmix-main-view-user-menu {
+ margin-inline-start: auto;
+}
+
+.user-menu-button-content,
+.user-menu-header-content {
+ display: grid;
+ grid-template: "avatar text"
+ "avatar subtext";
+ grid-template-columns: auto 1fr;
+ column-gap: var(--vaadin-gap-s);
+
+ width: max-content;
+ box-sizing: border-box;
+
+ color: var(--vaadin-text-color);
+ padding: var(--vaadin-padding-xs) var(--vaadin-padding-s);
+}
+
+.user-menu-header-content {
+ width: 100%;
+ padding-inline-end: var(--vaadin-padding-l);
+}
+
+.user-menu-button-content > .user-menu-avatar,
+.user-menu-header-content > .user-menu-avatar {
+ grid-area: avatar;
+ align-self: center;
+}
+
+.user-menu-button-content > .user-menu-text {
+ grid-row: text / subtext;
+}
+
+.jmix-main-view-user-menu[theme~='substituted'] .user-menu-button-content > .user-menu-text {
+ grid-row: text;
+}
+
+.user-menu-header-content > .user-menu-text {
+ grid-area: text;
+
+ color: var(--vaadin-text-color);
+ font-weight: 700;
+ font-size: var(--aura-font-size-m);
+}
+
+.user-menu-header-content > .user-menu-text-subtext {
+ grid-row: text / subtext;
+}
+
+.user-menu-button-content > .user-menu-text,
+.user-menu-header-content > .user-menu-text {
+ align-self: center;
+ text-align: start;
+
+ width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.user-menu-button-content > .user-menu-subtext,
+.user-menu-header-content > .user-menu-subtext {
+ grid-area: subtext;
+ align-self: center;
+ text-align: start;
+
+ color: var(--vaadin-text-color-secondary);
+ font-size: var(--aura-font-size-xs);
+
+ width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.jmix-main-view-user-menu:not([theme~='substituted']) .user-menu-button-content > .user-menu-subtext {
+ display: none;
+}
+
+/* Initial Layout */
+
+.jmix-initial-layout {
+ --title-size: round(var(--aura-font-size-m) * 2.5, 0.0625rem);
+ --title-color: var(--vaadin-text-color-secondary);
+
+ width: 100%;
+ height: 100%;
+
+ align-items: center;
+ justify-content: center;
+
+ container-type: inline-size;
+ container-name: jmix-initial-layout;
+}
+
+.jmix-initial-layout-content {
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+ max-width: 50rem;
+ padding: var(--vaadin-padding-xl);
+ box-sizing: border-box;
+}
+
+.jmix-initial-layout-title {
+ position: relative;
+
+ color: var(--title-color);
+ font-size: var(--title-size);
+ line-height: calc(4 * var(--title-size));
+ box-sizing: border-box;
+}
+
+.jmix-initial-layout-title:after {
+ position: absolute;
+ width: 100%;
+ height: 0.3rem;
+ content: '';
+ background: var(--title-color);
+ top: 0;
+ left: 0;
+}
+
+.jmix-initial-layout-logo {
+ --logo-size: calc(2.5 * var(--title-size));
+ width: var(--logo-size);
+ height: var(--logo-size);
+}
+
+@container jmix-initial-layout (max-width: 45rem) {
+ .jmix-initial-layout-content {
+ flex-direction: column-reverse;
+ align-items: center;
+ gap: var(--vaadin-gap-l);
+ }
+
+ .jmix-initial-layout-title {
+ padding-top: var(--vaadin-padding-m);
+ line-height: var(--aura-line-height-m);
+ text-align: center;
+ }
+}
+
+/* MenuFilterField component */
+
+.jmix-main-view-navigation > .jmix-menu-filter-field {
+ margin: var(--vaadin-padding-s) var(--vaadin-padding-m);
+}
+
+/* Search add-on */
+
+.jmix-main-view-navigation > .jmix-search-field {
+ display: flex;
+ margin: auto var(--vaadin-padding-m) var(--vaadin-padding-s) var(--vaadin-padding-m);
+}
+
+.jmix-main-view-header > .jmix-search-field {
+ margin-inline-start: auto;
+ margin-inline-end: var(--vaadin-padding-m);
+}
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-lumo/onboarding.css b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-lumo/onboarding.css
new file mode 100644
index 00000000..dadaed59
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-lumo/onboarding.css
@@ -0,0 +1,9 @@
+/* Define your styles here */
+
+.user-picture {
+ object-fit: contain;
+}
+
+vaadin-grid.onboarding-steps::part(overdue-step) {
+ color: red;
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-lumo/styles.css b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-lumo/styles.css
new file mode 100644
index 00000000..d065e2b0
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-lumo/styles.css
@@ -0,0 +1,4 @@
+@import url('view/main-view.css');
+@import url('view/main-view-top-menu.css');
+@import url('view/login-view.css');
+@import url('onboarding.css');
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-lumo/view/login-view.css b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-lumo/view/login-view.css
new file mode 100644
index 00000000..c04446c2
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-lumo/view/login-view.css
@@ -0,0 +1,5 @@
+/* Predefined styles for the LoginView */
+
+.jmix-login-main-layout {
+ background-color: var(--lumo-shade-5pct);
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-lumo/view/main-view-top-menu.css b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-lumo/view/main-view-top-menu.css
new file mode 100644
index 00000000..766cc3d2
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-lumo/view/main-view-top-menu.css
@@ -0,0 +1,54 @@
+/* Predefined styles for the MainView with a HorizontalMenu */
+
+vaadin-app-layout.jmix-main-view-top-menu-app-layout::part(navbar) {
+ min-height: 0;
+ border-bottom: 0;
+}
+
+.jmix-main-view-top-menu-navigation-bar-box {
+ padding: 0;
+ gap: 0;
+ width: 100%;
+}
+
+.jmix-main-view-top-menu-navigation {
+ display: flex;
+ flex-grow: 1;
+ overflow: auto;
+}
+
+.jmix-main-view-top-menu-header {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ border-bottom: 1px solid var(--lumo-contrast-10pct)
+}
+
+.jmix-main-view-top-menu-logo-container {
+ display: flex;
+ margin: 0 var(--lumo-space-m);
+}
+
+.jmix-main-view-top-menu-logo {
+ width: var(--lumo-size-m);
+ height: var(--lumo-size-m);
+}
+
+.jmix-main-view-top-menu-user-box {
+ align-self: flex-end;
+ align-items: center;
+ margin: 0 var(--lumo-space-m);
+ max-width: 20em;
+}
+
+.jmix-main-view-top-menu-view-header-box {
+ border-bottom: 1px solid var(--lumo-contrast-10pct);
+ padding: 0;
+ width: 100%;
+}
+
+.jmix-main-view-top-menu-view-title {
+ font-size: var(--lumo-font-size-l);
+ margin: var(--lumo-space-s) var(--lumo-space-m);
+}
+
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-lumo/view/main-view.css b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-lumo/view/main-view.css
new file mode 100644
index 00000000..994c1e25
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/META-INF/resources/themes/onboarding-lumo/view/main-view.css
@@ -0,0 +1,233 @@
+/* Predefined styles for the MainView */
+
+.jmix-main-view-header {
+ box-sizing: border-box;
+ display: flex;
+ height: var(--lumo-size-xl);
+ align-items: center;
+ width: 100%;
+}
+
+.jmix-main-view-drawer-toggle {
+ color: var(--lumo-secondary-text-color);
+}
+
+.jmix-main-view-title {
+ margin: 0;
+ font-size: var(--lumo-font-size-l);
+}
+
+.jmix-main-view-section {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ max-height: 100%;
+ min-height: 100%;
+}
+
+.jmix-main-view-application-title {
+ display: flex;
+ align-items: center;
+ height: var(--lumo-size-xl);
+ margin: 0;
+ padding-left: var(--lumo-space-m);
+ padding-right: var(--lumo-space-m);
+ font-size: var(--lumo-font-size-m);
+}
+
+.jmix-main-view-application-title-base-link {
+ color: var(--lumo-header-text-color);
+}
+
+.jmix-main-view-application-title-base-link:hover {
+ text-decoration: none;
+}
+
+.jmix-main-view-navigation {
+ display: flex;
+ flex-direction: column;
+ border-bottom: 1px solid;
+ border-color: var(--lumo-contrast-10pct);
+ flex-grow: 1;
+ overflow: auto;
+}
+
+.jmix-main-view-footer {
+ display: flex;
+ align-items: center;
+ margin-bottom: var(--lumo-space-s);
+ margin-top: var(--lumo-space-s);
+ padding: var(--lumo-space-xs) var(--lumo-space-m);
+ gap: var(--lumo-space-m);
+}
+
+.jmix-main-view-footer .jmix-user-indicator {
+ flex-grow: 1;
+}
+
+/* User Menu */
+
+.jmix-main-view-user-menu {
+ margin-inline-start: auto;
+ margin-inline-end: var(--lumo-space-m);
+}
+
+.jmix-main-view-footer :is(.jmix-user-menu-button-content, .user-menu-button-content) {
+ width: calc(var(--vaadin-app-layout-drawer-width, 16em) - var(--lumo-space-m) * 2);
+}
+
+.user-menu-button-content,
+.user-menu-header-content {
+ display: grid;
+ grid-template: "avatar text"
+ "avatar subtext";
+ grid-template-columns: auto 1fr;
+ column-gap: var(--lumo-space-s);
+
+ width: max-content;
+ box-sizing: border-box;
+
+ color: var(--lumo-body-text-color);
+ padding: var(--lumo-space-xs) var(--lumo-space-s);
+}
+
+.user-menu-header-content {
+ width: 100%;
+ padding-inline-end: var(--lumo-space-l);
+}
+
+.user-menu-button-content > .user-menu-avatar,
+.user-menu-header-content > .user-menu-avatar {
+ grid-area: avatar;
+ align-self: center;
+}
+
+.user-menu-button-content > .user-menu-text {
+ grid-row: text / subtext;
+}
+
+.jmix-main-view-user-menu[theme~='substituted'] .user-menu-button-content > .user-menu-text {
+ grid-row: text;
+}
+
+.user-menu-header-content > .user-menu-text {
+ grid-area: text;
+
+ color: var(--lumo-body-text-color);
+ font-weight: 700;
+ font-size: var(--lumo-font-size-m);
+}
+
+.user-menu-header-content > .user-menu-text-subtext {
+ grid-row: text / subtext;
+}
+
+.user-menu-button-content > .user-menu-text,
+.user-menu-header-content > .user-menu-text {
+ align-self: center;
+ text-align: start;
+
+ width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.user-menu-button-content > .user-menu-subtext,
+.user-menu-header-content > .user-menu-subtext {
+ grid-area: subtext;
+ align-self: center;
+ text-align: start;
+
+ color: var(--lumo-secondary-text-color);
+ font-size: var(--lumo-font-size-xs);
+
+ width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.jmix-main-view-user-menu:not([theme~='substituted']) .user-menu-button-content > .user-menu-subtext {
+ display: none;
+}
+
+/* Initial Layout */
+
+.jmix-initial-layout {
+ --title-size: var(--lumo-font-size-xxxl);
+ --title-color: var(--lumo-secondary-text-color);
+
+ width: 100%;
+ height: 100%;
+
+ align-items: center;
+ justify-content: center;
+
+ container-type: inline-size;
+ container-name: jmix-initial-layout;
+}
+
+.jmix-initial-layout-content {
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+ max-width: 50rem;
+ padding: var(--lumo-space-xl);
+ box-sizing: border-box;
+}
+
+.jmix-initial-layout-title {
+ position: relative;
+
+ color: var(--title-color);
+ font-size: var(--title-size);
+ line-height: calc(4 * var(--title-size));
+ box-sizing: border-box;
+}
+
+.jmix-initial-layout-title:after {
+ position: absolute;
+ width: 100%;
+ height: 0.3rem;
+ content: '';
+ background: var(--title-color);
+ top: 0;
+ left: 0;
+}
+
+.jmix-initial-layout-logo {
+ --logo-size: calc(2.5 * var(--title-size));
+ width: var(--logo-size);
+ height: var(--logo-size);
+}
+
+@container jmix-initial-layout (max-width: 45rem) {
+ .jmix-initial-layout-content {
+ flex-direction: column-reverse;
+ align-items: center;
+ gap: var(--lumo-space-l);
+ }
+
+ .jmix-initial-layout-title {
+ padding-top: var(--lumo-space-m);
+ line-height: var(--lumo-line-height-m);
+ text-align: center;
+ }
+}
+
+/* MenuFilterField component */
+
+.jmix-main-view-navigation > .jmix-menu-filter-field {
+ margin: var(--lumo-space-s) var(--lumo-space-m);
+}
+
+/* Search add-on */
+
+.jmix-main-view-navigation > .jmix-search-field {
+ display: flex;
+ margin: auto var(--lumo-space-m) var(--lumo-space-s) var(--lumo-space-m);
+}
+
+.jmix-main-view-header > .jmix-search-field {
+ margin-inline-start: auto;
+ margin-inline-end: var(--lumo-space-m);
+}
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/application.properties b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/application.properties
new file mode 100644
index 00000000..b4d7a70b
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/application.properties
@@ -0,0 +1,43 @@
+main.datasource.url = jdbc:hsqldb:file:.jmix/hsqldb/fc-onboarding
+main.datasource.username = sa
+main.datasource.password =
+
+main.liquibase.change-log=com/company/onboarding/liquibase/changelog.xml
+
+jmix.ui.login-view-id = LoginView
+jmix.ui.main-view-id = MainView
+jmix.ui.menu-config = com/company/onboarding/menu.xml
+jmix.ui.composite-menu = true
+
+ui.login.defaultUsername = admin
+ui.login.defaultPassword = admin
+
+jmix.core.available-locales = en
+
+jmix.ui.component.filter-show-non-jpa-properties=false
+# Launch the default browser when starting the application in development mode
+vaadin.launch-browser = false
+
+# Use pnpm to speed up project initialization and save disk space
+vaadin.pnpm.enable = true
+
+logging.level.org.atmosphere = warn
+
+# 'debug' level logs SQL generated by EclipseLink ORM
+logging.level.eclipselink.logging.sql = info
+
+# 'debug' level logs data store operations
+logging.level.io.jmix.core.datastore = info
+
+# 'debug' level logs access control constraints
+logging.level.io.jmix.core.AccessLogger = debug
+
+# 'debug' level logs all Jmix debug output
+logging.level.io.jmix = info
+
+# tag::data-load[]
+# Hide the User entity from the AI
+jmix.aitools.dataload.exclude-entities[0]=User
+# Default number of rows returned when a generated query does not specify one
+jmix.aitools.dataload.jpql-execution-max-result=50
+# end::data-load[]
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/alice.png b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/alice.png
new file mode 100644
index 00000000..29cee425
Binary files /dev/null and b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/alice.png differ
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/bob.png b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/bob.png
new file mode 100644
index 00000000..0c7a9c7b
Binary files /dev/null and b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/bob.png differ
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/james.png b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/james.png
new file mode 100644
index 00000000..ec0cddd2
Binary files /dev/null and b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/james.png differ
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/linda.png b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/linda.png
new file mode 100644
index 00000000..71a7d0d0
Binary files /dev/null and b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/linda.png differ
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/mary.png b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/mary.png
new file mode 100644
index 00000000..508583e6
Binary files /dev/null and b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/mary.png differ
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/susan.png b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/susan.png
new file mode 100644
index 00000000..7a89fd00
Binary files /dev/null and b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/demo/susan.png differ
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog.xml
new file mode 100644
index 00000000..56a5210e
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/010-init-user.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/010-init-user.xml
new file mode 100644
index 00000000..a310bb3e
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/010-init-user.xml
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/14-170208-7c1e52a6.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/14-170208-7c1e52a6.xml
new file mode 100644
index 00000000..40165576
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/14-170208-7c1e52a6.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/14-190902-7c1e52a6.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/14-190902-7c1e52a6.xml
new file mode 100644
index 00000000..a784f0e2
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/14-190902-7c1e52a6.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/15-130310-7c1e52a6.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/15-130310-7c1e52a6.xml
new file mode 100644
index 00000000..e1b9a9a4
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/15-130310-7c1e52a6.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/15-140050-8f351c46.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/15-140050-8f351c46.xml
new file mode 100644
index 00000000..c4d9584e
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/15-140050-8f351c46.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/15-150901-8f351c46.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/15-150901-8f351c46.xml
new file mode 100644
index 00000000..44cc13a5
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/15-150901-8f351c46.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/15-173101-8f351c46.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/15-173101-8f351c46.xml
new file mode 100644
index 00000000..c889086f
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/liquibase/changelog/2023/06/15-173101-8f351c46.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/menu.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/menu.xml
new file mode 100644
index 00000000..79370ca3
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/menu.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/messages_en.properties b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/messages_en.properties
new file mode 100644
index 00000000..9e22b437
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/messages_en.properties
@@ -0,0 +1,78 @@
+databaseUniqueConstraintViolation.IDX_USER__ON_USERNAME=A user with the same username already exists
+
+com.company.onboarding.entity/Department=Department
+com.company.onboarding.entity/Department.hrManager=HR manager
+com.company.onboarding.entity/Department.id=Id
+com.company.onboarding.entity/Department.name=Name
+com.company.onboarding.entity/Department.version=Version
+com.company.onboarding.entity/OnboardingStatus=Onboarding status
+com.company.onboarding.entity/OnboardingStatus.COMPLETED=Completed
+com.company.onboarding.entity/OnboardingStatus.IN_PROGRESS=In progress
+com.company.onboarding.entity/OnboardingStatus.NOT_STARTED=Not started
+com.company.onboarding.entity/Step=Step
+com.company.onboarding.entity/Step.duration=Duration
+com.company.onboarding.entity/Step.id=Id
+com.company.onboarding.entity/Step.name=Name
+com.company.onboarding.entity/Step.sortValue=Sort value
+com.company.onboarding.entity/Step.version=Version
+com.company.onboarding.entity/User=User
+com.company.onboarding.entity/User.id=ID
+com.company.onboarding.entity/User.joiningDate=Joining date
+com.company.onboarding.entity/User.username=Username
+com.company.onboarding.entity/User.firstName=First name
+com.company.onboarding.entity/User.lastName=Last name
+com.company.onboarding.entity/User.onboardingStatus=Onboarding status
+com.company.onboarding.entity/User.password=Password
+com.company.onboarding.entity/User.picture=Picture
+com.company.onboarding.entity/User.steps=Steps
+com.company.onboarding.entity/User.email=Email
+com.company.onboarding.entity/User.timeZoneId=Time zone
+com.company.onboarding.entity/User.active=Active
+com.company.onboarding.entity/User.department=Department
+com.company.onboarding.entity/User.version=Version
+com.company.onboarding.entity/UserStep=User step
+com.company.onboarding.entity/UserStep.completedDate=Completed date
+com.company.onboarding.entity/UserStep.dueDate=Due date
+com.company.onboarding.entity/UserStep.id=Id
+com.company.onboarding.entity/UserStep.sortValue=Sort value
+com.company.onboarding.entity/UserStep.step=Step
+com.company.onboarding.entity/UserStep.user=User
+com.company.onboarding.entity/UserStep.version=Version
+
+com.company.onboarding.view.main/MainView.title=Onboarding
+com.company.onboarding.view.main/applicationTitle.text=Onboarding
+com.company.onboarding.view.main/navigation.ariaLabel=Views
+com.company.onboarding.view.main/drawerToggle.ariaLabel=Menu toggle
+
+com.company.onboarding.view.department/departmentDetailView.title=Department
+com.company.onboarding.view.department/departmentListView.title=Departments
+
+com.company.onboarding.view.login/LoginView.title=Login - Onboarding
+com.company.onboarding.view.login/loginForm.headerTitle=Onboarding
+com.company.onboarding.view.login/loginForm.username=Username
+com.company.onboarding.view.login/loginForm.password=Password
+com.company.onboarding.view.login/loginForm.submit=Log in
+com.company.onboarding.view.login/loginForm.forgotPassword=Forgot password
+com.company.onboarding.view.login/loginForm.errorTitle=Login failed
+com.company.onboarding.view.login/loginForm.badCredentials=Check that you have entered the correct username and password and try again
+com.company.onboarding.view.login/loginForm.errorUsername=Username is required
+com.company.onboarding.view.login/loginForm.errorPassword=Password is required
+com.company.onboarding.view.login/loginForm.rememberMe=Remember me
+
+com.company.onboarding.view.user/UserListView.title=Users
+
+com.company.onboarding.view.myonboarding/myOnboardingView.title=My onboarding
+
+com.company.onboarding.view.assistant/assistantView.title=AI Assistant
+
+com.company.onboarding.view.step/stepDetailView.title=Step
+com.company.onboarding.view.step/stepListView.title=Steps
+
+com.company.onboarding.view.user/UserDetailView.title=User
+com.company.onboarding.view.user/confirmPassword=Confirm password
+com.company.onboarding.view.user/passwordsDoNotMatch=Passwords do not match
+com.company.onboarding.view.user/additionalMenu=Additional
+
+com.company.onboarding/menu.application.title=Application
+
+databaseUniqueConstraintViolation.IDX_DEPARTMENT_UNQ_NAME=A department with the same name already exists
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/assistant/assistant-view.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/assistant/assistant-view.xml
new file mode 100644
index 00000000..c3418dc0
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/assistant/assistant-view.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/department/department-detail-view.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/department/department-detail-view.xml
new file mode 100644
index 00000000..51f7e6b5
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/department/department-detail-view.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/department/department-list-view.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/department/department-list-view.xml
new file mode 100644
index 00000000..72333bf1
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/department/department-list-view.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/login/login-view.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/login/login-view.xml
new file mode 100644
index 00000000..d369e5b4
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/login/login-view.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/main/main-view.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/main/main-view.xml
new file mode 100644
index 00000000..a7f1e6ea
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/main/main-view.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/myonboarding/my-onboarding-view.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/myonboarding/my-onboarding-view.xml
new file mode 100644
index 00000000..dd7edd2d
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/myonboarding/my-onboarding-view.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/step/step-detail-view.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/step/step-detail-view.xml
new file mode 100644
index 00000000..66f621b2
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/step/step-detail-view.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/step/step-list-view.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/step/step-list-view.xml
new file mode 100644
index 00000000..3e4c8282
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/step/step-list-view.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/user/user-detail-view.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/user/user-detail-view.xml
new file mode 100644
index 00000000..2226cb40
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/user/user-detail-view.xml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/user/user-list-view.xml b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/user/user-list-view.xml
new file mode 100644
index 00000000..fb419e4b
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/com/company/onboarding/view/user/user-list-view.xml
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/vaadin-featureflags.properties b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/vaadin-featureflags.properties
new file mode 100644
index 00000000..3aafe51a
--- /dev/null
+++ b/content/modules/ai-tools/examples/ai-tools-ex1/src/main/resources/vaadin-featureflags.properties
@@ -0,0 +1 @@
+com.vaadin.experimental.themeComponentStyles=true
diff --git a/content/modules/ai-tools/images/ai-tools-chat.png b/content/modules/ai-tools/images/ai-tools-chat.png
new file mode 100644
index 00000000..fd613a34
Binary files /dev/null and b/content/modules/ai-tools/images/ai-tools-chat.png differ
diff --git a/content/modules/ai-tools/pages/getting-started.adoc b/content/modules/ai-tools/pages/getting-started.adoc
new file mode 100644
index 00000000..e4140515
--- /dev/null
+++ b/content/modules/ai-tools/pages/getting-started.adoc
@@ -0,0 +1,46 @@
+= Getting Started
+
+This section shows how to enable the assistant in an application: add the dependencies, connect a model, grant access, and ask the first question.
+
+[[prerequisites]]
+== Prerequisites
+
+Before you continue, install the add-on as described in xref:ai-tools:index.adoc#installation[Installation].
+
+[[configure-model]]
+== Connecting a Model
+
+The add-on communicates with the LLM through a Spring AI `ChatClient`, which requires a configured model provider. Choose one of the https://docs.spring.io/spring-ai/reference/api/index.html[Spring AI model starters^] and add it to your `build.gradle`. For example, for OpenAI:
+
+[source, groovy, indent=0]
+----
+implementation 'org.springframework.ai:spring-ai-starter-model-openai'
+----
+
+Then specify the provider settings in `application.properties`. These are standard https://docs.spring.io/spring-ai/reference/[Spring AI^] properties, not properties defined by this add-on:
+
+[source, properties, indent=0]
+----
+spring.ai.openai.api-key=${OPENAI_API_KEY}
+spring.ai.openai.chat.options.model=gpt-4o-mini
+----
+
+TIP: Keep secrets such as the API key out of `application.properties`. Pass them through an environment variable or another externalized configuration source, as shown above.
+
+If no model is configured, the application still starts and the chat UI still renders, but the assistant cannot answer. The composer is disabled, and the user sees a notification. This lets you add the UI before the model configured.
+
+[[grant-access]]
+== Granting Access
+
+Access to the chat is controlled by the `aitools-chat-user` resource role. Assign it to the users who should be able to use the assistant. See xref:ai-tools:ui.adoc#roles[Security Role] for details.
+
+[[first-question]]
+== Asking the First Question
+
+After you restart the application, a new *Chats* item appears under the *AI* section in the main menu.
+
+Open the chat and submit a request in natural language, for example _"How many active users are there?"_.
+
+image::ai-tools-chat.png[AI tools chat, align="center", width = 560]
+
+You can configure which data the assistant can access and how much data it includes in responses. See xref:ai-tools:tools.adoc[] and xref:ai-tools:properties.adoc[] for details.
diff --git a/content/modules/ai-tools/pages/index.adoc b/content/modules/ai-tools/pages/index.adoc
new file mode 100644
index 00000000..83d2fe3a
--- /dev/null
+++ b/content/modules/ai-tools/pages/index.adoc
@@ -0,0 +1,55 @@
+= AI Tools
+
+The AI Tools add-on brings a Large Language Model (LLM)-powered assistant to your Jmix application. Out of the box, it provides a ready-to-use chat UI where users ask questions in natural language and the assistant answers them by calling xref:ai-tools:tools.adoc[tools]. The add-on includes predefined tools tailored to Jmix applications and lets you add your own.
+
+The add-on is built on top of https://docs.spring.io/spring-ai/reference/[Spring AI^]. It does not bundle a specific model provider. Instead, you choose and configure a Spring AI model such as OpenAI, Anthropic, Azure OpenAI, or Ollama, and the add-on uses it through the standard Spring AI `ChatClient`.
+
+[[overview]]
+== Overview
+
+The add-on offers three groups of functionality:
+
+* Predefined tools. The add-on includes ready-made tools that the assistant can call. The current group, Data Load tools, lets the assistant inspect the available entities and their attributes, then generate, validate, and run a read-only JPQL query to load the requested data. See xref:ai-tools:tools.adoc[].
+
+* Extensible tool registry. Any Spring bean can contribute its own tools, and you can override the predefined ones. The `AiToolRegistry` collects all application tools. See xref:ai-tools:tools.adoc#custom-tools[Custom Tools and the Registry].
+
+* Chat UI. Ready-made views and reusable fragments let you embed a complete chat experience into your application, including a chat hub with conversation history and a single-conversation view. Chat history is persisted. See xref:ai-tools:ui.adoc[].
+
+In addition, you can use the assistant xref:ai-tools:programmatic-api.adoc[programmatically] from your own beans without the UI.
+
+[[modules]]
+== Add-on Structure
+
+The add-on is split into layers, so you can include only what you need:
+
+[cols="1,3", grid=rows, frame=none]
+|===
+|Starter |Description
+
+|`jmix-aitools-starter`
+|Core layer: the tool registry, predefined tools, and the programmatic assistant API. It has no UI and no persistence.
+
+|`jmix-aitools-flowui-starter`
+|Flow UI layer: chat views, fragments, and the security role. By itself, it provides the UI shell but leaves the chat services as no-op stubs.
+
+|`jmix-aitools-flowui-data-starter`
+|Persistence layer: stores conversations and messages in the database and provides working implementations of the chat services.
+|===
+
+The starters build on each other: `jmix-aitools-flowui-data-starter` -> `jmix-aitools-flowui-starter` -> `jmix-aitools-starter`.
+
+[[installation]]
+== Installation
+
+For automatic installation through Jmix Marketplace, follow the instructions in xref:ROOT:add-ons.adoc#installation[Add-ons].
+
+For manual installation, add the following dependencies to your `build.gradle`:
+
+[source, groovy, indent=0]
+----
+include::example$/ai-tools-ex1/build.gradle[tags=dependencies]
+----
+
+The add-on also requires a configured Spring AI model. See xref:ai-tools:getting-started.adoc[] for the complete setup.
+
+include::ROOT:single-menu-note.adoc[]
diff --git a/content/modules/ai-tools/pages/programmatic-api.adoc b/content/modules/ai-tools/pages/programmatic-api.adoc
new file mode 100644
index 00000000..0aef3024
--- /dev/null
+++ b/content/modules/ai-tools/pages/programmatic-api.adoc
@@ -0,0 +1,41 @@
+= Programmatic API
+
+You can also use the assistant from your own code, without the chat UI. This is useful for background processing, custom views, or integration with existing business logic.
+
+[[assistant-service]]
+== AiAssistantService
+
+`io.jmix.aitools.service.AiAssistantService` is the main programmatic entry point. It is stateless, does not persist chat history, and exposes every registered tool to the model. Inject it and send a message:
+
+[source, java, indent=0]
+----
+include::example$/ai-tools-ex1/src/main/java/com/company/onboarding/ai/SupportAssistant.java[tags=assistant-service]
+----
+<1> `send` blocks until the full reply is produced and returns it.
+
+For incremental output, use `stream(String)`, which returns a `reactor.core.publisher.Flux` that emits the reply in chunks.
+
+[[data-load-service]]
+== AiDataLoadService
+
+`io.jmix.aitools.dataload.AiDataLoadService` is a specialized entry point for data loading. In addition to the conversational `send` and `stream` methods, it provides:
+
+* `loadData(String userText)` – generates a JPQL query for the request, runs it, and returns a structured `EntityDataLoadResult`.
+
+`EntityDataLoadResult` exposes the full outcome of the load through the following fields:
+
+* `query` — the LLM-generated query, before any repair;
+* `validationResult` — the validation outcome;
+* `rows` — the fetched rows, each a `Map` keyed by result-property name;
+* `hasMore` — whether more rows are available beyond the returned page;
+* `executed` — whether the query actually ran;
+* `executionError` — the error message when it did not.
+
+Use this service when you need the data itself rather than a natural-language answer.
+
+[[system-prompt]]
+== Customizing the System Prompt
+
+The system prompt used by `AiAssistantService` is supplied by the `AiAssistantSystemPromptProvider` bean. The default implementation loads a bundled template that introduces the assistant and the tool usage rules. To use your own prompt, register a bean that implements `AiAssistantSystemPromptProvider` and returns your template.
+
+NOTE: The default `AiAssistantSystemPromptProvider` is registered only if the application does not already define a bean of that type. If you provide your own implementation, it automatically takes precedence.
diff --git a/content/modules/ai-tools/pages/properties.adoc b/content/modules/ai-tools/pages/properties.adoc
new file mode 100644
index 00000000..bbdf09d7
--- /dev/null
+++ b/content/modules/ai-tools/pages/properties.adoc
@@ -0,0 +1,113 @@
+= Application Properties
+
+This page describes the configuration properties defined by the AI Tools add-on. Configure the model provider separately through standard https://docs.spring.io/spring-ai/reference/[Spring AI^] properties such as `spring.ai.openai.*`. See xref:ai-tools:getting-started.adoc#configure-model[Connecting a Model].
+
+[[jmix.aitools.enabled]]
+== jmix.aitools.enabled
+
+Whether the AI Tools autoconfiguration is enabled.
+
+Default value: `true`
+
+[[dataload]]
+[[jmix.aitools.dataload.enabled]]
+== jmix.aitools.dataload.enabled
+
+Whether the data-load autoconfiguration, including the predefined data-load tools, is enabled.
+
+Default value: `true`
+
+[[jmix.aitools.dataload.exclude-system-level-entities]]
+== jmix.aitools.dataload.exclude-system-level-entities
+
+Whether system-level entities are hidden from the AI.
+
+Default value: `true`
+
+[[jmix.aitools.dataload.include-entities]]
+== jmix.aitools.dataload.include-entities
+
+Entity names to expose to the AI even if they would otherwise be hidden, as an indexed list. The listed entities are added to the default set and override the system-level, DTO, and `exclude-packages` exclusions. `exclude-entities` still takes precedence over this list.
+
+[source,properties,indent=0]
+----
+jmix.aitools.dataload.include-entities[0]=sample_Customer
+jmix.aitools.dataload.include-entities[1]=sample_Order
+----
+
+Default value: empty (no explicit includes)
+
+[[jmix.aitools.dataload.exclude-entities]]
+== jmix.aitools.dataload.exclude-entities
+
+Entity names to hide from the AI, as an indexed list.
+
+Default value: empty
+
+[[jmix.aitools.dataload.include-packages]]
+== jmix.aitools.dataload.include-packages
+
+Package prefixes whose entities are exposed to the AI even if they would otherwise be hidden, as an indexed list. Like `include-entities`, this property is additive: matching entities are added to the default set, including entities hidden by default, such as framework or system-level entities. It does not restrict the model to only these packages.
+
+[source,properties,indent=0]
+----
+jmix.aitools.dataload.include-packages[0]=com.company.sample
+----
+
+Default value: empty
+
+[[jmix.aitools.dataload.exclude-packages]]
+== jmix.aitools.dataload.exclude-packages
+
+Package prefixes to hide from the AI, as an indexed list. By default, the framework's own entities are excluded. Expose specific ones with `include-entities` or `include-packages`.
+
+Default value: `io.jmix`
+
+[[jmix.aitools.dataload.max-repair-attempts]]
+== jmix.aitools.dataload.max-repair-attempts
+
+Maximum number of attempts to repair an invalid generated query before giving up.
+
+Default value: `1`
+
+[[jmix.aitools.dataload.jpql-execution-max-result]]
+== jmix.aitools.dataload.jpql-execution-max-result
+
+Default maximum number of rows applied when a generated query does not specify one.
+
+Default value: `20`
+
+[[jmix.aitools.dataload.jpql-execution-max-result-limit]]
+== jmix.aitools.dataload.jpql-execution-max-result-limit
+
+Hard upper bound for the number of rows that a single query can request. Any larger value, whether supplied by the query or inherited from the default, is capped to this limit before execution.
+
+Default value: `100`
+
+[[jmix.aitools.ui.chat-hub-recent-chats-count]]
+== jmix.aitools.ui.chat-hub-recent-chats-count
+
+Number of recent chats shown next to the chat input in the chat hub view. You can also override it per fragment with `AiChatHubFragment.setRecentChatsCount(int)`.
+
+Default value: `6`
+
+[[jmix.aitools.ui.assistant-response-timeout]]
+== jmix.aitools.ui.assistant-response-timeout
+
+Timeout for the background task that runs the LLM call and produces the assistant response. When the timeout expires, the task is cancelled and the failure handler is invoked.
+
+Default value: `5m`
+
+[[jmix.aitools.ui.data.enabled]]
+== jmix.aitools.ui.data.enabled
+
+Whether the AI Tools Flow UI data autoconfiguration, including database-backed chat history and the working chat services, is enabled.
+
+Default value: `true`
+
+[[jmix.aitools.ui.data.chat-memory-max-messages]]
+== jmix.aitools.ui.data.chat-memory-max-messages
+
+Maximum number of most recent conversation messages kept as chat memory and sent with each request.
+
+Default value: `20`
diff --git a/content/modules/ai-tools/pages/tools.adoc b/content/modules/ai-tools/pages/tools.adoc
new file mode 100644
index 00000000..47a8359e
--- /dev/null
+++ b/content/modules/ai-tools/pages/tools.adoc
@@ -0,0 +1,154 @@
+= Tools
+
+The assistant does not access the database or external systems directly. Instead, it works through server-side tools. The add-on includes a set of predefined tools, and you can contribute your own or override the predefined ones.
+
+[[predefined]]
+== Predefined Tools
+
+The assistant comes with predefined tools that it can call on the user's behalf. They are grouped by purpose, and each group is described in its own section below. At the moment, the add-on provides one group: <>.
+
+[[data-loading]]
+== Data Load Tools
+
+The Data Load tools let the assistant answer questions about application data.
+
+[[data-load-flow]]
+=== The Data-Load Flow
+
+The Data Load tools implement a natural-language data loading flow. When a user asks for data, the assistant typically goes through the following steps:
+
+. Discover – list the entities available to the user and inspect the relevant ones, including attributes, relationships, and enums.
+. Generate – produce a read-only JPQL query with named parameters for the request.
+. Validate – check the query against the domain model and a set of rules, including read-only access, syntax, known entities and attributes, supported date/time constructs, and pagination.
+. Repair – if validation fails, ask the model to fix the query and validate it again, up to a configurable number of attempts.
+. Execute – run the query through `DataManager`, enforce entity and attribute permissions, and return a page of rows.
+
+All steps run on the server. The query is always a read-only `SELECT`. Write operations and native SQL escapes are rejected during validation, and execution also enforces the current user's read permissions. A query over an entity the user cannot read is rejected, while attributes the user cannot read are omitted from the returned rows, just as a data grid hides columns bound to unreadable attributes. In both cases, the user never receives data they are not allowed to see.
+
+[[available-tools]]
+=== Available Tools
+
+The flow is exposed to the model as three tools. The tool name is the identifier the model uses to call the tool. It is also the name you reference when <> a tool.
+
+[cols="1,3", grid=rows, frame=none]
+|===
+|Tool name |What it does
+
+|[[aitls_getAvailableEntities]]`aitls_getAvailableEntities`
+|Returns compact metadata for every entity currently available to the user: entity name, localized names, and property names. Entities hidden by application filtering or security are not returned. The assistant uses this tool to explore the data model and obtain the correct entity names for follow-up calls.
+
+|[[aitls_getDomainModelForEntities]]`aitls_getDomainModelForEntities`
+|Returns detailed metadata for the requested entities: exact attribute names, relationships for joins, property types and constraints, and enum value mappings. The assistant must call this tool for the entities it intends to query before it generates a query.
+
+|[[aitls_executeQuery]]`aitls_executeQuery`
+|Validates, repairs if needed, and runs a read-only JPQL query, returning the resulting rows. Parameters are passed as a structured request containing query text, named parameters, result column names, and paging information. The result reports the rows, whether more rows are available, and any validation or execution error.
+|===
+
+These three tools are bound together by the `DataLoadAiTool` marker interface, so the registry can collect them as a group. See <>.
+
+[[exposed-model]]
+=== Controlling the Exposed Model
+
+By default, the assistant can see every JPA entity in the application except framework entities, because the `io.jmix` packages are excluded, and system-level entities. You can narrow or widen this set with the `jmix.aitools.dataload.*` properties.
+
+For example, to hide a specific entity from the assistant and cap the default number of rows per query:
+
+[source, properties, indent=0]
+----
+include::example$/ai-tools-ex1/src/main/resources/application.properties[tags=data-load]
+----
+
+Inclusion and exclusion rules are evaluated per entity. The `exclude-*` properties hide entities. The `include-*` properties are additive and add entities to the default set, including entities hidden by default, such as framework or system-level entities. `exclude-entities` still takes precedence over any include. See xref:ai-tools:properties.adoc#dataload[Data-load properties] for the full list of options, including how to cap the number of rows that a single query can return.
+
+NOTE: Filtering by configuration changes only which entities are offered to the model. Regardless of configuration, every query still runs under the current user's xref:security:index.adoc[data access] permissions. A query over an entity the user cannot read is rejected, and attributes the user cannot read are removed from the result. A user can never read data they are not allowed to see.
+
+[[available-entity-filter]]
+=== Customizing Availability
+
+The set of entities offered to the model is resolved through an `AvailableEntityFilter` bean. The default implementation hides entities for which the current user has no read access. To change this behavior, for example to apply your own visibility rules, register your own bean that implements `io.jmix.aitools.dataload.introspection.AvailableEntityFilter`.
+
+The configured entities and their metadata are served by the `AvailableEntityService` bean, which you can also use from your own tools. See the <>.
+
+[[custom-tools]]
+== Custom Tools and the Registry
+
+Beyond the predefined tools, you can contribute your own tools to the assistant and override the tools provided by the add-on.
+
+[[defining-a-tool]]
+=== Defining a Tool
+
+A tool is a Spring bean that:
+
+* implements the `io.jmix.aitools.tool.JmixAiTool` marker interface
+* declares one or more methods annotated with Spring AI's `@Tool` annotation
+
+The add-on discovers all such beans at startup, collects their `@Tool` methods, and makes them available to the assistant. The following bean adds a tool that returns the catalog of onboarding steps:
+
+[source, java, indent=0]
+----
+include::example$/ai-tools-ex1/src/main/java/com/company/onboarding/ai/OnboardingTools.java[tags=custom-tool]
+----
+<1> Implementing `JmixAiTool` marks the bean as a source of tools.
+<2> The `@Tool` `name` and `description` are what the model sees. The description should explain when and how to use the tool.
+<3> A `ToolContext` parameter is supplied by the framework and is not exposed to the model. It is required only to publish status updates. See <>.
+<4> Publishes an in-progress status update before the long-running work starts.
+<5> Loads the `Step` entities through `DataManager` under the current user's data access permissions.
+
+The method parameters become the tool's input schema. Annotate them with `@ToolParam` to add descriptions. The return value is sent back to the model as the tool result.
+
+NOTE: Declaring `@Tool` methods requires Spring AI's model API on the application classpath. The Spring AI model starter that you add in xref:ai-tools:getting-started.adoc#configure-model[Connecting a Model] already provides it.
+
+[[registry]]
+=== AiToolRegistry
+
+`io.jmix.aitools.tool.AiToolRegistry` is the central registry of all tools available in the application. It is built once at startup from every `JmixAiTool` bean, after override resolution is applied. Its methods are:
+
+[cols="1,3", grid=rows, frame=none]
+|===
+|Method |Description
+
+|`getAll()`
+|Returns all resolved tools in registration order.
+
+|`findByName(String name)`
+|Returns the tool registered under the given name, or an empty result.
+
+|`findByMarker(Class extends JmixAiTool> marker)`
+|Returns the tools whose source bean implements the given marker sub-interface of `JmixAiTool`, for example `DataLoadAiTool.class`.
+
+|`getAllCallbacks()`
+|Returns the Spring AI `ToolCallback` objects of all resolved tools, ready to pass to a `ChatClient`.
+|===
+
+The assistant passes `getAllCallbacks()` to the model, so any tool you contribute becomes available automatically. Nothing else must be registered.
+
+[[overriding]]
+=== Overriding a Predefined Tool
+
+To replace an existing tool, including a predefined one, annotate your `@Tool` method with `@ToolOverride` and pass the name of the tool you want to replace. The method must still carry `@Tool` and be declared on a `JmixAiTool` bean.
+
+The following bean overrides <> and returns the available entities ordered by their localized name:
+
+[source, java, indent=0]
+----
+include::example$/ai-tools-ex1/src/main/java/com/company/onboarding/ai/SortedEntitiesTool.java[tags=tool-override]
+----
+<1> Implementing `DataLoadAiTool` keeps the override in the data-load tool group. For a tool unrelated to data loading, implement `JmixAiTool` directly.
+<2> `@ToolOverride` names the tool being replaced. The override is exposed under that name, so the override method's own `@Tool` name is irrelevant.
+<3> The default `AvailableEntityService` is reused, so the override still honors entity filtering and security and changes only the ordering.
+
+How override resolution works:
+
+* When several `@Tool` methods produce the same tool name, the one annotated with `@ToolOverride` wins and the original tool is excluded from the registry.
+* The override inherits the marker interfaces of the tool it replaces, so registry lookups by marker continue to work.
+* If the named tool does not exist, a warning is logged and the override is registered as a regular new tool under its own `@Tool` name. This fails with an exception if that name is already used by another tool, to avoid silently replacing an unrelated tool.
+
+[[status-updates]]
+=== Publishing Status Updates
+
+Long-running tools can surface progress to the UI through the `io.jmix.aitools.tool.AiToolStatusPublisher` bean shown in the <>. It implements a two-phase contract:
+
+* `update(message, toolContext)` – the step has started, and the UI shows an in-progress indicator.
+* `complete(message, snippet, toolContext)` – the step has finished, and the UI folds the result snippet into the same entry. The `message` must match the one passed to `update`.
+
+Both methods take the tool method's `ToolContext`. When the tool is invoked outside the chat UI, for example through the xref:ai-tools:programmatic-api.adoc[programmatic API], no status callback is present and the methods are silent no-ops, so it is always safe to call them.
diff --git a/content/modules/ai-tools/pages/ui.adoc b/content/modules/ai-tools/pages/ui.adoc
new file mode 100644
index 00000000..f90c4ded
--- /dev/null
+++ b/content/modules/ai-tools/pages/ui.adoc
@@ -0,0 +1,150 @@
+= User Interface
+
+The `jmix-aitools-flowui-starter` provides a complete chat UI with ready-to-use views, reusable fragments that you can embed into your own views, and the supporting services. Access to the UI is governed by the <>.
+
+[[views]]
+== Views
+
+The add-on registers two views out of the box.
+
+[cols="1,3", grid=rows, frame=none]
+|===
+|View |Description
+
+|`AiChatHubView`
+|The chat hub: a landing view with the message composer, the user's recent conversations, and a searchable, date-grouped history. It is added to the main menu automatically.
+
+|`AiChatView`
+|A single conversation view. It is opened from the hub for a selected conversation, or without one to start a new chat.
+|===
+
+Because the add-on contributes a menu item for `AiChatHubView`, users with the chat role get an entry point without any additional configuration. The menu item is added through the xref:flow-ui:menu-config.adoc[composite menu] mechanism.
+
+[[fragments]]
+== Fragments
+
+When the built-in views do not fit your layout, you can embed the chat UI into your own views by using fragments. Add a fragment to a view descriptor with the `` element and reference its class.
+
+[[ai-chat-hub-fragment]]
+=== AiChatHubFragment
+
+`io.jmix.aitoolsflowui.view.chathub.AiChatHubFragment` is the self-contained chat hub used by `AiChatHubView`. Add it to any view to get the composer, recent chats, and history in one component:
+
+[source, xml, indent=0]
+----
+include::example$/ai-tools-ex1/src/main/resources/com/company/onboarding/view/assistant/assistant-view.xml[tags=embed-hub]
+----
+
+Its public API:
+
+* `setRecentChatsCount(int)` – overrides how many recent chats are shown next to the composer. When unset, the value comes from the xref:ai-tools:properties.adoc#jmix.aitools.ui.chat-hub-recent-chats-count[`jmix.aitools.ui.chat-hub-recent-chats-count`] application property.
+* `setMarkIconSupplier(SerializableSupplier)` – replaces the brand mark icon shown on the hub and on conversation cards.
+
+[[ai-chat-fragment]]
+=== AiChatFragment
+
+`io.jmix.aitoolsflowui.view.chat.AiChatFragment` is the conversation panel used by `AiChatView`. It contains a title row, the message timeline, and the composer, all bound to one conversation. Embed it and bind a conversation programmatically:
+
+[source, xml, indent=0]
+----
+
+----
+
+[source, java, indent=0]
+----
+@ViewComponent
+private AiChatFragment chatFragment;
+
+@Autowired
+private AiConversationService conversationService;
+
+@Subscribe
+public void onInit(final InitEvent event) {
+ AiConversation conversation = conversationService.create();
+ chatFragment.setConversation(conversation);
+}
+----
+
+Key methods:
+
+* `setConversation(AiConversation)` / `setConversationId(UUID)` – binds the panel to a conversation.
+* `sendMessage(String)` – submits a user message programmatically.
+* `setReadOnly(boolean)` – hides the composer and the title edit button.
+* `setMessageInputEnabled(boolean)` / `focusMessageInput()` – controls the composer.
+* `isAwaitingResponse()` – indicates whether a reply is currently being generated.
+* `setAiAvatarIconSupplier(SerializableSupplier)` – customizes the assistant avatar.
+
+[[ai-chat-input-fragment]]
+=== AiChatInputFragment
+
+`io.jmix.aitoolsflowui.view.input.AiChatInputFragment` is the reusable message composer: a text area plus a send button. Press Enter to submit the message. Press Shift+Enter to insert a newline. It is used inside the two fragments above, and you can also embed it on its own when you build a custom chat layout. Configure it with `setSubmitHandler(Consumer)`, `setPlaceholder(String)`, `setInputEnabled(boolean)`, `focus()`, and `clear()`.
+
+[[services]]
+== Services
+
+The UI is backed by three services. They operate on the UI models `AiConversation` and `AiChatMessage` and are implicitly scoped to the current user.
+
+[cols="1,3a", grid=rows, frame=none]
+|===
+|Service |Responsibility
+
+|`AiChatService`
+|Generates the assistant's reply to a user message.
+
+* `processMessage(message)` – returns the reply.
+* `processMessage(message, statusCallback)` – same as above, but reports progress through a `Consumer`.
+* `isAvailable()` – whether replies can be produced, that is, whether a model is configured.
+
+|`AiConversationService`
+|Manages the current user's conversations.
+
+* `loadConversations()` – loads the user's conversations, newest first.
+* `loadConversation(id)` – loads one conversation by ID.
+* `create()` – creates a new conversation.
+* `save(conversation)` – persists a conversation.
+* `remove(conversation)` – deletes a conversation and its messages.
+
+|`AiChatMessageService`
+|Manages the messages of a conversation.
+
+* `createMessage(...)` – adds a message of a given type.
+* `loadMessages(conversation)` – loads all messages, oldest first.
+* `loadLatestMessage(...)` – loads the most recent message, optionally by type.
+|===
+
+[[persistence]]
+=== Persistence and Empty Implementations
+
+Where these services store data depends on which starter is present:
+
+* With `jmix-aitools-flowui-data-starter`, the services are backed by JPA entities, and conversations and messages are persisted in the database.
+* With only `jmix-aitools-flowui-starter`, the services are no-op stubs. `AiChatService.isAvailable()` returns `false`, nothing is persisted, and the chat is effectively disabled. This lets the UI render before persistence is in place.
+
+To use the chat with non-default storage, provide your own beans that implement `AiChatService`, `AiConversationService`, and `AiChatMessageService` instead of adding the data starter.
+
+[[icon-provider]]
+=== Icon Provider
+
+The brand icon shown on the hub and the assistant avatar is supplied by the `AiIconProvider` bean. Replace this bean to change the add-on's iconography globally, or override it per fragment with the icon supplier setters shown above.
+
+[[roles]]
+== Security Role
+
+The add-on provides one predefined xref:security:resource-roles.adoc[resource role].
+
+[cols="1,3", grid=rows, frame=none]
+|===
+|Name |`AI Tools: chat user`
+|Code |`aitools-chat-user`
+|Scope |UI
+|===
+
+It grants an end user access to the chat: the chat hub and the conversation view, plus management of their own conversations and messages, including starting, continuing, renaming, and deleting chats. Specifically, the role grants:
+
+* view access to `AiChatHubView` and `AiChatView`, and to the menu item for the chat hub
+* full access to the user's own conversations, and create/read access to their messages
+
+Assign this role to every user who should be able to use the assistant. See xref:security:users.adoc[] for how to assign roles to users.
+
+NOTE: The role grants access only to the chat UI. It does not widen data access. When the assistant loads business data through the predefined xref:ai-tools:tools.adoc#data-loading[data-load tools], the queries run under the current user's own xref:security:index.adoc[data access] permissions, so the role does not let a user read anything they could not otherwise read. Any custom tools you add are responsible for their own access control.
diff --git a/content/modules/ai-tools/partials/nav.adoc b/content/modules/ai-tools/partials/nav.adoc
new file mode 100644
index 00000000..3c7000cb
--- /dev/null
+++ b/content/modules/ai-tools/partials/nav.adoc
@@ -0,0 +1,6 @@
+* xref:ai-tools:index.adoc[]
+** xref:ai-tools:getting-started.adoc[]
+** xref:ai-tools:tools.adoc[]
+** xref:ai-tools:ui.adoc[]
+** xref:ai-tools:programmatic-api.adoc[]
+** xref:ai-tools:properties.adoc[]
diff --git a/content/modules/whats-new/pages/release-3.0.adoc b/content/modules/whats-new/pages/release-3.0.adoc
index 7f942cd7..0f05034c 100644
--- a/content/modules/whats-new/pages/release-3.0.adoc
+++ b/content/modules/whats-new/pages/release-3.0.adoc
@@ -117,7 +117,12 @@ See the following resources for more information:
The new xref:dyn-model:index.adoc[Dynamic Model] add-on lets you extend the data model of a running application without changing source code or restarting: you can add attributes to existing entities and define entirely new entities backed by dedicated database tables. The model is edited in a graphical admin UI, stored as a versioned definition, and can include runtime UI such as views and menu items, along with validation, uniqueness, and security based on resource roles.
+[[ai-tools]]
+=== AI Tools Add-on
+
+The new xref:ai-tools:index.adoc[AI Tools] add-on brings an LLM-powered assistant to Jmix applications. It provides a ready-to-use chat UI, a programmatic API, predefined tools for natural-language data access, and extension points for adding custom tools.
[[aura-theme]]
+
=== Aura Theme
With the upgrade to Vaadin 25, Jmix applications now support the new Aura theme. The existing Lumo theme remains available. When creating a project in Studio, you can choose the theme you want to use.
diff --git a/package-lock.json b/package-lock.json
index 974e02c9..eec8486f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,5 +1,5 @@
{
- "name": "jmix-docs-2.8-clean",
+ "name": "jmix-v3-docs",
"lockfileVersion": 2,
"requires": true,
"packages": {
diff --git a/settings.gradle b/settings.gradle
index e93862da..f1aecdf7 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -152,4 +152,6 @@ includeBuild 'content/modules/quartz/examples/quartz-ex1'
includeBuild 'content/modules/appsettings/examples/appsettings-ex1'
includeBuild 'content/modules/saml/examples/saml-ex1'
-includeBuild 'content/modules/dyn-model/examples/dyn-model-ex1'
\ No newline at end of file
+includeBuild 'content/modules/dyn-model/examples/dyn-model-ex1'
+
+includeBuild 'content/modules/ai-tools/examples/ai-tools-ex1'
\ No newline at end of file