Welcome to your Happy Place: Build a Reactive, AI driven Java App That Learns What Makes You Smile
Create a streaming social feed with Quarkus, LangChain4j, and Ollama that adapts to user sentiment using keyword-based memory and local LLMs.
What if your app could learn what makes you smile and give you more of it?
In this tutorial, we’ll build Happy Place: a reactive Java application that streams positive, uplifting posts to the user. Think of it like a social media feed but instead of doomscrolling, it's dopamine-scrolling. And here's the twist: the AI behind the content adapts based on what you like or dislike.
All powered by:
Quarkus: Fast, reactive Java backend.
LangChain4j: For talking to a local LLM.
Ollama: Serving our LLM
llama3
Mutiny: To stream joy reactively.
HTML/JS/CSS: For a simple but effective frontend.
Let’s build it. (Or grab the running example from my Github repository!)
Prerequisites
Make sure you’ve got:
Java 17+
Maven 3.8+
Ollama running with a pulled model:
Podman for Quarkus Dev Services
Bootstrapping the Happy Backend
Create the Project
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=com.happyplace \
-DprojectArtifactId=happy-place-app \
-DclassName="com.happyplace.HappyResource" \
-Dpath="/hello" \
-Dextensions="rest-jackson,quarkus-langchain4j-ollama"
cd happy-place-app
Configure application.properties
Open src/main/resources/application.properties
and add the following configuration:
quarkus.langchain4j.ollama.chat-model.model-name=llama3
quarkus.langchain4j.ollama.timeout=120s
quarkus.langchain4j.ollama.log-requests=true
quarkus.langchain4j.ollama.log-responses=true
AIService.java – Generating and Learning Joy
Create src/main/java/com/happyplace/services/AIService.java
and define prompts for:
Generating positive posts
Learning from user feedback via keyword extraction
You’ll use LangChain4j’s declarative @SystemMessage
and @UserMessage
prompts for clean separation of concerns.
package com.happyplace.services;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.RegisterAiService;
import io.smallrye.mutiny.Multi;
@RegisterAiService(chatMemoryProviderSupplier = RegisterAiService.NoChatMemoryProviderSupplier.class)
public interface AIService {
@SystemMessage("""
You are a friendly and enthusiastic AI assistant.
Your goal is to generate short, uplifting, and positive content.
This could be a mini-poem, a piece of good news, a happy thought, or a lighthearted, SFW joke.
Keep the content concise (1-3 sentences) and always positive.
Do not include any preamble like "Okay, here's a happy thought:". Just provide the content directly.
""")
Multi<String> generateHappyThought();
@SystemMessage("""
You are a friendly and enthusiastic AI assistant.
Your goal is to generate short, uplifting, and positive content based on user preferences (likes/dislikes).
The user preferences will be provided as sets of keywords or themes they like and dislike.
Generate content that aligns with the liked themes and avoids the disliked themes.
This could be a mini-poem, a piece of good news, a happy thought, or a lighthearted, SFW joke.
Keep the content concise (1-3 sentences) and always positive.
Do not include any preamble. Just provide the content directly.
""")
Multi<String> generateHappyThoughtWithPreferences(@UserMessage String preferences);
@SystemMessage("""
You are an AI assistant. Your task is to extract the 2-4 most relevant keywords or short themes
from the given text. Return them as a comma-separated list.
For example, if the input is 'A joyful poem about sunshine and happy dogs playing in a park',
you should output something like 'sunshine, happy dogs, joyful poem, park'.
Only output the keywords.
""")
Multi<String> extractKeywordsFromText(@UserMessage String text);
}
A note on chat memory which we are explicitly not using here:
Each method (generating a happy thought or extracting keywords) is independent and does not require context from previous interactions.
The AI should respond based only on the current input, and our sentiment and user likes that we keep track of separately.
Disabling chat memory reduces overhead and potential side effects, making responses faster and more predictable for single-turn tasks.
Say Hello to Happiness
Modify the generated src/main/java/com/happyplace/HappyResource.java
(we'll rename and adapt it later, this is just for the initial test). For now, let's simplify it for a quick test.
package com.happyplace;
import com.happyplace.services.AIService;
import io.smallrye.mutiny.Multi;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/api/thoughts") // Changed from /hello
public class HappyResource {
@Inject
AIService aiService;
@GET
@Path("/single")
@Produces(MediaType.TEXT_PLAIN)
public Multi<String> getSingleHappyThought() {
return aiService.generateHappyThought();
}
@GET
@Path("/extract-keywords")
@Produces(MediaType.TEXT_PLAIN)
public Multi<String> extractKeywordsTest() {
String sampleText = "This is a wonderful day full of sunshine and laughter. I saw a cute puppy!";
return aiService.extractKeywordsFromText(sampleText);
}
}
Fire up Quarkus:
quarkus dev
Open your browser or use curl to test:
curl http://localhost:8080/api/thoughts/single
You should see a happy thought generated by your LLM!curl http://localhost:8080/api/thoughts/extract-keywords
You should see a comma-separated list of keywords from the sample text.
The Happy Frontend
Basic HTML structure in index.html
A Facebook-style UI that feels familiar, built with plain HTML.
Create src/main/resources/META-INF/resources/index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Happy Place</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header>
<h1>Happy Place</h1>
</header>
<main>
<div class="whats-on-your-mind">
<textarea placeholder="What's on your mind? (Feature coming soon!)" readonly></textarea>
</div>
<div id="post-feed">
</div>
<div id="loading-indicator" style="display: none; text-align: center; padding: 20px;">
<p>Loading more happy thoughts...</p>
</div>
</main>
<script src="app.js"></script>
</body>
</html>
Basic Styling in styles.css
Simple and clean. The kind of UI that lets the positive content shine through.
Create src/main/resources/META-INF/resources/styles.css
with the content from my Github repository.
JavaScript for initial setup in app.js
Starts with a static loader, but by the end, it’ll dynamically render and stream posts with user feedback support. Create src/main/resources/META-INF/resources/app.js
: We will update it later for streaming!
document.addEventListener('DOMContentLoaded', () => {
const postFeed = document.getElementById('post-feed');
const loadingIndicator = document.getElementById('loading-indicator');
// Placeholder function for creating a post element
function createPostElement(post) {
const postDiv = document.createElement('div');
postDiv.classList.add('post');
postDiv.setAttribute('data-post-id', post.id || Date.now()); // Simple ID
const contentP = document.createElement('p');
contentP.classList.add('post-content');
contentP.textContent = post.text;
postDiv.appendChild(contentP);
const actionsDiv = document.createElement('div');
actionsDiv.classList.add('post-actions');
const likeButton = document.createElement('button');
likeButton.innerHTML = '👍 <span class="like-count">Like</span>';
likeButton.onclick = () => handleLike(post.id, post.text);
actionsDiv.appendChild(likeButton);
const dislikeButton = document.createElement('button');
dislikeButton.innerHTML = '👎 <span class="dislike-count">Dislike</span>';
dislikeButton.onclick = () => handleDislike(post.id, post.text);
actionsDiv.appendChild(dislikeButton);
postDiv.appendChild(actionsDiv);
return postDiv;
}
// Placeholder functions for like/dislike (will be implemented later)
async function handleLike(postId, postText) {
console.log('Liked:', postId, postText);
// Actual implementation in Module 4
}
async function handleDislike(postId, postText) {
console.log('Disliked:', postId, postText);
// Actual implementation in Module 4
}
console.log("Happy Place UI Initialized!");
// We will load initial posts in the next module
});
At this point, refreshing index.html
shows a static page.
Streaming an Endless Feed with SSE
We'll use Server-Sent Events (SSE). The backend will provide a Multi<String>
where each string is a JSON representation of a post. The key change here is how SentimentService
works with keywords.
Backend: SentimentService
with Keyword Extraction
This is the core of a sophisticated memory upgrade. Instead of storing full post texts, we'll extract and store keywords.
Create src/main/java/com/happyplace/services/SentimentService.java
:
package com.happyplace.services;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.infrastructure.Infrastructure;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class SentimentService {
@Inject
AIService aiService; // To extract keywords
// Store unique keywords, maintaining insertion order for potential pruning
private final Set<String> likedKeywords = Collections.synchronizedSet(new LinkedHashSet<>());
private final Set<String> dislikedKeywords = Collections.synchronizedSet(new LinkedHashSet<>());
private static final int MAX_KEYWORDS_PER_CATEGORY = 25; // Max keywords to remember per category
public Uni<Void> recordLike(String postText) {
if (postText == null || postText.isBlank())
return Uni.createFrom().voidItem();
return aiService.extractKeywordsFromText(postText)
.collect().asList()
.runSubscriptionOn(Infrastructure.getDefaultWorkerPool())
.invoke(tokens -> {
String keywordString = tokens.stream().collect(Collectors.joining());
if (keywordString != null && !keywordString.isBlank()) {
List<String> keywords = Arrays.asList(keywordString.toLowerCase().split(",\\s*"));
synchronized (likedKeywords) {
keywords.forEach(keyword -> {
likedKeywords.add(keyword.trim());
dislikedKeywords.remove(keyword.trim());
});
pruneKeywords(likedKeywords);
}
System.out.println("Extracted liked keywords: " + keywords);
}
})
.replaceWithVoid();
}
public Uni<Void> recordDislike(String postText) {
if (postText == null || postText.isBlank())
return Uni.createFrom().voidItem();
return aiService.extractKeywordsFromText(postText)
.collect().asList()
.runSubscriptionOn(Infrastructure.getDefaultWorkerPool())
.invoke(tokens -> {
String keywordString = tokens.stream().collect(Collectors.joining());
if (keywordString != null && !keywordString.isBlank()) {
List<String> keywords = Arrays.asList(keywordString.toLowerCase().split(",\\s*"));
synchronized (dislikedKeywords) {
keywords.forEach(keyword -> {
dislikedKeywords.add(keyword.trim());
likedKeywords.remove(keyword.trim());
});
pruneKeywords(dislikedKeywords);
}
System.out.println("Extracted disliked keywords: " + keywords);
}
})
.replaceWithVoid();
}
private void pruneKeywords(Set<String> keywordSet) {
// Prune oldest keywords if the set grows too large
while (keywordSet.size() > MAX_KEYWORDS_PER_CATEGORY) {
// LinkedHashSet maintains insertion order, so iterator().next() gives the
// oldest
if (keywordSet.iterator().hasNext()) {
String oldestKeyword = keywordSet.iterator().next();
keywordSet.remove(oldestKeyword);
System.out.println("Pruned keyword: " + oldestKeyword);
} else {
break; // Should not happen if size > 0
}
}
}
public String getAggregatedPreferences() {
StringBuilder preferences = new StringBuilder();
synchronized (likedKeywords) {
if (!likedKeywords.isEmpty()) {
preferences.append("User LIKES content related to themes/keywords such as: '")
.append(likedKeywords.stream().limit(10).collect(Collectors.joining("', '"))) // Limit for
// prompt length
.append("'. ");
}
}
synchronized (dislikedKeywords) {
if (!dislikedKeywords.isEmpty()) {
preferences.append("User DISLIKES content related to themes/keywords such as: '")
.append(dislikedKeywords.stream().limit(10).collect(Collectors.joining("', '")))
.append("'. ");
}
}
String result = preferences.toString().trim();
if (!result.isBlank()) {
System.out.println("Aggregated Preferences for LLM: " + result);
}
return result;
}
// For debugging or advanced use
public java.util.Map<String, Set<String>> getAllPreferences() {
return java.util.Map.of("liked", new LinkedHashSet<>(likedKeywords), "disliked",
new LinkedHashSet<>(dislikedKeywords));
}
}
What this class does:
Extracts keywords from user feedback posts using an injected AI service (
AIService
).Tracks two sets of keywords:
likedKeywords: keywords the user has liked
dislikedKeywords: keywords the user has disliked
Both are thread-safe and maintain insertion order.
Limits the number of stored keywords per category to a maximum (25), pruning the oldest when necessary.
Provides non-blocking methods (recordLike, recordDislike) to extract keywords from post text, update the sets, and ensure thread safety.
Aggregates preferences into a summary string for use in prompts or AI context.
Backend: Streaming Multiple Posts
Update HappyResource.java
package com.happyplace;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.happyplace.services.AIService;
import com.happyplace.services.SentimentService;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.Uni;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import org.jboss.resteasy.reactive.RestStreamElementType;
import java.time.Duration;
import java.util.UUID;
import java.util.List; // For initial posts list
@Path("/api/posts") // Updated path from /api/thoughts
public class HappyResource {
@Inject
AIService aiService;
@Inject
SentimentService sentimentService;
@Inject
ObjectMapper objectMapper; // For JSON conversion
// Simple Post record for structure
public record Post(String id, String text) {
}
@GET
@Path("/stream")
@Produces(MediaType.SERVER_SENT_EVENTS)
@RestStreamElementType(MediaType.APPLICATION_JSON)
public Multi<String> streamHappyPosts(@QueryParam("count") int count) {
int requestedCount = (count <= 0 || count > 10) ? 3 : count;
return Multi.createFrom().ticks().every(Duration.ofMillis(200)) // Slightly increased delay for generation +
// keyword extraction
.onItem().transformToUniAndMerge(tick -> Uni.createFrom().item(() -> {
String preferences = sentimentService.getAggregatedPreferences();
String thought;
if (preferences.isBlank()) {
thought = aiService.generateHappyThought()
.collect().first().await().indefinitely();
} else {
// The AIService's generateHappyThoughtWithPreferences prompt is already set up
// for this
thought = aiService.generateHappyThoughtWithPreferences(preferences)
.collect().first().await().indefinitely();
}
return new Post(UUID.randomUUID().toString(), thought);
}))
.map(post -> {
try {
return objectMapper.writeValueAsString(post);
} catch (JsonProcessingException e) {
System.err.println("Error serializing post: " + e.getMessage());
return "{\"error\":\"Failed to serialize post\", \"id\":\"error-" + UUID.randomUUID().toString()
+ "\"}";
}
})
.select().first(requestedCount);
}
@GET
@Path("/initial")
@Produces(MediaType.APPLICATION_JSON)
public Uni<List<Post>> getInitialPosts(@QueryParam("count") int count) {
int requestedCount = (count <= 0 || count > 5) ? 3 : count;
return Multi.createBy().repeating()
.uni(() -> Uni.createFrom().item(() -> {
String preferences = sentimentService.getAggregatedPreferences();
String thought;
if (preferences.isBlank()) {
thought = aiService.generateHappyThought()
.collect().first().await().indefinitely();
} else {
thought = aiService.generateHappyThoughtWithPreferences(preferences)
.collect().first().await().indefinitely();
}
return new Post(UUID.randomUUID().toString(), thought);
}))
.atMost(requestedCount)
.collect().asList();
}
}
This class:
Defines REST API endpoints for serving "happy posts" at
/api/posts
.Generates happy content using an injected AI service (AIService), optionally tailored to user preferences from SentimentService.
Streams posts reactively via Server-Sent Events (
/api/posts/stream
), sending a specified number of posts as JSON.Provides an endpoint for initial posts (
/api/posts/initial
) that returns a list of posts as JSON.Aggregates tokens from the AI service into complete post texts before sending to the client.
Serializes posts to JSON using an injected ObjectMapper.
Ensures non-blocking execution by running blocking operations on a worker pool.
Frontend: Consuming the Stream with SSE and Endless Scrolling
Update src/main/resources/META-INF/resources/app.js
:
document.addEventListener('DOMContentLoaded', () => {
const postFeed = document.getElementById('post-feed');
const loadingIndicator = document.getElementById('loading-indicator');
let eventSource;
let isFetching = false; // Renamed from isStreaming for clarity with scroll logic
let postsCurrentlyBeingFetchedCount = 0; // Keep track of how many we expect
function createPostElement(post) {
const postDiv = document.createElement('div');
postDiv.classList.add('post');
postDiv.setAttribute('data-post-id', post.id);
const contentP = document.createElement('p');
contentP.classList.add('post-content');
if (post.error) {
postDiv.classList.add('post-error');
contentP.textContent = `Error: ${post.error}`;
postDiv.appendChild(contentP);
return postDiv;
}
contentP.textContent = post.text;
postDiv.appendChild(contentP);
const actionsDiv = document.createElement('div');
actionsDiv.classList.add('post-actions');
const likeButton = document.createElement('button');
likeButton.innerHTML = '👍 <span class="like-count">Like</span>';
likeButton.onclick = () => handleLike(post.id, post.text);
actionsDiv.appendChild(likeButton);
const dislikeButton = document.createElement('button');
dislikeButton.innerHTML = '👎 <span class="dislike-count">Dislike</span>';
dislikeButton.onclick = () => handleDislike(post.id, post.text);
actionsDiv.appendChild(dislikeButton);
postDiv.appendChild(actionsDiv);
return postDiv;
}
async function fetchAndDisplayPosts(count = 3) {
if (isFetching) {
console.log("Already fetching posts.");
return;
}
isFetching = true;
loadingIndicator.style.display = 'block';
postsCurrentlyBeingFetchedCount = count;
let postsReceivedThisStream = 0;
// Defensive: fallback timeout in case backend fails to send enough posts
let fallbackTimeout = setTimeout(() => {
console.warn("Fetch timed out, resetting isFetching.");
isFetching = false;
loadingIndicator.style.display = 'none';
if (eventSource) eventSource.close();
}, 8000); // 8 seconds, adjust as needed
// Close existing stream if any before starting a new one
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
console.log("Closing previous EventSource connection.");
eventSource.close();
}
eventSource = new EventSource(`/api/posts/stream?count=${count}`);
console.log(`Requesting ${count} new posts from stream.`);
eventSource.onmessage = function(event) {
try {
const postData = JSON.parse(event.data);
if (postData.error) {
console.error("Error from stream:", postData.error);
const errorPostDiv = createPostElement(postData); // Use createPostElement to show error
postFeed.appendChild(errorPostDiv);
} else {
const postElement = createPostElement(postData);
postFeed.appendChild(postElement); // Append new post
}
postsReceivedThisStream++;
// Check if all requested posts for this stream have been received
if (postsReceivedThisStream >= postsCurrentlyBeingFetchedCount) {
console.log(`Received all ${postsCurrentlyBeingFetchedCount} expected posts for this stream.`);
loadingIndicator.style.display = 'none';
if(eventSource) eventSource.close(); // Close after receiving expected count
isFetching = false; // Reset global fetching flag
clearTimeout(fallbackTimeout); // Clear fallback
}
} catch (e) {
console.error('Failed to parse post data:', event.data, e);
// Potentially display a generic error post
}
};
eventSource.onerror = function(err) {
console.error("EventSource failed:", err);
loadingIndicator.style.display = 'none';
if(eventSource) eventSource.close();
isFetching = false; // Reset global fetching flag on error too
clearTimeout(fallbackTimeout); // Clear fallback
// Optionally, try to reconnect or inform the user
};
eventSource.onopen = function() {
console.log("Connection to stream opened.");
};
}
// Initial load
fetchAndDisplayPosts(3);
// Ensure enough posts to allow scrolling
function ensureScrollable() {
if (document.body.offsetHeight <= window.innerHeight && !isFetching) {
fetchAndDisplayPosts(2);
setTimeout(ensureScrollable, 500); // Check again after posts are added
}
}
setTimeout(ensureScrollable, 700);
// "Endless" Scrolling Logic
window.addEventListener('scroll', () => {
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 200 && !isFetching) {
console.log("Scrolled to bottom, fetching more posts...");
fetchAndDisplayPosts(2); // Fetch 2 more on scroll
}
});
// Functions for like/dislike
window.handleLike = async function(postId, postText) {
console.log('Liked Post ID:', postId, 'Text:', postText.substring(0,50) + "...");
try {
const response = await fetch(`/api/feedback/like`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: postText})
});
if (response.ok) console.log("Like recorded by backend.");
else console.error("Failed to record like", await response.text());
} catch (error) {
console.error("Error sending like:", error);
}
}
window.handleDislike = async function(postId, postText) {
console.log('Disliked Post ID:', postId, 'Text:', postText.substring(0,50) + "...");
try {
const response = await fetch(`/api/feedback/dislike`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: postText})
});
if (response.ok) console.log("Dislike recorded by backend.");
else console.error("Failed to record dislike", await response.text());
} catch (error) {
console.error("Error sending dislike:", error);
}
}
console.log("Happy Place UI Initialized and ready for streaming!");
});
Restart your Quarkus app. Refresh index.html
. You should see your screen foll with posts pre-load. Scrolling down should load more.
Remembering Joy - Storing Sentiment via Keywords
We've already updated SentimentService.java
earlier to use keyword extraction. Now, let's create the JAX-RS resource to interact with it.
Backend: FeedbackResource
for Likes/Dislikes
Create src/main/java/com/happyplace/FeedbackResource.java
:
package com.happyplace;
import com.happyplace.services.SentimentService;
import io.smallrye.mutiny.Uni;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/api/feedback")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class FeedbackResource {
@Inject
SentimentService sentimentService;
// Simple DTO for receiving post text
public static class FeedbackPayload {
public String text;
}
@POST
@Path("/like")
public Uni<Response> likePost(FeedbackPayload payload) {
if (payload == null || payload.text == null || payload.text.isBlank()) {
return Uni.createFrom().item(Response.status(Response.Status.BAD_REQUEST)
.entity("{\"error\":\"Post text is required\"}").build());
}
// Asynchronously record the like and extract keywords
return Uni.createFrom().completionStage(() -> Uni.createFrom().item(() -> {
sentimentService.recordLike(payload.text);
System.out.println("Like processing initiated for: "
+ payload.text.substring(0, Math.min(payload.text.length(), 50)) + "...");
return Response.ok("{\"message\":\"Like processing initiated\"}").build();
}).subscribeAsCompletionStage());
}
@POST
@Path("/dislike")
public Uni<Response> dislikePost(FeedbackPayload payload) {
if (payload == null || payload.text == null || payload.text.isBlank()) {
return Uni.createFrom().item(Response.status(Response.Status.BAD_REQUEST)
.entity("{\"error\":\"Post text is required\"}").build());
}
// Asynchronously record the dislike and extract keywords
return Uni.createFrom().completionStage(() -> Uni.createFrom().item(() -> {
sentimentService.recordDislike(payload.text);
System.out.println("Dislike processing initiated for: "
+ payload.text.substring(0, Math.min(payload.text.length(), 50)) + "...");
return Response.ok("{\"message\":\"Dislike processing initiated\"}").build();
}).subscribeAsCompletionStage());
}
}
Note that the frontend features for like/dislike are already implemented in the app.js code.
When users click 👍 or 👎:
We extract keywords from the post via the LLM
Maintain
Set<String>
s of liked and disliked topicsFeed those back into the LLM for future post generation
The LLM becomes heavily biased toward the things that make the user happy.
Like “puppies”? You’ll see more of them. Dislike “rainy days”? They'll fade into the background.
It’s a complete loop. Simple, elegant, and reactive.
🚀 Run It All Together
./mvnw quarkus:dev
Point your browser to: http://localhost:8080/
You’ll get a stream of uplifting, AI-generated posts. Interact with them. Watch how the feed evolves over time.
You Did It!
You built a local-first, AI-powered, sentiment-aware social feed using modern reactive Java tooling.
It’s not just another tutorial. It’s a glimpse into how adaptive AI systems can personalize experiences with simple, interpretable memory mechanisms.
Let me know how you want this to evolve in a next iteration.