Напишемо безкоштовну копію ChatGPT за допомогою Rails 8 — частина 1

текст перекладу
pic

Rails 8 нещодавно був випущений і має багато захоплюючих нових функцій, серед яких можливість створювати сучасні веб-додатки з мінімальними зусиллями, навіть менше, ніж у попередній версії. Основна команда Rails завжди орієнтувалася на продуктивність розробника, що також є особливістю Ruby та всіх, хто є частиною цієї спільноти.

Вже з версії 7 можна було уникнути складнощів з фронтендом, керувати ним, але головне — синхронізувати з бекендом без шкоди для ефективного користувацького досвіду. Подивимося, як Turbo 8 тепер робить це рішення ще більш ефективним.

В цьому підручнику

Створимо чат з ШІ, клон ChatGPT або Claude, але який також може працювати в вашій приватній мережі і без необхідності платити щомісячну підписку. Як порівнянний сервіс можна згадати venice.ai, що використовує відкриті моделі для генерації тексту та зображень. ШІ чат, який ми створимо, може працювати локально на тому ж комп’ютері або на іншому в тій же мережі… або навіть у хмарі.

pic

Для кого цей підручник?

Для всіх, хто хоче попрацювати з ШІ: від новачків, які можуть слідувати інструкціям крок за кроком, до експертів, які можуть пропустити опис деяких базових понять, але я прагнув зробити його доступним для всіх.

Передумови

  • Встановлений Ruby (з devkit, я рекомендую використовувати менеджер, як-от asdf, на Windows можна використати цей посилання). Або ви можете слідувати посібнику Rails 8. Я почав цей підручник з версії 3.3.6, але з’явилася версія 3.4.1, тому я оновив її і раджу вам використовувати цю.
  • Встановлений Rails 8, просто введіть в командному рядку gem install rails, щоб встановити останню версію (на даний момент 8.0.1).
  • Встановлений Ollama. Після встановлення потрібно встановити LLM, наприклад llama3.2:
# Скрипт використовує llama3.2, але ви можете змінити його на будь-який інший
ollama run llama3.2

Вийшла версія llama3.3, але наразі це лише версія 70B, яка вимагає багато оперативної пам’яті, ви можете використовувати її, якщо у вас є відповідне апаратне забезпечення. Однак ви можете встановити будь-які LLM, які вас цікавлять, і порівняти їх.

Починаємо

З командного рядка створюємо додаток.

Я написав і протестував цей підручник на двох різних проектах, щоб переконатися, що кожен крок правильний, тому ви можете побачити деякі скріншоти з назвою “Chatty”. Називайте як хочете, я буду називати його turbo-chat зараз.

Ми використовуємо стандартну базу даних SQLite і додаємо опцію -T, щоб уникнути стандартної тестової платформи, оскільки ми будемо використовувати Rspec:

rails new turbo-chat -T

Заходимо в папку і спробуємо запустити сервер:

cd turbo-chat  
bin/rails server

Ви повинні побачити щось подібне виведення сервера:

=> Booting Puma  
=> Rails 8.0.1 application starting in development  
=> Run `bin/rails server --help` for more startup options  
Puma starting in single mode...  
* Puma version: 6.5.0 ("Sky's Version")  
* Ruby version: ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [arm64-darwin24]  
* Min threads: 3  
* Max threads: 3  
* Environment: development  
* PID: 9077  
* Listening on http://127.0.0.1:3000  
* Listening on http://[::1]:3000  
Use Ctrl-C to stop

Перейдіть на http://localhost:3000, і ви повинні побачити домашню сторінку за замовчуванням:

pic

Зупиніть сервер (Ctrl+C) і давайте створимо репозиторій Git:

git init  
git add .

текст перекладу

