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 +![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 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 @@ +
+
<%= user_prompt %>
+
\ 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? %> +
+

<%= pluralize(chat.errors.count, "error") %> prohibited this chat from being saved:

+ + +
+ <% end %> + +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/chats/edit.html.erb b/app/views/chats/edit.html.erb new file mode 100644 index 0000000..081b9dd --- /dev/null +++ b/app/views/chats/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Editing chat" %> + +

Editing chat

+ +<%= render "form", chat: @chat %> + +
+ +
+ <%= link_to "Show this chat", @chat %> | + <%= link_to "Back to chats", chats_path %> +
diff --git a/app/views/chats/index.html.erb b/app/views/chats/index.html.erb new file mode 100644 index 0000000..c053a97 --- /dev/null +++ b/app/views/chats/index.html.erb @@ -0,0 +1,16 @@ +

<%= notice %>

+ +<% content_for :title, "Chats" %> + +

OpenAI Chat Interface

+ +<%= turbo_stream_from "chat" %> + +
+ +
+ +<%= form_with url: chats_path do |f| %> + <%= f.text_area :user_prompt, rows: 2, placeholder: "What's on your mind?" %> + <%= f.submit "Send" %> +<% end %> diff --git a/app/views/chats/index.json.jbuilder b/app/views/chats/index.json.jbuilder new file mode 100644 index 0000000..d2e78c5 --- /dev/null +++ b/app/views/chats/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @chats, partial: "chats/chat", as: :chat diff --git a/app/views/chats/new.html.erb b/app/views/chats/new.html.erb new file mode 100644 index 0000000..69e22b5 --- /dev/null +++ b/app/views/chats/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New chat" %> + +

New chat

+ +<%= render "form", chat: @chat %> + +
+ +
+ <%= link_to "Back to chats", chats_path %> +
diff --git a/app/views/chats/show.html.erb b/app/views/chats/show.html.erb new file mode 100644 index 0000000..42cab1a --- /dev/null +++ b/app/views/chats/show.html.erb @@ -0,0 +1,10 @@ +

<%= notice %>

+ +<%= render @chat %> + +
+ <%= link_to "Edit this chat", edit_chat_path(@chat) %> | + <%= link_to "Back to chats", chats_path %> + + <%= button_to "Destroy this chat", @chat, method: :delete %> +
diff --git a/app/views/chats/show.json.jbuilder b/app/views/chats/show.json.jbuilder new file mode 100644 index 0000000..c480788 --- /dev/null +++ b/app/views/chats/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "chats/chat", chat: @chat diff --git a/config/initializers/spectre.rb b/config/initializers/spectre.rb new file mode 100644 index 0000000..485f72c --- /dev/null +++ b/config/initializers/spectre.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 6b25521..995ab48 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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. diff --git a/config/sidekiq.yml b/config/sidekiq.yml index cbdd109..4855818 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -1,3 +1,3 @@ :concurrency: <%= Integer(ENV.fetch("SIDEKIQ_CONCURRENCY", 5)) %> :queues: - - default + - default \ No newline at end of file diff --git a/db/migrate/20251028025508_create_chats.rb b/db/migrate/20251028025508_create_chats.rb new file mode 100644 index 0000000..07c436f --- /dev/null +++ b/db/migrate/20251028025508_create_chats.rb @@ -0,0 +1,7 @@ +class CreateChats < ActiveRecord::Migration[8.1] + def change + create_table :chats do |t| + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..d24b313 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,21 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 2025_10_28_025508) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + + create_table "chats", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end +end diff --git a/doc/chat-interface-ssdiagram.svg b/doc/chat-interface-ssdiagram.svg new file mode 100644 index 0000000..f41f194 --- /dev/null +++ b/doc/chat-interface-ssdiagram.svg @@ -0,0 +1,102 @@ +📡 Turbo Stream Channel🗄️ Database (Chat Table)🤖 OpenAI (Spectre)⚙️ Sidekiq Worker🎯 ChatController (Rails)🧑‍💻 Frontend (Turbo)📡 Turbo Stream Channel🗄️ Database (Chat Table)🤖 OpenAI (Spectre)⚙️ Sidekiq Worker🎯 ChatController (Rails)🧑‍💻 Frontend (Turbo)1️⃣ Send message via POSTCreate or update chat session recordEnqueue job with message payloadSend user message via Spectre APIReturn generated AI responseSave AI response message recordBroadcast Turbo Stream updateRealtime update (new AI message displayed) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 54aeadc..77c9753 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,9 @@ services: POSTGRES_PASSWORD: postgres POSTGRES_DB: app_development REDIS_URL: redis://redis:6379/0 + OPENAI_API_KEY: ${OPENAI_API_KEY} + env_file: + - .env ports: - "3000:3000" depends_on: @@ -41,6 +44,9 @@ services: POSTGRES_PASSWORD: postgres POSTGRES_DB: app_development REDIS_URL: redis://redis:6379/0 + OPENAI_API_KEY: ${OPENAI_API_KEY} + env_file: + - .env depends_on: db: condition: service_healthy diff --git a/test/controllers/chats_controller_test.rb b/test/controllers/chats_controller_test.rb new file mode 100644 index 0000000..957ce78 --- /dev/null +++ b/test/controllers/chats_controller_test.rb @@ -0,0 +1,48 @@ +require "test_helper" + +class ChatsControllerTest < ActionDispatch::IntegrationTest + setup do + @chat = chats(:one) + end + + test "should get index" do + get chats_url + assert_response :success + end + + test "should get new" do + get new_chat_url + assert_response :success + end + + test "should create chat" do + assert_difference("Chat.count") do + post chats_url, params: { chat: {} } + end + + assert_redirected_to chat_url(Chat.last) + end + + test "should show chat" do + get chat_url(@chat) + assert_response :success + end + + test "should get edit" do + get edit_chat_url(@chat) + assert_response :success + end + + test "should update chat" do + patch chat_url(@chat), params: { chat: {} } + assert_redirected_to chat_url(@chat) + end + + test "should destroy chat" do + assert_difference("Chat.count", -1) do + delete chat_url(@chat) + end + + assert_redirected_to chats_url + end +end diff --git a/test/fixtures/chats.yml b/test/fixtures/chats.yml new file mode 100644 index 0000000..d7a3329 --- /dev/null +++ b/test/fixtures/chats.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +# This model initially had no columns defined. If you add columns to the +# model remove the "{}" from the fixture names and add the columns immediately +# below each fixture, per the syntax in the comments below +# +one: {} +# column: value +# +two: {} +# column: value diff --git a/test/models/chat_test.rb b/test/models/chat_test.rb new file mode 100644 index 0000000..69b9d2c --- /dev/null +++ b/test/models/chat_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ChatTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end