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

pic

Сідай у поїзд і зроби себе комфортно

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

Видалення чатів

Якщо ви реалізували видалення самостійно як вправу в попередній частині, порівняйте своє рішення.

Давайте почнемо додавати можливість видалення чатів, починаючи з написання тестів, як звичайно:

# 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, який видаляє чат зі списку, що відображається на сторінці.

Запустіть сервер і спробуйте видалити чат:

pic

Примітка: Якщо ви отримали помилку “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 %>

Розгляньте:

  1. якщо ми видалимо всі чати, часткове подання "empty_state" автоматично з'явиться, тому важливо, щоб не залишалося дочірніх тегів turbo
  2. для того, щоб анімація була видимою, потрібно застосувати її до div, а ми повинні видалити контейнер, який є turbo тегом
 <--- 2. ПІСЛЯ АНІМАЦІЇ ЦЕ БУДЕ ВИДАЛЕНО  

 <--- 1. МИ ПРИХОВУЄМО ЦЕ

Тепер оновимо часткове подання, призначивши id для div:

<%= turbo_frame_tag ai_chat do %>  
   ...  

Спробуємо ефект:

pic

Перезапустіть тести, щоб перевірити, що все працює правильно:

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  
 ...

Примітка:

  1. Я рекомендую використовувати паузу між частинами, щоб уникнути проблем з одночасним виконанням, які можуть змінити позиції. Але ви також можете пропустити це, якщо хочете отримати найшвидшу відповідь, тому що пізніше ми застосуємо рішення, яке усуне цю проблему.
  2. Ми перенесли створення 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 помилок

Перевіримо результат:

pic

Подивіться на поточну відповідь через потік

Коміт 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 не інформує користувача про проблему, а спиннер продовжує показувати, що програма чекає
  • повідомлення ШІ створюється, але, звісно, без відповіді

pic

Давайте вирішимо це, почнемо з тестів:

# 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:  ![pic](https://drive.javascript.org.ua/19433c8b871_9pyao9t3RFIl4B4GT0pEUQ_gif)  **Примітка**: щоб не ускладнювати туторіал, ми продовжуємо використовувати 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 %>  
...

⚠️ Перезапустіть сервер, оскільки ми додали нові гемми до бандла.

Перевіримо результат, оновивши сторінку:

pic

Як бачите, повідомлення тепер відформатоване!

Однак, якщо ви надішлете новий запит, ви все одно не побачите його відформатованим, оскільки це працює лише з тими, що прийшли з бази даних. Ті, що йдуть через стрімінг безпосередньо від 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

pic

Ми закінчили на цьому, але перш ніж завершити, давайте запустимо rubocop для виправлення дрібних недоліків і зробимо commit.

bin/rubocop -a

git commit -a -m "Added markdown formatting"

Декілька вправ для завершення цієї частини

Спробуйте реалізувати ці пункти:

  1. Покращення UI: Було б непогано, якщо натискання Enter у текстовій області для підсумку надішле форму. Для того, щоб додати новий рядок, можна використати комбінацію SHIFT+ENTER. Для цього слід створити контролер стимулів, щоб прив'язати подію keydown: якщо натискали Enter без клавіші Shift, запобігти стандартній поведінці та замість цього надіслати форму через turbo.
  2. Нова функція: ми додали видалення чатів, що каскадно видаляє всі повідомлення всередині. Крім того, було б корисно мати можливість виключати/видаляти деякі повідомлення з чату, тому що іноді в обговоренні відкриваються дужки, які не мають значення для продовження чату.
    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

Leave a Reply

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