git commit -a -m "First commit"
```

Конфігурацію бази даних можна знайти в config/database.yml, і за замовчуванням файли будуть розміщені в папці storage, яка створюється автоматично під час першої міграції, як ми побачимо пізніше.

З версії Rails 7 можна використовувати import map і уникати використання бандлів, але в цьому підручнику я хочу показати, як легко інтегрувати Vite. Ви можете переглянути офіційну документацію або слідувати простим крокам.

  1. Додайте JavaScript пакети до бандлу:
yarn add vite vite-plugin-ruby vite-plugin-full-reload --dev
  1. Додайте node_modules/ до файлу .gitignore, якщо ви виконаєте команду git status, ви повинні побачити тільки ці файли:
package.json  
yarn.lock
  1. Тепер додайте гем Vite і виконайте генератор:
bundle add vite_rails  
bundle exec vite install

В кінці процесу встановлення ви повинні побачити:

Vite ⚡️ Ruby successfully installed! 🎉

Перевірте ваш шаблон, ви повинні побачити щось подібне:






 ...  

 <%# Включає всі файли стилів з app/assets/stylesheets %>  
 <%= vite_client_tag %>  
 <%= vite_javascript_tag 'application' %>  
 <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>  

...

Тепер давайте перейдемо до стадії git add . і ваш список змінених файлів на цей момент повинен виглядати так:

.gitignore  
Gemfile  
Gemfile.lock  
app/frontend/entrypoints/application.js  
app/views/layouts/application.html.erb  
bin/vite  
config/initializers/content_security_policy.rb  
config/vite.json  
package.json  
vite.config.mts  
yarn.lock
git commit -m "Added vite"

Давайте додамо головну сторінку, щоб перевірити результат:

bin/rails g controller home index  

create app/controllers/home_controller.rb  
 route get "home/index"  
invoke erb  
create app/views/home  
create app/views/home/index.html.erb  
invoke helper  
create app/helpers/home_helper.rb

Приберіть get "home/index" і додайте корінь:

#config/routes.rb   

...  
root "home#index"

Тепер ми можемо запустити сервер Rails і середовище розробки за допомогою двох окремих команд:

bin/vite dev запустить середовище розробки Vite для гарячого перезавантаження на фронтенді.

bin/rails s запустить сервер Rails на http://localhost:3000

Ви можете зробити це з однією командою, встановивши Foreman як гем (не додавайте його до бандлу, як вказано в документації):

gem install foreman

Перевірте, що в файлі Procfile.dev в корені є два процеси:

# Procfile.dev  
vite: bin/vite dev  
web: bin/rails s

І введіть foreman start -f Procfile.dev

Перейдіть за адресою http://localhost:5100, і ви повинні побачити “Home#index”. Відкрийте консоль JavaScript в браузері і ви повинні побачити “Vite ⚡️ Rails”

pic

Давайте відредагуємо цей вигляд і збережемо його, але без перезавантаження сторінки:

# app/views/home/index.html.erb  


Hello world

Не бачите змін, правда? Було б чудово, якби ви могли побачити їх, тому давайте це виправимо…

Гаряче перезавантаження

Щоб заощадити час на розробку, дуже корисно мати можливість бачити зміни в реальному часі.

Коли ми додали vite, ми також додали плагін vite-plugin-full-reload, який забезпечує функцію гарячого перезавантаження. Нам потрібно просто активувати його в конфігураційному файлі Vite, який знаходиться в корені проекту:

# vite.config.mts      
...   
import FullReload from 'vite-plugin-full-reload'      
export default defineConfig({    
  plugins: [    
    ...    
    FullReload(['config/routes.rb', 'app/views/**/*'], { delay: 100 })    
  ],   
})  

Опційно ви можете додати затримку, я додав 100 мс в наведеному вище прикладі, щоб показати, як це працює.

Перезапустіть сервер і змініть вигляд:

# app/views/home/index.html.erb      
Turbo chat

 Welcome to Turbo chat! This is a simple chat application built with Rails 8, Hotwire and Turbo.

текст перекладу
It uses SQLite for the database and Vite for the JavaScript bundler.  

You will see the change after saving and if you check the server log in the shell you will see something like this:

...
12:38:56 vite.1 | 12:38:56 [vite] full reload app/views/home/index.html.erb
12:38:56 web.1 | Started GET "/" for ::1 at 2024-12-15 12:38:56 +0100
12:38:56 web.1 | Processing by HomeController#index as HTML
...

Давайте зафіксуємо зміни:

git add .  
git commit -m "Hot reload configured"

Rspec

Додамо Rspec як фреймворк для тестування. Ми будемо використовувати його для визначення специфікацій і керівництва в розробці коду, але не тільки. Коли розробка завершиться, ми отримаємо документацію, яка ніколи не стане застарілою, для тих, хто працюватиме з додатком після нас, як це передбачає методологія Agile. Специфікації працюють як автоматичні тести, які дозволяють нам спати спокійно, коли йдеться про керування продуктивністю.

Додайте rspec-rails до групи development і test.

# Gemfile
group :development, :test do  
  ...  
  gem 'rspec-rails', '~> 7.0.0'  
end

З командного рядка:

# Встановіть гем  
bundle install  

# Генеруємо конфігураційні файли  
rails generate rspec:install

Ми вже написали контролер, тому давайте додамо специфікацію вручну за допомогою генератора:

bin/rails generate rspec:controller home

Оновіть згенеровані файли таким чином:

# spec/requests/home_spec.rb  
require 'rails_helper'  
RSpec.describe "Home", type: :request do  
  describe "GET /" do  
    it "returns http success" do  
      get "/"  
      expect(response).to have_http_status(:success)  
    end  

    it "renders the home page" do  
      get "/"  
      expect(response.body).to include("Turbo chat")  
    end  
  end  
end

Це поки що не говорить багато, але завжди корисно мати специфікацію, навіть якщо вона тільки показує, що на головній сторінці є деякі елементи.

Запустимо її. Це також автоматично створить тестову базу даних storage/test.sqlite3, коли будуть виконані міграції:

bundle exec rspec spec/requests/home_spec.rb  
...  
2 example, 0 failures

Оскільки ми досить ледачі і команда bundle exec rspec занадто багатослівна для нас, давайте згенеруємо binstub для bin/rspec і будемо використовувати його замість цього:

bundle binstubs rspec-core  

# Тепер можна виконувати  
bin/rspec spec/requests/home_spec.rb

Перевіримо, чи все написане до цього часу в порядку. Rails 8 вже має все необхідне для перевірки коду за допомогою стандартних правил.

bin/rubocop -a

Це повинно знайти деякі файли і автоматично виправити їх:

26 files inspected, 18 offenses detected, 18 offenses corrected

Якщо ви запустите це ще раз, ви повинні побачити: no offenses detected.

Це хороший момент для коміту:

git add . && git commit -m "Added rspec"

Аутентифікація користувачів з Devise

Rails 8 надає базову аутентифікацію, і це було б чудово для такого підручника, але мета цього тут — також надати стартову основу для продакшн-середовища, і в цьому випадку я наразі віддаю перевагу більш повному і перевіреному гему devise.

Зупиніть сервер на хвилину і додайте devise, слідуючи документації або цьому короткому опису:

  • bundle add devise
  • bin/rails g devise:install
  • bin/rails g devise User

Тепер давайте виконаємо міграцію для оновлення бази даних розробки. Оскільки це перший раз, файл development.sqlite3 також буде створений:

  • bin/rails db:migrate

Тепер у нас є таблиця users і все необхідне для керування аутентифікацією. Додамо користувача в seed:

# db/seeds.rb  
puts "### Starting seeds ###"  
unless User.exists?  
  users = []  
  users << { email: '[email protected]', password: 'password', password_confirmation: 'password' }  
  print("Creating #{users.size} users...")  
  User.create!(users)  
  puts ' DONE'  
end

Рекомендую зробити seed ідемпотентним.
текст перекладу
So that if you change it for example to add some new resources, you can just rerun it to add only the new part.

Запустіть seed:

bin/rails db:seed  

### Starting seeds ###  
Creating 1 users... DONE

Налаштуємо специфікації моделі користувача:

# spec/models/user_spec.rb  

require 'rails_helper'  

RSpec.describe User, type: :model do  
  context 'validations' do  
    let(:valid_attributes) do  
      {  
        email: '[email protected]',  
        password: 'password',  
        password_confirmation: 'password'  
      }  
    end  

    let(:invalid_attributes) do  
      {  
        email: 'username',  
        password: 'password',  
        password_confirmation: 'password'  
      }  
    end  

    context 'when the attributes are valid' do  
      let(:user) { User.new(valid_attributes) }  

      it 'is valid with an email, password, and password_confirmation' do  
        expect(user).to be_valid  
      end  
    end  

    context 'when the attributes are invalid' do  
      let(:user) { User.new(invalid_attributes) }  

      it 'is invalid without a valid email' do  
        expect(user).to_not be_valid  
      end  
    end  
  end  
end

Спробуйте їх:

bin/rspec spec/models/user_spec.rb  
..  

2 examples, 0 failures

І зафіксуйте зміни:

git add . && git commit -m "Added devise and user model"

Bootstrap та Css bundling

Додамо bootstrap та cssbundling як менеджери для css-бандлів, ми будемо використовувати sass для написання стилів з мінімальними зусиллями:

  • bundle add bootstrap
  • bundle add cssbundling-rails
  • bin/rails css:install:bootstrap
git add . && git commit -m "Added styles stuff"

Simple form

Хоча це і не є обов'язковим для цього тесту, я рекомендую знову використати simple_form через готовність до використання в продакшн-середовищі:

  • bundle add simple_form
  • bundle install
  • bin/rails g simple_form:install --bootstrap

Ви можете налаштувати devise views для використання simple form і bootstrap. Я наведу лише форму входу, а решту ви можете знайти в репозиторії:

# app/views/devise/sessions/new.html.erb  

<%= simple_form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: "session" }) do |f| %>  

Login  
    <%= f.input :email,    required: false,    autofocus: true,    placeholder: "Email",    label_html: { class: "visually-hidden" } %>    
    <%= f.input :password,    required: false,    placeholder: "Password",    label_html: { class: "visually-hidden" } %>       
    <%= f.button :submit, "Log in", class: "btn btn--primary" %>    
    <%= render "devise/shared/links" %>    
   <% end %> 

Додамо трохи стилю

Навіть око хоче свого, і навіть для простого тестового додатку, якщо все виглядає приємно, це також додає більше задоволення. Тому давайте додамо деякі користувацькі scss для покращення вигляду.

Мені подобається розбивати стилі на дрібні частини, щоб їх було легко редагувати без страху щось зламати. Я поділюсь мінімальним набором, таким як кольори, межі та інші змінні, а решту — у окремих компонентах, кожен з власним обсягом. Це називається методологією BEM.
текст перекладу
Іноді це вимагає певного повторення, але це краще, ніж занадто багато спільного коду, що ускладнює обслуговування:

# app/assets/stylesheets/components/_navbar.scss  

.navbar {  
  box-shadow: var(--shadow-large);  
  margin-bottom: var(--space-xxl);  
  background-color: var(--color-white);  
}
# app/assets/stylesheets/components/_session.scss  

.session {  
  display: flex;  
  align-items: center;  
  justify-content: center;  
  flex-wrap: wrap;  
  padding: var(--space-xs);  

  &__inputs {  
    background-color: var(--color-white);  
    border: var(--main-border);  
    border-radius: var(--border-radius);  
    padding: var(--space-l);  

    .form__group {  
      margin-bottom: var(--space-l);  
    }  

    h2 {  
      margin-bottom: var(--space-s);  
    }  
  }  

  &__shared-links {  
    padding: var(--space-s) 0;  
  }  
}

Тепер оновіть файл application.bootstrap.scss, додавши ці два нові компоненти:

...  

// Компоненти  
@import "components/navbar";  
@import "components/session";

Перейменуйте application.bootstrap.scss у application.scss. Вам потрібно лише використати те саме ім'я в команді “build:css:compile” у файлі package.json.

Додайте частину заголовка:

# app/views/layouts/_header.html.erb  

Turbo chat ⚡️                      
    <% if user_signed_in? %>    
    <%= current_user.email %>    
    <%= button_to "Sign out",    destroy_user_session_path,    method: :delete,    class: "btn btn--dark" %>    
    <% else %>    
    <%= link_to "Sign in",    new_user_session_path,    class: "btn btn--dark navbar__right" %>    
    <% end %>    

І оновіть макет:

# app/views/layouts/application.html.erb       
...
текст перекладу
<%= render "layouts/header" %>  

<%= yield %>  

Запустіть сервер, і ви побачите новий лог css для компіляції sass.

Давайте подивимося на результат:

![pic](https://drive.javascript.org.ua/b3c3f9264d1_woSlCxUsRYj_X0vk9w6_pQ_png)

Але ви скажете, що це не дуже добре, і ви праві, тому давайте завершимо роботу, додавши ці нові scss файли:

app/assets/stylesheets/config/_reset.scss

*,
*::before,
*::after {
box-sizing: border-box;
}

  • {
    margin: 0;
    padding: 0;
    }

html {
overflow-y: scroll;
height: 100%;
}

body {
display: flex;
flex-direction: column;
min-height: 100%;

background-color: var(--color-background);
color: var(--color-text-body);
line-height: var(--line-height-body);
font-family: var(--font-family-sans);
}

img,
picture,
svg {
display: block;
max-width: 100%;
}

input,
button,
textarea,
select {
font: inherit;
}

h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--color-text-header);
line-height: var(--line-height-headers);
}

h1 {
font-size: var(--font-size-xxxl);
}

h2 {
font-size: var(--font-size-xxl);
}

h3 {
font-size: var(--font-size-xl);
}

h4 {
font-size: var(--font-size-l);
}

a {
color: var(--color-primary);
text-decoration: none;
transition: color 200ms;

&:hover,
&:focus,
&:active {
color: var(--color-primary-rotate);
}
}
```

