Skip to content
This repository was archived by the owner on Mar 21, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/dashboard/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ rust-ai-agents-monitoring = { path = "../monitoring" }
# Agents crate for real data integration
rust-ai-agents-agents = { path = "../agents", optional = true }

# Providers crate for LLM streaming
rust-ai-agents-providers = { path = "../providers", optional = true }

# Core types
rust-ai-agents-core = { path = "../core", optional = true }

# Async streams for SSE
tokio-stream = "0.1"
async-stream = "0.3"

# Time
chrono = { workspace = true }

Expand All @@ -47,3 +57,4 @@ metrics-exporter-prometheus = "0.15"
[features]
default = []
agents = ["rust-ai-agents-agents"]
streaming = ["rust-ai-agents-providers", "rust-ai-agents-core"]
6 changes: 6 additions & 0 deletions crates/dashboard/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ mod websocket;
#[cfg(feature = "agents")]
pub mod integration;

#[cfg(feature = "streaming")]
pub mod streaming;

pub use server::DashboardServer;
pub use state::{
AgentStatus, DashboardMetrics, DashboardState, Session, SessionStatus, TraceEntry,
Expand All @@ -46,3 +49,6 @@ pub use state::{
pub use integration::{
add_demo_data, AgentBridge, DashboardBridge, SessionBridge, TrajectoryBridge,
};

#[cfg(feature = "streaming")]
pub use streaming::{streaming_routes, StreamEventOutput, StreamInferenceRequest, StreamingState};
294 changes: 294 additions & 0 deletions crates/dashboard/src/streaming.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
//! Server-Sent Events (SSE) streaming for LLM responses.
//!
//! This module provides SSE endpoints for streaming LLM inference results
//! to clients in real-time.
//!
//! # Feature Flag
//!
//! This module requires the `streaming` feature:
//!
//! ```toml
//! rust-ai-agents-dashboard = { version = "0.1", features = ["streaming"] }
//! ```

use axum::{
extract::State,
http::StatusCode,
response::{
sse::{Event, KeepAlive, Sse},
IntoResponse,
},
Json,
};
use rust_ai_agents_core::{tool::ToolSchema, LLMMessage, MessageRole};
use rust_ai_agents_providers::{LLMBackend, StreamEvent};
use serde::{Deserialize, Serialize};
use std::{convert::Infallible, sync::Arc, time::Duration};
use tokio_stream::StreamExt;

use crate::state::DashboardState;

/// Request body for streaming inference
#[derive(Debug, Clone, Deserialize)]
pub struct StreamInferenceRequest {
/// Messages to send to the LLM
pub messages: Vec<MessageInput>,
/// Optional tools available to the model
#[serde(default)]
pub tools: Vec<ToolSchema>,
/// Temperature for sampling (0.0 - 1.0)
#[serde(default = "default_temperature")]
pub temperature: f32,
/// Model to use (optional, uses default if not specified)
pub model: Option<String>,
}

fn default_temperature() -> f32 {
0.7
}

/// Input message format
#[derive(Debug, Clone, Deserialize)]
pub struct MessageInput {
pub role: String,
pub content: String,
}

impl From<MessageInput> for LLMMessage {
fn from(msg: MessageInput) -> Self {
let role = match msg.role.to_lowercase().as_str() {
"system" => MessageRole::System,
"assistant" => MessageRole::Assistant,
"tool" => MessageRole::Tool,
_ => MessageRole::User,
};
LLMMessage {
role,
content: msg.content,
tool_calls: None,
tool_call_id: None,
name: None,
}
}
}

/// SSE event types sent to clients
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", content = "data")]
pub enum StreamEventOutput {
/// Incremental text chunk
#[serde(rename = "text_delta")]
TextDelta { text: String },
/// Tool call started
#[serde(rename = "tool_call_start")]
ToolCallStart { id: String, name: String },
/// Tool call arguments chunk
#[serde(rename = "tool_call_delta")]
ToolCallDelta { id: String, arguments_delta: String },
/// Tool call completed
#[serde(rename = "tool_call_end")]
ToolCallEnd { id: String },
/// Stream completed
#[serde(rename = "done")]
Done {
input_tokens: Option<u32>,
output_tokens: Option<u32>,
},
/// Error occurred
#[serde(rename = "error")]
Error { message: String },
}

impl From<StreamEvent> for StreamEventOutput {
fn from(event: StreamEvent) -> Self {
match event {
StreamEvent::TextDelta(text) => StreamEventOutput::TextDelta { text },
StreamEvent::ToolCallStart { id, name } => {
StreamEventOutput::ToolCallStart { id, name }
}
StreamEvent::ToolCallDelta {
id,
arguments_delta,
} => StreamEventOutput::ToolCallDelta {
id,
arguments_delta,
},
StreamEvent::ToolCallEnd { id } => StreamEventOutput::ToolCallEnd { id },
StreamEvent::Done { token_usage } => StreamEventOutput::Done {
input_tokens: token_usage.as_ref().map(|t| t.prompt_tokens as u32),
output_tokens: token_usage.as_ref().map(|t| t.completion_tokens as u32),
},
StreamEvent::Error(message) => StreamEventOutput::Error { message },
}
}
}

/// Streaming state that holds the LLM backend
pub struct StreamingState<B: LLMBackend> {
pub backend: Arc<B>,
pub dashboard: Arc<DashboardState>,
}

impl<B: LLMBackend> StreamingState<B> {
pub fn new(backend: Arc<B>, dashboard: Arc<DashboardState>) -> Self {
Self { backend, dashboard }
}
}

/// SSE streaming inference handler
///
/// Returns a Server-Sent Events stream with LLM response chunks.
///
/// # Example
///
/// ```javascript
/// const eventSource = new EventSource('/api/inference/stream');
/// eventSource.onmessage = (event) => {
/// const data = JSON.parse(event.data);
/// if (data.type === 'text_delta') {
/// appendText(data.data.text);
/// }
/// };
/// ```
pub async fn stream_inference_handler<B: LLMBackend + 'static>(
State(state): State<Arc<StreamingState<B>>>,
Json(request): Json<StreamInferenceRequest>,
) -> impl IntoResponse {
let messages: Vec<LLMMessage> = request.messages.into_iter().map(Into::into).collect();
let tools = request.tools;
let temperature = request.temperature;
let backend = state.backend.clone();

// Create the SSE stream
let stream = async_stream::stream! {
match backend.infer_stream(&messages, &tools, temperature).await {
Ok(mut response_stream) => {
while let Some(result) = response_stream.next().await {
match result {
Ok(event) => {
let output: StreamEventOutput = event.into();
let json = serde_json::to_string(&output).unwrap_or_default();
yield Ok::<_, Infallible>(Event::default().data(json));
}
Err(e) => {
let error = StreamEventOutput::Error {
message: e.to_string(),
};
let json = serde_json::to_string(&error).unwrap_or_default();
yield Ok(Event::default().data(json));
break;
}
}
}
}
Err(e) => {
let error = StreamEventOutput::Error {
message: e.to_string(),
};
let json = serde_json::to_string(&error).unwrap_or_default();
yield Ok(Event::default().data(json));
}
}
};

Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("keep-alive"),
)
}

