Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
28bddec
web: update biome and fix lint errors
fa-sharp Aug 30, 2025
2ea3c3c
server: file upload and download working
fa-sharp Aug 30, 2025
f90f97e
server: refactor/organize
fa-sharp Aug 31, 2025
3c1bf83
server: can upload, download, list, and delete session files
fa-sharp Aug 31, 2025
7a3a034
server: add LLM tools for basic file read/write (with permissions)
fa-sharp Sep 1, 2025
14b7979
Update ARCHITECTURE.md
fa-sharp Sep 1, 2025
c16a97d
web: update frontend types
fa-sharp Sep 1, 2025
84a28a1
web: can drag/drop and upload files
fa-sharp Sep 1, 2025
4e2e4b3
web: add file selection dialog
fa-sharp Sep 1, 2025
549923d
web: tweak tool manager to look like other pages
fa-sharp Sep 1, 2025
8747558
web: tweak data fetching
fa-sharp Sep 1, 2025
aedf1c6
Update chat-bubble.tsx
fa-sharp Sep 1, 2025
5d1592f
Update guard.rs
fa-sharp Sep 1, 2025
0dd302c
server: support file input
fa-sharp Sep 2, 2025
7b12377
server: organize providers module
fa-sharp Sep 2, 2025
3318bff
Update README.md
fa-sharp Sep 2, 2025
0b1a769
server: move import
fa-sharp Sep 2, 2025
f6e4a06
server: tweak serialization
fa-sharp Sep 2, 2025
06810cb
server: fix openai file types
fa-sharp Sep 2, 2025
2799d2f
server: fix base64 URLs
fa-sharp Sep 2, 2025
68a2151
web: support attaching files to messages
fa-sharp Sep 2, 2025
155d7fa
web: show user's file attachments below message
fa-sharp Sep 2, 2025
9943822
web: allow uploading through file dialog
fa-sharp Sep 2, 2025
af189d8
server: set openai store parameter
fa-sharp Sep 2, 2025
a0f9467
server: fix anthropic file format
fa-sharp Sep 2, 2025
0ddcbbd
docker: fix data/storage permissions
fa-sharp Sep 2, 2025
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
23 changes: 11 additions & 12 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Overview

RsChat is a real-time chat application that provides resumable streaming conversations with LLM providers. The architecture is designed for high performance, scalability across multiple server instances, and resilient streaming that can survive network interruptions.
RsChat is an application for chatting with multiple LLM providers. The architecture is designed for high performance, scalability across multiple server instances, and resilient streaming that can survive network interruptions.

## Core Architecture

Expand All @@ -14,7 +14,7 @@ RsChat is a real-time chat application that provides resumable streaming convers

### Backend (Rust/Rocket)
- **Location**: `server/`
- **Framework**: Rocket with async/await support
- **Framework**: Rocket
- **Database**: PostgreSQL for persistent storage
- **Cache/Streaming**: Redis for stream management and caching

Expand All @@ -24,8 +24,8 @@ RsChat is a real-time chat application that provides resumable streaming convers

RsChat uses a hybrid streaming architecture that provides both real-time performance and cross-instance resumability:

1. **Server**: Redis Streams for resumability and multi-instance support
2. **Client**: Server-Sent Events (SSE) read from the Redis streams
1. **Server**: Redis Streams created from the provider responses, for resumability and multi-instance support, as well as simultaneous streaming to multiple clients.
2. **Client**: Server-Sent Events (SSE) read from the Redis streams.

### Key Components

Expand All @@ -34,9 +34,8 @@ RsChat uses a hybrid streaming architecture that provides both real-time perform
The core component that processes LLM provider streams and manages Redis stream output.

**Key Features:**
- **Batching**: Accumulates chunks from the provider stream, up to a max length or timeout
- **Batching**: Accumulates chunks from the provider stream, up to a max length or timeout, and adds them to the Redis stream
- **Background Pings**: Sends regular keepalive pings
- **Database Integration**: Saves final responses to PostgreSQL