# app/assets/stylesheets/config/_variables.scss  

:root {  
  // Простий шрифт  
  --font-family-sans: 'Lato', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;  

  // Класичні висоти рядків  
  --line-height-headers: 1.1;  
  --line-height-body: 1.5;  

  // Класична та зручна система розмірів шрифтів  
  --font-size-xs: 0.75rem; // 12px  
  --font-size-s: 0.875rem; // 14px  
  --font-size-m: 1rem; // 16px  
  --font-size-l: 1.125rem; // 18px  
  --font-size-xl: 1.25rem; // 20px  
  --font-size-xxl: 1.5rem; // 24px  
  --font-size-xxxl: 2rem; // 32px  
  --font-size-xxxxl: 2.5rem; // 40px  

  // Три різні кольори тексту  
  --color-text-header: hsl(0, 1%, 16%);  
  --color-text-body: hsl(0, 5%, 25%);  
  --color-text-muted: hsl(0, 1%, 44%);  
  --color-text-notification: hsl(24, 100%, 78%);  

  // Класична та зручна система відступів  
  --space-xxxs: 0.25rem; // 4px  
  --space-xxs: 0.375rem; // 6px  
  --space-xs: 0.5rem; // 8px  
  --space-s: 0.75rem; // 12px  
  --space-m: 1rem; // 16px  
  --space-l: 1.5rem; // 24px  
  --space-xl: 2rem; // 32px  
  --space-xxl: 2.5rem; // 40px  
  --space-xxxl: 3rem; // 48px  
  --space-xxxxl: 4rem; // 64px  

  // Кольори застосунку  
  --color-primary: hsl(350, 67%, 50%);  
  --color-primary-rotate: hsl(10, 73%, 54%);  
  --color-primary-alternative: brown;  
  --color-primary-bg: hsl(0, 85%, 96%);  
  --color-secondary: hsl(101, 45%, 56%);  
  --color-secondary-rotate: hsl(120, 45%, 56%);  
  --color-tertiary: hsl(49, 89%, 64%);  
  --color-glint: hsl(210, 100%, 82%);  

  // Нейтральні кольори  
  --color-white: hsl(0, 0%, 100%);  
  --color-background: hsl(30, 50%, 98%);  
  --color-background-dark: hsl(30, 45%, 96%);  
  --color-light: hsl(0, 6%, 93%);  
  --color-dark: var(--color-text-header);  
  --color-red: hsl(350, 67%, 50%);  
  --color-yellow: hsl(49, 89%, 64%);  
  --color-green: hsl(101, 45%, 56%);  

  // Радіус кордонів  
  --border-radius: 0.375rem;  

  // Кордони  
  --border: solid 2px var(--color-light);  
  --main-border: dashed 2px var(--color-light);  

  // Тіні  
  --shadow-large: 2px 4px 10px hsl(0 0% 0% / 0.1);  
  --shadow-small: 1px 3px 6px hsl(0 0% 0% / 0.1);  
}
# app/assets/stylesheets/components/_btn.scss  