/// POST handler for streaming inference (accepts JSON body)
pub async fn post_stream_inference_handler<B: LLMBackend + 'static>(
State(state): State<Arc<StreamingState<B>>>,
Json(request): Json<StreamInferenceRequest>,
) -> impl IntoResponse {
stream_inference_handler(State(state), Json(request)).await
}

/// Simple chat completion endpoint (non-streaming, for comparison)
pub async fn chat_completion_handler<B: LLMBackend + 'static>(
State(state): State<Arc<StreamingState<B>>>,
Json(request): Json<StreamInferenceRequest>,
) -> impl IntoResponse {
let messages: Vec<LLMMessage> = request.messages.into_iter().map(Into::into).collect();
let tools = request.tools;
let temperature = request.temperature;

match state.backend.infer(&messages, &tools, temperature).await {
Ok(output) => Json(serde_json::json!({
"content": output.content,
"tool_calls": output.tool_calls,
"token_usage": output.token_usage,
}))
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
.into_response(),
}
}

/// Create SSE routes for streaming
///
/// # Example
///
/// ```rust,ignore
/// use rust_ai_agents_dashboard::streaming::{streaming_routes, StreamingState};
/// use rust_ai_agents_providers::AnthropicProvider;
///
/// let backend = Arc::new(AnthropicProvider::new(api_key));
/// let streaming_state = Arc::new(StreamingState::new(backend, dashboard_state));
///
/// let app = Router::new()
/// .merge(streaming_routes())
/// .with_state(streaming_state);
/// ```
pub fn streaming_routes<B: LLMBackend + 'static>() -> axum::Router<Arc<StreamingState<B>>> {
use axum::routing::post;

axum::Router::new()
.route(
"/api/inference/stream",
post(post_stream_inference_handler::<B>),
)
.route("/api/inference/chat", post(chat_completion_handler::<B>))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_message_input_conversion() {
let input = MessageInput {
role: "user".to_string(),
content: "Hello!".to_string(),
};

let llm_msg: LLMMessage = input.into();
assert!(matches!(llm_msg.role, MessageRole::User));
assert_eq!(llm_msg.content, "Hello!");
}

#[test]
fn test_stream_event_output_serialization() {
let event = StreamEventOutput::TextDelta {
text: "Hello".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("text_delta"));
assert!(json.contains("Hello"));
}

#[test]
fn test_stream_event_conversion() {
let event = StreamEvent::TextDelta("test".to_string());
let output: StreamEventOutput = event.into();
match output {
StreamEventOutput::TextDelta { text } => assert_eq!(text, "test"),
_ => panic!("Wrong variant"),
}
}
}
5 changes: 5 additions & 0 deletions crates/studio/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ web-sys = { version = "0.3", features = [
"ErrorEvent",
"Location",
"Storage",
"KeyboardEvent",
"MouseEvent",
"ReadableStream",
"ReadableStreamDefaultReader",
"TextDecoder",
] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
Expand Down
1 change: 1 addition & 0 deletions crates/studio/src/components/sidebar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub fn Sidebar() -> impl IntoView {

<NavSection title="Control">
<NavLink href="/agents" icon="🤖">"Agents"</NavLink>
<NavLink href="/chat" icon="✨">"Chat"</NavLink>
</NavSection>

<NavSection title="System">
Expand Down
1 change: 1 addition & 0 deletions crates/studio/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ pub fn App() -> impl IntoView {
<Route path=path!("/sessions/:id") view=pages::SessionDetailPage />
<Route path=path!("/agents") view=pages::AgentsPage />
<Route path=path!("/metrics") view=pages::MetricsPage />
<Route path=path!("/chat") view=pages::ChatPage />
<Route path=path!("/settings") view=pages::SettingsPage />
</Routes>
</components::Layout>
Expand Down
Loading