Files
sure/app/models/chat.rb
Michal Tajchert ccd6a53071 fix(chat): eager pending AssistantMessage to fix Turbo subscribe race (#1657) (#1658)
* fix(chat): persist eager pending assistant message to fix subscribe race

When the LLM replies in ~1-2s the assistant message broadcast could
fire before the client's Turbo stream subscription was established,
leaving the UI stuck on the thinking indicator while the response was
already persisted.

Create the AssistantMessage as `pending` synchronously in
`Chat#ask_assistant_later`, so it is rendered server-side on the chat
show page with a "Thinking ..." inline placeholder. The worker then
finds and updates the existing row via `append_text!`, which flips the
status to `complete` and broadcasts updates against a DOM id that is
already in the page — no race possible. On error, the placeholder is
destroyed if no content streamed, otherwise demoted to `failed`.

Replaces the standalone thinking indicator partial and the
`Assistant::Broadcastable` thinking helpers, both now redundant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): bind each assistant job to its specific pending placeholder

Addressing review feedback on #1658:

1. The pending placeholder lookup based on `last pending` was racy —
   back-to-back user messages would let one job fill another job's
   placeholder. Pass the placeholder through the job arguments
   (`AssistantResponseJob.perform_later(user_message, pending)`) so
   each turn is bound to its own row.

2. In `Assistant::External#respond_to`, the configured/authorized
   guards raise before the local was bound, leaving rescue cleanup
   with `nil` and the placeholder visible forever. Bind the parameter
   first so cleanup can destroy it on the misconfigured path.

The kwarg defaults to nil so the API#retry path
(`AssistantResponseJob.perform_later(new_message)`) and the model-level
test calls continue to work — they fall back to an in-memory new
message, restoring the original test count assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): i18n the pending assistant placeholder string

Move the hardcoded "Thinking ..." indicator into the locale file per
CLAUDE.md i18n guidelines. With i18n.fallbacks enabled, non-en locales
fall back to English until translated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add thinking label translations

* Fix chat pending assistant expectations

* Fix external assistant pending test lookup

* Scope chat stream targets per chat

* Update message broadcast target tests

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:33:29 +02:00

175 lines
4.5 KiB
Ruby

class Chat < ApplicationRecord
include Debuggable
RATE_LIMIT_PATTERNS = [
/\b429\b/i,
/rate limit/i,
/too many requests/i,
/quota exceeded/i
].freeze
TEMPORARY_PROVIDER_PATTERNS = [
/\b5\d\d\b/i,
/service unavailable/i,
/temporarily unavailable/i,
/gateway timeout/i,
/bad gateway/i,
/overloaded/i,
/time(?:out|d?\s*out)/i,
/connection reset/i
].freeze
AUTH_CONFIGURATION_PATTERNS = [
/unauthorized/i,
/authentication/i,
/invalid api key/i,
/incorrect api key/i,
/access token/i
].freeze
belongs_to :user
has_one :viewer, class_name: "User", foreign_key: :last_viewed_chat_id, dependent: :nullify # "Last chat user has viewed"
has_many :messages, dependent: :destroy
validates :title, presence: true
scope :ordered, -> { order(created_at: :desc) }
class << self
def start!(prompt, model:)
# Ensure we have a valid model by using the default if none provided
effective_model = model.presence || default_model
create!(
title: generate_title(prompt),
messages: [ UserMessage.new(content: prompt, ai_model: effective_model) ]
)
end
def generate_title(prompt)
prompt.first(80)
end
# Returns the default AI model to use for chats
# Priority: AI Config > Setting
def default_model
Provider::Openai.effective_model.presence || Setting.openai_model
end
end
def needs_assistant_response?
conversation_messages.ordered.last.role != "assistant"
end
def retry_last_message!
update!(error: nil)
last_message = conversation_messages.ordered.last
if last_message.present? && last_message.role == "user"
ask_assistant_later(last_message)
end
end
def update_latest_response!(provider_response_id)
update!(latest_assistant_response_id: provider_response_id)
end
def add_error(e)
update!(error: build_error_payload(e).to_json)
broadcast_append target: messages_target, partial: "chats/error", locals: { chat: self }
end
def presentable_error_message
return nil if error.blank?
parsed_error_payload["message"].presence || classify_error_message(error)
end
def technical_error_message
parsed_error_payload["technical_message"].presence || parsed_legacy_error_message || error
end
def clear_error
update! error: nil
broadcast_remove target: error_target
end
def conversation_messages
messages.where(type: [ "UserMessage", "AssistantMessage" ])
end
def messages_target
ActionView::RecordIdentifier.dom_id(self, :messages)
end
def error_target
ActionView::RecordIdentifier.dom_id(self, :chat_error)
end
def ask_assistant_later(message)
clear_error
pending = messages.create!(type: "AssistantMessage", content: "", ai_model: message.ai_model, status: :pending)
AssistantResponseJob.perform_later(message, pending)
end
def ask_assistant(message, assistant_message: nil)
assistant.respond_to(message, assistant_message: assistant_message)
end
private
def build_error_payload(error)
technical_message = error_message_for(error)
{
message: classify_error_message(technical_message),
technical_message: technical_message,
type: error.class.name
}
end
def classify_error_message(message)
normalized_message = message.to_s.strip
return I18n.t("chat.errors.default") if normalized_message.blank?
if RATE_LIMIT_PATTERNS.any? { |pattern| normalized_message.match?(pattern) }
I18n.t("chat.errors.rate_limited")
elsif TEMPORARY_PROVIDER_PATTERNS.any? { |pattern| normalized_message.match?(pattern) }
I18n.t("chat.errors.temporarily_unavailable")
elsif AUTH_CONFIGURATION_PATTERNS.any? { |pattern| normalized_message.match?(pattern) }
I18n.t("chat.errors.misconfigured")
else
I18n.t("chat.errors.default")
end
end
def parsed_error_payload
return {} if error.blank?
return error if error.is_a?(Hash)
parsed = JSON.parse(error)
parsed.is_a?(Hash) ? parsed : {}
rescue JSON::ParserError, TypeError
{}
end
def error_message_for(error)
error.respond_to?(:message) ? error.message.to_s : error.to_s
rescue StandardError
""
end
def parsed_legacy_error_message
parsed = JSON.parse(error)
parsed.is_a?(String) ? parsed : nil
rescue JSON::ParserError, TypeError
nil
end
def assistant
@assistant ||= Assistant.for_chat(self)
end
end