.btn {  
  &:hover,  
  &:focus,  
  &:focus-within,  
  &:active {  
    transition: filter 250ms, color 200ms;  
  }  

  &--primary {  
    color: var(--color-white);  
    background-image: linear-gradient(to right, var(--color-primary), var(--color-primary-rotate));  
  }  
}

текст перекладу
&:hover,  
  &:focus,  
  &:focus-within,  
  &:active {  
    color: var(--color-white);  
    filter: saturate(1.4) brightness(115%);  
  }  
}  

&--secondary {  
  color: var(--color-white);  
  background-image: linear-gradient(to right, var(--color-secondary), var(--color-secondary-rotate));  

  &:hover,  
  &:focus,  
  &:focus-within,  
  &:active {  
    color: var(--color-white);  
    filter: saturate(1.2) brightness(110%);  
  }  
}  

&--light {  
  color: var(--color-dark);  
  background-color: var(--color-light);  

  &:hover,  
  &:focus,  
  &:focus-within,  
  &:active {  
    color: var(--color-dark);  
    background-color: var(--color-light);  
    filter: brightness(92%);  
  }  
}  

&--dark {  
  color: var(--color-white);  
  border-color: var(--color-dark);  
  background-color: var(--color-dark);  

  &:hover,  
  &:focus,  
  &:focus-within,  
  &:active {  
    color: var(--color-white);  
    background-color: var(--color-dark);  
    filter: brightness(150%);  
  }  
}  

// Модифікатори будуть додаватись сюди  
# app/assets/stylesheets/layouts/_container.scss  

.container {  
  width: 100%;  
  padding-right: var(--space-xs);  
  padding-left: var(--space-xs);  
  margin-left: auto;  
  margin-right: auto;  

  @include media(tabletAndUp) {  
    padding-right: var(--space-m);  
    padding-left: var(--space-m);  
    max-width: 60rem;  
  }  
}
# app/assets/stylesheets/mixins/_media.scss  

@mixin media($query) {  
  @if $query == tabletAndUp {  
    @media (min-width: 50rem) { @content; }  
  }  
}

Тепер оновіть файл application.scss, як ми робили раніше, додаючи нові файли:

...
текст перекладу
// Міксини  
@import "mixins/media";  

// Налаштування  
@import "config/variables";  
@import "config/reset";  

// Компоненти  
@import "components/navbar";  
@import "components/session";  
@import "components/btn";  

// Макети  
@import "layouts/container";  

Спробуємо знову:

pic

Не очікуйте нічого неймовірного, але, на мою думку, це набагато краще.

git add . && git commit -m "Додано власні стилі"

Показати помилки

Спробуємо увійти, ввівши неправильні дані для перевірки, як ми обробляємо помилки … ну, ми не показуємо повідомлення про помилки.

Давайте додамо цей парціал:

# app/views/layouts/_flash.html.erb  

<% flash.each do |flash_type, message| %>  

  <%= flash_type %>">    <%= message %>    
<% end %>  

І додамо його у шаблон:
```

app/views/layouts/application.html.erb

...
<%= render "layouts/flash" %>
<%= yield %>
```
Спробуємо знову:
pic

Тепер повідомлення є, але стилі відсутні.

# app/assets/stylesheets/components/_flash.scss  
  .flash {  
    position:fixed;  
    top: 5rem;  
    left: 50%;  
    transform: translateX(-50%);  
    display: flex;  
    flex-direction: column;  
    align-items: center;  
    gap: var(--space-s);  
    max-width: 100%;  
    width: max-content;  
    padding: 0 var(--space-m);  

    &__message {  
      font-size: var(--font-size-s);  
      color: var(--color-white);  
      padding: var(--space-xs) var(--space-m);  
      background-color: var(--color-dark);  
      border-radius: 999px;  
    }  

    i.error { color: var(--color-red); }  
    i.alert { color: var(--color-yellow); }  
    i.notice { color: var(--color-green); }  
  }  

