mirror of
https://github.com/we-promise/sure.git
synced 2026-05-08 05:04:59 +00:00
* 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>
175 lines
4.5 KiB
Ruby
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
|