Сідай у поїзд і зроби себе комфортно
У попередній частині туторіалу ми почали створювати інтерфейс для взаємодії з ШІ. У цій частині ми завершуємо роботу. Це репозиторій для початку.
Видалення чатів
Якщо ви реалізували видалення самостійно як вправу в попередній частині, порівняйте своє рішення.
Давайте почнемо додавати можливість видалення чатів, починаючи з написання тестів, як звичайно:
# spec/requests/ai_chats_spec.rb
RSpec.describe "AiChats", type: :request do
let(:user) { create(:user) }
...
describe "DELETE /destroy" do
let(:action) { -> { delete "/ai/#{ai_chat.id}" } }
let!(:ai_chat) { create(:ai_chat, user:) }
it_behaves_like 'a not logged user'
context 'when user is logged in' do
before do
login_as user
end
it 'destroys the chat' do
expect { action.call }.to change { AiChat.count }.by(-1)
end
it 'shows a flash message' do
action.call
expect(flash[:notice]).to eq("AI chat `#{ai_chat.title}` was successfully destroyed.")
end
end
end
end
Тепер давайте додамо спільний приклад. Це не новий код, а те, що ми вже використовували в попередніх контекстах:
# spec/support/shared_examples/not_logged_user_spec.rb
shared_examples 'a not logged user' do
it 'redirects to the login page' do
action.call
expect(response).to redirect_to(new_user_session_path)
end
end
Ми використовуємо спільний приклад, щоб повторно використовувати ту саму логіку в інших контекстах, давайте замінимо попередній код:
# Замінимо це...
context 'when user is not logged in' do
it 'redirects to the login page' do
action.call
expect(response).to redirect_to(new_user_session_path)
end
end
# на це...
it_behaves_like 'a not logged user'
Про спільні приклади: це в основному як додавати поведінки та ділитися ними. Як функція, яку можна викликати в будь-якому місці. Уявіть проект, де є багато об'єктів. Деякі з них мають спільні поведінки, які має сенс визначати лише один раз, щоб спростити обслуговування. Ось тоді спільні приклади дійсно стають корисними:
Behaviors:
- Поведінка "може керувати собою"
- Поведінка "може керувати іншими"
User:
- він "може керувати собою"
- коли користувач — адміністратор:
- він "може керувати іншими"
Customer:
- він "може керувати собою"
Продовжуємо, і тепер можемо реалізувати нову дію в контролері:
# app/controllers/ai_chats_controller.rb
class AiChatsController < PrivateController
...
before_action :set_ai_chat, only: [:show, :ask, :destroy] # <-- Додаємо destroy
...
# DELETE /ai_chats/:id
def destroy
@ai_chat.destroy!
message = "AI chat `#{@ai_chat.title}` був успішно видалений."
respond_to do |format|
format.html { redirect_to(ai_chat_url, notice: message) }
format.turbo_stream { flash.now[:notice] = message }
format.json { head(:no_content) }
end
end
Додайте допоміжний метод для показу повідомлень, і дозвольте мені пояснити, для чого це потрібно:
# app/helpers/application_helper.rb
# frozen_string_literal: true
module ApplicationHelper
def render_turbo_stream_flash_messages
turbo_stream.prepend("flash", partial: "layouts/flash")
end
end
turbo_stream.prepend— це метод Turbo Streams, який вставляє вміст на початок цільового елемента.'flash'— це ID цільового елемента.partial: 'layouts/flash'вказує на часткове подання, яке має бути відображено та вставлене.
Створимо дію turbo stream, розглядаючи її як вигляд, але такий, що виконує інструкції:
# app/views/ai_chats/destroy.turbo_stream.erb
<%= turbo_stream.remove @ai_chat %>
<%= render_turbo_stream_flash_messages %>
turbo_stream.remove— це метод Turbo Streams, який видаляє чат зі списку, що відображається на сторінці.
Запустіть сервер і спробуйте видалити чат:
Примітка: Якщо ви отримали помилку “undefined method ‘destroy!’ for nil”, будь ласка, перевірте, чи є цей рядок before_action :set_ai_chat, only: [:show, :ask, :destroy] у контролері.
Давайте додамо просту анімацію:
/* app/assets/stylesheets/config/_animations.scss */
...
@keyframes popout {
0%{
transform: scale(1);
opacity: 1;
}
100%{
transform: scale(0.5);
opacity: 0;
}
}
Тепер призначимо анімацію до CSS класу:
/* app/assets/stylesheets/components/_ai_chat.scss */
.ai_chat {
&__index {
...
&.remove {
animation: popout ease 0.4s;
}
}
Анімація може визначати прогресію протягом часу або лише початок і кінець. Тоді, як довго вона повинна тривати та як вона повинна прогресувати, визначається в кожному конкретному випадку, як ми зробили для класу remove:
easeвизначає криву прогресії, ви можете спробувати інші, щоб знайти найбільш підходящий для вас0.4s— це максимальна тривалість
Щоб анімація почалася, нам потрібно призначити CSS клас remove, замість того щоб видаляти HTML за допомогою turbo_stream.remove, і додати зворотний виклик для видалення контейнера після завершення анімації:
# app/views/ai_chats/destroy.turbo_stream.erb
<%= turbo_stream.append "ai_chat_#{@ai_chat.id}" do %>
<%= javascript_tag do %>
document.getElementById("ai_chat__index_<%= @ai_chat.id %>").classList.add("remove");
document.getElementById("ai_chat__index_<%= @ai_chat.id %>").addEventListener("animationend", () => {
document.getElementById("ai_chat_<%= @ai_chat.id %>").remove();
});
<% end %>
<% end %>
<%= render_turbo_stream_flash_messages %>
Розгляньте:
- якщо ми видалимо всі чати, часткове подання "empty_state" автоматично з'явиться, тому важливо, щоб не залишалося дочірніх тегів turbo
- для того, щоб анімація була видимою, потрібно застосувати її до div, а ми повинні видалити контейнер, який є turbo тегом
<--- 2. ПІСЛЯ АНІМАЦІЇ ЦЕ БУДЕ ВИДАЛЕНО
<--- 1. МИ ПРИХОВУЄМО ЦЕ
Тепер оновимо часткове подання, призначивши id для div:
<%= turbo_frame_tag ai_chat do %>
...
Спробуємо ефект:
Перезапустіть тести, щоб перевірити, що все працює правильно:
bin/rspec spec/requests/ai_chats_spec.rb
...
27 прикладів, 0 помилок
І ми можемо продовжувати, якщо вони зелені
git commit -a -m "Додано видалення чату"
Відповідь штучного інтелекту, що повертається через стрімінг
Ми додали анімації, щоб зробити інтерфейс більш приємним. Людям подобається природність, і в природі все відбувається поступово. Сонце сходить і заходить поступово. Ось чому нам подобаються анімації. Та ж сама концепція може бути застосована до того, як повертається відповідь.
Самі моделі штучного інтелекту зазвичай генерують повну відповідь усередині, перш ніж надіслати її — ефект стрімінгу додається пізніше за допомогою інтерфейсу додатку. Ми маємо багато LLM (моделей обробки природної мови), і, чесно кажучи, я не знаю, чи всі вони поводяться однаково. До речі, це гарна практика на мою думку, оскільки вона має такі переваги:
- Ефективність передачі даних: надсилання великої кількості даних через мережу може призвести до сповільнення часу відповіді і збільшення затримок. Розбиття відповіді на менші частини допомагає зменшити цей вплив.
- Більш захоплюючий користувацький досвід: відповіді, що пишуться частинами, імітують реальний час взаємодії. Такий підхід також може створити враження, що ШІ «думає» і генерує відповіді динамічно, що може бути більш природним і інтерактивним для користувачів.
Давайте перейдемо до справи:
# app/services/create_ai_chat_message_service.rb
class CreateAiChatMessageService
def call
...
if errors.any?
# notify_error
return
end
show_spinner
ai_message = nil
answer_chunks = []
llm.chat(messages:) do |response_chunk|
unless ai_message
remove_spinner
ai_message = ai_chat.ai_messages.create!(prompt:, answer: "")
add_ai_message(ai_message:)
end
answer_chunk = response_chunk.chat_completion
answer_chunks << answer_chunk
update_ai_message_answer(ai_message_id: ai_message.id, answer_chunk:)
sleep 0.01 # Вищі значення -> повільніше написання
end
ai_message.update(answer: answer_chunks.join)
ai_message
rescue StandardError
...
Примітка:
- Я рекомендую використовувати паузу між частинами, щоб уникнути проблем з одночасним виконанням, які можуть змінити позиції. Але ви також можете пропустити це, якщо хочете отримати найшвидшу відповідь, тому що пізніше ми застосуємо рішення, яке усуне цю проблему.
- Ми перенесли створення AiMessage після взаємодії з ШІ, щоб уникнути ситуації, коли воно створюється, але залишається без відповіді у разі, якщо трапляється щось непередбачуване (див. наступний розділ про помилки).
Додайте новий метод update_ai_message_answer:
# app/lib/ai_chats/messageable.rb
module AiChats
module Messageable
private
...
# Оновлює відповідь на повідомлення ШІ з отриманим фрагментом
# Turbo frame для оновлення "ai_message_#{ai_message.id}_answer"
def update_ai_message_answer(ai_message_id:, answer_chunk:)
# Ми не маємо часткового вигляду цього разу, лише фрагмент відповіді
Turbo::StreamsChannel.broadcast_append_to([ai_chat, 'ai_messages'],
target: "ai_message_#{ai_message_id}_answer",
content: answer_chunk)
end
І, нарешті, редагуємо частковий вигляд, щоб додати необхідні посилання для ідентифікації секції для оновлення:
Ви: <%= ai_message&.prompt %>
<%= ai_chat.ai_model_name %>: <%= turbo_frame_tag "ai_message_#{ai_message.id}_answer" do %> <%= ai_message&.answer %> <% end %>
``` Зміни, внесені до сервісу, не повинні порушувати специфікацій: ``` bin/rspec spec/requests/ai_chats_spec.rb ``` Якщо ви їх запустите, деякі з них будуть порушені, оскільки змінився об'єкт `llm`, тепер він не повинен повертати один єдиний відгук, а ітерувати n разів, передаючи відповідь як параметр блоку. Давайте оновимо їх: ``` # spec/services/create_ai_chat_message_service_spec.rb ... describe CreateAiChatMessageService, type: :service do ... # ---- Стубінг зовнішнього сервісу ---- let(:llm) { double } let(:stubbed_answer) { 'Це замінена відповідь' } # --------------------------------------- before do # Стубуємо виклик зовнішнього сервісу для створення екземпляра llm без фактичного виклику allow(service).to receive(:llm).and_return(llm) # Стубуємо метод чату для передачі кількох замінених відповідей chat_stub = allow(llm).to receive(:chat) stubbed_answer.split.each.with_index do |chunk, i| chat_completion = i == 0 ? chunk : " #{chunk}" # Ми стубуємо цей блок -> llm.chat(messages:) do |response_chunk| chat_stub.and_yield(double(chat_completion:)) end end ... context 'коли надається ai_chat_id' do ... it 'викликає трансляцію через ActionCable' do expect(service).to receive(:show_spinner).ordered ... end end ``` Ми також видаляємо `.with(message: prompt)` і змінюємо порядок методів, оскільки тепер спиннер видаляється після `add_ai_message`: ``` # spec/services/create_ai_chat_message_service_spec.rb:67 ... context 'коли надається ai_chat_id' do ... it 'викликає трансляцію через ActionCable' do expect(service).to receive(:show_spinner).ordered expect(service).to receive(:remove_spinner).ordered expect(service).to receive(:add_ai_message).with(ai_message: an_instance_of(AiMessage)).ordered service.call end end ``` Оскільки ми вирішили дозволити створення AiMessage без відповіді, щоб додати її пізніше, ми видаляємо `validates :answer, presence: true` з моделі: ``` # app/models/ai_message.rb ... class AiMessage < ApplicationRecord belongs_to :ai_chat validates :prompt, presence: true end ``` Тепер вони повинні бути зеленими: ``` bin/rspec spec/services/create_ai_chat_message_service_spec.rb 18 прикладів, 0 помилок ``` Також оновлюємо специфікації моделі AiMessage, видаляючи валідацію: ``` # spec/models/ai_message_spec.rb ... describe 'validations' do it { is_expected.to validate_presence_of(:prompt) } end ... ``` ``` bin/rspec spec/models/ai_message_spec.rb ..
2 приклади, 0 помилок
Перевіримо результат:
Подивіться на поточну відповідь через потік
Коміт git commit -a -m "Added data streaming"
Повідомлення про помилку
Додамо можливість відображати flash-повідомлення до дії ask, так само як ми це робили з дією destroy:
<%= render_turbo_stream_flash_messages %>
Тепер давайте симулюємо помилку, закриємо ollama, щоб імітувати проблему з сервісом ШІ і спробуємо надіслати запит.
В журналі сервера ми читаємо:
Не вдалося відкрити TCP-з'єднання до localhost:11434 (Підключення відмовлено - connect(2) для "localhost" порту 11434) (Faraday::ConnectionFailed)
Незважаючи на помилку:
- UI не інформує користувача про проблему, а спиннер продовжує показувати, що програма чекає
- повідомлення ШІ створюється, але, звісно, без відповіді
Давайте вирішимо це, почнемо з тестів:
# spec/services/create_ai_chat_message_service_spec.rb
...
describe CreateAiChatMessageService, type: :service do
...
let(:valid_parameters) { { prompt:, ai_chat_id: ai_chat.id } }
# ---- Стубінг зовнішнього сервісу ----
...
context 'коли llm піднімає помилку' do
let(:parameters) { valid_parameters }
let(:error_message) { 'Сталася помилка' }
before do
allow(llm).to receive(:chat).and_raise(StandardError.new(error_message))
end
it_behaves_like 'a service that fails'
it 'повідомляє про помилку' do
expect(service).to receive(:notify_error).with(message: error_message)
service.call
end
end
end
Додамо метод для повідомлення про помилку:
# app/lib/ai_chats/messageable.rb
module AiChats
module Messageable
...
# Це надсилає повідомлення про помилку клієнту, яке має бути показано в області сповіщень користувача
def notify_error(message:)
Turbo::StreamsChannel.broadcast_replace_to([ai_chat.user, "notifications"], target: "ai_chat_#{ai_chat&.id || ai_chat_id}_notification", partial: "layouts/error_notification", locals: { message: })
end
Нам потрібно використати його в сервісі:
# app/services/create_ai_chat_message_service.rb:72
...
def call
...
if errors.any?
notify_error(message: errors.full_messages.to_sentence)
return
end
...
rescue StandardError => e
remove_spinner
errors.add(:generic, e.message)
notify_error(message: e.message)
end
Додамо частковий вигляд для відображення повідомлення:
<%= message %>
``` Додамо стилі: ``` /* app/assets/stylesheets/components/_error_notification.scss */ .error_notification { position: sticky; top: 1rem; display: flex; justify-content: center; &__message { font-size: var(--font-size-s); color: var(--color-white); padding: var(--space-xs) var(--space-m); background-color: var(--color-dark); animation: popup 0.4s; border-radius: 999px; width: max-content; } a { color: var(--color-text-notification); text-decoration: dotted underline; transition: color 200ms; &:hover, &:focus, &:active { color: inherit; } } } ``` Додамо новий sass-компонент: ``` // app/assets/stylesheets/application.scss ... // Компоненти ...
@import "components/error_notification";
...
Оновимо вигляд, щоб підключити новий потік:
<%= turbo_stream_from current_user, "notifications" %>
<%= turbo_stream_from @ai_chat, "ai_messages" %>
...
І, зрештою, додаємо div для виведення вмісту сповіщення:
<%= ai_chat.title || 'Ask to AI' %>
... ``` Перевіримо тести: ``` bin/rspec spec/services/create_ai_chat_message_service_spec.rb ... 21 приклади, 0 помилок ``` Все працює. Тепер переконайтеся, що ollama ще закрите, щоб воно видавало помилку, і спробуємо ще раз в UI:  **Примітка**: щоб не ускладнювати туторіал, ми продовжуємо використовувати llama3.2, хоча варто врахувати, що він не є ідеальним для генерування коду, ви можете перевірити це самі, порівнюючи його навіть з подібними моделями, такими як `qwen2.5-coder`. Ми ще не турбувалися про форматування відповіді, і тепер час це зробити.
Давайте створимо клас для цієї мети і допоміжний метод, щоб спростити його використання у вигляді.
Перш ніж почати писати наш код, давайте додамо ці два гем у бандл: `bundle add redcarpet rouge`
Вони займаються важкою роботою, спрощуючи нашу задачу.
Тепер допоміжний метод:
app/helpers/aichatshelper.rb
frozenstringliteral: true
module AiChatsHelper
def markdown(text)
@markdown ||= AiChats::Markdown.new
raw(@markdown.render(text).html_safe)
end
end
```
Тепер створимо тести:
# spec/lib/ai_chats/markdown_spec.rb
# frozen_string_literal: true
require 'rails_helper'
describe AiChats::Markdown do
describe '.new' do
it 'повертає екземпляр Markdown' do
is_expected.to be_a(described_class)
end
end
describe '#render' do
let(:result) { subject.render(input) }
context 'коли введення містить текст у markdown' do
let(:input) { '# Hello World' }
let(:expected) { "
Hello World
\n" }
it 'перетворює markdown у HTML' do
expect(result).to eq(expected)
end
end
context 'коли введення містить код' do
let(:input) do
<<~MARKDOWN
```ruby
def hello_world
puts 'Hello World!'
end
MARKDOWN
end
it 'перетворює блоки коду' do
expect(result).to include('
')
expect(result).to include('hello_world')
end
end
end
describe '#markdown' do
it 'повертає внутрішній екземпляр Redcarpet::Markdown' do
expect(subject.markdown).to be_a(Redcarpet::Markdown)
end
end
end
```
Тепер реалізуємо клас:
# app/lib/ai_chats/markdown.rb
# frozen_string_literal: true
module AiChats
require "redcarpet"
require "rouge"
require "rouge/plugins/redcarpet"
class Markdown
class RougeHTML < Redcarpet::Render::HTML
include Rouge::Plugins::Redcarpet
end
EXTENSIONS = {
autolink: true,
hightlight: true,
superscript: true,
fenced_code_blocks: true,
no_intra_emphasis: true,
lax_spacing: true,
strikethrough: true,
tables: true
}
OPTIONS = {
filter_html: true,
hard_wrap: true,
link_attributes: { rel: "nofollow", target: "_blank" }
}
attr_reader :markdown
def initialize(options: OPTIONS, extensions: EXTENSIONS)
renderer = RougeHTML.new(options)
@markdown = Redcarpet::Markdown.new(renderer, extensions)
end
delegate :render, to: :markdown
end
end
Нарешті, давайте додамо стиль для цього нового компонента:
/* app/assets/stylesheets/components/rouge.scss */
:root {
--rouge-color-text: #fbf1c7;
--rouge-color-background: #282828;
--rouge-color-error: #fb4934;
--rouge-color-comment: #928374;
--rouge-color-keyword: #fb4934;
--rouge-color-constant: #d3869b;
--rouge-color-type: #fabd2f;
--rouge-color-decorator: #fe8019;
--rouge-color-string: #b8bb26;
--rouge-color-name: #8ec07c;
--rouge-color-number: #d3869b;
--rouge-color-special: #83a598;
}
pre.highlight {
padding: 0.3rem 0.6rem;
border-radius: var(--border-radius);
color: var(--rouge-color-text);
background-color: var(--rouge-color-background);
}
.highlight table td {
padding: 5px;
}
.highlight table pre {
margin: 0;
}
.highlight .w {
color: var(--rouge-color-text);
background-color: var(--rouge-color-background);
}
.highlight .err {
color: var(--rouge-color-error);
background-color: var(--rouge-color-background);
font-weight: bold;
}
.highlight .c, .highlight .ch, .highlight .cd, .highlight .cm, .highlight .cpf, .highlight .c1, .highlight .cs {
color: var(--rouge-color-comment);
font-style: italic;
}
.highlight .cp {
color: var(--rouge-color-name);
}
.highlight .nt {
color: var(--rouge-color-error);
}
.highlight .o, .highlight .ow {
color: var(--rouge-color-text);
}
.highlight .p, .highlight .pi {
.color: var(--rouge-color-text);
}
.highlight .gi {
color: var(--rouge-color-string);
background-color: var(--rouge-color-background);
}
.highlight .gd {
color: var(--rouge-color-error);
background-color: var(--rouge-color-background);
}
.highlight .gh {
color: var(--rouge-color-string);
font-weight: bold;
}
.highlight .ge {
font-style: italic;
}
.highlight .ges {
font-weight: bold;
font-style: italic;
}
.highlight .gs {
font-weight: bold;
}
.highlight .k, .highlight .kn, .highlight .kp, .highlight .kr, .highlight .kv {
color: var(--rouge-color-keyword);
}
.highlight .kc {
color: var(--rouge-color-constant);
}
.highlight .kt {
color: var(--rouge-color-type);
}
.highlight .kd {
color: var(--rouge-color-decorator);
}
.highlight .s, .highlight .sb, .highlight .sc, .highlight .dl, .highlight .sd, .highlight .s2, .highlight .sh, .highlight .sx, .highlight .s1 {
color: var(--rouge-color-string);
font-style: italic;
}
.highlight .si {
color: var(--rouge-color-string);
font-style: italic;
}
.highlight .sr {
color: var(--rouge-color-string);
font-style: italic;
}
.highlight .sa {
color: var(--rouge-color-error);
}
.highlight .se {
color: var(--rouge-color-decorator);
}
.highlight .nn {
color: var(--rouge-color-name);
}
.highlight .nc {
color: var(--rouge-color-name);
}
.highlight .no {
color: var(--rouge-color-constant);
}
.highlight .na {
color: var(--rouge-color-string);
}
.highlight .m, .highlight .mb, .highlight .mf, .highlight .mh, .highlight .mi, .highlight .il, .highlight .mo, .highlight .mx {
color: var(--rouge-color-number);
}
.highlight .ss {
color: var(--rouge-color-special);
}
Включіть sass компонент:
// app/assets/stylesheets/application.scss
...
# Components
...
@import "components/rouge";
...
Тепер використаємо новий хелпер у вигляді:
...
<%= turbo_frame_tag "ai_message_#{ai_message.id}_answer" do %>
<%= markdown(ai_message&.answer) %>
<% end %>
...
⚠️ Перезапустіть сервер, оскільки ми додали нові гемми до бандла.
Перевіримо результат, оновивши сторінку:
Як бачите, повідомлення тепер відформатоване!
Однак, якщо ви надішлете новий запит, ви все одно не побачите його відформатованим, оскільки це працює лише з тими, що прийшли з бази даних. Ті, що йдуть через стрімінг безпосередньо від AI, все ще мають проблему.
Щоб відформатувати текст, нам потрібно отримати повне повідомлення або хоча б частково завершене. Для цього з стрімінгом ми потребуємо рішення: наприклад, ми можемо залишити стрім без змін і застосувати форматування кожні n частин. Зробимо це кожні 50 частин, в якому випадку ми оновимо повідомлення, замінюючи текст, наданий через стрімінг, а потім знову почнемо з нього. Ефект, який ви отримаєте, буде таким, що створюється відчуття невідкладності з систематичним коригуванням.
# app/services/create_ai_chat_message_service.rb
class CreateAiChatMessageService
...
def call
...
show_spinner
ai_message = nil
answer_chunks = []
llm.chat(messages:) do |response_chunk|
...
sleep 0.01 # Вищі значення -> повільніше введення
# Кожні n частин, оновлюємо повідомлення, оскільки потрібно відформатувати відповідь
if answer_chunks.size % 50 == 0
ai_message.update(answer: answer_chunks.join)
update_ai_message(ai_message:)
sleep 0.05
end
end
ai_message.update(answer: answer_chunks.join)
update_ai_message(ai_message:)
...
Нам потрібен новий метод update_ai_message, щоб замінити повідомлення вмістом, отриманим з потоку на цей момент.
Давайте додамо його:
# app/lib/ai_chats/messageable.rb:31
...
# Оновлює AI повідомлення з наданим об'єктом
# Turbo-фрейм для оновлення "ai_message_#{ai_message.id}_answer"
def update_ai_message(ai_message:)
Turbo::StreamsChannel.broadcast_replace_to([ai_chat, "ai_messages"],
target: "ai_chat--message_#{ai_message.id}",
partial: "ai_messages/ai_message",
locals: { ai_chat: ai_chat, ai_message: })
end
І спробуємо знову, надіславши запит:
Тепер напишіть той самий скрипт на JavaScript
Ми закінчили на цьому, але перш ніж завершити, давайте запустимо rubocop для виправлення дрібних недоліків і зробимо commit.
bin/rubocop -a
git commit -a -m "Added markdown formatting"
Декілька вправ для завершення цієї частини
Спробуйте реалізувати ці пункти:
- Покращення UI: Було б непогано, якщо натискання Enter у текстовій області для підсумку надішле форму. Для того, щоб додати новий рядок, можна використати комбінацію SHIFT+ENTER. Для цього слід створити контролер стимулів, щоб прив'язати подію keydown: якщо натискали Enter без клавіші Shift, запобігти стандартній поведінці та замість цього надіслати форму через turbo.
- Нова функція: ми додали видалення чатів, що каскадно видаляє всі повідомлення всередині. Крім того, було б корисно мати можливість виключати/видаляти деякі повідомлення з чату, тому що іноді в обговоренні відкриваються дужки, які не мають значення для продовження чату.
3.
Turbo progress bar: просто додайте scss компонент з стилем, який я покажу нижче, і перевірте, що це
.turbo-progress-bar {
background: linear-gradient(to right, var(--color-primary), var(--color-primary-rotate));
}
Висновок
Ми додали видалення чатів, повідомлення про помилки та покращили відповідь, додавши стрімінг і форматування markdown.
Це оновлений репозиторій з усіма цими змінами: https://github.com/marcomd/turbo_chat/tree/end-part-3
Наступне
У наступній частині ми реалізуємо вищезгадані вправи, і ви зможете порівняти своє рішення.
← Попередня частина | Наступне буде скоро 🚧
Перекладено з: Let’s write a free ChatGPT clone with Rails 8 — part 3