Додамо це у application.scss :
```

app/assets/stylesheets/application.scss

...
// Компоненти
...
@import "components/flash";
// Макети
...
```
Спробуємо знову:
pic

Тепер повідомлення чітко відображається, але ми ще не закінчили. Ми вирішили показувати повідомлення Toast, і воно має мати певну тривалість, після якої буде видалене. Ми можемо зробити це через CSS, щоб приховати його. Давайте додамо анімацію:

# app/assets/stylesheets/config/_animations.scss  
  @keyframes appear-then-fade {  
    0%, 100% {  
      opacity:0  
    }  
    5%, 60% {  
      opacity:1  
    }  
  }  

Оновимо application.scss :

// Налаштування
...
@import "config/animations";

Тепер, коли ми додали анімацію для згасання, ми можемо використовувати її, оновивши клас flash:

# app/assets/stylesheets/components/_flash.scss  
  .flash {  
    ...  
    &__message {  
      ...  
      animation: appear-then-fade 4s both;  
    }  
    ...  
  }  

Добре, це працює. Але якщо ми перевіримо сторінку, повідомлення все ще там.
pic

Краще видаляти його й з HTML. Зараз саме час використати Stimulus.

Спочатку потрібно запустити генератор для його встановлення:

bin/rails stimulus:install  

Це додасть кілька JavaScript файлів і також контролер hello, але нам він не потрібен, тому ви можете його видалити.

Додамо контролери Stimulus у точку входу Vite:

  // app/frontend/entrypoints/application.js  
  ...  
  import "../controllers"  

Тепер ми можемо створити контролер для видалення повідомлення:

bin/rails g stimulus removals  

Стандартний шлях для JavaScript файлів у Rails — це /javascript, а у Vite — /frontend, тому давайте перемістимо файли у папку frontend.

текст перекладу
Ви повинні отримати таку структуру:

- app  
 - frontend  
 - controllers  
 - application.js  
 - index.js  
 - removals_controller.js  
 - entrypoints  
 - application.js

Тепер оновимо контролер removals, додавши дію remove:

# app/frontend/controllers/removals_controller.js  

import { Controller } from "@hotwired/stimulus"  

// Підключається до data-controller="removals"  
export default class extends Controller {  
  ...  

  remove() {  
    this.element.remove()  
  }  
}

Тепер ми можемо це використовувати. Давайте додамо атрибути data-controller та data-action до повідомлень:

# app/views/layouts/_flash.html.erb  

<% flash.each do |flash_type, message| %>  

    ...  
  • значення "removals" у data-controller посилається на нещодавно створений контролер Stimulus. Завдяки цьому з'єднанню метод "connected" виконується в контролері removals.
  • data-action="animationend->removals#remove" означає, що коли спрацьовує хук animationend, метод remove на контролері removals викликається.

Тепер повідомлення повністю видаляється з HTML-сторінки після того, як воно буде приховане.

Думаю, наш веб-додаток готовий до подальших кроків і впровадження логіки додатку. Давайте зафіксуємо зміни:

git add . && git commit -m "Додано парціал flash та removals як контролер Stimulus"

Langchain

Кожен поважаючий себе LLM, чи то в хмарі, чи керується локально, надає API, щоб можна було використовувати його з коду, як ми й хочемо це зробити. Щоб спростити роботу, але головним чином для того, щоб мати інтерфейс, який дозволяє легко здійснювати заміну сервісів без зайвих зусиль (наприклад, перейти з ollama на OpenAI чи Anthropic, коли ви хочете випустити сервіс у мережу), ми використаємо Langchainrb.
- bundle add langchainrb faraday

Моделі AiChat і AiMessage

Тепер перейдемо до основної частини.

Ми додаємо модель AiChat (у світі ORM модель обробляє таблицю в базі даних), щоб мати можливість вести різні дискусії з ШІ. Кожен AiChat має багато AiMessages. По суті, кожен AiChat є дискусією, у якій ми будемо перевіряти історію повідомлень, тому доцільно, щоб кожен чат обробляв певну тему одночасно, як це слід робити з будь-яким ШІ для отримання найбільш точних відповідей. Щоб було зрозуміло: якщо спочатку задати питання про підгузки, а потім запитати про космічні подорожі, це може призвести до дивних відповідей. Причину цього залишимо зараз поза темою, але майте це на увазі і завжди пам'ятайте основне правило:
- garbage in means garbage out
Тому завжди пишіть ваші запити точно і створюйте окремий чат для кожної теми.

Перш ніж створювати ці два ресурси, давайте додамо гем, який допоможе нам легко писати тести. Для тих, хто не знає, factory_bot_rails дозволяє швидко створювати екземпляри ресурсів всередині тестів за допомогою шаблонів, які називаються “фабриками”. Після додавання цього гему, створення ресурсів автоматично викликає генератор фабрик.
- bundle add factory_bot_rails, після цього відкрийте Gemfile і перемістіть його всередину групи :development, :test

group :development, :test do  
  ...  
  gem "factory_bot_rails", "~> 6.4"  
end  

Додайте цей конфігураційний файл для завантаження factory bot:

# spec/support/factory_bot.rb  
  RSpec.configure do |config|  
    config.include(FactoryBot::Syntax::Methods)  
  end  

Нарешті, раскоментуйте цей рядок, щоб завантажити файли з папки підтримки:

# spec/rails_helper.rb  
  ...  
  Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f }  
  ...  

Тепер ми можемо перейти до створення цих двох моделей:

bin/rails g model ai_chats user:references title ai_model_name  
bin/rails g model ai_messages ai_chat:references prompt:text answer:text  
  • references означає, що це належить користувачу.

текст перекладу
Ми створюємо відношення з ним (яке ми створили раніше), щоб кожен користувач мав свої власні чати.
- щодо ai_model_name, ми використовуємо його для зберігання того, який LLM згенерував відповідь, оскільки з ollama ми можемо використовувати різні моделі.

