
Note
|
For the Gradle build configuration see Build section. |
This tutorial assumes you have at least skimmed the Domain model part of the Codion manual.
Llemmy
The table the application is based on, LLEMMY.CHAT, which records the chats to an in-memory database.
SQL
create schema llemmy;
create table llemmy.chat (
id integer generated by default as identity primary key,
session uuid not null,
timestamp timestamp not null,
name text not null,
message_type varchar(25) not null,
message text,
stack_trace text,
response_time integer,
input_tokens integer,
output_tokens integer,
total_tokens integer,
json json,
deleted boolean default false not null
);
comment on column llemmy.chat.session is 'Identifies the chat session';
comment on column llemmy.chat.name is 'The chat participant name';
comment on column llemmy.chat.response_time is 'The AI response time in milliseonds';
comment on column llemmy.chat.input_tokens is 'The input token count';
comment on column llemmy.chat.output_tokens is 'The output token count';
comment on column llemmy.chat.total_tokens is 'The total token count';
comment on column llemmy.chat.json is 'The message in JSON format';
comment on column llemmy.chat.deleted is 'True if the row has been soft-deleted';
Domain model
The domain model is created by extending the DefaultDomain class and defining a DomainType constant identifying the domain model.
In the constructor we call a method adding a single Entity definition to the domain model. The subsections below continue the Llemmy class.
public final class Llemmy extends DomainModel {
// Identifies this domain model
public static final DomainType DOMAIN = domainType(Llemmy.class);
public Llemmy() {
super(DOMAIN);
chat();
}
Display full Llemmy domain model class
/*
* This file is part of Codion Llemmy Demo.
*
* Codion Llemmy Demo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Codion Llemmy Demo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Codion Llemmy Demo. If not, see <http://www.gnu.org/licenses/>.
*
* Copyright (c) 2025, Björn Darri Sigurðsson.
*/
package is.codion.demos.llemmy.domain;
import is.codion.framework.domain.DomainModel;
import is.codion.framework.domain.DomainType;
import is.codion.framework.domain.entity.EntityType;
import is.codion.framework.domain.entity.StringFactory;
import is.codion.framework.domain.entity.attribute.Attribute;
import is.codion.framework.domain.entity.attribute.Column;
import is.codion.framework.domain.entity.attribute.Column.Converter;
import dev.langchain4j.data.message.ChatMessageType;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.UUID;
import static is.codion.framework.domain.DomainType.domainType;
import static is.codion.framework.domain.entity.KeyGenerator.identity;
import static is.codion.framework.domain.entity.OrderBy.descending;
public final class Llemmy extends DomainModel {
// Identifies this domain model
public static final DomainType DOMAIN = domainType(Llemmy.class);
public Llemmy() {
super(DOMAIN);
chat();
}
public interface Chat {
// Identifies this entity type
EntityType TYPE = DOMAIN.entityType("llemmy.chat");
// Primary key column
Column<Integer> ID = TYPE.integerColumn("id");
// Maps automatically to a native UUID H2 column
Column<UUID> SESSION = TYPE.column("session", UUID.class);
Column<String> NAME = TYPE.stringColumn("name");
// Column based on an Enum
Column<ChatMessageType> MESSAGE_TYPE = TYPE.column("message_type", ChatMessageType.class);
Column<LocalDateTime> TIMESTAMP = TYPE.localDateTimeColumn("timestamp");
// Derived from TIMESTAMP, not a table column
Attribute<LocalTime> TIME = TYPE.localTimeAttribute("time");
Column<String> MESSAGE = TYPE.stringColumn("message");
Column<String> STACK_TRACE = TYPE.stringColumn("stack_trace");
// Duration used as custom column type
Column<Duration> RESPONSE_TIME = TYPE.column("response_time", Duration.class);
Column<Integer> INPUT_TOKENS = TYPE.integerColumn("input_tokens");
Column<Integer> OUTPUT_TOKENS = TYPE.integerColumn("output_tokens");
Column<Integer> TOTAL_TOKENS = TYPE.integerColumn("total_tokens");
Column<String> JSON = TYPE.stringColumn("json");
// For implementing soft-delete
Column<Boolean> DELETED = TYPE.booleanColumn("deleted");
}
private void chat() {
add(Chat.TYPE.define(
Chat.ID.define()
.primaryKey(),
Chat.SESSION.define()
.column(),
Chat.TIMESTAMP.define()
.column()
.nullable(false)
.dateTimePattern("yyyy-MM-dd HH:mm:ss")
.caption("Time"),
Chat.TIME.define()
.derived(Chat.TIMESTAMP)
.provider(values -> values.optional(Chat.TIMESTAMP)
.map(LocalDateTime::toLocalTime)
.orElse(null))
.dateTimePattern("HH:mm:ss"),
Chat.NAME.define()
.column()
.nullable(false)
.caption("Name"),
Chat.MESSAGE_TYPE.define()
.column()
// Specify that the enum is represented by an underlying
// String column and provide a converter for converting
// between the enum and the underlying column value
.columnClass(String.class, new MessageTypeConverter())
.caption("Type"),
Chat.MESSAGE.define()
.column()
.caption("Message"),
Chat.STACK_TRACE.define()
.column()
.caption("Stack trace"),
Chat.RESPONSE_TIME.define()
.column()
.columnClass(Integer.class, new DurationConverter())
.caption("Duration"),
Chat.INPUT_TOKENS.define()
.column()
.caption("Input tokens"),
Chat.OUTPUT_TOKENS.define()
.column()
.caption("Output tokens"),
Chat.TOTAL_TOKENS.define()
.column()
.caption("Total tokens"),
Chat.JSON.define()
.column()
.caption("JSON"),
Chat.DELETED.define()
.column()
.nullable(false)
.caption("Deleted")
.defaultValue(false)
.columnHasDefaultValue(true))
.keyGenerator(identity())
.stringFactory(StringFactory.builder()
// 12:38:12 @ OPEN_AI: Hello! How can I assist you today?
.value(Chat.TIME)
.text(" @ ")
.value(Chat.NAME)
.text(": ")
.value(Chat.MESSAGE)
.build())
.orderBy(descending(Chat.TIMESTAMP))
.caption("Chat Log")
.build());
}
private static final class MessageTypeConverter implements Converter<ChatMessageType, String> {
@Override
public String toColumnValue(ChatMessageType chatMessageType, Statement statement) throws SQLException {
return chatMessageType.toString();
}
@Override
public ChatMessageType fromColumnValue(String columnValue) throws SQLException {
return ChatMessageType.valueOf(columnValue);
}
}
private static class DurationConverter implements Converter<Duration, Integer> {
@Override
public Integer toColumnValue(Duration duration, Statement statement) throws SQLException {
return (int) duration.toMillis();
}
@Override
public Duration fromColumnValue(Integer millis) throws SQLException {
return Duration.ofMillis(millis);
}
}
}
API
We start by creating a Chat
interface and defining the domain API constants for the LLEMMY.CHAT table and its columns, providing the table and column names as parameters.
public interface Chat {
// Identifies this entity type
EntityType TYPE = DOMAIN.entityType("llemmy.chat");
// Primary key column
Column<Integer> ID = TYPE.integerColumn("id");
// Maps automatically to a native UUID H2 column
Column<UUID> SESSION = TYPE.column("session", UUID.class);
Column<String> NAME = TYPE.stringColumn("name");
// Column based on an Enum
Column<ChatMessageType> MESSAGE_TYPE = TYPE.column("message_type", ChatMessageType.class);
Column<LocalDateTime> TIMESTAMP = TYPE.localDateTimeColumn("timestamp");
// Derived from TIMESTAMP, not a table column
Attribute<LocalTime> TIME = TYPE.localTimeAttribute("time");
Column<String> MESSAGE = TYPE.stringColumn("message");
Column<String> STACK_TRACE = TYPE.stringColumn("stack_trace");
// Duration used as custom column type
Column<Duration> RESPONSE_TIME = TYPE.column("response_time", Duration.class);
Column<Integer> INPUT_TOKENS = TYPE.integerColumn("input_tokens");
Column<Integer> OUTPUT_TOKENS = TYPE.integerColumn("output_tokens");
Column<Integer> TOTAL_TOKENS = TYPE.integerColumn("total_tokens");
Column<String> JSON = TYPE.stringColumn("json");
// For implementing soft-delete
Column<Boolean> DELETED = TYPE.booleanColumn("deleted");
}
Implementation
We then define an Entity along with its columns based on the domain API constants, configuring each for persistance and presentation.
We use the StringFactory.builder()
method to build a Function<Entity, String>
instance which provides the toString()
implementation for entities of this type.
We also specify a default OrderBy
clause to use when selecting entities of this type.
private void chat() {
add(Chat.TYPE.define(
Chat.ID.define()
.primaryKey(),
Chat.SESSION.define()
.column(),
Chat.TIMESTAMP.define()
.column()
.nullable(false)
.dateTimePattern("yyyy-MM-dd HH:mm:ss")
.caption("Time"),
Chat.TIME.define()
.derived(Chat.TIMESTAMP)
.provider(values -> values.optional(Chat.TIMESTAMP)
.map(LocalDateTime::toLocalTime)
.orElse(null))
.dateTimePattern("HH:mm:ss"),
Chat.NAME.define()
.column()
.nullable(false)
.caption("Name"),
Chat.MESSAGE_TYPE.define()
.column()
// Specify that the enum is represented by an underlying
// String column and provide a converter for converting
// between the enum and the underlying column value
.columnClass(String.class, new MessageTypeConverter())
.caption("Type"),
Chat.MESSAGE.define()
.column()
.caption("Message"),
Chat.STACK_TRACE.define()
.column()
.caption("Stack trace"),
Chat.RESPONSE_TIME.define()
.column()
.columnClass(Integer.class, new DurationConverter())
.caption("Duration"),
Chat.INPUT_TOKENS.define()
.column()
.caption("Input tokens"),
Chat.OUTPUT_TOKENS.define()
.column()
.caption("Output tokens"),
Chat.TOTAL_TOKENS.define()
.column()
.caption("Total tokens"),
Chat.JSON.define()
.column()
.caption("JSON"),
Chat.DELETED.define()
.column()
.nullable(false)
.caption("Deleted")
.defaultValue(false)
.columnHasDefaultValue(true))
.keyGenerator(identity())
.stringFactory(StringFactory.builder()
// 12:38:12 @ OPEN_AI: Hello! How can I assist you today?
.value(Chat.TIME)
.text(" @ ")
.value(Chat.NAME)
.text(": ")
.value(Chat.MESSAGE)
.build())
.orderBy(descending(Chat.TIMESTAMP))
.caption("Chat Log")
.build());
}
private static final class MessageTypeConverter implements Converter<ChatMessageType, String> {
@Override
public String toColumnValue(ChatMessageType chatMessageType, Statement statement) throws SQLException {
return chatMessageType.toString();
}
@Override
public ChatMessageType fromColumnValue(String columnValue) throws SQLException {
return ChatMessageType.valueOf(columnValue);
}
}
private static class DurationConverter implements Converter<Duration, Integer> {
@Override
public Integer toColumnValue(Duration duration, Statement statement) throws SQLException {
return (int) duration.toMillis();
}
@Override
public Duration fromColumnValue(Integer millis) throws SQLException {
return Duration.ofMillis(millis);
}
}
Unit test
final class LlemmyTest extends DomainTest {
public LlemmyTest() {
super(new Llemmy(), LlemmyEntityFactory::new);
}
@Test
void test() {
test(Chat.TYPE);
}
/**
* We provide a {@link EntityFactory} since we use a few
* column types for which the framework can not automatically
* create the random values required for the unit tests.
*/
private static class LlemmyEntityFactory extends DefaultEntityFactory {
private LlemmyEntityFactory(EntityConnection connection) {
super(connection);
}
@Override
protected <T> T value(Attribute<T> attribute) {
if (attribute.equals(Chat.MESSAGE_TYPE)) {
return (T) ChatMessageType.AI;
}
if (attribute.equals(Chat.JSON)) {
return null;
}
if (attribute.equals(Chat.RESPONSE_TIME)) {
return (T) Duration.ofMillis(10);
}
return super.value(attribute);
}
}
}
LlemmyApp
public final class LlemmyApp extends EntityApplicationPanel<SwingEntityApplicationModel> {
private LlemmyApp(SwingEntityApplicationModel applicationModel) {
super(applicationModel,
// See LlemmyAppModel at the bottom of this class
List.of(new ChatPanel(((LlemmyAppModel) applicationModel).chatModel())), List.of(),
// We replace the default application layout factory, which
// produces a layout arranging the main panels in a tabbed pane,
// but here we only have a single panel, no need for tabs
applicationPanel -> () ->
// Simply return our single panel, initialized
applicationPanel.entityPanel(Chat.TYPE).initialize());
}
/**
* Override the default View menu, to exclude the Look & Feel selection
* menu item, since {@link ChatEditPanel} contains a Look & Feel combo box.
* @return the {@link Controls} on which to base the main view menu
*/
@Override
protected Optional<Controls> createViewMenuControls() {
return Optional.of(Controls.builder()
.caption(FrameworkMessages.view())
.mnemonic(FrameworkMessages.viewMnemonic())
// Just include the Always on top control
.control(createAlwaysOnTopControl())
.build());
}
/**
* Override the default Help menu, to replace the default
* Help menu item with a control toggling the help state,
* showing/hiding the help panel.
* @return the {@link Controls} on which to base the main Help menu
*/
@Override
protected Optional<Controls> createHelpMenuControls() {
State help = ((ChatPanel) entityPanel(Chat.TYPE)).help();
return Optional.of(Controls.builder()
.caption("Help")
.mnemonic('H')
// A Control for toggling the help state
// presented as a check box in the menu
.control(Control.builder()
.toggle(help)
.caption("Help")
.build())
.separator()
// Include the default log and about controls, separated
.control(createLogControls())
.separator()
.control(createAboutControl())
.build());
}
/**
* Manually instantiate a new {@link LocalEntityConnectionProvider} instance,
* since we are always running with a in-memory H2 database
* @param user the user
* @return a new {@link EntityConnectionProvider} instance
*/
private static EntityConnectionProvider createConnectionProvider(User user) {
return LocalEntityConnectionProvider.builder()
// Returns a Database based on the
// 'codion.db.url' system property
.database(Database.instance())
// Inject the domain model
.domain(new Llemmy())
// Supply the user
.user(user)
.build();
}
public static void start(Supplier<List<ChatLanguageModel>> languageModels) {
requireNonNull(languageModels, "languageModels is null");
// Configure the jdbc URL ('codion.db.url')
Database.DATABASE_URL.set("jdbc:h2:mem:h2db");
// and the database initialization script
Database.DATABASE_INIT_SCRIPTS.set("classpath:create_schema.sql");
// Configure FlatLaf related things, the inspector is not necessary
// but very helpful when debugging UI related stuff
FlatInspector.install("ctrl shift alt X");
// Configure a decent font
FlatInterFont.install();
FlatLaf.setPreferredFontFamily(FlatInterFont.FAMILY);
FlatLaf.setPreferredLightFontFamily(FlatInterFont.FAMILY_LIGHT);
FlatLaf.setPreferredSemiboldFontFamily(FlatInterFont.FAMILY_SEMIBOLD);
Locale.setDefault(Locale.of("en", "EN"));
FilterTableCellRenderer.TEMPORAL_HORIZONTAL_ALIGNMENT.set(LEADING);
// Display table column selection in a menu, instead of a dialog
EntityTablePanel.Config.COLUMN_SELECTION.set(MENU);
EntityApplicationPanel.builder(SwingEntityApplicationModel.class, LlemmyApp.class)
.applicationName(LlemmyAppModel.APPLICATION_NAME)
.applicationVersion(LlemmyAppModel.APPLICATION_VERSION)
.frameTitle(LlemmyAppModel.APPLICATION_NAME + " " + LlemmyAppModel.APPLICATION_VERSION)
// The H2Database super user
.user(user("sa"))
// We provide a factory for the EntityConnectionProvider,
// since we just manually instantiate a Local one,
// instead of relying on the ServiceLoader
.connectionProvider(LlemmyApp::createConnectionProvider)
// We must supply the language models when instatiating
// the application model, so here we provide a factory,
// which receives the EntityConnectionProvider from above
.applicationModel(connectionProvider ->
new LlemmyAppModel(languageModels.get(), connectionProvider))
// We provide a factory for the panel instantiation,
// which receives the LlemmyAppModel from above,
// allowing us to keep the constructor private
.applicationPanel(LlemmyApp::new)
// No need, startup should be pretty quick
.displayStartupDialog(false)
.defaultLookAndFeel(Dracula.class)
.start();
}
private static final class LlemmyAppModel extends SwingEntityApplicationModel {
private static final String APPLICATION_NAME = "Llemmy";
private static final Version APPLICATION_VERSION =
Version.parse(LlemmyAppModel.class, "/version.properties");
private LlemmyAppModel(List<ChatLanguageModel> languageModels,
EntityConnectionProvider connectionProvider) {
super(connectionProvider,
List.of(new ChatModel(languageModels, connectionProvider)),
APPLICATION_VERSION);
}
private ChatModel chatModel() {
return (ChatModel) entityModels().get(Chat.TYPE);
}
}
}
ChatModel
public final class ChatModel extends SwingEntityModel {
/**
* Instantiates a new {@link ChatModel} instance
* @param languageModels the language models
* @param connectionProvider the connection provider
*/
public ChatModel(List<ChatLanguageModel> languageModels, EntityConnectionProvider connectionProvider) {
super(new ChatTableModel(languageModels, connectionProvider));
}
}
ChatPanel
public final class ChatPanel extends EntityPanel {
private final HelpPanel helpPanel = new HelpPanel();
private final State help = State.builder()
.consumer(this::onHelpChanged)
.build();
/**
* Instantiates a new {@link ChatPanel}
* @param model the {@link ChatModel} on which to base the panel
*/
public ChatPanel(ChatModel model) {
super(model,
new ChatEditPanel((ChatEditModel) model.editModel()),
new ChatTablePanel((ChatTableModel) model.tableModel()),
config -> config
// Skip the default CRUD operation controls
.includeControls(false)
// No base panel needed for the edit panel since we
// want it to fill the whole width of the parent panel
.editBasePanel(editPanel -> editPanel));
setupKeyEvents();
}
/**
* @return the {@link State} controlling whether the help panel is visible
*/
public State help() {
return help;
}
@Override
public void updateUI() {
super.updateUI();
// Here we update the UI of components that may
// not be visible during Look & Feel selection
Utilities.updateUI(helpPanel);
}
private void setupKeyEvents() {
// Set up some global key events for this panel.
// Note that calling addKeyEvent() assures that the key event is
// added to this base panel and to the edit panel as well,
// since that may be displayed in a separate window.
ChatEditModel editModel = (ChatEditModel) editModel();
ChatEditPanel editPanel = (ChatEditPanel) editPanel();
ChatTablePanel tablePanel = (ChatTablePanel) tablePanel();
// Set the base parameters, the modifier and the condition
KeyEvents.Builder keyEvent = KeyEvents.builder()
.modifiers(ALT_DOWN_MASK)
.condition(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT);
// Set the keycode and action and add the key event
addKeyEvent(keyEvent.keyCode(VK_1)
.action(command(editPanel::requestModelFocus)));
addKeyEvent(keyEvent.keyCode(VK_2)
.action(command(editPanel::requestLookAndFeelFocus)));
addKeyEvent(keyEvent.keyCode(VK_3)
.action(command(editPanel::requestPromptFocus)));
addKeyEvent(keyEvent.keyCode(VK_4)
.action(command(editPanel::requestAttachmentFocus)));
addKeyEvent(keyEvent.keyCode(VK_5)
.action(command(tablePanel::requestChatFocus)));
addKeyEvent(keyEvent.keyCode(VK_6)
.action(command(tablePanel::requestHistoryFocus)));
addKeyEvent(keyEvent.keyCode(VK_7)
.action(command(helpPanel.shortcuts::requestFocus)));
addKeyEvent(keyEvent.keyCode(VK_UP)
.modifiers(CTRL_DOWN_MASK)
.action(Control.builder()
// Use the built in method to decrement the selected table model indexes
.command(tablePanel.tableModel().selection().indexes()::decrement)
// Only enabled while the model is not processing
.enabled(editModel.processing().not())
.build()));
addKeyEvent(keyEvent.keyCode(VK_DOWN)
.action(Control.builder()
// Use the built in method to increment the selected table model indexes
.command(tablePanel.tableModel().selection().indexes()::increment)
// Only enabled while the model is not processing
.enabled(editModel.processing().not())
.build()));
}
private void onHelpChanged(boolean visible) {
if (visible) {
add(helpPanel, BorderLayout.EAST);
}
else {
remove(helpPanel);
}
revalidate();
repaint();
}
private static final class HelpPanel extends JPanel {
private final JTextArea shortcuts = textArea()
.value(shortcutsText())
.font(monospaceFont())
.editable(false)
.build();
private HelpPanel() {
super(borderLayout());
setBorder(createTitledBorder("Help"));
add(scrollPane(shortcuts).build(), BorderLayout.CENTER);
}
@Override
public void updateUI() {
super.updateUI();
Utilities.updateUI(shortcuts);
}
private static String shortcutsText() {
try (InputStream inputStream = LlemmyApp.class.getResourceAsStream("shortcuts.txt")) {
return new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))
.lines()
.collect(joining("\n"));
}
catch (IOException e) {
throw new RuntimeException("Unable to load shortcuts.txt", e);
}
}
private static Font monospaceFont() {
Font font = UIManager.getFont("TextArea.font");
return new Font(Font.MONOSPACED, font.getStyle(), font.getSize());
}
}
}
ChatEditModel
public final class ChatEditModel extends SwingEntityEditModel {
// The mime types available for attachments
public enum MimeType {
PDF("application/pdf", new FileNameExtensionFilter("PDF", "pdf")),
JPEG("image/jpeg", new FileNameExtensionFilter("JPEG", "jpg", "jpeg")),
PNG("image/png", new FileNameExtensionFilter("PNG", "png")),
PLAIN_TEXT("text/plain", new FileNameExtensionFilter("Text", "txt", "csv"));
private final String type;
private final FileFilter fileFilter;
MimeType(String type, FileFilter fileFilter) {
this.type = type;
this.fileFilter = fileFilter;
}
public String type() {
return type;
}
public FileFilter fileFilter() {
return fileFilter;
}
}
private static final Base64.Encoder BASE64_ENCODER = Base64.getEncoder();
private static final String USER = getProperty("user.name");
private static final String SYSTEM = "System";
// Identifies the current chat session
private final UUID session = randomUUID();
// Indicates that the prompt text is empty
private final State promptEmpty = State.state(true);
// Indicates that the attachments list model is empty
private final State attachmentsEmpty = State.state(true);
// Indicates whether processing is ongoing
private final State processing = State.state();
// Indicates whether the last prompt resulted in an error
private final State error = State.state();
// Indicates whether prompt data is available and the model is not processing
private final ObservableState ready =
and(processing.not(), and(promptEmpty, attachmentsEmpty).not());
// Holds the time the last prompt was issued to the chat model
private final Value<LocalDateTime> started = Value.nullable();
// Holds the elapsed processing time of the current query
private final Value<Duration> elapsed = Value.nonNull(ZERO);
// Updates the elapsed time every second during processing
private final TaskScheduler elapsedUpdater =
TaskScheduler.builder(this::updateElapsed)
.interval(1, TimeUnit.SECONDS)
.build();
// Contains the available language models
private final FilterComboBoxModel<Item<ChatLanguageModel>> languageModels;
// Contains the file attachments
private final FilterListModel<Attachment> attachments =
FilterListModel.<Attachment>filterListModel();
// Contains the prompt text
private final Value<String> prompt = Value.builder()
.nonNull("")
// Update the promptEmpty state each time the value changes
.consumer(value -> promptEmpty.set(value.trim().isEmpty()))
.build();
/**
* Instantiates a new {@link ChatEditModel} instance
* @param languageModels the language models
* @param connectionProvider the connection provider
* @throws IllegalArgumentException in case {@code languageModels} is empty
*/
public ChatEditModel(List<ChatLanguageModel> languageModels,
EntityConnectionProvider connectionProvider) {
super(Chat.TYPE, connectionProvider);
if (languageModels.isEmpty()) {
throw new IllegalArgumentException("No language model(s) provided");
}
// Wrap the language models in Item instances, for a caption to display in the combo box
this.languageModels = FilterComboBoxModel.builder(languageModels.stream()
.map(model -> item(model, model.provider().name()))
.toList())
.selected(languageModels.getFirst())
.build();
}
public UUID session() {
return session;
}
public Value<String> prompt() {
return prompt;
}
public FilterListModel<Attachment> attachments() {
return attachments;
}
public void addAttachment(Path path, MimeType mimeType) {
attachments.items().add(createAttachment(requireNonNull(path), requireNonNull(mimeType)));
attachmentsEmpty.set(false);
}
public void removeAttachment(Attachment attachment) {
attachments.items().remove(requireNonNull(attachment));
attachmentsEmpty.set(attachments.items().count() == 0);
}
/**
* Updated on the Event Dispatch Thread
* @return the ready indicator
*/
public ObservableState ready() {
return ready;
}
/**
* Updated on the Event Dispatch Thread
* @return the processing indicator
*/
public ObservableState processing() {
return processing.observable();
}
/**
* Updated in a worker thread.
* @return the elapsed time
*/
public Observable<Duration> elapsed() {
return elapsed.observable();
}
/**
* Updated on the Event Dispatch Thread
* @return the error indicator
*/
public ObservableState error() {
return error.observable();
}
/**
* @return the available language models
*/
public FilterComboBoxModel<Item<ChatLanguageModel>> languageModels() {
return languageModels;
}
/**
* Sends the current prompt along with all attachments.
*/
public void send() {
// Here we perform a couple of async operations, calling the
// model and inserting the results in a background thread.
// We assume this method gets called on the EDT.
UserMessageTask task = new UserMessageTask();
// UserMessageTask splits the task into a couple of
// background tasks and some extra methods
// that must be performed on the EDT.
send(task);
}
@Override
protected void delete(Collection<Entity> entities, EntityConnection connection) {
// We override the default delete implementation, in order to implement soft delete
connection.update(entities.stream()
.map(this::setDeleted)
.filter(Entity::modified)
.toList());
}
private Entity setDeleted(Entity entity) {
entity.set(Chat.DELETED, true);
return entity;
}
private void updateElapsed() {
elapsed.set(started.optional()
.map(time -> between(time, LocalDateTime.now()))
.orElse(ZERO));
}
private static void send(UserMessageTask task) {
// On the Event Dispatch Thread
task.prepare();
// The user message is created and inserted
// into the database in a background thread
ProgressWorker.builder(task)
// On the Event Dispatch Thread
.onException(task::fail)
// Propagate the resulting task
// to the next async send method
.onResult(ChatEditModel::send)
.execute();
}
private static void send(ChatResponseTask task) {
// On the Event Dispatch Thread
task.prepare();
// The language model is prompted and the result
// inserted into the database in a background thread
ProgressWorker.builder(task)
// On the Event Dispatch Thread
.onResult(task::finish)
.execute();
}
private static Attachment createAttachment(Path path, MimeType mimeType) {
return switch (mimeType) {
case PNG, JPEG -> new Attachment(path,
ImageContent.from(toBase64Bytes(path), mimeType.type()));
case PLAIN_TEXT -> new Attachment(path,
TextFileContent.from(toBase64Bytes(path), mimeType.type()));
case PDF -> new Attachment(path,
PdfFileContent.from(toBase64Bytes(path), mimeType.type()));
};
}
private static String toBase64Bytes(Path attachment) {
try {
return BASE64_ENCODER.encodeToString(readAllBytes(attachment));
}
catch (IOException e) {
throw new RuntimeException(e);
}
}
public record Attachment(Path path, Content content) {
@Override
public String toString() {
return path.toString();
}
}
private final class UserMessageTask implements ResultTask<ChatResponseTask> {
@Override
public ChatResponseTask execute() {
UserMessage userMessage = createMessage();
return new ChatResponseTask(new Result(insert(userMessage), userMessage));
}
private UserMessage createMessage() {
UserMessage.Builder builder = UserMessage.builder().name(USER);
prompt.optional()
.filter(not(String::isBlank))
.ifPresent(text -> builder.addContent(TextContent.from(text)));
attachments.items().get().forEach(attachment ->
builder.addContent(attachment.content()));
return builder.build();
}
private Entity insert(UserMessage message) {
return connection().insertSelect(entities().builder(Chat.TYPE)
.with(Chat.MESSAGE_TYPE, ChatMessageType.USER)
.with(Chat.SESSION, session)
.with(Chat.NAME, USER)
.with(Chat.TIMESTAMP, LocalDateTime.now())
.with(Chat.MESSAGE, messageText(message))
.with(Chat.JSON, messageToJson(message))
.build());
}
private static String messageText(UserMessage message) {
return message.contents().stream()
.filter(TextContent.class::isInstance)
.map(TextContent.class::cast)
.map(TextContent::text)
.collect(joining("\n"));
}
// Must be called on the Event Dispatch Thread
// since this affects one or more UI components
private void prepare() {
elapsed.clear();
started.set(LocalDateTime.now());
processing.set(true);
elapsedUpdater.start();
}
// Must be called on the Event Dispatch Thread
// since this affects one or more UI components
private void fail(Exception exception) {
elapsedUpdater.stop();
processing.set(false);
elapsed.clear();
started.clear();
}
private record Result(Entity entity, UserMessage userMessage) {}
}
private final class ChatResponseTask implements ResultTask<Entity> {
private final UserMessageTask.Result result;
private ChatResponseTask(UserMessageTask.Result result) {
this.result = result;
}
@Override
public Entity execute() {
ChatLanguageModel languageModel = languageModel();
LocalDateTime start = LocalDateTime.now();
try {
return insert(languageModel.provider().name(),
languageModel.chat(result.userMessage()),
Duration.between(start, LocalDateTime.now()));
}
catch (Exception e) {
return insert(e);
}
}
private Entity insert(String name, ChatResponse response, Duration responseTime) {
TokenUsage tokenUsage = response.metadata().tokenUsage();
return connection().insertSelect(entities().builder(Chat.TYPE)
.with(Chat.MESSAGE_TYPE, ChatMessageType.AI)
.with(Chat.SESSION, session)
.with(Chat.NAME, name)
.with(Chat.TIMESTAMP, LocalDateTime.now())
.with(Chat.MESSAGE, response.aiMessage().text())
.with(Chat.RESPONSE_TIME, responseTime)
.with(Chat.JSON, messageToJson(response.aiMessage()))
.with(Chat.INPUT_TOKENS, tokenUsage.inputTokenCount())
.with(Chat.OUTPUT_TOKENS, tokenUsage.outputTokenCount())
.with(Chat.TOTAL_TOKENS, tokenUsage.totalTokenCount())
.build());
}
private Entity insert(Exception exception) {
return connection().insertSelect(entities().builder(Chat.TYPE)
.with(Chat.MESSAGE_TYPE, ChatMessageType.SYSTEM)
.with(Chat.SESSION, session)
.with(Chat.NAME, SYSTEM)
.with(Chat.TIMESTAMP, LocalDateTime.now())
.with(Chat.MESSAGE, exception.getMessage())
.with(Chat.STACK_TRACE, stackTrace(exception))
.build());
}
private ChatLanguageModel languageModel() {
return languageModels.selection().item().optional()
.map(Item::value)
.orElseThrow();
}
// Must be called on the Event Dispatch Thread
// since this affects one or more UI components
private void prepare() {
prompt.clear();
notifyAfterInsert(List.of(result.entity()));
}
// Must be called on the Event Dispatch Thread
// since this affects one or more UI components
private void finish(Entity entity) {
elapsedUpdater.stop();
processing.set(false);
elapsed.clear();
started.clear();
error.set(SYSTEM.equals(entity.get(Chat.NAME)));
notifyAfterInsert(List.of(entity));
}
}
private static String stackTrace(Exception exception) {
StringWriter writer = new StringWriter();
exception.printStackTrace(new PrintWriter(writer));
return writer.toString();
}
}
ChatEditPanel

