Сідай у поїзд і зроби себе комфортно
У попередній частині туторіалу ми почали створювати інтерфейс для взаємодії з ШІ. У цій частині ми завершуємо роботу. Це репозиторій для початку.
Видалення чатів
Якщо ви реалізували видалення самостійно як вправу в попередній частині, порівняйте своє рішення.
Давайте почнемо додавати можливість видалення чатів, починаючи з написання тестів, як звичайно:
# 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