Тепер завершуємо модель AiChat:

# app/models/ai_chat.rb  

# frozen_string_literal: true  

class AiChat < ApplicationRecord  
  belongs_to :user  

  has_many :ai_messages, -> { order(id: :asc) }, dependent: :delete_all  

  SUPPORTED_AI_MODELS = %w[llama3.2 llama3.1 llama3 mistral openhermes2.5-mistral qwen2.5-coder gemma2].freeze  

  validates :ai_model_name, presence: true, inclusion: { in: SUPPORTED_AI_MODELS }  
end
  • у SUPPORTED_AI_MODELS додайте всі моделі, які вам потрібні, пам'ятайте, що ви повинні їх встановити через ollama перед використанням, дивіться приклад вище з llama3.2.

Тепер налаштуємо фабрику для AiChat, щоб переконатися, що створення проходить валідацію:

FactoryBot.define do  
  factory :ai_chat do  
    user { nil }  
    title { "Hi" }  
    ai_model_name { "llama3.2" }  
  end  
end

Тепер створимо другу модель, AiMessage:

# app/models/ai_message.rb  

# frozen_string_literal: true  

class AiMessage < ApplicationRecord  
  belongs_to :ai_chat  

  validates :prompt, presence: true  
  validates :answer, presence: true  
end

Для цієї моделі фабрика змінювати не потрібно, і стандартна буде достатньо.

Тепер додамо фабрику для користувача. Оскільки ми створили модель до того, як додали гем factory_bot_rails, генератор не був викликаний. Створіть цей файл:

# frozen_string_literal: true  

# spec/factories/users.rb  

FactoryBot.define do  
  factory :user do  
    sequence(:email) { |n| "user#{n+1}@rspec.com" }  
    password { 'Blabla12345!' }  
    password_confirmation { 'Blabla12345!' }  
  end  
end

Перед тим, як продовжити, давайте додамо кілька юніт-тестів. Нам потрібно розширити rspec, щоб зробити тести більш читабельними. Для цього використовуємо гем:

  • bundle add shoulda-matchers
  • як і для factory bot, відкрийте Gemfile і перемістіть його всередину групи :development, :test

Давайте додамо цей конфігураційний файл:

# spec/support/shoulda_matchers.rb  

require 'shoulda/matchers'  

Shoulda::Matchers.configure do |config|  
  config.integrate do |with|  
    with.test_framework(:rspec)  
    with.library(:rails)  
  end  
end

Нічого іншого не потрібно, оскільки зараз усі файли в папці "spec/support" завантажуються автоматично.

текст перекладу
Ми можемо додати спек для моделі AiChat:

# spec/models/ai_chat_spec.rb  

# frozen_string_literal: true  

require 'rails_helper'  

RSpec.describe AiChat, type: :model do  
  describe 'validations' do  
    it { is_expected.to validate_presence_of(:ai_model_name) }  
  end  

  describe 'associations' do  
    it { is_expected.to belong_to(:user) }  
    it { is_expected.to have_many(:ai_messages).dependent(:delete_all) }  
  end  

  describe 'constants' do  
    it 'has a SUPPORTED_AI_MODELS constant' do  
      expect(described_class::SUPPORTED_AI_MODELS).to be_a(Array)  
      expect(described_class::SUPPORTED_AI_MODELS).to include('llama3.2')  
    end  
  end  
end

А тепер додаємо спек для моделі AiMessage:

# spec/models/ai_message_spec.rb  

require 'rails_helper'  

RSpec.describe AiMessage, type: :model do  
  describe 'validations' do  
    it { is_expected.to validate_presence_of(:prompt) }  
    it { is_expected.to validate_presence_of(:answer) }  
  end  

  describe 'associations' do  
    it { is_expected.to belong_to(:ai_chat) }  
  end  
end

Запускаємо всі тести, щоб переконатися, що все працює:

bin/rspec  

11 examples, 0 failures

Один сервіс для всього

Створимо сервіс для централізації управління цими двома моделями: CreateAiChatMessageService

# app/services/create_ai_chat_message_service.rb  

# frozen_string_literal: true  

class CreateAiChatMessageService  
  DEFAULT_MODEL_NAME = "llama3.2"  

  def initialize(prompt:, ai_chat_id: nil, user_id: nil)  
    @ai_chat_id = ai_chat_id  
    @prompt = prompt  
    @user_id = user_id  
  end  

  # Це буде виконувати роботу...  
  def call  
  end  

  private  

  attr_reader :ai_chat_id, :prompt, :user_id  

  # Це буде обробляти API...  

текст перекладу
Ми можемо додати спек для сервісу CreateAiChatMessageService:

spec/services/createaichatmessageservice_spec.rb

require 'rails_helper'

describe CreateAiChatMessageService, type: :service do
let(:user) { create(:user) }
let(:aichat) { create(:aichat, user:) }
let(:prompt) { 'Hello!' }
let(:service) { described_class.new(**parameters) }

# ---- Стубінг зовнішнього сервісу ----
let(:llm) { double(chat: llmresponse) }
let(:llm
response) { double(chatcompletion: stubbedanswer) }
let(:stubbed_answer) { 'Це підставлена відповідь' }
# ---------------------------------------

before do
# Стубимо виклик до зовнішнього сервісу для створення екземпляра llm без фактичного виклику
allow(service).to receive(:llm).and_return(llm)
end

sharedexamples 'сервіс, що не вдалося' do
it 'не створює нове AiMessage' do
expect { service.call }.to
not change { AiMessage.count }
end

it 'не успішно завершується' do  
  service.call  
  expect(service.success?).to be_falsey  
  expect(service.errors.any?).to be_truthy  
end  

end

context 'коли всі параметри присутні' do
# Хоча не обов'язково надавати як aichatid, так і userid, це допустимий випадок, userid буде проігноровано
let(:parameters) { { prompt:, aichatid: aichat.id, userid: user.id } }

it 'створює нове AiMessage' do  
  expect { service.call }.to change { AiMessage.count }.by(1)  
  expect(AiMessage.last.answer).to eq(stubbed_answer)  
end  

