Skip to content
Open
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
OPENAI_API_KEY=your_openai_api_key
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

# Ignore all environment files.
/.env*
!.env.example

# Ignore all logfiles and tempfiles.
/log/*
Expand All @@ -33,3 +34,6 @@
# Ignore key files for decrypting credentials and more.
/config/*.key

# Ignore .idea SDE files
/idea/*
.idea
79 changes: 79 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Overview

Web chat interface to chatGPT

## Application
Visit http://0.0.0.0:3000/chats for the web chat interface to chatGPT

# System Design

## Chat Interface System Sequence Diagram
![Chat Architecture Diagram](./doc/chat-interface-ssdiagram.svg)

```mermaid
sequenceDiagram
participant F as 🧑‍💻 Frontend (Turbo)
participant C as 🎯 ChatController (Rails)
participant W as ⚙️ Sidekiq Worker
participant O as 🤖 OpenAI (Spectre)
participant D as 🗄️ Database (Chat Table)
participant T as 📡 Turbo Stream Channel

%% 1. Controller creates or updates session chat record
F->>C: 1️⃣ Send message via POST
C->>D: Create or update chat session record

%% 2. Controller sends message to worker
C->>W: Enqueue job with message payload

%% 3. Worker sends message to OpenAI
W->>O: Send user message via Spectre API

%% 4. OpenAI responds
O-->>W: Return generated AI response

%% 5. Worker saves message to DB
W->>D: Save AI response message record

%% 6. Worker broadcasts to Turbo Stream
W->>T: Broadcast Turbo Stream update
T-->>F: Realtime update (new AI message displayed)
```

# Integration & External Services

The chat interface uses Ruby Spectre gem to interface with the OpenAI foundation model.

Add the OpenAI API key to the app root directory ~/.env file

or

If there's no .env file in app root directory yet, add the OpenAI API key to .env.example

```vim
OPENAI_API_KEY=your_openai_api_key
```

Then copy .env.example to .env

```bash
cp .env.example .env
```

# Future Work

- Add a external service object OpenAI fetcher for external API fetch (app/services/external/openai.rb)
- Add a message broadcaster object for handling Turbo stream broadcasts (app/broadcasters/message_broadcaster.rb)
- Add turbo partial for progress bar or loading... indicator
- Place stylesheets in app/assets/stylesheets/pages/chat_interface.css and import files in application.css
- Add SpectreOpenAIJob error handling
- Add prompt validation for user input (mirror at minimum chatGPT prompt validation, excessive spam, SQL injection)
- Add end to end message encryption
- Add Roadauth user authentication and user table, tie chats to user sessions
- Add chat session tab feature with new data table modeling (chat, questions, responses)
- Add Tailwind for nice UI layout
- Cleanup `rails generate scaffold chat` files
- Add sidekiq worker, chat controller, service object, and broadcaster object tests
- Add capybara end to end system test
- Update system sequence diagrams (include all turbo-stream calls, call parameters, service & broadcaster objects)
- Add persistence table design for users, questions and answers session
6 changes: 6 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"

# Enable interface with text generative OpenAI foundation model
gem 'spectre_ai'

# Enable Action cable redis pubsub adapter
gem 'redis'

# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
# gem "bcrypt", "~> 3.1.7"

Expand Down
5 changes: 5 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,8 @@ GEM
erb
psych (>= 4.0.0)
tsort
redis (5.4.1)
redis-client (>= 0.22.0)
redis-client (0.26.1)
connection_pool
regexp_parser (2.11.3)
Expand All @@ -260,6 +262,7 @@ GEM
logger (>= 1.6.2)
rack (>= 3.1.0)
redis-client (>= 0.23.2)
spectre_ai (2.0.0)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.7)
Expand Down Expand Up @@ -309,8 +312,10 @@ DEPENDENCIES
propshaft
puma (>= 5.0)
rails (~> 8.1.0)
redis
selenium-webdriver
sidekiq
spectre_ai
stimulus-rails
turbo-rails
tzinfo-data
Expand Down
2 changes: 2 additions & 0 deletions Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
web: bundle exec puma -C config/puma.rb
worker: bundle exec sidekiq -C config/sidekiq.yml
8 changes: 8 additions & 0 deletions app/assets/stylesheets/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,11 @@
*
* Consider organizing styles into separate files for maintainability.
*/

.message pre {
white-space: pre-wrap; /* Preserve line breaks, allow wrapping */
word-wrap: break-word; /* Break long words if necessary */
overflow-wrap: anywhere; /* Modern: break at any character if needed */
max-width: 100%; /* Never exceed parent width */
box-sizing: border-box; /* Include padding in width calc */
}
69 changes: 69 additions & 0 deletions app/controllers/chats_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
class ChatsController < ApplicationController
before_action :set_chat, only: %i[ show edit update destroy ]

# GET /chats or /chats.json
def index
@chats = Chat.all
end

# GET /chats/1 or /chats/1.json
def show
end

# GET /chats/new
def new
@chat = Chat.new
end

# GET /chats/1/edit
def edit
end

# POST /chats or /chats.json
def create
user_prompt = params.require(:user_prompt)

Turbo::StreamsChannel.broadcast_append_to(
"chat",
target: "messages",
partial: "chats/chat",
locals: { user_prompt: user_prompt }
)

SpectreOpenaiJob.perform_async(user_prompt)
end

