Files
sure/app/models/assistant/external.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

97 lines
3.2 KiB
Ruby

class Assistant::External < Assistant::Base
Config = Struct.new(:url, :token, :agent_id, :session_key, keyword_init: true)
MAX_CONVERSATION_MESSAGES = 20
class << self
def for_chat(chat)
new(chat)
end
def configured?
config.url.present? && config.token.present?
end
def available_for?(user)
configured? && allowed_user?(user)
end
def allowed_user?(user)
allowed = ENV["EXTERNAL_ASSISTANT_ALLOWED_EMAILS"]
return true if allowed.blank?
return false if user&.email.blank?
allowed.split(",").map { |e| e.strip.downcase }.include?(user.email.downcase)
end
def config
Config.new(
url: ENV["EXTERNAL_ASSISTANT_URL"].presence || Setting.external_assistant_url.presence,
token: ENV["EXTERNAL_ASSISTANT_TOKEN"].presence || Setting.external_assistant_token.presence,
agent_id: ENV["EXTERNAL_ASSISTANT_AGENT_ID"].presence || Setting.external_assistant_agent_id.presence || "main",
session_key: ENV.fetch("EXTERNAL_ASSISTANT_SESSION_KEY", "agent:main:main")
)
end
end
def respond_to(message, assistant_message: nil)
response_completed = false
assistant_message ||= AssistantMessage.new(chat: chat, content: "", ai_model: "external-agent")
unless self.class.configured?
raise Assistant::Error,
"External assistant is not configured. Set the URL and token in Settings > Self-Hosting or via environment variables."
end
unless self.class.allowed_user?(chat.user)
raise Assistant::Error, "Your account is not authorized to use the external assistant."
end
client = build_client
messages = build_conversation_messages
model = client.chat(
messages: messages,
user: "sure-family-#{chat.user.family_id}"
) do |text|
assistant_message.append_text!(text)
end
if assistant_message.content.blank?
raise Assistant::Error, "External assistant returned an empty response."
end
response_completed = true
assistant_message.update!(ai_model: model) if model.present?
rescue Assistant::Error, ActiveRecord::ActiveRecordError => e
cleanup_partial_response(assistant_message) unless response_completed
chat.add_error(e)
rescue => e
Rails.logger.error("[Assistant::External] Unexpected error: #{e.class} - #{e.message}")
cleanup_partial_response(assistant_message) unless response_completed
chat.add_error(Assistant::Error.new("Something went wrong with the external assistant. Check server logs for details."))
end
private
def cleanup_partial_response(assistant_message)
assistant_message&.destroy! if assistant_message&.persisted?
rescue ActiveRecord::ActiveRecordError => e
Rails.logger.warn("[Assistant::External] Failed to clean up partial response: #{e.message}")
end
def build_client
Assistant::External::Client.new(
url: self.class.config.url,
token: self.class.config.token,
agent_id: self.class.config.agent_id,
session_key: self.class.config.session_key
)
end
def build_conversation_messages
chat.conversation_messages.where(status: "complete").ordered.last(MAX_CONVERSATION_MESSAGES).map do |msg|
{ role: msg.role, content: msg.content }
end
end
end