it 'не створює новий AiChat' do  
  expect { service.call }.to_not change { AiChat.count }  
end  

end

context 'коли надано aichatid' do
let(:parameters) { { prompt:, aichatid: ai_chat.id } }

it 'створює нове AiMessage' do  
  expect { service.call }.to change { AiMessage.count }.by(1)  
end  

it 'не створює новий AiChat' do  
  expect { service.call }.to_not change { AiChat.count }  
end  

end

context 'коли aichatid не надано' do
let(:parameters) { { prompt:, user_id: user.id } }

it 'створює новий AiChat та AiMessage' do  
  expect { service.call }.to change { AiChat.count }.by(1).and change { AiMessage.count }.by(1)  
end  

end

context 'коли aichatid та user_id не надано' do
let(:parameters) { { prompt: prompt } }

it_behaves_like 'сервіс, що не вдалося'  

it 'додає конкретне повідомлення про помилку' do  
  service.call  
  expect(service.errors[:ai_chat_id]).to include('або user_id є обов\'язковим')  
end  

end

context 'коли prompt порожній' do
let(:parameters) { { prompt: '', aichatid: ai_chat.id } }

it_behaves_like 'сервіс, що не вдалося'  

it 'додає конкретне повідомлення про помилку' do  
  service.call  
  expect(service.errors[:prompt]).to include('є обов\'язковим')  
end  

end

context 'коли aichatid надано, але не знайдено' do
let(:parameters) { { prompt:, aichatid: -1 } }

it_behaves_like 'сервіс, що не вдалося'  

it 'додає помилку для ai_chat, який не знайдено' do  
  service.call  
  expect(service.errors[:ai_chat]).to include('не знайдено')  
end  

end
end
```

Якщо ми запустимо тести, багато з них не пройдуть, саме ті, де ми визначаємо, що має відбуватись.
текст перекладу
```
bin/rspec ./spec/services/createaichatmessageservice_spec.rb

14 прикладів, 9 помилок
```

Ті, де ми визначаємо, що не має статися, не зазнають помилок: наприклад,
текст перекладу
```
bin/rspec ./spec/services/createaichatmessageservice_spec.rb

14 прикладів, 0 помилок
```

Тепер, коли ми маємо наш сервіс (хоча він поки що порожній), давайте визначимо, як він має працювати, враховуючи наступні сценарії:

  • коли всі параметри присутні
  • коли присутній параметр ai_chat_id
  • коли параметр ai_chat_id відсутній
  • коли відсутні як ai_chat_id, так і user_id, тобто коли тільки prompt присутній
  • коли prompt порожній
  • коли параметр ai_chat_id надано, але він не знайдений

Розглянемо такі моменти:

  • ми не хочемо виконувати реальні зовнішні виклики, тому ми підставляємо запит до сервісу AI, який повертає очікувану відповідь
  • ми не хочемо повторювати себе, визначаючи поведінку в кількох контекстах, наприклад, коли сервіс не працює
  • ми хочемо, щоб метод success? підсумовував результат із булевим значенням, а у разі помилки errors описуватиме причину
# app/services/create_ai_chat_message_service.rb  

# frozen_string_literal: true  

# Створює нове повідомлення AiMessage, пов'язане з наданим AiChat.  
# Крім того, створює AiChat, якщо його не існує.  
#  
# Наприклад:  
# Щоб створити новий чат:  
# - CreateAiChatMessageService.call(prompt: 'Hi!', user_id: 1)  
# Щоб створити нове повідомлення в існуючому чаті:  
# - CreateAiChatMessageService.call(prompt: 'Define the term "AI"', ai_chat_id: 1)  
# результат:  
# #  
class CreateAiChatMessageService  
 prepend SimpleCommand  

 DEFAULT_MODEL_NAME = "llama3.2"  

 def initialize(prompt:, ai_chat_id: nil, user_id: nil)  
 @ai_chat_id = ai_chat_id  
 @prompt = prompt  
 @user_id = user_id  
 end  

 # Створює і повертає нове повідомлення AiMessage, пов'язане з наданим або створеним AiChat  
 # @param prompt [String] повідомлення користувача до AI  
 # @param ai_chat_id [Integer] [НЕОБОВ'ЯЗКОВИЙ] id чату AI, якщо не надано, буде створено новий AiChat  
 # @param user_id [Integer] [НЕОБОВ'ЯЗКОВИЙ] id користувача для створення нового AiChat, якщо AiChat не надано  
 # @return [AiMessage] створене повідомлення AiMessage  
 def call  
 if !ai_chat_id && !user_id  
 errors.add(:ai_chat_id, "or user_id is required")  
 elsif ai_chat_id && !ai_chat  
 errors.add(:ai_chat, "not found")  
 elsif !ai_chat_id && !ai_chat  
 errors.add(:ai_chat, "not created, check attributes")  
 elsif ai_chat.errors.any?  
 errors.add(:ai_chat, ai_chat.errors.full_messages.to_sentence)  
 end  

 errors.add(:prompt, "is required") if prompt.blank?  

 if errors.any?  
 # notify_error  
 return  
 end  

 llm_response = llm.chat(messages:)  

 ai_message = ai_chat.ai_messages.create(prompt:, answer: llm_response.chat_completion)  

 ai_message  
 rescue StandardError  
 # notify_error  
 end  

 private  

 attr_reader :ai_chat_id, :prompt, :user_id  

 # Клієнт LLM.  
 # @return [Langchain::LLM::Ollama] клієнт LLM  
 def llm  
 @llm ||= Langchain::LLM::Ollama.new(url: "http://localhost:11434", default_options: { chat_model: DEFAULT_MODEL_NAME })  
 end  

 # Знайти або створити AiChat, до якого буде додано AiMessage.  
 def ai_chat  
 @ai_chat ||=  
 if ai_chat_id  
 AiChat.find_by(id: ai_chat_id)  
 else  
 # Зазвичай AI Chat створюється в контролері для надання користувачеві URL чату та очікування відповіді.  
 AiChat.create(user_id:, title: prompt.truncate(100), ai_model_name: DEFAULT_MODEL_NAME)  
 end  
 end  

 # Формує історію повідомлень для поточного чату  
 # @return [Array] історія повідомлень  
 # @example  
 # [  
 # { role: 'user', content: 'Hi! My name is Purple.' },  
 # { role: 'assistant',  
 # content: 'Hi, Purple!' },  
 # { role: 'user', content: "What's my name?" }  
 # ]  
 def messages  
 return [] unless ai_chat  

 @messages ||=  
 begin  
 ai_chat.ai_messages.flat_map do |ai_message|  
 [  
 { role: "user", content: ai_message.prompt },  
 { role: "assistant", content: ai_message.answer }  
 ]  
 end << { role: "user", content: prompt }  
 end  
 end  
end

Примітка: notify_error буде замінено на спосіб повідомлення про помилку, ми зробимо це пізніше.

Якщо ми зараз запустимо тести, вони повинні пройти:

bin/rspec ./spec/services/create_ai_chat_message_service_spec.rb  

14 прикладів, 0 помилок

Тепер спробуємо взаємодію з консолі:

  • переконайтесь, що ollama працює та що модель llama3.2 була завантажена (див. попередні вимоги).
    текст перекладу
    ```
    bin/rspec ./spec/services/createaichatmessageservice_spec.rb