# PATCH/PUT /chats/1 or /chats/1.json
def update
respond_to do |format|
if @chat.update(chat_params)
format.html { redirect_to @chat, notice: "Chat was successfully updated.", status: :see_other }
format.json { render :show, status: :ok, location: @chat }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @chat.errors, status: :unprocessable_entity }
end
end
end

# DELETE /chats/1 or /chats/1.json
def destroy
@chat.destroy!

respond_to do |format|
format.html { redirect_to chats_path, notice: "Chat was successfully destroyed.", status: :see_other }
format.json { head :no_content }
end
end

private
# Use callbacks to share common setup or constraints between actions.
def set_chat
@chat = Chat.find(params.expect(:id))
end

# Only allow a list of trusted parameters through.
def chat_params
params.fetch(:chat, {})
end
end
2 changes: 2 additions & 0 deletions app/helpers/chats_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module ChatsHelper
end
25 changes: 25 additions & 0 deletions app/jobs/spectre_openai_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

class SpectreOpenaiJob
include Sidekiq::Job

def perform(prompt)
messages = [
{ role: "system", content: "You are a concise assistant." },
{ role: "user", content: prompt }
]

result = Spectre.provider_module::Completions.create(
messages: messages,
model: "gpt-4",
openai: { max_tokens: 60 }
)

Turbo::StreamsChannel.broadcast_append_to(
"chat",
target: "messages",
partial: "chats/chat",
locals: { user_prompt: "ChatGPT-4: #{result[:content]}" }
)
end
end
2 changes: 2 additions & 0 deletions app/models/chat.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class Chat < ApplicationRecord
end
5 changes: 5 additions & 0 deletions app/spectre/prompts/rag/system.yml.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
system: |
You are a helpful assistant designed to provide answers based on specific documents and context provided to you.
Follow these guidelines:
1. Only provide answers based on the context provided to you.
2. Never mention the context directly in your responses.
3 changes: 3 additions & 0 deletions app/spectre/prompts/rag/user.yml.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
user: |
User's query: <%= @query %>
Context: <%= @objects.join(", ") %>
3 changes: 3 additions & 0 deletions app/views/chats/_chat.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div class="message">
<pre><%= user_prompt %></pre>
</div>
2 changes: 2 additions & 0 deletions app/views/chats/_chat.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
json.extract! chat, :id, :created_at, :updated_at
json.url chat_url(chat, format: :json)
17 changes: 17 additions & 0 deletions app/views/chats/_form.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<%= form_with(model: chat) do |form| %>
<% if chat.errors.any? %>
<div style="color: red">
<h2><%= pluralize(chat.errors.count, "error") %> prohibited this chat from being saved:</h2>

<ul>
<% chat.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>

<div>
<%= form.submit %>
</div>
<% end %>
12 changes: 12 additions & 0 deletions app/views/chats/edit.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<% content_for :title, "Editing chat" %>

<h1>Editing chat</h1>

<%= render "form", chat: @chat %>

<br>

<div>
<%= link_to "Show this chat", @chat %> |
<%= link_to "Back to chats", chats_path %>
</div>
16 changes: 16 additions & 0 deletions app/views/chats/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<p style="color: green"><%= notice %></p>

<% content_for :title, "Chats" %>

<h1>OpenAI Chat Interface</h1>

<%= turbo_stream_from "chat" %>

<div id="messages" class="space-y-2">
<!-- messages will append here -->
</div>

<%= form_with url: chats_path do |f| %>
<%= f.text_area :user_prompt, rows: 2, placeholder: "What's on your mind?" %>
<%= f.submit "Send" %>
<% end %>
1 change: 1 addition & 0 deletions app/views/chats/index.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
json.array! @chats, partial: "chats/chat", as: :chat
11 changes: 11 additions & 0 deletions app/views/chats/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<% content_for :title, "New chat" %>

<h1>New chat</h1>

<%= render "form", chat: @chat %>

<br>

<div>
<%= link_to "Back to chats", chats_path %>
</div>
10 changes: 10 additions & 0 deletions app/views/chats/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<p style="color: green"><%= notice %></p>

<%= render @chat %>

<div>
<%= link_to "Edit this chat", edit_chat_path(@chat) %> |
<%= link_to "Back to chats", chats_path %>

<%= button_to "Destroy this chat", @chat, method: :delete %>
</div>
1 change: 1 addition & 0 deletions app/views/chats/show.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
json.partial! "chats/chat", chat: @chat
25 changes: 25 additions & 0 deletions config/initializers/spectre.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

require 'spectre'

Spectre.setup do |config|
# Chose your LLM (openai, ollama, claude, gemini)
config.default_llm_provider = :openai

config.openai do |openai|
openai.api_key = ENV['OPENAI_API_KEY']
end

config.ollama do |ollama|
ollama.host = ENV['OLLAMA_HOST']
ollama.api_key = ENV['OLLAMA_API_KEY']
end

config.claude do |claude|
claude.api_key = ENV['ANTHROPIC_API_KEY']
end

config.gemini do |gemini|
gemini.api_key = ENV['GEMINI_API_KEY']
end
end
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
Rails.application.routes.draw do
resources :chats
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
Expand Down
2 changes: 1 addition & 1 deletion config/sidekiq.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
:concurrency: <%= Integer(ENV.fetch("SIDEKIQ_CONCURRENCY", 5)) %>
:queues:
- default
- default
7 changes: 7 additions & 0 deletions db/migrate/20251028025508_create_chats.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class CreateChats < ActiveRecord::Migration[8.1]
def change
create_table :chats do |t|
t.timestamps
end
end
end
Loading