-
-
Notifications
You must be signed in to change notification settings - Fork 761
AI LLM Streaming
Atmosphere 4.0 provides a first-class SPI for streaming AI/LLM responses token-by-token to browsers over WebSocket. The atmosphere-ai module defines the core interfaces, and optional adapter modules bridge popular AI frameworks.
Browser ←—WebSocket—→ @ManagedService → StreamingSession → AiStreamingAdapter
↑
Direct write to
AtmosphereResource
(bypasses broadcaster)
When a user sends a prompt, the @Message handler creates a StreamingSession and passes it to an adapter. The adapter calls the AI model's streaming API, forwarding each token to session.send(token). Tokens are written directly to the WebSocket connection — they bypass the broadcaster to avoid re-triggering @Message handlers.
| Module | Artifact | Purpose |
|---|---|---|
| Core SPI | atmosphere-ai |
StreamingSession, AiStreamingAdapter, AiConfig, built-in LLM client |
| Spring AI | atmosphere-spring-ai |
Adapter for Spring AI ChatClient (Flux-based) |
| LangChain4j | atmosphere-langchain4j |
Adapter for LangChain4j StreamingChatLanguageModel
|
| Embabel | atmosphere-embabel |
Adapter for Embabel Agent Framework (Kotlin) |
The simplest path uses atmosphere-ai with its built-in OpenAiCompatibleClient. This works with OpenAI, Google Gemini, Ollama, Azure OpenAI, and any OpenAI-compatible endpoint — with zero additional dependencies.
<dependency>
<groupId>org.atmosphere</groupId>
<artifactId>atmosphere-ai</artifactId>
<version>4.0.0-SNAPSHOT</version>
</dependency>Set environment variables:
| Variable | Description | Default |
|---|---|---|
LLM_MODE |
remote (cloud API) or local (Ollama) |
remote |
LLM_MODEL |
Model name: gemini-2.5-flash, gpt-4o, llama3.2, etc. |
gemini-2.5-flash |
LLM_API_KEY |
API key (or GEMINI_API_KEY for Gemini) |
— |
LLM_BASE_URL |
Override the endpoint (auto-detected if omitted) | auto |
The endpoint is auto-detected from the model name:
-
gemini-*→https://generativelanguage.googleapis.com/v1beta/openai -
gpt-*→https://api.openai.com/v1 -
localmode →http://localhost:11434/v1(Ollama)
Or configure programmatically in Spring Boot:
@Configuration
public class LlmConfig {
@Bean
AiConfig.LlmSettings llmSettings(@Value("${llm.api-key}") String apiKey,
@Value("${llm.model:gemini-2.5-flash}") String model) {
return AiConfig.configure(apiKey, model, "remote", null);
}
}@ManagedService(path = "/ai-chat")
public class AiChat {
@Inject private AtmosphereResource resource;
@Message
public void onMessage(String prompt) {
var settings = AiConfig.get();
var session = StreamingSessions.start(resource);
var request = ChatCompletionRequest.builder(settings.model())
.system("You are a helpful assistant.")
.user(prompt)
.build();
// Stream on a virtual thread — non-blocking
Thread.startVirtualThread(() -> settings.client().streamChatCompletion(request, session));
}
}import { subscribeStreaming } from 'atmosphere.js';
const handle = await subscribeStreaming(atmosphere, {
url: '/ai-chat',
transport: 'websocket',
}, {
onToken: (token) => output.textContent += token,
onProgress: (msg) => status.textContent = msg,
onComplete: () => console.log('Done'),
onError: (err) => console.error(err),
});
handle.send('Explain virtual threads in Java 21');Or with React:
import { useStreaming } from 'atmosphere.js/react';
function AiChat() {
const { fullText, isStreaming, send } = useStreaming({
request: { url: '/ai-chat', transport: 'websocket' },
});
return (
<div>
<button onClick={() => send('What is Atmosphere?')} disabled={isStreaming}>Ask</button>
<p>{fullText}</p>
</div>
);
}Every message is a JSON object written directly to the WebSocket:
{"type":"token","data":"Hello","sessionId":"abc-123","seq":1}
{"type":"token","data":" world","sessionId":"abc-123","seq":2}
{"type":"progress","data":"Thinking...","sessionId":"abc-123","seq":3}
{"type":"metadata","data":"{\"model\":\"gemini-2.5-flash\"}","sessionId":"abc-123","seq":4}
{"type":"complete","data":"","sessionId":"abc-123","seq":5}| Type | Description |
|---|---|
token |
A single token/chunk from the LLM |
progress |
A human-readable status update (e.g., "Searching documents...") |
metadata |
Structured metadata (model name, usage stats) |
complete |
Stream finished successfully |
error |
Stream failed — data contains the error message |
The seq field is a monotonically increasing counter for deduplication on reconnect.
The StreamingSession interface is the bridge between any AI framework and Atmosphere:
public interface StreamingSession extends AutoCloseable {
String sessionId();
void send(String token); // push a token
void sendMetadata(String key, Object value); // structured metadata
void progress(String message); // status update
void complete(); // success
void complete(String summary); // success with summary
void error(Throwable t); // failure
boolean isClosed();
}Create one with StreamingSessions.start(resource). The DefaultStreamingSession implementation writes JSON directly to AtmosphereResource, bypassing the broadcaster.
Implement this to bridge any AI framework:
public interface AiStreamingAdapter<T> {
String name();
void stream(T request, StreamingSession session);
}Example for a custom framework:
public class MyAdapter implements AiStreamingAdapter<MyPrompt> {
@Override public String name() { return "my-ai"; }
@Override
public void stream(MyPrompt request, StreamingSession session) {
myModel.streamTokens(request,
token -> session.send(token),
() -> session.complete(),
err -> session.error(err));
}
}Bridges Spring AI's ChatClient Flux-based streaming to StreamingSession.
<dependency>
<groupId>org.atmosphere</groupId>
<artifactId>atmosphere-spring-ai</artifactId>
<version>4.0.0-SNAPSHOT</version>
</dependency>@ManagedService(path = "/ai-chat")
public class AiChat {
@Inject private AtmosphereResource resource;
@Inject private ChatClient chatClient;
private final SpringAiStreamingAdapter adapter = new SpringAiStreamingAdapter();
@Message
public void onMessage(String prompt) {
var session = StreamingSessions.start(resource);
adapter.stream(chatClient, prompt, session);
}
}The adapter subscribes to chatClient.prompt(prompt).stream().chatResponse() and pushes each ChatResponse token through the session.
Bridges LangChain4j's callback-based StreamingChatLanguageModel to StreamingSession.
<dependency>
<groupId>org.atmosphere</groupId>
<artifactId>atmosphere-langchain4j</artifactId>
<version>4.0.0-SNAPSHOT</version>
</dependency>@ManagedService(path = "/ai-chat")
public class AiChat {
@Inject private AtmosphereResource resource;
private final LangChain4jStreamingAdapter adapter = new LangChain4jStreamingAdapter();
@Message
public void onMessage(String prompt) {
var session = StreamingSessions.start(resource);
var model = OpenAiStreamingChatModel.builder()
.baseUrl(AiConfig.get().baseUrl())
.apiKey(AiConfig.get().client().apiKey())
.modelName(AiConfig.get().model())
.build();
var chatRequest = ChatRequest.builder()
.messages(List.of(UserMessage.from(prompt)))
.build();
Thread.startVirtualThread(() -> adapter.stream(model, chatRequest, session));
}
}AtmosphereStreamingResponseHandler converts LangChain4j's onNext/onComplete/onError callbacks to StreamingSession calls.
Bridges Embabel's OutputChannel pattern for agentic AI (Kotlin).
<dependency>
<groupId>org.atmosphere</groupId>
<artifactId>atmosphere-embabel</artifactId>
<version>4.0.0-SNAPSHOT</version>
</dependency>@ManagedService(path = "/ai-agent")
class AgentChat {
@Inject lateinit var resource: AtmosphereResource
@Message
fun onMessage(prompt: String) {
val session = StreamingSessions.start(resource)
val channel = AtmosphereOutputChannel(session)
// Agent progress events stream to the browser
Thread.startVirtualThread { agentPlatform.runAgent(prompt, outputChannel = channel) }
}
}AtmosphereOutputChannel translates agent events (thinking, tool calls, results) into StreamingSession calls with appropriate progress / token / complete messages.
import { atmosphere, subscribeStreaming } from 'atmosphere.js';
const handle = await subscribeStreaming(atmosphere, {
url: '/ai-chat',
transport: 'websocket',
}, {
onToken: (token) => { /* append token to UI */ },
onProgress: (msg) => { /* show status */ },
onMetadata: (meta) => { /* model info, usage */ },
onComplete: () => { /* done */ },
onError: (err) => { /* handle error */ },
});
handle.send('Your prompt here');
handle.close(); // disconnect when doneimport { useStreaming } from 'atmosphere.js/react';
function AiChat() {
const { fullText, tokens, isStreaming, progress, metadata, error, send, reset } = useStreaming({
request: { url: '/ai-chat', transport: 'websocket' },
});
return (
<div>
<button onClick={() => send('What is Atmosphere?')} disabled={isStreaming}>Ask</button>
{isStreaming && <span>{progress ?? 'Generating…'}</span>}
<p>{fullText}</p>
<button onClick={reset}>Clear</button>
</div>
);
}<script setup>
import { useStreaming } from 'atmosphere.js/vue';
const { fullText, isStreaming, send } = useStreaming(
{ url: '/ai-chat', transport: 'websocket' },
);
</script>
<template>
<button @click="send('What is Atmosphere?')" :disabled="isStreaming">Ask</button>
<p>{{ fullText }}</p>
</template><script>
import { createStreamingStore } from 'atmosphere.js/svelte';
const { store: ai, send } = createStreamingStore(
{ url: '/ai-chat', transport: 'websocket' },
);
</script>
<button on:click={() => send('What is Atmosphere?')} disabled={$ai.isStreaming}>Ask</button>
<p>{$ai.fullText}</p>| Sample | AI Framework | Source |
|---|---|---|
| AI Chat (built-in client) | atmosphere-ai |
spring-boot-ai-chat |
| LangChain4j Chat | atmosphere-langchain4j |
spring-boot-langchain4j-chat |
| Embabel Agent Chat | atmosphere-embabel |
spring-boot-embabel-chat |
- Understanding @ManagedService — annotation-driven endpoints
-
Framework Hooks — React, Vue, Svelte —
useAtmosphere,useRoom,useStreaming - atmosphere.js API Reference — full type reference
This wiki is archived. Current documentation: async-io.live/docs/