public final class ChatEditPanel extends EntityEditPanel {
private final ChatEditModel model;
private final JComboBox<Item<ChatLanguageModel>> languageModelComboBox;
private final JPanel languageModelPanel;
private final JTextArea promptTextArea;
private final JScrollPane promptScrollPane;
private final JList<Attachment> attachmentsList;
private final JScrollPane attachmentsScrollPane;
private final JProgressBar progressBar;
private final JButton clearButton;
private final JButton sendButton;
private final JComboBox<Item<LookAndFeelEnabler>> lookAndFeelComboBox =
LookAndFeelComboBox.builder().build();
public ChatEditPanel(ChatEditModel model) {
super(model);
this.model = model;
this.languageModelComboBox = createLanguageModelComboBox();
this.languageModelPanel = borderLayoutPanel()
.centerComponent(languageModelComboBox)
.build();
Control sendControl = createSendControl();
this.promptTextArea = createPromptTextArea(sendControl);
this.promptScrollPane = scrollPane(promptTextArea).build();
this.attachmentsList = createAttachmentsList();
this.attachmentsScrollPane = scrollPane(attachmentsList).build();
this.progressBar = createProgressBar();
this.clearButton = button(createClearControl()).build();
this.sendButton = button(sendControl).build();
model.processing().addConsumer(this::onProcessingChanged);
model.elapsed().addConsumer(this::onElapsedChanged);
focus().initial().set(promptTextArea);
}
@Override
public void updateUI() {
super.updateUI();
// Here we update the UI of components that may
// not be visible during Look & Feel selection
Utilities.updateUI(progressBar, languageModelComboBox);
}
void requestModelFocus() {
languageModelComboBox.requestFocus();
}
void requestPromptFocus() {
promptTextArea.requestFocus();
}
void requestAttachmentFocus() {
attachmentsList.requestFocus();
}
void requestLookAndFeelFocus() {
lookAndFeelComboBox.requestFocus();
}
@Override
protected void initializeUI() {
setLayout(borderLayout());
add(splitPane()
.continuousLayout(true)
.oneTouchExpandable(true)
.resizeWeight(0.75)
.leftComponent(borderLayoutPanel()
.northComponent(createModelPanel())
.centerComponent(borderLayoutPanel()
.border(createTitledBorder("Prompt"))
.centerComponent(promptScrollPane)
.build())
.build())
.rightComponent(borderLayoutPanel()
.northComponent(createLookAndFeelPanel())
.centerComponent(borderLayoutPanel()
.border(createTitledBorder("Attachments"))
.centerComponent(attachmentsScrollPane)
.build())
.build())
.build(), BorderLayout.CENTER);
}
private JPanel createModelPanel() {
return borderLayoutPanel()
.border(createTitledBorder("Model"))
.centerComponent(languageModelPanel)
.eastComponent(gridLayoutPanel(1, 2)
.addAll(clearButton, sendButton)
.build())
.build();
}
private JPanel createLookAndFeelPanel() {
return borderLayoutPanel()
.border(createTitledBorder("Look & Feel"))
.centerComponent(lookAndFeelComboBox)
.build();
}
private Control createClearControl() {
return Control.builder()
.command(model.prompt()::clear)
.caption("Clear")
.mnemonic('C')
// Only enabled when the model is ready
.enabled(model.ready())
.build();
}
private Control createSendControl() {
return Control.builder()
.command(model::send)
.caption("Send")
.mnemonic('S')
// Only enabled when the model is ready
.enabled(model.ready())
.build();
}
private JComboBox<Item<ChatLanguageModel>> createLanguageModelComboBox() {
return comboBox(model.languageModels())
// Only enabled when the model is not processing
.enabled(model.processing().not())
.preferredWidth(200)
.build();
}
private JTextArea createPromptTextArea(Control sendControl) {
return textArea(model.prompt())
.rowsColumns(5, 40)
.lineWrap(true)
.wrapStyleWord(true)
// Only enabled when the model is not processing
.enabled(model.processing().not())
.validIndicator(model.error().not())
// Ctrl-Enter sends the prompt
.keyEvent(KeyEvents.builder(VK_ENTER)
.modifiers(CTRL_DOWN_MASK)
.action(sendControl))
.build();
}
private JList<Attachment> createAttachmentsList() {
return Components.list(model.attachments())
// The List value is based on the items in
// the list, as opposed to the selected items.
.items()
// Only enabled when the model is not processing
.enabled(model.processing().not())
// Insert to add attachment
.keyEvent(KeyEvents.builder(VK_INSERT)
.action(command(this::addAttachment)))
// Delete to remove the selected attachment
.keyEvent(KeyEvents.builder(VK_DELETE)
.action(command(this::removeAttachment)))
.build();
}
private void addAttachment() {
// Select the file mime type
listSelectionDialog(List.of(MimeType.values()))
// Set the modal dialog owner
.owner(attachmentsList)
// Restricts the selection to a single item
.selectSingle()
// Returns an empty Optional in case the user cancels
.ifPresent(mimeType ->
// Select the file to attach
fileSelectionDialog()
// Filter files by the selected mime type
.fileFilter(mimeType.fileFilter())
// Select one or more files
.selectFiles()
// Add the attachments
.forEach(file ->
model.addAttachment(file.toPath(), mimeType)));
}
private void removeAttachment() {
model.attachments().selection().items().get().forEach(model::removeAttachment);
}
private JProgressBar createProgressBar() {
return progressBar()
.indeterminate(true)
.stringPainted(true)
.string("")
.build();
}
private void onProcessingChanged(boolean processing) {
languageModelPanel.removeAll();
languageModelPanel.add(processing ? progressBar : languageModelComboBox, BorderLayout.CENTER);
progressBar.requestFocus();
languageModelPanel.revalidate();
languageModelPanel.repaint();
}
private void onElapsedChanged(Duration elapsed) {
// Use invokeLater() since this gets called in a background thread
invokeLater(() -> progressBar.setString(
format("%02d:%02d", elapsed.toMinutes(), elapsed.toSecondsPart())));
}
}
ChatTableModel
public final class ChatTableModel extends SwingEntityTableModel {
/**
* Instantiates a new {@link ChatTableModel} instance
* @param languageModels the language models
* @param connectionProvider the connection provider
*/
public ChatTableModel(List<ChatLanguageModel> languageModels, EntityConnectionProvider connectionProvider) {
super(new ChatEditModel(languageModels, connectionProvider));
ChatEditModel editModel = (ChatEditModel) editModel();
// Include only chat logs from our session
queryModel().condition().get(Chat.SESSION).set().equalTo(editModel.session());
// We implement soft delete (see ChatEditModel), so include
// only chat log history records not marked as deleted
queryModel().condition().get(Chat.DELETED).set().equalTo(false);
// Hardcode the history sorting to the latest at top
sort().descending(Chat.TIMESTAMP);
// Display the message in the prompt when a history record is selected
selection().item().addConsumer(this::onSelection);
}
private void onSelection(Entity chat) {
ChatEditModel model = (ChatEditModel) editModel();
if (chat == null) {
model.prompt().clear();
}
else if (chat.get(Chat.MESSAGE_TYPE) == USER) {
model.prompt().set(chat.get(Chat.MESSAGE));
}
}
}
ChatTablePanel