#### 2. Redis and SSE Stream Structure

Expand All @@ -46,6 +45,7 @@ The core component that processes LLM provider streams and manages Redis stream
- `start`: Stream initialization
- `text`: Accumulated text chunks
- `tool_call`: LLM tool invocations (JSON stringified)
- `pending_tool_call`: Pending tool call invocations
- `error`: Error messages
- `ping`: Keepalive messages
- `end`: Stream completion
Expand All @@ -54,7 +54,7 @@ The core component that processes LLM provider streams and manages Redis stream
#### 3. Stream Lifecycle

```
Client Request → SSE Connection → LlmStreamWriter.create()
Client Request → LLM streaming response → LlmStreamWriter.create()
LLM Provider Stream → Batching Data Chunks → Redis XADD
Expand All @@ -77,17 +77,16 @@ Stream End → Database Save → Redis DEL
```
Client → POST /api/chat/{session_id}
→ Send request to LLM Provider
→ SSE Response Stream created
→ LlmStreamWriter.create()
→ Redis Stream created
→ LLM response received, streamed to Redis with the `LlmStreamWriter`
→ GET /api/chat/{session_id}/stream to connect to the stream and stream the response
```

### 2. Stream Processing
```
LLM Chunk → Process text, tool calls, usage, and error chunks
→ Batching Logic
→ Redis XADD (if conditions met)
Continue SSE Stream
Client(s) receive the new chunks
```

### 3. Stream Completion
Expand All @@ -101,5 +100,5 @@ LLM End → Final Database Save
### 4. Reconnection/Resume
```
Client Reconnect → Check ongoing streams via GET /api/chat/streams
→ Reconnect to stream (if active)
→ Reconnect to any active streams
```
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ RUN apt-get update -qq && \
apt-get install -y -qq ca-certificates libpq5 && \
apt-get clean

# Use non-root user
# Create non-root user and data directory
ARG UID=10001
RUN adduser \
--disabled-password \
Expand All @@ -53,6 +53,9 @@ RUN adduser \
--shell "/sbin/nologin" \
--uid "${UID}" \
appuser
RUN mkdir -p /data
RUN chown -R appuser:appuser /data

USER appuser

# Copy app files
Expand All @@ -61,6 +64,7 @@ COPY --from=backend-build /app/run-server /usr/local/bin/

# Run
ENV RS_CHAT_STATIC_PATH=/var/www
ENV RS_CHAT_DATA_DIR=/data
ENV RS_CHAT_ADDRESS=0.0.0.0
ENV RS_CHAT_PORT=8080
EXPOSE 8080
Expand Down
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@

A fast, secure, self-hostable chat application built with Rust, TypeScript, and React. Chat with multiple AI providers using your own API keys, with real-time streaming built-in.

!! **Submission to the [T3 Chat Cloneathon](https://cloneathon.t3.chat/)** !!

Demo link: https://rschat.fasharp.io (⚠️ This is a demo - don't expect your account/chats to be there when you come back. It may intermittently delete data. Please also don't enter any sensitive information or confidential data)
Demo link: https://rs-chat-demo.up.railway.app/ (⚠️ This is a demo - don't expect your account/chats to be there when you come back. It may intermittently delete all data. Please also don't enter any sensitive information or confidential data)

## ✨ Features

Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,16 @@ services:
RS_CHAT_SERVER_ADDRESS: http://localhost:8080
RS_CHAT_DATABASE_URL: postgres://postgres:postgres@postgres/postgres
RS_CHAT_REDIS_URL: redis://redis:6379
RS_CHAT_DATA_DIR: /data
env_file: server/.env
volumes:
- ./.docker:/certs
- rschat_data:/data
depends_on:
- db
- redis

volumes:
postgres_data:
redis_data:
rschat_data:
3 changes: 3 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,8 @@ RS_CHAT_GITHUB_CLIENT_SECRET=your_github_client_secret_here
# You can generate one with: openssl rand -hex 32
RS_CHAT_SECRET_KEY=hex-secret-key-for-encryption-change-this

# Local data directory
RS_CHAT_DATA_DIR=.local

# Postgres URL for running migrations via the Diesel CLI
DATABASE_URL=postgres://postgres:postgres@localhost/postgres
2 changes: 2 additions & 0 deletions server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Local storage files
.local/
1 change: 1 addition & 0 deletions server/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ strip = true
[dependencies]
aes-gcm = "0.10.3"
astral-tokio-tar = "0.5.2"
base64 = "0.22.1"
bollard = { version = "0.19.1", features = ["ssl"] }
chrono = { version = "0.4.41", features = ["serde"] }
const_format = "0.2.34"
Expand Down
1 change: 1 addition & 0 deletions server/migrations/2025-08-31-034235_add_files/down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE files;
18 changes: 18 additions & 0 deletions server/migrations/2025-08-31-034235_add_files/up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users (id),
session_id UUID REFERENCES chat_sessions (id) ON UPDATE CASCADE ON DELETE SET NULL,
path TEXT NOT NULL,
file_type TEXT NOT NULL,
content_type TEXT NOT NULL,
size INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

SELECT
diesel_manage_updated_at ('files');

CREATE INDEX idx_files_user_id ON files (user_id);

CREATE INDEX idx_files_session_id ON files (session_id);
2 changes: 2 additions & 0 deletions server/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod info;
mod provider;
mod secret;
mod session;
mod storage;
mod tool;

pub use api_key::get_routes as api_key_routes;
Expand All @@ -14,4 +15,5 @@ pub use info::get_routes as info_routes;
pub use provider::get_routes as provider_routes;
pub use secret::get_routes as secret_routes;
pub use session::get_routes as session_routes;
pub use storage::get_routes as storage_routes;
pub use tool::get_routes as tool_routes;
14 changes: 2 additions & 12 deletions server/src/api/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,8 @@ use rocket_okapi::{
use schemars::JsonSchema;

use crate::{
auth::{
ChatRsAuthSession, DiscordOAuthConfig, GitHubOAuthConfig, GoogleOAuthConfig, OIDCConfig,
SSOHeaderMergedConfig,
},
db::{
models::ChatRsUser,
services::{
ApiKeyDbService, ChatDbService, ProviderDbService, SecretDbService, ToolDbService,
UserDbService,
},
DbConnection,
},
auth::*,
db::{models::ChatRsUser, services::*, DbConnection},
errors::ApiError,
};

Expand Down
75 changes: 37 additions & 38 deletions server/src/api/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,16 @@ use crate::{
api::session::DEFAULT_SESSION_TITLE,
auth::ChatRsUserId,
db::{
models::{
AssistantMeta, ChatRsMessageMeta, ChatRsMessageRole, ChatRsSessionMeta,
NewChatRsMessage, UpdateChatRsSession,
},
models::*,
services::{ChatDbService, ProviderDbService, ToolDbService},
DbConnection, DbPool,
},
errors::ApiError,
provider::{build_llm_provider_api, LlmError, LlmProviderOptions},
provider::{build_llm_messages, build_llm_provider_api, LlmError, LlmProviderOptions},
redis::{ExclusiveRedisClient, RedisClient},
stream::{
cancel_current_chat_stream, check_chat_stream_exists, get_current_chat_streams,
LastEventId, LlmStreamWriter, SseStreamReader,
},
tools::{get_llm_tools_from_input, SendChatToolInput},
storage::LocalStorage,
stream::*,
tools::SendChatToolInput,
utils::{generate_title, Encryptor},
};

Expand Down Expand Up @@ -72,6 +67,8 @@ pub struct SendChatInput<'a> {
options: LlmProviderOptions,
/// Configuration of tools available to the assistant
tools: Option<SendChatToolInput>,
/// IDs of the file(s) to attach to this message
files: Option<Vec<Uuid>>,
}

#[derive(JsonSchema, serde::Serialize)]
Expand All @@ -92,6 +89,7 @@ pub async fn send_chat_stream(
redis: RedisClient,
redis_writer: ExclusiveRedisClient,
encryptor: &State<Encryptor>,
storage: &State<LocalStorage>,
http_client: &State<reqwest::Client>,
session_id: Uuid,
mut input: Json<SendChatInput<'_>>,
Expand Down Expand Up @@ -123,12 +121,26 @@ pub async fn send_chat_stream(

// Get the user's chosen tools
let mut tools = None;
if let Some(tool_input) = input.tools.as_ref() {
let mut tool_db_service = ToolDbService::new(&mut db);
tools = Some(get_llm_tools_from_input(&user_id, tool_input, &mut tool_db_service).await?);
if let Some(conf) = input.tools.take() {
let llm_tools = conf
.get_llm_tools(&user_id, &mut ToolDbService::new(&mut db))
.await?;
tools = Some(llm_tools);

// Update session metadata with new tools if needed
if session.meta.tool_config.as_ref().is_none_or(|c| *c != conf) {
let data = UpdateChatRsSession {
meta: Some(ChatRsSessionMeta::new(Some(conf))),
..Default::default()
};
ChatDbService::new(&mut db)
.update_session(&user_id, &session_id, data)
.await?;
}
}

// Generate session title if needed, and save user message to database
let attached_file_ids = input.files.take();
if let Some(user_message) = &input.message {
if messages.is_empty() && session.title == DEFAULT_SESSION_TITLE {
generate_title(
Expand All @@ -140,47 +152,34 @@ pub async fn send_chat_stream(
db_pool,
);
}
let new_message = ChatDbService::new(&mut db)
let message_meta = attached_file_ids
.map(|ids| ChatRsMessageMeta::new_user(UserMeta { files: Some(ids) }))
.unwrap_or_default();
let message = ChatDbService::new(&mut db)
.save_message(NewChatRsMessage {
content: user_message,
session_id: &session_id,
role: ChatRsMessageRole::User,
meta: ChatRsMessageMeta::default(),
meta: message_meta,
})
.await?;
messages.push(new_message);
messages.push(message);
}

// Update session metadata if needed
if let Some(tool_input) = input.tools.take() {
if session
.meta
.tool_config
.is_none_or(|config| config != tool_input)
{
let meta = ChatRsSessionMeta::new(Some(tool_input));
let data = UpdateChatRsSession {
meta: Some(&meta),
..Default::default()
};
ChatDbService::new(&mut db)
.update_session(&user_id, &session_id, data)
.await?;
}
}

// Get the provider's stream response
// Convert the messages, and get the provider's response stream
let llm_messages =
build_llm_messages(messages, &user_id, &session_id, &mut db, &storage).await?;
let stream = provider_api
.chat_stream(messages, tools, &input.options)
.chat_stream(llm_messages, tools, &input.options)
.await?;
let provider_id = input.provider_id;
let provider_options = input.options.clone();

// Create the Redis stream
let mut stream_writer = LlmStreamWriter::new(redis_writer, &user_id, &session_id);
stream_writer.start().await?;

// Spawn a task to stream and save the response
let provider_id = input.provider_id.clone();
let provider_options = input.options.clone();
tokio::spawn(async move {
let (text, tool_calls, usage, errors, cancelled) = stream_writer.process(stream).await;
let assistant_meta = AssistantMeta {
Expand Down
8 changes: 2 additions & 6 deletions server/src/api/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,12 @@ use uuid::Uuid;
use crate::{
auth::ChatRsUserId,
db::{
models::{
ChatRsProvider, ChatRsProviderType, NewChatRsProvider, NewChatRsSecret,
UpdateChatRsProvider, UpdateChatRsSecret,
},
models::*,
services::{ProviderDbService, SecretDbService},
DbConnection,
},
errors::ApiError,
provider::build_llm_provider_api,
provider_models::LlmModel,
provider::{build_llm_provider_api, models::LlmModel},
redis::RedisClient,
utils::Encryptor,
};
Expand Down
Loading
Loading