текст перекладу
Rails 8 нещодавно був випущений і має багато захоплюючих нових функцій, серед яких можливість створювати сучасні веб-додатки з мінімальними зусиллями, навіть менше, ніж у попередній версії. Основна команда Rails завжди орієнтувалася на продуктивність розробника, що також є особливістю Ruby та всіх, хто є частиною цієї спільноти.
Вже з версії 7 можна було уникнути складнощів з фронтендом, керувати ним, але головне — синхронізувати з бекендом без шкоди для ефективного користувацького досвіду. Подивимося, як Turbo 8 тепер робить це рішення ще більш ефективним.
В цьому підручнику
Створимо чат з ШІ, клон ChatGPT або Claude, але який також може працювати в вашій приватній мережі і без необхідності платити щомісячну підписку. Як порівнянний сервіс можна згадати venice.ai, що використовує відкриті моделі для генерації тексту та зображень. ШІ чат, який ми створимо, може працювати локально на тому ж комп’ютері або на іншому в тій же мережі… або навіть у хмарі.
Для кого цей підручник?
Для всіх, хто хоче попрацювати з ШІ: від новачків, які можуть слідувати інструкціям крок за кроком, до експертів, які можуть пропустити опис деяких базових понять, але я прагнув зробити його доступним для всіх.
Передумови
- Встановлений 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, і ви повинні побачити домашню сторінку за замовчуванням:
Зупиніть сервер (Ctrl+C) і давайте створимо репозиторій Git:
git init
git add .
текст перекладу
git commit -a -m "First commit"
```
Конфігурацію бази даних можна знайти в config/database.yml
, і за замовчуванням файли будуть розміщені в папці storage
, яка створюється автоматично під час першої міграції, як ми побачимо пізніше.
З версії Rails 7 можна використовувати import map
і уникати використання бандлів, але в цьому підручнику я хочу показати, як легко інтегрувати Vite
. Ви можете переглянути офіційну документацію або слідувати простим крокам.
- Додайте JavaScript пакети до бандлу:
yarn add vite vite-plugin-ruby vite-plugin-full-reload --dev
- Додайте
node_modules/
до файлу.gitignore
, якщо ви виконаєте командуgit status
, ви повинні побачити тільки ці файли:
package.json
yarn.lock
- Тепер додайте гем 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”
Давайте відредагуємо цей вигляд і збережемо його, але без перезавантаження сторінки:
# 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.
Давайте подивимося на результат:

Але ви скажете, що це не дуже добре, і ви праві, тому давайте завершимо роботу, додавши ці нові 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";
Спробуємо знову:
Не очікуйте нічого неймовірного, але, на мою думку, це набагато краще.
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 %>
```
Спробуємо знову:
Тепер повідомлення є, але стилі відсутні.
# 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";
// Макети
...
```
Спробуємо знову:
Тепер повідомлення чітко відображається, але ми ще не закінчили. Ми вирішили показувати повідомлення 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;
}
...
}
Добре, це працює. Але якщо ми перевіримо сторінку, повідомлення все ще там.
Краще видаляти його й з 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(:llmresponse) { 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 }.tonot 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