diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f94fa37 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +OPENAI_API_KEY=your_openai_api_key diff --git a/.gitignore b/.gitignore index fbcab40..5831cc1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ # Ignore all environment files. /.env* +!.env.example # Ignore all logfiles and tempfiles. /log/* @@ -33,3 +34,6 @@ # Ignore key files for decrypting credentials and more. /config/*.key +# Ignore .idea SDE files +/idea/* +.idea diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..2ff0e85 --- /dev/null +++ b/ARCHITECTURE.md @@ -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 + + +```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 diff --git a/Gemfile b/Gemfile index 65ae1fb..c6d7b5c 100644 --- a/Gemfile +++ b/Gemfile @@ -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" diff --git a/Gemfile.lock b/Gemfile.lock index 1651c41..efa04f2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -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 diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..fd4fe01 --- /dev/null +++ b/Procfile @@ -0,0 +1,2 @@ +web: bundle exec puma -C config/puma.rb +worker: bundle exec sidekiq -C config/sidekiq.yml diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index fe93333..90fde16 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -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 */ +} \ No newline at end of file diff --git a/app/controllers/chats_controller.rb b/app/controllers/chats_controller.rb new file mode 100644 index 0000000..9119660 --- /dev/null +++ b/app/controllers/chats_controller.rb @@ -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 diff --git a/app/helpers/chats_helper.rb b/app/helpers/chats_helper.rb new file mode 100644 index 0000000..cf92be3 --- /dev/null +++ b/app/helpers/chats_helper.rb @@ -0,0 +1,2 @@ +module ChatsHelper +end diff --git a/app/jobs/spectre_openai_job.rb b/app/jobs/spectre_openai_job.rb new file mode 100644 index 0000000..2ab6cb2 --- /dev/null +++ b/app/jobs/spectre_openai_job.rb @@ -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 diff --git a/app/models/chat.rb b/app/models/chat.rb new file mode 100644 index 0000000..9fcfdd3 --- /dev/null +++ b/app/models/chat.rb @@ -0,0 +1,2 @@ +class Chat < ApplicationRecord +end diff --git a/app/spectre/prompts/rag/system.yml.erb b/app/spectre/prompts/rag/system.yml.erb new file mode 100644 index 0000000..a77fb2d --- /dev/null +++ b/app/spectre/prompts/rag/system.yml.erb @@ -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. diff --git a/app/spectre/prompts/rag/user.yml.erb b/app/spectre/prompts/rag/user.yml.erb new file mode 100644 index 0000000..6ebd97d --- /dev/null +++ b/app/spectre/prompts/rag/user.yml.erb @@ -0,0 +1,3 @@ +user: | + User's query: <%= @query %> + Context: <%= @objects.join(", ") %> diff --git a/app/views/chats/_chat.html.erb b/app/views/chats/_chat.html.erb new file mode 100644 index 0000000..64f2da3 --- /dev/null +++ b/app/views/chats/_chat.html.erb @@ -0,0 +1,3 @@ +
\ No newline at end of file diff --git a/app/views/chats/_chat.json.jbuilder b/app/views/chats/_chat.json.jbuilder new file mode 100644 index 0000000..2dd791d --- /dev/null +++ b/app/views/chats/_chat.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! chat, :id, :created_at, :updated_at +json.url chat_url(chat, format: :json) diff --git a/app/views/chats/_form.html.erb b/app/views/chats/_form.html.erb new file mode 100644 index 0000000..2f14097 --- /dev/null +++ b/app/views/chats/_form.html.erb @@ -0,0 +1,17 @@ +<%= form_with(model: chat) do |form| %> + <% if chat.errors.any? %> +<%= notice %>
+ +<% content_for :title, "Chats" %> + +<%= notice %>
+ +<%= render @chat %> + +