public final class ChatTablePanel extends EntityTablePanel {
private final JTextPane chatPane = textPane()
.editable(false)
.build();
private final StyledDocument document = chatPane.getStyledDocument();
private final Style userStyle = document.addStyle("user", null);
private final Style systemStyle = document.addStyle("system", null);
/**
* Instantiates a new {@link ChatTablePanel}
* @param tableModel the {@link ChatTableModel} on which to base the panel
*/
public ChatTablePanel(ChatTableModel tableModel) {
super(tableModel, config -> config
// Lets skip the query conditions
.includeConditions(false)
// but include the filters
.includeFilters(true)
// and no south panel
.includeSouthPanel(false)
// Lets not allow the Chat.SESSION value
// to be edited via the table popup menu
.editable(attributes ->
attributes.remove(Chat.SESSION)));
// Refresh the chat each time the visible items or selection changes
tableModel.items().visible().addListener(this::refreshChat);
tableModel.selection().items().addListener(this::refreshChat);
configureTable();
configureStyles();
}
@Override
public void updateUI() {
super.updateUI();
if (chatPane != null) {
// In case the Look & Feel changed
// which affects the colors
refreshChat();
}
}
@Override
protected void layoutPanel(JComponent tableComponent, JPanel southPanel) {
setLayout(borderLayout());
add(splitPane()
.continuousLayout(true)
.oneTouchExpandable(true)
.resizeWeight(0.75)
.leftComponent(borderLayoutPanel()
.border(createTitledBorder("Chat"))
.centerComponent(scrollPane(chatPane).build())
.build())
.rightComponent(borderLayoutPanel()
.border(createTitledBorder("History"))
.centerComponent(tableComponent)
.build())
.build(), BorderLayout.CENTER);
}
void requestChatFocus() {
chatPane.requestFocus();
}
void requestHistoryFocus() {
table().requestFocus();
}
private void configureStyles() {
configureUserStyle();
StyleConstants.setForeground(systemStyle, Color.WHITE);
StyleConstants.setBackground(systemStyle, Color.RED);
}
private void configureUserStyle() {
// Keep this separate since these colors are Look & Feel dependent
// and need to be updated each time it changes
StyleConstants.setForeground(userStyle, getColor("TextPane.background"));
StyleConstants.setBackground(userStyle, getColor("TextPane.foreground"));
}
private void refreshChat() {
// In case the Look & Feel has changed
configureUserStyle();
// We display all the chat history if the selection is empty,
// otherwise only the selected history
List<Entity> chats = tableModel().selection().empty().get() ?
tableModel().items().visible().get() :
tableModel().selection().items().get();
chatPane.setText("");
chats.stream()
.sorted(comparing(chat -> chat.get(Chat.TIMESTAMP)))
.forEach(this::addToChatDocument);
}
private void addToChatDocument(Entity chat) {
try {
document.insertString(document.getLength(), chat.toString() + "\n\n", style(chat));
}
catch (BadLocationException e) {
throw new RuntimeException(e);
}
}
private AttributeSet style(Entity chat) {
return switch (chat.get(Chat.MESSAGE_TYPE)) {
case USER -> userStyle;
case SYSTEM -> systemStyle;
default -> null;
};
}
private void configureTable() {
FilterTable<Entity, Attribute<?>> table = table();
ChatEditModel editModel = (ChatEditModel) tableModel().editModel();
// Disable the table while the model is processing
enableComponents(editModel.processing().not(), table);
// Set some minimum table column widths
table.columnModel().column(Chat.TIMESTAMP).setMinWidth(160);
table.columnModel().column(Chat.MESSAGE_TYPE).setMinWidth(80);
table.columnModel().column(Chat.NAME).setMinWidth(80);
// Set the default visible columns
table.columnModel().visible().set(Chat.TIMESTAMP, Chat.MESSAGE_TYPE, Chat.NAME);
// and the column auto resize mode
table.setAutoResizeMode(JTable.AUTO_RESIZE_ALL_COLUMNS);
// We hardcoded the sorting in ChatTableModel
table.sortingEnabled().set(false);
}
}
Ollama
Ollama Model Runner
Uses testcontainers to download and run the selected local Ollama model (langchain4j/ollama-{selected}:latest, ~2GB).
public final class Runner {
private Runner() {}
public static void main(String[] args) {
findLookAndFeel(FlatDarkLaf.class).ifPresent(LookAndFeelEnabler::enable);
try (var ollama = new GenericContainer<>("langchain4j/ollama-" + model() + ":latest")
.withCreateContainerCmdModifier(cmd -> cmd.withHostConfig(new HostConfig()
.withPortBindings(new PortBinding(bindPort(PORT), new ExposedPort(PORT)))))) {
ollama.start();
var baseUrlField = stringField()
.value(format("http://%s:%d", ollama.getHost(), PORT))
.selectAllOnFocusGained(true)
.columns(25)
.editable(false)
.horizontalAlignment(CENTER)
.build();
actionDialog(borderLayoutPanel()
.border(emptyBorder())
.northComponent(label(ollama.getDockerImageName())
.horizontalAlignment(CENTER)
.build())
.centerComponent(baseUrlField)
.build())
.title("Ollama")
.defaultAction(Control.builder()
.command(() -> setClipboard(baseUrlField.getText()))
.caption("Copy")
.mnemonic('C')
.build())
.escapeAction(Control.builder()
.command(() -> parentWindow(baseUrlField).dispose())
.caption("Stop")
.mnemonic('S')
.build())
.show();
ollama.stop();
}
}
private static String model() {
return inputDialog(comboBox(FilterComboBoxModel.builder(MODELS).build())
.value(ORCA_MINI)
.preferredWidth(250))
.title("Select model")
.show();
}
}
gradlew ollama-model:run
Ollama Llemmy
Runs the Llemmy application configured for the local Ollama model
public final class Runner {
public static final int PORT = 11434;
public static final String ORCA_MINI = "orca-mini";
public static final List<String> MODELS = List.of(
ORCA_MINI,
"llama3",
"llama2",
"mistral",
"codellama",
"phi",
"tinyllama",
"ollama-test");
private Runner() {}
public static void main(String[] args) {
LlemmyApp.start(() -> List.of(OllamaChatModel.builder()
.baseUrl("http://localhost:" + PORT)
.modelName(comboBoxSelectionDialog(MODELS)
.defaultSelection(ORCA_MINI)
.title("Select model")
.select()
.orElseThrow(CancelException::new))
.build()));
}
}
gradlew llemmy-ollama:run
OpenAI
OpenAI Llemmy
Runs the Llemmy application configured for the OpenAI models
public final class Runner {
private static final List<String> MODELS =
Stream.of(OpenAiChatModelName.values())
.map(Objects::toString)
.toList();
private Runner() {}
public static void main(String[] args) {
LlemmyApp.start(() -> List.of(OpenAiChatModel.builder()
.apiKey(inputDialog(stringField()
.columns(25))
.title("OpenAI API Key")
.show())
.modelName(comboBoxSelectionDialog(MODELS)
.defaultSelection(GPT_4_O_MINI.toString())
.title("Select model")
.select()
.orElseThrow(CancelException::new))
.build()));
}
}
gradlew llemmy-ollama:run
Module Info
/**
* Llemmy demo.
*/
module is.codion.demos.llemmy.ui {
requires java.net.http;
requires is.codion.framework.db.local;
requires is.codion.swing.framework.ui;
requires is.codion.plugin.flatlaf.intellij.themes;
requires dev.langchain4j.core;
requires com.fasterxml.jackson.core;
requires com.formdev.flatlaf.extras;
requires com.formdev.flatlaf.fonts.inter;
exports is.codion.demos.llemmy;
}
Build
settings.gradle
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0"
}
rootProject.name = "llemmy"
include "llemmy"
include "llemmy-ollama"
project(":llemmy-ollama").projectDir = file("models/ollama/llemmy")
include "ollama-model"
project(":ollama-model").projectDir = file("models/ollama/model")
include "llemmy-openai"
project(":llemmy-openai").projectDir = file("models/openai/llemmy")
dependencyResolutionManagement {
repositories {
mavenCentral()
mavenLocal()
}
versionCatalogs {
libs {
version("codion", "0.18.34")
version("langchain4j", "1.0.0-beta3")
version("flatlaf", "3.6")
version("flatlaf.inter", "4.1")
version("h2", "2.3.232")
version("jackson", "2.17.3")
library("codion-dbms-h2", "is.codion", "codion-dbms-h2").versionRef("codion")
library("codion-swing-common-ui", "is.codion", "codion-swing-common-ui").versionRef("codion")
library("codion-framework-domain-test", "is.codion", "codion-framework-domain-test").versionRef("codion")
library("codion-framework-db-local", "is.codion", "codion-framework-db-local").versionRef("codion")
library("codion-swing-framework-ui", "is.codion", "codion-swing-framework-ui").versionRef("codion")
library("codion-plugin-logback-proxy", "is.codion", "codion-plugin-logback-proxy").versionRef("codion")
library("codion-plugin-flatlaf", "is.codion", "codion-plugin-flatlaf").versionRef("codion")
library("codion-plugin-flatlaf-intellij-themes", "is.codion", "codion-plugin-flatlaf-intellij-themes").versionRef("codion")
library("flatlaf-extras", "com.formdev", "flatlaf-extras").versionRef("flatlaf")
library("flatlaf-fonts-inter", "com.formdev", "flatlaf-fonts-inter").versionRef("flatlaf.inter")
library("jackson-databind", "com.fasterxml.jackson.core", "jackson-databind").versionRef("jackson")
library("langchain4j", "dev.langchain4j", "langchain4j").versionRef("langchain4j")
library("langchain4j-core", "dev.langchain4j", "langchain4j-core").versionRef("langchain4j")
library("langchain4j-ollama", "dev.langchain4j", "langchain4j-ollama").versionRef("langchain4j")
library("langchain4j-open-ai", "dev.langchain4j", "langchain4j-open-ai").versionRef("langchain4j")
library("langchain4j-http-client", "dev.langchain4j", "langchain4j-http-client").versionRef("langchain4j")
library("langchain4j-http-client-jdk", "dev.langchain4j", "langchain4j-http-client-jdk").versionRef("langchain4j")
library("h2", "com.h2database", "h2").versionRef("h2")
}
}
}
build.gradle.kts
plugins {
id("java")
// Just for managing the license headers
id("com.diffplug.spotless") version "7.0.1"
// For the asciidoctor docs
id("org.asciidoctor.jvm.convert") version "4.0.4"
}
allprojects {
version = "0.9.0"
}
java {
toolchain {
// Use the latest possible Java version
languageVersion.set(JavaLanguageVersion.of(24))
}
}
configure(allprojects) {
apply(plugin = "com.diffplug.spotless")
spotless {
// Just the license headers
java {
licenseHeaderFile("${rootDir}/license_header").yearSeparator(" - ")
}
format("javaMisc") {
target("src/**/package-info.java", "src/**/module-info.java")
licenseHeaderFile("${rootDir}/license_header", "\\/\\*\\*").yearSeparator(" - ")
}
}
}
llemmy/build.gradle.kts
plugins {
id("java-library")
id("org.gradlex.extra-java-module-info") version "1.12"
id("org.asciidoctor.jvm.convert") version "4.0.4"
}
dependencies {
// The Codion framework UI module, transitively pulls in all required
// modules, such as the model layer and the core database module
api(libs.codion.swing.framework.ui)
// Provides the local JDBC connection implementation
implementation(libs.codion.framework.db.local)
// Include all the standard Flat Look and Feels and a bunch of IntelliJ
// theme based ones, available via the View -> Select Look & Feel menu
implementation(libs.codion.plugin.flatlaf)
implementation(libs.codion.plugin.flatlaf.intellij.themes)
implementation(libs.flatlaf.fonts.inter)
// FlatInspector
implementation(libs.flatlaf.extras);
implementation(libs.langchain4j.core)
// Provides the Logback logging library as a transitive dependency
runtimeOnly(libs.codion.plugin.logback.proxy)
// The H2 database implementation
runtimeOnly(libs.codion.dbms.h2)
// And the H2 database driver
runtimeOnly(libs.h2)
// The domain model unit test module
testImplementation(libs.codion.framework.domain.test)
}
apply(from = "../langchain4j-module-info.gradle")
testing {
suites {
val test by getting(JvmTestSuite::class) {
useJUnitJupiter()
targets {
all {
// System properties required for running the unit tests
testTask.configure {
// The JDBC url
systemProperty("codion.db.url", "jdbc:h2:mem:h2db")
// The database initialization script
systemProperty("codion.db.initScripts", "classpath:create_schema.sql")
// The user to use when running the tests
systemProperty("codion.test.user", "sa")
}
}
}
}
}
}
// Configure the docs generation
tasks.asciidoctor {
inputs.dir("src")
baseDirFollowsSourceFile()
attributes(
mapOf(
"codion-version" to project.version,
"source-highlighter" to "prettify",
"tabsize" to "2"
)
)
asciidoctorj {
setVersion("2.5.13")
}
}
tasks.register<Sync>("copyToGitHubPages") {
group = "documentation"
description = "Copies the documentation to the Codion github pages repository, nevermind"
from(tasks.asciidoctor)
into("../../codion-pages/doc/" + libs.versions.codion.get()
.replace("-SNAPSHOT", "") + "/tutorials/llemmy")
}
tasks.register<WriteProperties>("writeVersion") {
group = "build"
description = "Create a version.properties file containing the application version"
destinationFile = file("${temporaryDir.absolutePath}/version.properties")
property("version", project.version)
}
// Include the version.properties file from above in the
// application resources, see usage in LlemmyAppModel
tasks.processResources {
from(tasks.named("writeVersion"))
}