diff --git a/AGENTS.md b/AGENTS.md index fe6ed6bb..d71547d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,8 @@ git config core.hooksPath .githooks Optimize PNG screenshots with `pngquant --quality=65-85 --strip --force --ext .png `. See `CONTRIBUTING.md` for the full table. Bypass (rarely) with `git commit --no-verify`. +Screenshots are captured at **2x** and declared at half their pixel width (`image::foo.png[width="413"]` for an 826px-wide file), so they stay sharp on high-DPI displays. `tools/screenshot-2x.mjs` does this: it drives Playwright directly with `deviceScaleFactor: 2` and captures a single element, which the Playwright MCP tool cannot do — its resize action takes a width and a height only, so every capture it makes is 1x. Run it with `--url`, `--selector` and `--out`; it prints the resulting pixel size and the width to declare. See the comment at the top of the file for the rest of its options. + ## Conventions - In multi-locale examples use German (`de`) as the secondary locale. diff --git a/build.gradle b/build.gradle index e5845dec..b3f7ae30 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,7 @@ ext.jmixCompositeProjectRoot = true def projectNames = [ + 'ai-chat-ex1', 'audit-ex1', 'authserver-ex1', 'bpm-ex1', diff --git a/content/modules/ROOT/nav.adoc b/content/modules/ROOT/nav.adoc index 1dc8e438..7f2eae5d 100644 --- a/content/modules/ROOT/nav.adoc +++ b/content/modules/ROOT/nav.adoc @@ -39,6 +39,7 @@ include::bpm:partial$nav.adoc[] * xref:ROOT:add-ons.adoc[] + -- +include::ai-chat:partial$nav.adoc[] include::ai-tools:partial$nav.adoc[] include::appsettings:partial$nav.adoc[] include::audit:partial$nav.adoc[] diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/.gitignore b/content/modules/ai-chat/examples/ai-chat-ex1/.gitignore new file mode 100644 index 00000000..a9bccac8 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/.gitignore @@ -0,0 +1,35 @@ +.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 + +.agents/ +.claude/ +.skills/ diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/.idea/encodings.xml b/content/modules/ai-chat/examples/ai-chat-ex1/.idea/encodings.xml new file mode 100644 index 00000000..da0415a0 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/README.md b/content/modules/ai-chat/examples/ai-chat-ex1/README.md new file mode 100644 index 00000000..7046c7bf --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/README.md @@ -0,0 +1,44 @@ +# Ai Chat Ex1 + +This is a web application based on the [Jmix](https://www.jmix.io) framework. + +## Getting Started + +The newly created project requires Java 21 and uses the embedded HSQL database. + +Use the following resources to learn more about Jmix: +* [Jmix Documentation](https://docs.jmix.io) +* [Online Demo Applications](https://www.jmix.io/live-demo) +* [Jmix AI Assistant](https://ai-assistant.jmix.io) (also available in the **Jmix AI** tool window of IntelliJ IDEA) + +## Development + +- [Setup](https://docs.jmix.io/jmix/setup.html) your development environment. +- [Open](https://docs.jmix.io/jmix/studio/project.html#opening-existing-project) the project in the IDE. +- If you want to use AI agents to develop the application, check out the [Jmix AI Agent Guidelines](https://github.com/jmix-framework/jmix-agent-guidelines) repository. + +## Running + +To start the application, use the **Ai Chat Ex1 Jmix Application** run configuration in your IDE, or run the following command in the project root directory: + +```bash +./gradlew bootRun +``` + +The application will be available at . + +The default user credentials are: +* Login: `admin` +* Password: `admin` + +**WARNING**: Change admin password and remove `ui.login.defaultUsername` and `ui.login.defaultPassword` application properties when deploying the application to production. + +## The language model + +The chat views talk to a local [Ollama](https://ollama.com) server. Nothing is contacted at startup, so the application runs without it — a chat reports the failure on the first message instead. + +Attachments need a model that accepts them. The default `qwen3:8b` handles text only and refuses any attached file — a plain text file as readily as an image — so the Attachments view reports an error until you point the application at a [multimodal model](https://ollama.com/search?c=vision): + +```bash +OLLAMA_CHAT_MODEL= ./gradlew bootRun +``` diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/build.gradle b/content/modules/ai-chat/examples/ai-chat-ex1/build.gradle new file mode 100644 index 00000000..e3f595df --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/build.gradle @@ -0,0 +1,83 @@ +plugins { + id 'io.jmix' version '3.1.0-M1' + id 'java' + id 'org.jetbrains.gradle.plugin.idea-ext' version '1.4.1' +} + +apply plugin: 'org.springframework.boot' +apply plugin: 'com.vaadin' + +jmix { + bomVersion = '3.1.999-SNAPSHOT' +} + +group = 'com.company' +version = '0.0.1-SNAPSHOT' + +repositories { + mavenCentral() + mavenLocal() + maven { + url = 'https://nexus.jmix.io/repository/public' + } + maven { + url = 'https://nexus.jmix.io/repository/premium' + credentials { + username = rootProject['premiumRepoUser'] + password = rootProject['premiumRepoPass'] + } + } +} + +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 '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' + + // tag::dependencies[] + implementation 'io.jmix.aichat:jmix-aichat-flowui-starter' + // end::dependencies[] + + // tag::provider-dependencies[] + implementation 'org.springframework.ai:spring-ai-starter-model-ollama' + // end::provider-dependencies[] + + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'com.vaadin:vaadin-dev' + + runtimeOnly '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' +} + +configurations.implementation { + exclude group: 'com.vaadin', module: 'hilla' + exclude group: 'com.vaadin', module: 'hilla-dev' + exclude group: 'com.vaadin', module: 'copilot' +} + +test { + useJUnitPlatform() +} + +idea { + module { + excludeDirs.addAll(files '.jmix', 'node_modules', 'src/main/frontend/generated/', 'src/main/bundles') + } +} + +vaadin { + optimizeBundle = false + productionMode = false +} diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/gradle.properties b/content/modules/ai-chat/examples/ai-chat-ex1/gradle.properties new file mode 100644 index 00000000..8b4371be --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-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-chat/examples/ai-chat-ex1/gradle/wrapper/gradle-wrapper.jar b/content/modules/ai-chat/examples/ai-chat-ex1/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..b1b8ef56 Binary files /dev/null and b/content/modules/ai-chat/examples/ai-chat-ex1/gradle/wrapper/gradle-wrapper.jar differ diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/gradle/wrapper/gradle-wrapper.properties b/content/modules/ai-chat/examples/ai-chat-ex1/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..df6a6ad7 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/gradlew b/content/modules/ai-chat/examples/ai-chat-ex1/gradlew new file mode 100755 index 00000000..b9bb139f --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 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/3d91ce3b8caaf77ad09f381f43615b715b53f72c/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 + + + +# 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" ) + + 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" \ + -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-chat/examples/ai-chat-ex1/gradlew.bat b/content/modules/ai-chat/examples/ai-chat-ex1/gradlew.bat new file mode 100644 index 00000000..24c62d56 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/gradlew.bat @@ -0,0 +1,82 @@ +@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, and ensure extensions are enabled +setlocal EnableExtensions + +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 + +"%COMSPEC%" /c exit 1 + +: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 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/settings.gradle b/content/modules/ai-chat/examples/ai-chat-ex1/settings.gradle new file mode 100644 index 00000000..91787dbe --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'ai-chat-ex1' diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/frontend/index.html b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/frontend/index.html new file mode 100644 index 00000000..2b20a458 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/frontend/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + +
+ + diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/AiChatEx1Application.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/AiChatEx1Application.java new file mode 100644 index 00000000..2f922a27 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/AiChatEx1Application.java @@ -0,0 +1,64 @@ +package com.company.demo; + +import com.google.common.base.Strings; +import com.vaadin.flow.component.dependency.StyleSheet; +import com.vaadin.flow.component.page.AppShellConfigurator; +import com.vaadin.flow.component.page.Push; +import com.vaadin.flow.server.PWA; +import com.vaadin.flow.theme.aura.Aura; +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; + +// tag::push[] +@Push +// end::push[] +@StyleSheet(Aura.STYLESHEET) +@StyleSheet(JmixAura.STYLESHEET) +@StyleSheet("themes/ai-chat-ex1-aura/styles.css") +@PWA(name = "Ai Chat Ex1", shortName = "Ai Chat Ex1", offline = false) +// tag::app-class[] +@SpringBootApplication +public class AiChatEx1Application implements AppShellConfigurator { +// end::app-class[] + + @Autowired + private Environment environment; + + public static void main(String[] args) { + SpringApplication.run(AiChatEx1Application.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(AiChatEx1Application.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-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/entity/ChatMessage.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/entity/ChatMessage.java new file mode 100644 index 00000000..1a9d5a3e --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/entity/ChatMessage.java @@ -0,0 +1,118 @@ +package com.company.demo.entity; + +import io.jmix.aichat.data.AiChatMessage; +import io.jmix.aichat.entity.AiMessageRole; +import io.jmix.core.FileRef; +import io.jmix.core.entity.FileRefConverter; +import io.jmix.core.entity.annotation.JmixGeneratedValue; +import io.jmix.core.metamodel.annotation.JmixEntity; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; +import jakarta.persistence.OrderColumn; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import org.jspecify.annotations.Nullable; +import org.springframework.data.annotation.CreatedDate; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +// tag::entity[] +@JmixEntity +@Table(name = "CHAT_MESSAGE") +@Entity +public class ChatMessage implements AiChatMessage { + + @JmixGeneratedValue + @Column(name = "ID", nullable = false) + @Id + private UUID id; + + @Version + @Column(name = "VERSION", nullable = false) + private Integer version; + + @Column(name = "ROLE", nullable = false, length = 50) + private String role; + + @Column(name = "CONTENT") + @Lob + private String content; + + @CreatedDate + @Column(name = "CREATED_DATE") + private OffsetDateTime createdDate; + + @ElementCollection + @CollectionTable(name = "CHAT_MESSAGE_ATTACHMENTS", + joinColumns = @JoinColumn(name = "CHAT_MESSAGE_ID")) + @OrderColumn(name = "ATTACHMENTS_ORDER") + @Convert(converter = FileRefConverter.class) + @Column(name = "ATTACHMENTS", length = 1024) + private List attachments; + + @Override + public AiMessageRole getRole() { + return role == null ? null : AiMessageRole.fromId(role); + } + + public void setRole(AiMessageRole role) { + this.role = role == null ? null : role.getId(); + } + + @Override + public String getContent() { + return content == null ? "" : content; + } + + public void setContent(@Nullable String content) { + this.content = content; + } + + @Override + public @Nullable Instant getTime() { + return createdDate == null ? null : createdDate.toInstant(); + } + + @Override + public List getAttachments() { + return attachments == null ? List.of() : attachments; // <1> + } + // end::entity[] + + public void setAttachments(List attachments) { + this.attachments = attachments; + } + + public @Nullable OffsetDateTime getCreatedDate() { + return createdDate; + } + + public void setCreatedDate(@Nullable OffsetDateTime createdDate) { + this.createdDate = createdDate; + } + + public @Nullable UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public @Nullable Integer getVersion() { + return version; + } + + public void setVersion(@Nullable Integer version) { + this.version = version; + } +} diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/entity/User.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/entity/User.java new file mode 100644 index 00000000..3faa5edd --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/entity/User.java @@ -0,0 +1,189 @@ +package com.company.demo.entity; + +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.DependsOnProperties; +import io.jmix.core.metamodel.annotation.InstanceName; +import io.jmix.core.metamodel.annotation.JmixEntity; +import io.jmix.security.authentication.JmixUserDetails; +import io.jmix.security.user.PasswordChangeRequired; +import org.springframework.security.core.GrantedAuthority; +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; + +@JmixEntity +@Entity +@Table(name = "USER_", indexes = { + @Index(name = "IDX_USER__ON_USERNAME", columnList = "USERNAME", unique = true) +}) +public class User implements JmixUserDetails, HasTimeZone { + + @Id + @Column(name = "ID") + @JmixGeneratedValue + private UUID id; + + @Version + @Column(name = "VERSION", nullable = false) + private Integer version; + + @Column(name = "USERNAME", nullable = false) + private String username; + + @Secret + @SystemLevel + @Column(name = "PASSWORD") + private String password; + + @Column(name = "FIRST_NAME") + private String firstName; + + @Column(name = "LAST_NAME") + private String lastName; + + @Email + @Column(name = "EMAIL") + private String email; + + @Column(name = "ACTIVE") + private Boolean active = true; + + @Column(name = "TIME_ZONE_ID") + private String timeZoneId; + + @PasswordChangeRequired + @Column(name = "PASSWORD_CHANGE_REQUIRED") + private Boolean passwordChangeRequired = false; + + @Transient + private Collection authorities; + + 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; + } + + public Boolean getPasswordChangeRequired() { + return passwordChangeRequired; + } + + public void setPasswordChangeRequired(final Boolean passwordChangeRequired) { + this.passwordChangeRequired = passwordChangeRequired; + } + + @Override + public Collection getAuthorities() { + return authorities != null ? authorities : Collections.emptyList(); + } + + @Override + public void setAuthorities(final Collection 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; + } + + @Override + public boolean isAutoTimeZone() { + return true; + } + + public void setTimeZoneId(final String timeZoneId) { + this.timeZoneId = timeZoneId; + } +} \ No newline at end of file diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/security/AiChatEx1SecurityConfiguration.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/security/AiChatEx1SecurityConfiguration.java new file mode 100644 index 00000000..7c2976cd --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/security/AiChatEx1SecurityConfiguration.java @@ -0,0 +1,49 @@ +package com.company.demo.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 org.springframework.core.annotation.Order} should be defined for the bean. The order value from the + * {@link io.jmix.core.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 AiChatEx1SecurityConfiguration { + + @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-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/security/DatabaseUserRepository.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/security/DatabaseUserRepository.java new file mode 100644 index 00000000..89e16370 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/security/DatabaseUserRepository.java @@ -0,0 +1,31 @@ +package com.company.demo.security; + +import com.company.demo.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-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/security/FullAccessRole.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/security/FullAccessRole.java new file mode 100644 index 00000000..f380c5fb --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/security/FullAccessRole.java @@ -0,0 +1,23 @@ +package com.company.demo.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-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/security/UiMinimalRole.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/security/UiMinimalRole.java new file mode 100644 index 00000000..c557d27b --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/security/UiMinimalRole.java @@ -0,0 +1,20 @@ +package com.company.demo.security; + +import io.jmix.security.model.SecurityScope; +import io.jmix.security.role.annotation.ResourceRole; +import io.jmix.security.role.annotation.SpecificPolicy; +import io.jmix.securityflowui.role.UiMinimalPolicies; +import io.jmix.securityflowui.role.annotation.ViewPolicy; + +@ResourceRole(name = "UI: minimal access", code = UiMinimalRole.CODE, scope = SecurityScope.UI) +public interface UiMinimalRole extends UiMinimalPolicies { + + String CODE = "ui-minimal"; + + @ViewPolicy(viewIds = "MainView") + void main(); + + @ViewPolicy(viewIds = "LoginView") + @SpecificPolicy(resources = "ui.loginToUi") + void login(); +} diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/attachments/AttachmentsView.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/attachments/AttachmentsView.java new file mode 100644 index 00000000..2e1f1279 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/attachments/AttachmentsView.java @@ -0,0 +1,123 @@ +package com.company.demo.view.attachments; + +import com.company.demo.entity.ChatMessage; +import com.company.demo.view.main.MainView; +import com.vaadin.flow.component.ai.common.AIAttachment; +import com.vaadin.flow.component.ai.provider.LLMProvider; +import com.vaadin.flow.router.Route; +import io.jmix.aichat.component.chat.AttachmentClickEvent; +import io.jmix.aichat.component.chat.AttachmentRejectedEvent; +import io.jmix.aichat.data.AiChatMessageContext; +import io.jmix.core.DataManager; +import io.jmix.core.FileRef; +import io.jmix.core.FileStorage; +import io.jmix.core.FileStorageLocator; +import io.jmix.flowui.Notifications; +import io.jmix.flowui.download.Downloader; +import io.jmix.flowui.view.Install; +import io.jmix.flowui.view.MessageBundle; +import io.jmix.flowui.view.StandardView; +import io.jmix.flowui.view.Subscribe; +import io.jmix.flowui.view.ViewComponent; +import io.jmix.flowui.view.ViewController; +import io.jmix.flowui.view.ViewDescriptor; +import org.springframework.ai.content.Media; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.util.MimeTypeUtils; +import org.springframework.beans.factory.annotation.Autowired; +import reactor.core.publisher.Flux; + +import java.io.ByteArrayInputStream; +import java.util.ArrayList; +import java.util.List; + +@Route(value = "attachments", layout = MainView.class) +@ViewController(id = "AttachmentsView") +@ViewDescriptor(path = "attachments-view.xml") +public class AttachmentsView extends StandardView { + + @ViewComponent + private MessageBundle messageBundle; + + @Autowired + private ChatClient.Builder chatClientBuilder; + @Autowired + private DataManager dataManager; + @Autowired + private FileStorageLocator fileStorageLocator; + @Autowired + private Downloader downloader; + @Autowired + private Notifications notifications; + + private ChatClient chatClient; + + @Subscribe + public void onInit(final InitEvent event) { + chatClient = chatClientBuilder.build(); + } + + // tag::llm-provider-attachments[] + @Install(to = "chat", subject = "llmProvider") + private Flux llmProvider(final LLMProvider.LLMRequest request) { + String userMessage = request.userMessage(); + if (userMessage == null || userMessage.isBlank()) { // <1> + userMessage = "Describe the attached files."; + } + String text = userMessage; + + Media[] media = request.attachments().stream() // <2> + .map(attachment -> Media.builder() + .name(attachment.name()) + .mimeType(MimeTypeUtils.parseMimeType(attachment.mimeType())) + .data(new ByteArrayResource(attachment.data())) + .build()) + .toArray(Media[]::new); + + return chatClient.prompt() + .user(spec -> spec.text(text).media(media)) + .stream() + .content(); + } + // end::llm-provider-attachments[] + + // tag::attachment-factory[] + @Install(to = "chat", subject = "messageFactory") + private ChatMessage messageFactory(final AiChatMessageContext context) { + ChatMessage message = dataManager.create(ChatMessage.class); + message.setRole(context.getRole()); + message.setContent(context.getContent()); + + FileStorage fileStorage = fileStorageLocator.getDefault(); + List refs = new ArrayList<>(); + for (AIAttachment attachment : context.getAttachments()) { + refs.add(fileStorage.saveStream(attachment.name(), + new ByteArrayInputStream(attachment.data()))); + } + message.setAttachments(refs); + + return message; + } + // end::attachment-factory[] + + // tag::rejected[] + @Subscribe("chat") + public void onChatAttachmentRejected( + final AttachmentRejectedEvent event) { + notifications.show(messageBundle.formatMessage("attachmentRejected.notification", + event.getFileName(), event.getReason().name())); + } + // end::rejected[] + + // tag::download[] + @Subscribe("chat") + public void onChatAttachmentClick( + final AttachmentClickEvent event) { + FileRef fileRef = event.getFileRef(); + if (fileRef != null) { + downloader.download(fileRef); + } + } + // end::download[] +} diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/chat/ChatView.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/chat/ChatView.java new file mode 100644 index 00000000..48368a36 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/chat/ChatView.java @@ -0,0 +1,76 @@ +package com.company.demo.view.chat; + +import com.company.demo.entity.ChatMessage; +import com.company.demo.view.main.MainView; +import com.vaadin.flow.component.ai.provider.LLMProvider; +import com.vaadin.flow.router.Route; +import io.jmix.aichat.component.chat.AiChat; +import io.jmix.aichat.data.AiChatMessageContext; +import io.jmix.core.DataManager; +import io.jmix.flowui.model.CollectionContainer; +import io.jmix.flowui.view.Install; +import io.jmix.flowui.view.StandardView; +import io.jmix.flowui.view.Subscribe; +import io.jmix.flowui.view.ViewComponent; +import io.jmix.flowui.view.ViewController; +import io.jmix.flowui.view.ViewDescriptor; +import io.jmix.flowui.kit.action.ActionPerformedEvent; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.beans.factory.annotation.Autowired; +import reactor.core.publisher.Flux; + +@Route(value = "chat", layout = MainView.class) +@ViewController(id = "ChatView") +@ViewDescriptor(path = "chat-view.xml") +public class ChatView extends StandardView { + + @ViewComponent + private AiChat chat; + @ViewComponent + private CollectionContainer messagesDc; + + @Autowired + private DataManager dataManager; + + @Autowired + private ChatClient.Builder chatClientBuilder; + + private ChatClient chatClient; + + @Subscribe + public void onInit(final InitEvent event) { + chatClient = chatClientBuilder.build(); + } + + // tag::system-prompt[] + @Install(to = "chat", subject = "llmProvider") + private Flux llmProvider(final LLMProvider.LLMRequest request) { + ChatClient.ChatClientRequestSpec prompt = chatClient.prompt(); + + String systemPrompt = request.systemPrompt(); // <1> + if (systemPrompt != null && !systemPrompt.isBlank()) { + prompt = prompt.system(systemPrompt); + } + + return prompt.user(request.userMessage()) + .stream() + .content(); + } + // end::system-prompt[] + + // tag::message-factory[] + @Install(to = "chat", subject = "messageFactory") + private ChatMessage messageFactory(final AiChatMessageContext context) { + ChatMessage message = dataManager.create(ChatMessage.class); + message.setRole(context.getRole()); + message.setContent(context.getContent()); + return message; + } + // end::message-factory[] + + @Subscribe("chat.clearAction") + public void onClearAction(final ActionPerformedEvent event) { + dataManager.remove(messagesDc.getItems()); + messagesDc.setItems(java.util.List.of()); + } +} diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/codeblock/CodeBlockView.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/codeblock/CodeBlockView.java new file mode 100644 index 00000000..bd799a6a --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/codeblock/CodeBlockView.java @@ -0,0 +1,44 @@ +package com.company.demo.view.codeblock; + +import com.company.demo.view.main.MainView; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.Route; +import io.jmix.aichat.component.codeblock.AiCodeBlock; +import io.jmix.aichat.kit.component.codeblock.AiCodeBlockLanguage; +import io.jmix.flowui.UiComponents; +import io.jmix.flowui.view.StandardView; +import io.jmix.flowui.view.Subscribe; +import io.jmix.flowui.view.ViewComponent; +import io.jmix.flowui.view.ViewController; +import io.jmix.flowui.view.ViewDescriptor; +import org.springframework.beans.factory.annotation.Autowired; + +@Route(value = "code-block", layout = MainView.class) +@ViewController(id = "CodeBlockView") +@ViewDescriptor(path = "code-block-view.xml") +public class CodeBlockView extends StandardView { + + @ViewComponent + private VerticalLayout programmaticBox; + + @Autowired + private UiComponents uiComponents; + + @Subscribe + public void onInit(final InitEvent event) { + // tag::programmatic[] + AiCodeBlock codeBlock = uiComponents.create(AiCodeBlock.class); + codeBlock.setLanguage(AiCodeBlockLanguage.JAVA); + codeBlock.setCode(""" + @Autowired + private Notifications notifications; + + @Subscribe("saveButton") + public void onSaveButtonClick(final ClickEvent event) { + notifications.show("Saved"); + }"""); + + programmaticBox.add(codeBlock); + // end::programmatic[] + } +} diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/generation/GenerationView.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/generation/GenerationView.java new file mode 100644 index 00000000..ef4a151f --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/generation/GenerationView.java @@ -0,0 +1,90 @@ +package com.company.demo.view.generation; + +import com.company.demo.view.main.MainView; +import com.vaadin.flow.component.ClickEvent; +import com.vaadin.flow.component.ai.provider.LLMProvider; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.router.Route; +import io.jmix.aichat.component.chat.AiChat; +import io.jmix.aichat.component.chat.GenerationFailedEvent; +import io.jmix.aichat.component.chat.GenerationStateChangeEvent; +import io.jmix.aichat.component.messagelist.GenerationState; +import io.jmix.aichat.data.SimpleAiChatMessage; +import io.jmix.flowui.Notifications; +import io.jmix.flowui.kit.component.button.JmixButton; +import io.jmix.flowui.view.MessageBundle; +import io.jmix.flowui.view.StandardView; +import io.jmix.flowui.view.Subscribe; +import io.jmix.flowui.view.ViewComponent; +import io.jmix.flowui.view.ViewController; +import io.jmix.flowui.view.ViewDescriptor; +import io.jmix.flowui.view.Install; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.beans.factory.annotation.Autowired; +import reactor.core.publisher.Flux; + +@Route(value = "generation", layout = MainView.class) +@ViewController(id = "GenerationView") +@ViewDescriptor(path = "generation-view.xml") +public class GenerationView extends StandardView { + + @ViewComponent + private AiChat chat; + @ViewComponent + private JmixButton stopButton; + @ViewComponent + private Span stateSpan; + @ViewComponent + private MessageBundle messageBundle; + + @Autowired + private Notifications notifications; + + @Autowired + private ChatClient.Builder chatClientBuilder; + + private ChatClient chatClient; + + @Subscribe + public void onInit(final InitEvent event) { + chatClient = chatClientBuilder.build(); + } + + @Install(to = "chat", subject = "llmProvider") + private Flux llmProvider(final LLMProvider.LLMRequest request) { + return chatClient.prompt() + .user(request.userMessage()) + .stream() + .content(); + } + + // tag::state-listener[] + @Subscribe("chat") + public void onChatGenerationStateChange( + final GenerationStateChangeEvent event) { + stateSpan.setText(event.getNewState().name()); + stopButton.setEnabled(event.getNewState() == GenerationState.GENERATING); + } + // end::state-listener[] + + // tag::regenerate[] + @Subscribe("stopButton") + public void onStopButtonClick(final ClickEvent event) { + chat.stop(); + } + + @Subscribe("regenerateButton") + public void onRegenerateButtonClick(final ClickEvent event) { + chat.regenerate(); + } + // end::regenerate[] + + // tag::failed-listener[] + @Subscribe("chat") + public void onChatGenerationFailed( + final GenerationFailedEvent event) { + notifications.show(messageBundle.formatMessage("generationFailed.notification", + event.getError().getMessage())); + } + // end::failed-listener[] +} diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/localization/LocalizationView.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/localization/LocalizationView.java new file mode 100644 index 00000000..18acf195 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/localization/LocalizationView.java @@ -0,0 +1,37 @@ +package com.company.demo.view.localization; + +import com.company.demo.view.main.MainView; +import com.vaadin.flow.component.ai.provider.LLMProvider; +import com.vaadin.flow.router.Route; +import io.jmix.flowui.view.Install; +import io.jmix.flowui.view.StandardView; +import io.jmix.flowui.view.Subscribe; +import io.jmix.flowui.view.ViewController; +import io.jmix.flowui.view.ViewDescriptor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.beans.factory.annotation.Autowired; +import reactor.core.publisher.Flux; + +@Route(value = "localization", layout = MainView.class) +@ViewController(id = "LocalizationView") +@ViewDescriptor(path = "localization-view.xml") +public class LocalizationView extends StandardView { + + @Autowired + private ChatClient.Builder chatClientBuilder; + + private ChatClient chatClient; + + @Subscribe + public void onInit(final InitEvent event) { + chatClient = chatClientBuilder.build(); + } + + @Install(to = "chat", subject = "llmProvider") + private Flux llmProvider(final LLMProvider.LLMRequest request) { + return chatClient.prompt() + .user(request.userMessage()) + .stream() + .content(); + } +} diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/login/LoginView.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/login/LoginView.java new file mode 100644 index 00000000..2d2b9183 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/login/LoginView.java @@ -0,0 +1,140 @@ +package com.company.demo.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.component.notification.Notification; +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.CoreProperties; +import io.jmix.core.MessageTools; +import io.jmix.core.security.AccessDeniedException; +import io.jmix.flowui.Notifications; +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.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; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Route(value = "login") +@ViewController(id = "LoginView") +@ViewDescriptor(path = "login-view.xml") +public class LoginView extends StandardView implements LocaleChangeObserver { + + private static final Logger log = LoggerFactory.getLogger(LoginView.class); + + @Autowired + private CoreProperties coreProperties; + + @Autowired + private LoginViewSupport loginViewSupport; + + @Autowired + private MessageTools messageTools; + + @Autowired + private Notifications notifications; + + @ViewComponent + private JmixLoginForm login; + + @ViewComponent + private MessageBundle messageBundle; + + @Value("${ui.login.defaultUsername:}") + private String defaultUsername; + + @Value("${ui.login.defaultPassword:}") + private String defaultPassword; + + @Subscribe + public void onInit(final InitEvent event) { + initLocales(); + initDefaultCredentials(); + showWarningIfDefaultCredentials(); + } + + private void showWarningIfDefaultCredentials() { + if ("admin".equals(defaultPassword)) { + notifications.create("WARNING: Change admin password and remove ui.login.default* properties when deploying the application to production") + .withPosition(Notification.Position.BOTTOM_CENTER) + .withDuration(10000) + .show(); + } + } + + private void initLocales() { + LinkedHashMap locales = coreProperties.getAvailableLocales().stream() + .collect(Collectors.toMap(Function.identity(), messageTools::getLocaleDisplayName, (s1, s2) -> s1, + LinkedHashMap::new)); + + ComponentUtils.setItemsMap(login, locales); + + login.setSelectedLocale(VaadinSession.getCurrent().getLocale()); + } + + private 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")); + errorMessage.setUsername(messageBundle.getMessage("loginForm.errorUsername")); + errorMessage.setPassword(messageBundle.getMessage("loginForm.errorPassword")); + loginI18n.setErrorMessage(errorMessage); + + login.setI18n(loginI18n); + } +} diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/main/MainView.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/main/MainView.java new file mode 100644 index 00000000..1b5068d8 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/main/MainView.java @@ -0,0 +1,118 @@ +package com.company.demo.view.main; + +import com.company.demo.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(id = "MainView") +@ViewDescriptor(path = "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.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-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/messageinput/MessageInputView.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/messageinput/MessageInputView.java new file mode 100644 index 00000000..ad98bd97 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/messageinput/MessageInputView.java @@ -0,0 +1,69 @@ +package com.company.demo.view.messageinput; + +import com.company.demo.view.main.MainView; +import com.vaadin.flow.component.BlurNotifier; +import com.vaadin.flow.component.ClickEvent; +import com.vaadin.flow.component.FocusNotifier; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.router.Route; +import io.jmix.aichat.component.messageinput.AiMessageInput; +import io.jmix.flowui.Notifications; +import io.jmix.flowui.kit.component.button.JmixButton; +import io.jmix.flowui.view.MessageBundle; +import io.jmix.flowui.view.StandardView; +import io.jmix.flowui.view.Subscribe; +import io.jmix.flowui.view.ViewComponent; +import io.jmix.flowui.view.ViewController; +import io.jmix.flowui.view.ViewDescriptor; +import org.springframework.beans.factory.annotation.Autowired; + +@Route(value = "message-input", layout = MainView.class) +@ViewController(id = "MessageInputView") +@ViewDescriptor(path = "message-input-view.xml") +public class MessageInputView extends StandardView { + + @ViewComponent + private Span lastMessageSpan; + @ViewComponent + private Span focusStatusSpan; + @ViewComponent + private MessageBundle messageBundle; + + @Autowired + private Notifications notifications; + + // tag::submit[] + @Subscribe("basicsComposer") + public void onBasicsComposerSubmit(final AiMessageInput.SubmitEvent event) { + lastMessageSpan.setText(event.getValue()); + } + // end::submit[] + + // tag::slot-injection[] + @ViewComponent + private JmixButton promptLibraryButton; + + @Subscribe + public void onInit(final InitEvent event) { + promptLibraryButton.addClickListener(clickEvent -> + notifications.show(messageBundle.getMessage("promptLibraryButton.notification"))); + } + // end::slot-injection[] + + @Subscribe("clearButton") + public void onClearButtonClick(final ClickEvent event) { + lastMessageSpan.setText(""); + } + + // tag::focus-events[] + @Subscribe("focusComposer") + public void onFocusComposerFocus(final FocusNotifier.FocusEvent event) { + focusStatusSpan.setText(messageBundle.getMessage("focusStatus.focused")); + } + + @Subscribe("focusComposer") + public void onFocusComposerBlur(final BlurNotifier.BlurEvent event) { + focusStatusSpan.setText(messageBundle.getMessage("focusStatus.blurred")); + } + // end::focus-events[] +} diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/messagelist/MessageListView.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/messagelist/MessageListView.java new file mode 100644 index 00000000..7f6cdd02 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/messagelist/MessageListView.java @@ -0,0 +1,218 @@ +package com.company.demo.view.messagelist; + +import com.company.demo.view.main.MainView; +import com.vaadin.flow.component.ClickEvent; +import com.vaadin.flow.router.Route; +import io.jmix.aichat.action.AiMessageActionPerformedEvent; +import io.jmix.aichat.component.messagelist.AiMessageList; +import io.jmix.aichat.component.messagelist.AiMessageListItem; +import io.jmix.aichat.component.messagelist.GenerationState; +import io.jmix.aichat.component.messagelist.ThinkingStatusItem; +import io.jmix.aichat.component.messagelist.ThinkingStatusPublisher; +import io.jmix.flowui.Notifications; +import io.jmix.flowui.backgroundtask.BackgroundTask; +import io.jmix.flowui.backgroundtask.BackgroundWorker; +import io.jmix.flowui.backgroundtask.TaskLifeCycle; +import io.jmix.flowui.kit.component.button.JmixButton; +import io.jmix.flowui.view.MessageBundle; +import io.jmix.flowui.view.StandardView; +import io.jmix.flowui.view.Subscribe; +import io.jmix.flowui.view.ViewComponent; +import io.jmix.flowui.view.ViewController; +import io.jmix.flowui.view.ViewDescriptor; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Route(value = "message-list", layout = MainView.class) +@ViewController(id = "MessageListView") +@ViewDescriptor(path = "message-list-view.xml") +public class MessageListView extends StandardView { + + private static final List ANSWER_CHUNKS = List.of( + "A fetch plan tells Jmix which attributes to load. ", + "Declare one in the view descriptor, or pass it to `DataManager`, ", + "and the reference is loaded in the same query instead of one query per row."); + + @ViewComponent + private AiMessageList transcriptList; + @ViewComponent + private AiMessageList drivenList; + @ViewComponent + private AiMessageList customIndicatorList; + @ViewComponent + private AiMessageList actionsList; + @ViewComponent + private AiMessageList announceList; + @ViewComponent + private AiMessageList i18nList; + @ViewComponent + private MessageBundle messageBundle; + + @Autowired + private Notifications notifications; + @Autowired + private BackgroundWorker backgroundWorker; + + @Subscribe + public void onInit(final InitEvent event) { + // tag::items[] + transcriptList.setItems( + AiMessageListItem.user("How do I load orders for a customer?"), + AiMessageListItem.assistant(""" + ## Loading orders + + Use `DataManager` with a JPQL query: + + ```java + List orders = dataManager.load(Order.class) + .query("select o from Order o where o.customer = :customer") + .parameter("customer", customer) + .list(); + ``` + + A few things to keep in mind: + + - add a fetch plan when you need referenced entities + - use `maxResults` to page the result + """), + AiMessageListItem.user(LONG_QUESTION) + ); + // end::items[] + + actionsList.setItems( + AiMessageListItem.user("Summarise this thread."), + AiMessageListItem.assistant("The customer reports slow order loading; " + + "the fix is to add a fetch plan.") + ); + announceList.setItems(AiMessageListItem.assistant("New messages are announced " + + "to screen readers as they arrive.")); + i18nList.setItems(AiMessageListItem.assistant(""" + This list re-words its own labels: + + ```sql + select * from ORDER_ + ``` + """)); + + registerRetryListener(); + } + + // tag::add-item[] + @Subscribe("addItemButton") + public void onAddItemButtonClick(final ClickEvent event) { + transcriptList.addItem(AiMessageListItem.user("And how do I page the result?")); + } + // end::add-item[] + + // tag::driving[] + @Subscribe("generateButton") + public void onGenerateButtonClick(final ClickEvent event) { + drivenList.addItem(AiMessageListItem.user("What is a fetch plan?")); + + AiMessageListItem answer = AiMessageListItem.assistant(""); + drivenList.addItem(answer); + drivenList.setState(GenerationState.GENERATING); + + backgroundWorker.handle(new AnswerTask(answer)).execute(); + } + + private class AnswerTask extends BackgroundTask { + + private final AiMessageListItem answer; + + private AnswerTask(AiMessageListItem answer) { + super(30, TimeUnit.SECONDS, MessageListView.this); + this.answer = answer; + } + + @Override + public Void run(TaskLifeCycle taskLifeCycle) throws Exception { + // tag::thinking-status[] + ThinkingStatusPublisher statuses = drivenList.getThinkingStatusPublisher(); + + ThinkingStatusItem searching = statuses.start("Searching the documentation"); + Thread.sleep(1200); + searching.complete("4 pages"); + + ThinkingStatusItem writing = statuses.start("Writing the answer"); + // end::thinking-status[] + + for (String chunk : ANSWER_CHUNKS) { + Thread.sleep(400); + taskLifeCycle.publish(chunk); + } + + writing.complete(); + return null; + } + + @Override + public void progress(List chunks) { // <1> + chunks.forEach(answer::appendText); + } + + @Override + public void done(Void result) { + drivenList.setState(GenerationState.IDLE); + drivenList.getThinkingStatusPublisher().clear(); + } + } + // end::driving[] + + // tag::retry[] + @Subscribe("failButton") + public void onFailButtonClick(final ClickEvent event) { + drivenList.addItem(AiMessageListItem.user("Why did that fail?")); + drivenList.setState(GenerationState.ERROR); + } + + private void registerRetryListener() { + drivenList.addRetryListener(event -> { + AiMessageListItem userMessage = event.getUserMessage(); + notifications.show(messageBundle.formatMessage("retry.notification", + userMessage != null ? userMessage.getText() : "-")); + drivenList.setState(GenerationState.IDLE); + }); + } + // end::retry[] + + @Subscribe("toggleIndicatorButton") + public void onToggleIndicatorButtonClick(final ClickEvent event) { + if (customIndicatorList.getState() == GenerationState.GENERATING) { + customIndicatorList.setState(GenerationState.IDLE); + return; + } + // the indicator is shown on the message being generated, so the list + // needs a trailing assistant message for it to attach to + customIndicatorList.setItems( + AiMessageListItem.user("What is a fetch plan?"), + AiMessageListItem.assistant("")); + customIndicatorList.setState(GenerationState.GENERATING); + } + + // tag::action-handler[] + @Subscribe("actionsList.reportAction") + public void onReportAction(final AiMessageActionPerformedEvent event) { + AiMessageListItem item = event.getItem(); + notifications.show(messageBundle.formatMessage("reportAction.notification", + item.getText())); + } + // end::action-handler[] + + private static final String LONG_QUESTION = """ + Thanks, that helps. One more thing, and it is the part I keep going \ + back and forth on: we import orders from an external system every night, \ + and a fair number of them arrive without a customer attached at all, \ + usually because the upstream system lets an operator save a draft order \ + before the account has been created. The nightly import job should not \ + fail on those rows, and it should not silently drop them either, because \ + somebody has to look at them the next morning and assign the right \ + customer by hand. At the same time the customer attribute is genuinely \ + required everywhere else in the application, and I do not want to make it \ + optional across the whole data model just to accommodate one import path. \ + What is the cleanest way to model that in Jmix, and is there a pattern for \ + keeping the validation strict in the UI while letting the import write rows \ + that are temporarily incomplete?"""; +} diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/quickstart/QuickStartView.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/quickstart/QuickStartView.java new file mode 100644 index 00000000..c50e32ee --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/quickstart/QuickStartView.java @@ -0,0 +1,39 @@ +package com.company.demo.view.quickstart; + +import com.company.demo.view.main.MainView; +import com.vaadin.flow.component.ai.provider.LLMProvider; +import com.vaadin.flow.router.Route; +import io.jmix.flowui.view.Install; +import io.jmix.flowui.view.StandardView; +import io.jmix.flowui.view.Subscribe; +import io.jmix.flowui.view.ViewController; +import io.jmix.flowui.view.ViewDescriptor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.beans.factory.annotation.Autowired; +import reactor.core.publisher.Flux; + +@Route(value = "quick-start", layout = MainView.class) +@ViewController(id = "QuickStartView") +@ViewDescriptor(path = "quick-start-view.xml") +public class QuickStartView extends StandardView { + + // tag::llm-provider[] + @Autowired + private ChatClient.Builder chatClientBuilder; + + private ChatClient chatClient; + + @Subscribe + public void onInit(final InitEvent event) { + chatClient = chatClientBuilder.build(); + } + + @Install(to = "chat", subject = "llmProvider") + private Flux llmProvider(final LLMProvider.LLMRequest request) { + return chatClient.prompt() + .user(request.userMessage()) + .stream() + .content(); + } + // end::llm-provider[] +} diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/styling/StylingView.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/styling/StylingView.java new file mode 100644 index 00000000..d26cb725 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/styling/StylingView.java @@ -0,0 +1,37 @@ +package com.company.demo.view.styling; + +import com.company.demo.view.main.MainView; +import com.vaadin.flow.component.ai.provider.LLMProvider; +import com.vaadin.flow.router.Route; +import io.jmix.flowui.view.Install; +import io.jmix.flowui.view.StandardView; +import io.jmix.flowui.view.Subscribe; +import io.jmix.flowui.view.ViewController; +import io.jmix.flowui.view.ViewDescriptor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.beans.factory.annotation.Autowired; +import reactor.core.publisher.Flux; + +@Route(value = "styling", layout = MainView.class) +@ViewController(id = "StylingView") +@ViewDescriptor(path = "styling-view.xml") +public class StylingView extends StandardView { + + @Autowired + private ChatClient.Builder chatClientBuilder; + + private ChatClient chatClient; + + @Subscribe + public void onInit(final InitEvent event) { + chatClient = chatClientBuilder.build(); + } + + @Install(to = "chat", subject = "llmProvider") + private Flux llmProvider(final LLMProvider.LLMRequest request) { + return chatClient.prompt() + .user(request.userMessage()) + .stream() + .content(); + } +} diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/user/UserDetailView.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/user/UserDetailView.java new file mode 100644 index 00000000..1eb2d5db --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/user/UserDetailView.java @@ -0,0 +1,94 @@ +package com.company.demo.view.user; + +import com.company.demo.entity.User; +import com.company.demo.view.main.MainView; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.notification.NotificationVariant; +import com.vaadin.flow.component.textfield.PasswordField; +import com.vaadin.flow.router.Route; +import io.jmix.core.EntityStates; +import io.jmix.flowui.Notifications; +import io.jmix.flowui.component.textfield.TypedTextField; +import io.jmix.flowui.view.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.List; +import java.util.Objects; +import java.util.TimeZone; + +@Route(value = "users/:id", layout = MainView.class) +@ViewController(id = "User.detail") +@ViewDescriptor(path = "user-detail-view.xml") +@EditedEntityContainer("userDc") +public class UserDetailView extends StandardDetailView { + + @ViewComponent + private TypedTextField usernameField; + @ViewComponent + private PasswordField passwordField; + @ViewComponent + private PasswordField confirmPasswordField; + @ViewComponent + private ComboBox timeZoneField; + @ViewComponent + private MessageBundle messageBundle; + @Autowired + private Notifications notifications; + + @Autowired + private EntityStates entityStates; + @Autowired + private PasswordEncoder passwordEncoder; + + private boolean newEntity; + + @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); + } + + @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 + public void onBeforeSave(final BeforeSaveEvent event) { + if (entityStates.isNew(getEditedEntity())) { + getEditedEntity().setPassword(passwordEncoder.encode(passwordField.getValue())); + + newEntity = true; + } + } + + @Subscribe + public void onAfterSave(final AfterSaveEvent event) { + if (newEntity) { + notifications.create(messageBundle.getMessage("noAssignedRolesNotification")) + .withThemeVariant(NotificationVariant.WARNING) + .withPosition(Notification.Position.TOP_END) + .show(); + + newEntity = false; + } + } +} \ No newline at end of file diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/user/UserListView.java b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/user/UserListView.java new file mode 100644 index 00000000..8fd327b2 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/java/com/company/demo/view/user/UserListView.java @@ -0,0 +1,14 @@ +package com.company.demo.view.user; + +import com.company.demo.entity.User; +import com.company.demo.view.main.MainView; +import com.vaadin.flow.router.Route; +import io.jmix.flowui.view.*; + +@Route(value = "users", layout = MainView.class) +@ViewController(id = "User.list") +@ViewDescriptor(path = "user-list-view.xml") +@LookupComponent("usersDataGrid") +@DialogMode(width = "64em") +public class UserListView extends StandardListView { +} \ No newline at end of file diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/icons/icon.png b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/icons/icon.png new file mode 100644 index 00000000..910199d9 Binary files /dev/null and b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/icons/icon.png differ diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/public/images/logo.png b/content/modules/ai-chat/examples/ai-chat-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-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/public/images/logo.png differ diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/ai-chat-ex1.css b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/ai-chat-ex1.css new file mode 100644 index 00000000..d85e934f --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/ai-chat-ex1.css @@ -0,0 +1 @@ +/* Define your styles here */ \ No newline at end of file diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/styles.css b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/styles.css new file mode 100644 index 00000000..2ace919f --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/styles.css @@ -0,0 +1,6 @@ +@import url('view/main-view.css'); +@import url('view/main-view-top-menu.css'); +@import url('view/login-view.css'); +@import url('view/code-block-view.css'); +@import url('view/styling-view.css'); +@import url('ai-chat-ex1.css'); \ No newline at end of file diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/view/code-block-view.css b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/view/code-block-view.css new file mode 100644 index 00000000..5e017aff --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/view/code-block-view.css @@ -0,0 +1,6 @@ +/* tag::styling[] */ +.tinted-code-block { + --jmix-ai-code-block-background: var(--vaadin-background-container); + --jmix-ai-code-block-color-keyword: #7c3aed; +} +/* end::styling[] */ diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/view/login-view.css b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/view/login-view.css new file mode 100644 index 00000000..cbab2bef --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-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-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/view/main-view-top-menu.css b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/view/main-view-top-menu.css new file mode 100644 index 00000000..9fc6f093 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-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-drawer-width: 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-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/view/main-view.css b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/view/main-view.css new file mode 100644 index 00000000..77188e8f --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/view/main-view.css @@ -0,0 +1,273 @@ +/* Predefined styles for the MainView */ + +.jmix-main-view-app-layout { + --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'])::part(content) { + 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'])::part(content) { + 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-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/view/styling-view.css b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/view/styling-view.css new file mode 100644 index 00000000..0d0a6459 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-aura/view/styling-view.css @@ -0,0 +1,14 @@ +/* tag::knobs[] */ +.branded-chat { + --jmix-ai-chat-max-width: 40em; + --jmix-ai-code-block-background: var(--vaadin-background-container); + --jmix-ai-message-input-gap: var(--vaadin-gap-m); +} +/* end::knobs[] */ + +/* tag::part[] */ +.branded-chat jmix-ai-message-input::part(input-container) { + border-radius: 0; + border-color: var(--vaadin-border-color-secondary); +} +/* end::part[] */ diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/ai-chat-ex1.css b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/ai-chat-ex1.css new file mode 100644 index 00000000..9ae8e5e9 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/ai-chat-ex1.css @@ -0,0 +1 @@ +/* Define your styles here */ diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/styles.css b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/styles.css new file mode 100644 index 00000000..2ace919f --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/styles.css @@ -0,0 +1,6 @@ +@import url('view/main-view.css'); +@import url('view/main-view-top-menu.css'); +@import url('view/login-view.css'); +@import url('view/code-block-view.css'); +@import url('view/styling-view.css'); +@import url('ai-chat-ex1.css'); \ No newline at end of file diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/view/code-block-view.css b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/view/code-block-view.css new file mode 100644 index 00000000..5e017aff --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/view/code-block-view.css @@ -0,0 +1,6 @@ +/* tag::styling[] */ +.tinted-code-block { + --jmix-ai-code-block-background: var(--vaadin-background-container); + --jmix-ai-code-block-color-keyword: #7c3aed; +} +/* end::styling[] */ diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/view/login-view.css b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/view/login-view.css new file mode 100644 index 00000000..c04446c2 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-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-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/view/main-view-top-menu.css b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/view/main-view-top-menu.css new file mode 100644 index 00000000..766cc3d2 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-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-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/view/main-view.css b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/view/main-view.css new file mode 100644 index 00000000..994c1e25 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-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-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/view/styling-view.css b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/view/styling-view.css new file mode 100644 index 00000000..0d0a6459 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/META-INF/resources/themes/ai-chat-ex1-lumo/view/styling-view.css @@ -0,0 +1,14 @@ +/* tag::knobs[] */ +.branded-chat { + --jmix-ai-chat-max-width: 40em; + --jmix-ai-code-block-background: var(--vaadin-background-container); + --jmix-ai-message-input-gap: var(--vaadin-gap-m); +} +/* end::knobs[] */ + +/* tag::part[] */ +.branded-chat jmix-ai-message-input::part(input-container) { + border-radius: 0; + border-color: var(--vaadin-border-color-secondary); +} +/* end::part[] */ diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/application.properties b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/application.properties new file mode 100644 index 00000000..5febe259 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/application.properties @@ -0,0 +1,47 @@ +main.datasource.url=jdbc:hsqldb:file:.jmix/hsqldb/aichatex1 +main.datasource.username=sa +main.datasource.password= + +main.liquibase.change-log=com/company/demo/liquibase/changelog.xml + +jmix.ui.login-view-id=LoginView +jmix.ui.main-view-id=MainView +jmix.ui.menu-config=com/company/demo/menu.xml +jmix.ui.composite-menu=true + +ui.login.defaultUsername=admin +ui.login.defaultPassword=admin + +jmix.core.available-locales=en + +# Launch the default browser when starting the application in development mode +vaadin.launch-browser=false + +# Enable colors in logs +spring.output.ansi.enabled=always + +# '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 + +# 'debug' or 'trace' levels help to troubleshoot security problems +logging.level.org.springframework.security=info + +logging.level.org.atmosphere=warn + +# Attachments require a multimodal model: the default qwen3:8b is text-only and +# refuses any attached file. Override with OLLAMA_CHAT_MODEL to try them. +# tag::provider-properties[] +spring.ai.model.chat=ollama +spring.ai.ollama.base-url=${OLLAMA_BASE_URL:http://localhost:11434} +spring.ai.ollama.chat.model=${OLLAMA_CHAT_MODEL:qwen3:8b} +spring.ai.ollama.init.pull-model-strategy=never +# end::provider-properties[] diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/liquibase/changelog.xml b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/liquibase/changelog.xml new file mode 100644 index 00000000..d9999f0f --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/liquibase/changelog.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/liquibase/changelog/010-init-user.xml b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/liquibase/changelog/010-init-user.xml new file mode 100644 index 00000000..261a3cd2 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/liquibase/changelog/010-init-user.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/liquibase/changelog/020-chat-message.xml b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/liquibase/changelog/020-chat-message.xml new file mode 100644 index 00000000..5722761d --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/liquibase/changelog/020-chat-message.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/liquibase/changelog/2026/09/15-150757-a91207ed.xml b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/liquibase/changelog/2026/09/15-150757-a91207ed.xml new file mode 100644 index 00000000..3bd1ec71 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/liquibase/changelog/2026/09/15-150757-a91207ed.xml @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/menu.xml b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/menu.xml new file mode 100644 index 00000000..5b509d64 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/menu.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/messages_en.properties b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/messages_en.properties new file mode 100644 index 00000000..ecbc680a --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/messages_en.properties @@ -0,0 +1,146 @@ +databaseUniqueConstraintViolation.IDX_USER__ON_USERNAME=A user with the same username already exists + +com.company.demo.entity/User=User +com.company.demo.entity/User.id=ID +com.company.demo.entity/User.username=Username +com.company.demo.entity/User.firstName=First name +com.company.demo.entity/User.lastName=Last name +com.company.demo.entity/User.password=Password +com.company.demo.entity/User.email=Email +com.company.demo.entity/User.timeZoneId=Time zone +com.company.demo.entity/User.active=Active +com.company.demo.entity/User.passwordChangeRequired=Password change required +com.company.demo.entity/User.version=Version + +com.company.demo.view.main/MainView.title=Ai Chat Ex1 +com.company.demo.view.main/applicationTitle.text=Ai Chat Ex1 +com.company.demo.view.main/navigation.ariaLabel=Views +com.company.demo.view.main/drawerToggle.ariaLabel=Menu toggle + +com.company.demo.view.login/LoginView.title=Login - Ai Chat Ex1 +com.company.demo.view.login/loginForm.headerTitle=Ai Chat Ex1 +com.company.demo.view.login/loginForm.username=Username +com.company.demo.view.login/loginForm.password=Password +com.company.demo.view.login/loginForm.submit=Log in +com.company.demo.view.login/loginForm.forgotPassword=Forgot password +com.company.demo.view.login/loginForm.errorTitle=Login failed +com.company.demo.view.login/loginForm.badCredentials=Check that you have entered the correct username and password and try again +com.company.demo.view.login/loginForm.errorUsername=Username is required +com.company.demo.view.login/loginForm.errorPassword=Password is required +com.company.demo.view.login/loginForm.rememberMe=Remember me + +com.company.demo.view.user/UserListView.title=Users +com.company.demo.view.user/UserDetailView.title=User +com.company.demo.view.user/confirmPassword=Confirm password +com.company.demo.view.user/passwordsDoNotMatch=Passwords do not match +com.company.demo.view.user/additionalMenu=Additional +com.company.demo.view.user/noAssignedRolesNotification=Assign roles to the new user + +com.company.demo/menu.application.title=Application +com.company.demo.view.codeblock/CodeBlockView.title=Code block +com.company.demo.view.codeblock/basicsSection.title=Basics +com.company.demo.view.codeblock/codeFromBundleSection.title=Code from the message bundle +com.company.demo.view.codeblock/noLanguageSection.title=Without a language +com.company.demo.view.codeblock/highlightOffSection.title=Highlighting disabled +com.company.demo.view.codeblock/actionsDisabledSection.title=Actions disabled +com.company.demo.view.codeblock/programmaticSection.title=Created in the controller +com.company.demo.view.codeblock/i18nSection.title=Per-instance localization +com.company.demo.view.codeblock/stylingSection.title=Styling + +# tag::code-block-code[] +com.company.demo.view.codeblock/runCommand.code=./gradlew -Pvaadin.productionMode=true bootJar +# end::code-block-code[] + +# tag::code-block-i18n[] +com.company.demo.view.codeblock/i18nBlock.copy=Copy the compose file +com.company.demo.view.codeblock/i18nBlock.copied=Compose file copied +# end::code-block-i18n[] + +com.company.demo.view.messageinput/MessageInputView.title=Message input +com.company.demo.view.messageinput/basicsSection.title=Basics +com.company.demo.view.messageinput/maxRowsSection.title=Limited rows +com.company.demo.view.messageinput/enterActionSection.title=Enter inserts a newline +com.company.demo.view.messageinput/slotsSection.title=Prefix and suffix +com.company.demo.view.messageinput/bandsSection.title=Header and footer bands +com.company.demo.view.messageinput/fixedToolbarSection.title=Fixed toolbar +com.company.demo.view.messageinput/noGapSection.title=Band against the box +com.company.demo.view.messageinput/focusSection.title=Focus and blur +com.company.demo.view.messageinput/i18nSection.title=Per-instance localization +com.company.demo.view.messageinput/promptLibraryButton.title=Add context +com.company.demo.view.messageinput/promptLibraryButton.notification=Attachment and context options would open here +com.company.demo.view.messageinput/clearButton.title=Voice input +com.company.demo.view.messageinput/modelButton.text=GPT-4o +com.company.demo.view.messageinput/contextSpan.text=Replying in: Support ticket #4711 +com.company.demo.view.messageinput/focusStatus.focused=The composer has focus +com.company.demo.view.messageinput/focusStatus.blurred=The composer does not have focus + +# tag::message-input-bands[] +com.company.demo.view.messageinput/disclaimer.text=AI can make mistakes. Check important information. +# end::message-input-bands[] + +# tag::message-input-i18n[] +com.company.demo.view.messageinput/i18nComposer.placeholder=Ask about this ticket +# end::message-input-i18n[] + +com.company.demo.view.messagelist/MessageListView.title=Message list +com.company.demo.view.messagelist/transcriptSection.title=A transcript +com.company.demo.view.messagelist/actionsSection.title=Per-message actions +com.company.demo.view.messagelist/announceSection.title=Announced messages +com.company.demo.view.messagelist/i18nSection.title=Per-instance localization +com.company.demo.view.messagelist/drivingSection.title=Driving the list by hand +com.company.demo.view.messagelist/customIndicatorSection.title=A custom thinking indicator +com.company.demo.view.messagelist/addItemButton.text=Add a message +com.company.demo.view.messagelist/generateButton.text=Generate an answer +com.company.demo.view.messagelist/failButton.text=Simulate a failure +com.company.demo.view.messagelist/toggleIndicatorButton.text=Toggle generating +com.company.demo.view.messagelist/retry.notification=Retrying: %s +com.company.demo.view.messagelist/reportAction.description=Report this answer +com.company.demo.view.messagelist/reportAction.notification=Reported: %s +com.company.demo.view.messagelist/customIndicator.alternateText=Assistant is working +com.company.demo.view.messagelist/thinkingStage.thinking=Thinking +com.company.demo.view.messagelist/thinkingStage.stillThinking=Still working on it + +# tag::message-list-i18n[] +com.company.demo.view.messagelist/i18nList.showMore=Read the whole question +com.company.demo.view.messagelist/i18nList.showLess=Collapse the question +com.company.demo.view.messagelist/i18nList.codeCopy=Copy this snippet +# end::message-list-i18n[] + +com.company.demo.view.quickstart/QuickStartView.title=Quick start +com.company.demo.view.chat/ChatView.title=Chat +com.company.demo.view.chat/clearAction.text=Clear +com.company.demo.view.chat/contextSpan.text=Replying in: Support ticket #4711 +com.company.demo.view.chat/emptyStateHeader.text=How can I help? +com.company.demo.view.chat/emptyStateFooter.text=Ask about orders, customers or fetch plans. +com.company.demo.view.generation/GenerationView.title=Generation lifecycle +com.company.demo.view.generation/stopButton.text=Stop +com.company.demo.view.generation/regenerateButton.text=Regenerate +com.company.demo.view.generation/generationFailed.notification=Generation failed: %s + +# tag::chat-bands[] +com.company.demo.view.chat/disclaimer.text=AI can make mistakes. Check important information. +# end::chat-bands[] + +com.company.demo.view.attachments/AttachmentsView.title=Attachments +com.company.demo.view.attachments/menuAttachTrigger.text=Attach a file +com.company.demo.view.attachments/attachmentRejected.notification=%s was rejected: %s +com.company.demo.view.localization/LocalizationView.title=Localization +com.company.demo.view.styling/StylingView.title=Styling + +# tag::localization-keys[] +com.company.demo.view.localization/chat.generating=Working on it +com.company.demo.view.localization/chat.failed=That did not work +com.company.demo.view.localization/chat.placeholder=Ask about this ticket +com.company.demo.view.localization/chat.send=Send the question +com.company.demo.view.localization/chat.retry=Try again +com.company.demo.view.localization/chat.showMore=Read the whole question +com.company.demo.view.localization/chat.codeCopy=Copy this snippet +com.company.demo.view.localization/chat.codeWrapOn=Wrap the lines +# end::localization-keys[] + +# Overriding add-on keys here re-words every chat in the application, +# unlike the nested element, which re-words one instance. +# tag::global-i18n[] +aiChat.i18n.generating=Generating the answer +aiMessageInput.i18n.send=Send the message +# end::global-i18n[] diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/view/attachments/attachments-view.xml b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/view/attachments/attachments-view.xml new file mode 100644 index 00000000..4f8a7fe4 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/view/attachments/attachments-view.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/view/chat/chat-view.xml b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/view/chat/chat-view.xml new file mode 100644 index 00000000..213c7791 --- /dev/null +++ b/content/modules/ai-chat/examples/ai-chat-ex1/src/main/resources/com/company/demo/view/chat/chat-view.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + +