Module: Assistant::EditOpStreamer
- Defined in:
- app/services/assistant/edit_op_streamer.rb
Overview
Stage 9 of the Sunny blog editor fix plan: real-time per-op feedback in
the conversation transcript. Each edit_blog_post / remove_embed /
replace_embed / move_embed op appends a row to a "live edit log" frame
inside the assistant message bubble so the user can see ops apply as
they happen (and hit Stop earlier — now that Stop actually works, from
Stage 1 — if Sunny goes off-rails).
Built on Turbo::StreamsChannel.broadcast_append_to so each row is a
single ActionCable message, not a full re-render.
Constant Summary collapse
- MAX_VISIBLE_OPS =
10
Class Method Summary collapse
-
.callback_for(conversation_id) ⇒ Object
Build an
on_opProc that can be passed to BlockAddressedEditor#apply_ops (or invoked directly from the atomic embed tools). -
.emit_single(conversation_id:, op:, detail: nil, status: 'applied') ⇒ Object
Emit a single op event outside of a multi-op edit context (used by remove_embed / replace_embed / move_embed — each is a single "op" from the user's perspective).
- .frame_target(conversation_id) ⇒ Object
-
.reset_frame_states_for_test! ⇒ Object
Test hook: clear the per-process frame-rendered cache so each test starts from a clean slate.
- .stream_name(conversation_id) ⇒ Object
Class Method Details
.callback_for(conversation_id) ⇒ Object
Build an on_op Proc that can be passed to
BlockAddressedEditor#apply_ops (or invoked directly from the
atomic embed tools). If conversation_id is nil, returns a no-op
Proc so the editor still works outside of a chat context.
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
# File 'app/services/assistant/edit_op_streamer.rb', line 28 def callback_for(conversation_id) return ->(_payload) {} unless conversation_id target = frame_target(conversation_id) stream = stream_name(conversation_id) counter = frame_state(conversation_id) counter[:seen] ||= 0 lambda do |payload| ensure_frame(stream, target, counter) counter[:seen] += 1 # Cap visible rows — after the cap, broadcast a single "+N more" row. if counter[:seen] == MAX_VISIBLE_OPS + 1 broadcast_append(stream, target, overflow_html) end next if counter[:seen] > MAX_VISIBLE_OPS broadcast_append(stream, target, op_row_html(payload)) rescue StandardError => e Rails.logger.warn("[EditOpStreamer] broadcast failed: #{e.class}: #{e.}") end end |
.emit_single(conversation_id:, op:, detail: nil, status: 'applied') ⇒ Object
Emit a single op event outside of a multi-op edit context
(used by remove_embed / replace_embed / move_embed — each is a
single "op" from the user's perspective).
55 56 57 58 59 60 61 62 63 64 65 66 |
# File 'app/services/assistant/edit_op_streamer.rb', line 55 def emit_single(conversation_id:, op:, detail: nil, status: 'applied') return unless conversation_id target = frame_target(conversation_id) stream = stream_name(conversation_id) ensure_frame(stream, target, frame_state(conversation_id)) payload = { op: op.to_s, status: status.to_s, detail: detail } broadcast_append(stream, target, op_row_html(payload)) rescue StandardError => e Rails.logger.warn("[EditOpStreamer] emit_single failed: #{e.class}: #{e.}") end |
.frame_target(conversation_id) ⇒ Object
68 69 70 |
# File 'app/services/assistant/edit_op_streamer.rb', line 68 def frame_target(conversation_id) "edit-op-log-#{conversation_id}" end |
.reset_frame_states_for_test! ⇒ Object
Test hook: clear the per-process frame-rendered cache so each test
starts from a clean slate.
78 79 80 |
# File 'app/services/assistant/edit_op_streamer.rb', line 78 def reset_frame_states_for_test! @frame_states_mutex.synchronize { @frame_states.clear } end |
.stream_name(conversation_id) ⇒ Object
72 73 74 |
# File 'app/services/assistant/edit_op_streamer.rb', line 72 def stream_name(conversation_id) "assistant_chat:#{conversation_id}" end |