14 прикладів, 0 помилок
```

Якщо ви хочете використовувати іншу модель, потрібно просто змінити константу DEFAULT_MODEL_NAME всередині сервісу.
- переконайтесь, що ви виконали команду bin/rails db:seed, як зазначено вище, і додали користувача
- запустіть bin/rails console або просто bin/rails c

Loading development environment (Rails 8.0.1)  

turbo-chat(dev)> CreateAiChatMessageService.call(user_id: 1, prompt: "Hi").result  

#

Якщо все працює, то через кілька секунд ви повинні отримати новостворене повідомлення AiMessage і відповідь типу "How can I assist you today?"

Продовжимо спілкування, виводячи відповідь, використовуючи щойно створений чат #1:

puts CreateAiChatMessageService.call(ai_chat_id: 1, prompt: "Tell me a funny joke").result.answer
Here's one:  

What do you call a fake noodle?  

An impasta!  

I hope that made you laugh! Do you want to hear another one?
puts CreateAiChatMessageService.call(ai_chat_id: 1, prompt: "Pathetic!").result.answer
I guess it was a bit of a groaner. Okay, okay, I'll try again.  

Here's another one:  

Why don't eggs tell jokes?  

(wait for it...)  

Because they'd crack each other up!  

How's that? Better?

Цим прикладом я хотів показати, що кожен запит використовує історію попередніх повідомлень. Вони форматуються та передаються в модель, щоб вона могла оцінити контекст.

У людській бесіді це відбувається автоматично, але ШІ не зберігає спогади, і нам потрібно робити це, передаючи всю інформацію в кожному запиті.

Для практичного прикладу припустимо, що ви зустріли сусіда:
A: Good morning!
B: Good morning to you!

A: I wonder if it will rain today.
B: Yes, the forecast says it will rain this evening.

З ШІ це буде виглядати так:

Ітерація 1:
A: Good morning!
B: Good morning to you!

Ітерація 2:
A: Good morning!
B: Good morning to you!
A: I wonder if it will rain today.
B: Yes, the forecast says it will rain this evening.

В основному ми визначаємо учасників та асоціюємо їхні репліки з ними. Це те, що робить сервіс, який ми створили.

Якщо ви перевірите базу даних, ви знайдете один запис AiChat з трьома зв'язаними AiMessages.

Інший спосіб передавати контекст до ШІ — це використовувати embeddings, фактично це інформація, перетворена на числа (або точніше, вектори), яку ШІ може використовувати без необхідності генерувати її кожного разу. Швидше, коли дані великі, але потребує багато місця для зберігання.

Зупинимось на цьому зараз. Перед комітом давайте запустимо rubocop:

bin/rubocop -a  

git add . && git commit -m "Added AiChat, AiMessage and CreateAiChatMessageService"

Висновок

Ми створили веб-додаток на Rails 8, налаштували js та css бандл з vite. Додали аутентифікацію, графічний стиль, два прості архіви в базі даних SQLite, щоб зберігати розмови з ШІ. Для управління ними ми написали сервіс, який відповідає специфікаціям, які ми собі визначили.

Будь ласка, повідомте про будь-які помилки або напишіть мені, якщо якийсь крок не зрозумілий. Це можна зробити тут, у соціальних мережах або через email.

Наступного разу

Ми використаємо сервіс через UI.
текст перекладу
```
Ми надішлемо запит до ШІ за допомогою асинхронного завдання та оновимо UI, коли запит буде оброблено, з використанням turbo 8 і websocket.

Наступна частина буде доступна скоро 🚧

Посилання

Репозиторій: https://github.com/marcomd/turbo_chat/tree/end-part-1

Статті, що надихнули мене

[

Як вивчити Hotwire та Turbo за допомогою безкоштовного туторіалу Rails 7

У цьому безкоштовному туторіалі Turbo Rails ми дізнаємось, як використовувати Hotwire та Turbo з Ruby on Rails, створюючи…

www.hotrails.dev

](https://www.hotrails.dev/turbo-rails?source=post_page-----85ee5668d8fb--------------------------------)

[

Як використовувати відкриту модель LLM локально та віддалено

Запустіть відкриту мовну модель на своєму локальному комп'ютері та віддалено.

thoughtbot.com

](https://thoughtbot.com/blog/how-to-use-open-source-LLM-model-locally?source=post_page-----85ee5668d8fb--------------------------------)

[

Покроковий посібник із створення додатків LLM за допомогою Ruby (використовуючи Langchain і Qdrant)

У світі розробки програмного забезпечення вибір мов програмування та інструментів не є просто питанням…

medium.com

](/@shaikhrayyan123/step-by-step-guide-to-building-llm-applications-with-ruby-using-langchain-and-qdrant-5345f51d8a76?source=post_page-----85ee5668d8fb--------------------------------)

Перекладено з: Let’s write a free ChatGPT clone with Rails 8 — part 1

Leave a Reply

Your email address will not be published. Required fields are marked *