maquina_stream 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/.rdoc_options +30 -0
  3. data/CHANGELOG.md +38 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +163 -0
  6. data/app/assets/stylesheets/maquina_stream/components/attachment.css +35 -0
  7. data/app/assets/stylesheets/maquina_stream/components/code_block.css +30 -0
  8. data/app/assets/stylesheets/maquina_stream/components/shimmer.css +31 -0
  9. data/app/assets/stylesheets/maquina_stream/components/snippet.css +22 -0
  10. data/app/assets/stylesheets/maquina_stream/components/source_citation.css +14 -0
  11. data/app/assets/stylesheets/maquina_stream/components/suggestion.css +32 -0
  12. data/app/assets/stylesheets/maquina_stream/reveal.css +48 -0
  13. data/app/assets/stylesheets/maquina_stream/themes/dark.css +235 -0
  14. data/app/assets/stylesheets/maquina_stream/themes/light.css +117 -0
  15. data/app/controllers/maquina_stream/application_controller.rb +21 -0
  16. data/app/controllers/maquina_stream/blocks_controller.rb +41 -0
  17. data/app/controllers/maquina_stream/manifests_controller.rb +15 -0
  18. data/app/helpers/maquina_stream/components_helper.rb +80 -0
  19. data/app/javascript/maquina_stream/controllers/application_controller.js +169 -0
  20. data/app/javascript/maquina_stream/controllers/ms_autoscroll_controller.js +110 -0
  21. data/app/javascript/maquina_stream/controllers/ms_code_controller.js +96 -0
  22. data/app/javascript/maquina_stream/controllers/ms_deferred_controller.js +223 -0
  23. data/app/javascript/maquina_stream/controllers/ms_diagram_controller.js +40 -0
  24. data/app/javascript/maquina_stream/controllers/ms_link_safety_controller.js +196 -0
  25. data/app/javascript/maquina_stream/controllers/ms_math_controller.js +32 -0
  26. data/app/javascript/maquina_stream/controllers/ms_repair_controller.js +167 -0
  27. data/app/javascript/maquina_stream/controllers/ms_reveal_controller.js +320 -0
  28. data/app/javascript/maquina_stream/controllers/ms_table_controller.js +183 -0
  29. data/app/javascript/maquina_stream/index.js +59 -0
  30. data/app/views/maquina_stream/components/_attachment.html.erb +139 -0
  31. data/app/views/maquina_stream/components/_code_block.html.erb +74 -0
  32. data/app/views/maquina_stream/components/_shimmer.html.erb +36 -0
  33. data/app/views/maquina_stream/components/_snippet.html.erb +50 -0
  34. data/app/views/maquina_stream/components/_source_citation.html.erb +42 -0
  35. data/app/views/maquina_stream/components/_suggestion.html.erb +73 -0
  36. data/config/importmap.rb +10 -0
  37. data/config/locales/en.yml +79 -0
  38. data/config/locales/es.yml +82 -0
  39. data/config/routes.rb +11 -0
  40. data/docs/configuration.md +219 -0
  41. data/docs/deferred-renderers.md +184 -0
  42. data/docs/getting-started.md +356 -0
  43. data/docs/javascript.md +298 -0
  44. data/docs/registries.md +283 -0
  45. data/docs/repair.md +162 -0
  46. data/docs/security.md +247 -0
  47. data/docs/streaming.md +308 -0
  48. data/lib/generators/maquina_stream/install/USAGE +26 -0
  49. data/lib/generators/maquina_stream/install/install_generator.rb +199 -0
  50. data/lib/generators/maquina_stream/install/templates/initializer.rb.tt +121 -0
  51. data/lib/generators/maquina_stream/streamable/USAGE +28 -0
  52. data/lib/generators/maquina_stream/streamable/streamable_generator.rb +187 -0
  53. data/lib/generators/maquina_stream/streamable/templates/migration.rb.tt +21 -0
  54. data/lib/generators/maquina_stream/streamable/templates/model.rb.tt +4 -0
  55. data/lib/maquina_stream/block.rb +99 -0
  56. data/lib/maquina_stream/broadcaster.rb +233 -0
  57. data/lib/maquina_stream/component_cache.rb +0 -0
  58. data/lib/maquina_stream/components/contract.rb +184 -0
  59. data/lib/maquina_stream/components.rb +135 -0
  60. data/lib/maquina_stream/configuration.rb +240 -0
  61. data/lib/maquina_stream/document.rb +296 -0
  62. data/lib/maquina_stream/engine.rb +46 -0
  63. data/lib/maquina_stream/errors.rb +17 -0
  64. data/lib/maquina_stream/export.rb +66 -0
  65. data/lib/maquina_stream/frame.rb +73 -0
  66. data/lib/maquina_stream/manifest.rb +137 -0
  67. data/lib/maquina_stream/registries.rb +116 -0
  68. data/lib/maquina_stream/renderer/fence.rb +115 -0
  69. data/lib/maquina_stream/renderer/post_pass.rb +363 -0
  70. data/lib/maquina_stream/renderer/tag_blocks.rb +276 -0
  71. data/lib/maquina_stream/renderer/view_context.rb +72 -0
  72. data/lib/maquina_stream/renderer.rb +128 -0
  73. data/lib/maquina_stream/sanitizer.rb +392 -0
  74. data/lib/maquina_stream/streamable.rb +281 -0
  75. data/lib/maquina_stream/text_direction.rb +56 -0
  76. data/lib/maquina_stream/themes.rb +84 -0
  77. data/lib/maquina_stream/version.rb +5 -0
  78. data/lib/maquina_stream.rb +175 -0
  79. metadata +204 -0
data/docs/streaming.md ADDED
@@ -0,0 +1,308 @@
1
+ # Streaming
2
+
3
+ What you implement, what the engine does with it, and where the line between
4
+ the two is.
5
+
6
+ ## What the engine owns, and what you own
7
+
8
+ The engine renders markdown to HTML, splits it into blocks, decides which blocks
9
+ may freeze, works out what changed since the last frame, and emits Turbo Stream
10
+ actions. It serves the repair routes.
11
+
12
+ You own three things, and none of them has a default the engine could guess:
13
+
14
+ | Yours | Where it goes |
15
+ |---|---|
16
+ | Persisting the buffer, the sequence and the status | `MaquinaStream::Streamable` |
17
+ | Looking a record up by its stream id | `config.find_stream` |
18
+ | Deciding whether a request may see it | `config.authorize` |
19
+
20
+ The engine also never:
21
+
22
+ - guesses a broadcast target — `stream_for:` is yours;
23
+ - writes host columns other than through `#maquina_stream_append` and
24
+ `#maquina_stream_seal!`;
25
+ - creates the `#ms-msg-<sid>` element it appends blocks into;
26
+ - sends markdown to the browser.
27
+
28
+ ## The Streamable contract
29
+
30
+ ```ruby
31
+ class Message < ApplicationRecord
32
+ include MaquinaStream::Streamable
33
+
34
+ maquina_stream buffer: :content,
35
+ stream_for: ->(m) { [:conversation, m.conversation_id, :messages] }
36
+ end
37
+ ```
38
+
39
+ The macro generates each method below when its backing column exists:
40
+
41
+ | Method | Returns | Column it reads |
42
+ |---|---|---|
43
+ | `#maquina_stream_id` | `String`, stable and unique per message | none — it is `to_param` |
44
+ | `#maquina_stream_buffer` | `String`, the raw markdown so far | the one named by `buffer:` |
45
+ | `#maquina_stream_append(text)` | the whole buffer, appended and persisted | the one named by `buffer:` |
46
+ | `#maquina_stream_sequence` | `Integer`, monotonic, one per frame that went out | `stream_sequence` |
47
+ | `#maquina_stream_advance` | `Integer`, the next sequence number, incremented atomically | `stream_sequence` |
48
+ | `#maquina_stream_open?` | `Boolean` | `stream_status` |
49
+ | `#maquina_stream_status` | the end state as a Symbol, `nil` while open | `stream_status` |
50
+ | `#maquina_stream_seal!(status: :complete)` | the status Symbol it sealed with | `stream_status` |
51
+ | `#maquina_stream_target` | the Turbo broadcast target | none — it calls `stream_for:` |
52
+
53
+ `stream_status` holds `open` while the stream is being written; every other
54
+ value is a seal. The four seal statuses are `:complete`, `:cancelled`,
55
+ `:errored` and `:timed_out`. A stream that timed out is not one that errored:
56
+ nothing went wrong, the model simply stopped answering, and the partial text is
57
+ still worth keeping and replaying.
58
+
59
+ ### When your columns are named differently
60
+
61
+ The macro includes a module, so anything you define in the class body wins:
62
+
63
+ ```ruby
64
+ class Message < ApplicationRecord
65
+ include MaquinaStream::Streamable
66
+ maquina_stream buffer: :body, stream_for: ->(m) { [m.chat, :messages] }
67
+
68
+ def maquina_stream_sequence = frame_number
69
+ def maquina_stream_open? = finished_at.nil?
70
+ def maquina_stream_seal!(status: :complete) = update!(finished_at: Time.current, outcome: status)
71
+ end
72
+ ```
73
+
74
+ A model that cannot use the macro at all implements the nine methods itself and
75
+ never includes the concern.
76
+
77
+ ### When the contract is unmet
78
+
79
+ Calling a method whose column is missing raises `MaquinaStream::ContractError`,
80
+ naming the method, the column and the class:
81
+
82
+ ```
83
+ Note does not satisfy MaquinaStream::Streamable: #maquina_stream_sequence
84
+ needs a `stream_sequence` column, and Note has none. Add the column,
85
+ or define #maquina_stream_sequence on Note yourself.
86
+ ```
87
+
88
+ `Message.maquina_stream_contract_gaps` answers the same question without calling
89
+ anything, and is worth one assertion in your own suite:
90
+
91
+ ```ruby
92
+ assert_empty Message.maquina_stream_contract_gaps # => []
93
+ ```
94
+
95
+ ### The broadcast target
96
+
97
+ `stream_for:` receives the record and returns whatever `Turbo::StreamsChannel`
98
+ accepts as a stream name — a record, an array, a string:
99
+
100
+ ```ruby
101
+ maquina_stream stream_for: ->(m) { [:conversation, m.conversation_id, :messages] }
102
+ ```
103
+
104
+ The page subscribes to the same thing:
105
+
106
+ ```erb
107
+ <%= turbo_stream_from :conversation, @conversation.id, :messages %>
108
+ ```
109
+
110
+ The stream name is yours, which means who may subscribe is yours too. Sign or
111
+ scope it exactly as you would any other Turbo stream. If `stream_for:` is
112
+ missing or not callable, `#maquina_stream_target` raises rather than guessing.
113
+
114
+ ## The broadcaster
115
+
116
+ ```ruby
117
+ broadcaster = MaquinaStream::Broadcaster.new(message)
118
+ model.stream { |token| broadcaster.append(token) }
119
+ broadcaster.seal!
120
+ ```
121
+
122
+ `#append(text)` appends to your column — the host owns persistence, so it writes
123
+ first and only then has something to broadcast — and emits a frame if the frame
124
+ budget has elapsed. It returns the `Frame` that went out, or `nil` when this
125
+ append was coalesced into the next one.
126
+
127
+ `#broadcast` emits without appending, for a buffer that moved by some other
128
+ route. `#seal!(status:)` seals the record and emits the final frame.
129
+
130
+ **One broadcaster per stream, held for the life of that stream.** What the
131
+ browser already has lives in the instance, so a fresh broadcaster mid-stream
132
+ re-sends every block. It is not thread-safe; drive one stream from one place.
133
+
134
+ ### What a frame carries
135
+
136
+ A frame appends the blocks the browser has never seen and patches the open tail,
137
+ and nothing else. A block that has not changed is never re-sent, which is the
138
+ difference between bandwidth tracking drift and bandwidth tracking message
139
+ length.
140
+
141
+ ```ruby
142
+ b = MaquinaStream::Broadcaster.new(message)
143
+ b.append("# Informe\n\n")
144
+ b.append("Todo bien.\n\n")
145
+ b.append("Segunda parte.\n")
146
+ b.seal!
147
+ ```
148
+
149
+ ```
150
+ seq=1 final=false appends=["ms-1-b0"] patch=[]
151
+ seq=2 final=false appends=["ms-1-b1"] patch=[]
152
+ seq=3 final=false appends=["ms-1-b2"] patch=[]
153
+ seq=4 final=true appends=[] patch=[]
154
+ ```
155
+
156
+ Over the wire, appends go out as `broadcast_append_to` against
157
+ `#ms-msg-<sid>`; patches as `broadcast_replace_to` with `method: "morph"`, so
158
+ idiomorph patches the node in place instead of recreating it. Every stream
159
+ action carries `data-ms-seq` and `data-ms-frame`, and the final one is marked
160
+ `final`.
161
+
162
+ Block ids are index-derived (`ms-<sid>-b<n>`), never content-derived, because
163
+ idiomorph keys on `id` and a content-derived id makes morph delete and recreate.
164
+
165
+ ### Coalescing
166
+
167
+ Frames inside `frame_budget_ms` accumulate instead of going out one per token,
168
+ and the coalescing happens *before* the render rather than after it: building a
169
+ frame means rendering the whole buffer, so doing that per token and throwing the
170
+ result away is how a stream becomes quadratic in message length.
171
+
172
+ Skipping a frame costs nothing. The next one is computed against what the
173
+ browser actually has, so it carries the accumulated difference. Choosing the
174
+ budget is in [configuration.md](configuration.md).
175
+
176
+ ### Deltas are an optimization
177
+
178
+ Only the open tail is patched. A block that changes after it has stopped being
179
+ the tail — a heading that completes as the paragraph below it begins — is left
180
+ for the repair path, which fixes it for free because that is a *content* change
181
+ and content is exactly what a manifest digest covers. See [repair.md](repair.md).
182
+
183
+ ## Sealing
184
+
185
+ **Always seal, including when the stream failed.** The client has no other way
186
+ to learn the stream is over, and the final frame is never coalesced and never
187
+ skipped: it is what makes every intra-stream drift cosmetic and self-correcting.
188
+
189
+ ```ruby
190
+ def stream_from(chat, prompt, broadcaster: MaquinaStream::Broadcaster.new(self))
191
+ chat.ask(prompt) { |chunk| broadcaster.append(chunk.content.to_s) }
192
+ broadcaster.seal!
193
+ rescue
194
+ broadcaster.seal!(status: :errored)
195
+ raise
196
+ end
197
+ ```
198
+
199
+ A run that raises somewhere else — a timeout, a refusal, an endpoint that went
200
+ away — can leave records still open, and an open record is a client waiting for
201
+ a frame that is never coming. Only you know the run ended, so only you can send
202
+ that frame:
203
+
204
+ ```ruby
205
+ def self.seal_abandoned!(conversation_id:)
206
+ where(conversation_id: conversation_id, stream_status: "open").each do |record|
207
+ MaquinaStream::Broadcaster.new(record).seal!(status: :errored)
208
+ record.broadcast_shell
209
+ end
210
+ end
211
+ ```
212
+
213
+ Sealing through `Broadcaster#seal!` emits the final frame. Calling
214
+ `record.maquina_stream_seal!` directly only records the status — use that when
215
+ you mean to close a stream silently.
216
+
217
+ `Message#broadcast_shell` in the dummy app shows the other half: re-broadcasting
218
+ the message wrapper as a morph once it seals, which is what takes
219
+ `data-ms-streaming` off, stops the reveal and re-enables the controls.
220
+
221
+ ## Streaming an agent run
222
+
223
+ An agent run is a loop — think, call a tool, read the result, continue. **A tool
224
+ call is its own `Streamable` record**, not a block inside the assistant's
225
+ message.
226
+
227
+ ```ruby
228
+ thinking = Message.create!(conversation_id: id, role: "assistant")
229
+ tool = Message.create!(conversation_id: id, role: "tool", tool_name: "read_file")
230
+ ```
231
+
232
+ - Each step seals independently, with its own status. A tool call that errors
233
+ does not mark the reasoning before it as errored.
234
+ - Each step has its own sequence and its own manifest, so a lost frame in one
235
+ step never drags another into a repair.
236
+ - Steps of one conversation **share a cable stream** — that is what `stream_for:`
237
+ is for. Their frames never collide, because block ids are namespaced by
238
+ message id.
239
+ - Two streams open at once is what a parallel tool call *is*, and there is no
240
+ other way to represent it.
241
+ - A late tool result is simply its own stream, still open, sealing when it
242
+ finishes. Held inside one buffer it would instead rewrite blocks in the middle
243
+ of a message whose tail has already moved on — which is exactly the case
244
+ `seal_lag` cannot cover, because the lag protects the last few blocks, not one
245
+ twenty back.
246
+
247
+ One caveat: a markdown link reference cannot cross records. If step one writes
248
+ `[docs][ref]` and step three defines `[ref]:`, the link never resolves, because
249
+ each record is rendered on its own. If a run's steps genuinely share link
250
+ references, keep them in one record.
251
+
252
+ `Message.stream_agent_run` in `test/dummy/app/models/message.rb` is a worked
253
+ example against a real [Nexo](https://maquina.app/documentation/nexo/) agent,
254
+ and `/harness/agent` runs it. Nexo reports tool activity through the block
255
+ `Agent#prompt` takes, so each `:tool_call` opens a record and each
256
+ `:tool_result` seals one. Nothing in the engine depends on it — any client
257
+ that hands you text as it arrives works the same way.
258
+
259
+ ## History, and rendering a sealed message
260
+
261
+ A sealed message is immutable, so render it through the cache:
262
+
263
+ ```erb
264
+ <%= MaquinaStream.render(message) %>
265
+ ```
266
+
267
+ ```html
268
+ <h1 id="ms-1-b0" data-ms-element="h1" data-ms-block data-ms-block-digest="bebc578f97924e6b">Informe…</h1>
269
+ <p id="ms-1-b1" data-ms-element="p" data-ms-block data-ms-block-digest="1bc2ef335d92b2a5">Todo bien.</p>
270
+ <p id="ms-1-b2" data-ms-element="p" data-ms-block data-ms-block-digest="453593ffc6cfefe9">Segunda parte.</p>
271
+ ```
272
+
273
+ Cached by buffer digest once sealed, rendered live while open, and a new key if
274
+ you edit the message. Re-rendering fifty finished messages on every page load is
275
+ work nobody asked for.
276
+
277
+ Pagination is yours — which messages, in what order. `/history` in the dummy app
278
+ shows one pattern: a lazy Turbo Frame at the top of each page loads the page
279
+ above it on scroll, so history grows upward without a pagination bar.
280
+
281
+ Live, reload, replay and export produce the same document byte for byte. There
282
+ is no separate "streaming mode" output to reconcile against a "static" one.
283
+
284
+ ## Export
285
+
286
+ ```ruby
287
+ MaquinaStream::Export.markdown(message)
288
+ ```
289
+
290
+ The buffer already *is* markdown, so export is mostly a question of the parts
291
+ that are not clean. A cancelled stream ends mid-token, so the buffer is repaired
292
+ first with the same preprocessor the renderer uses — an export matches what was
293
+ on screen:
294
+
295
+ ```ruby
296
+ message.content # => "Un **inform"
297
+ MaquinaStream::Export.markdown(message)
298
+ # => "Un **inform**\n\n> _Respuesta cancelada antes de terminar._\n"
299
+ ```
300
+
301
+ Anything that did not finish gets a status footer, because a cancelled message
302
+ that exports as though it were complete is a lie in a file somebody keeps. Pass
303
+ `annotate: false` to suppress it — for a caller re-ingesting the text that will
304
+ carry the status some other way.
305
+
306
+ Deferred content exports as its source: a diagram exports as its `mermaid`
307
+ fence, verbatim, because that is what the model wrote and what another tool can
308
+ read.
@@ -0,0 +1,26 @@
1
+ Description:
2
+ Wires maquina_stream into a host application: the initializer, the engine
3
+ mount, the importmap pins, the Stimulus registration and the stylesheets.
4
+
5
+ It never overwrites a file. Anything already in place is reported and left
6
+ alone, so re-running is safe.
7
+
8
+ Two seams are deliberately left stubbed — `find_stream` and `authorize`.
9
+ The engine cannot guess a host's authorization, and a guessed one is how an
10
+ engine leaks other people's messages. Until `authorize` is written, every
11
+ repair request is refused.
12
+
13
+ Example:
14
+ bin/rails generate maquina_stream:install
15
+
16
+ Creates or reports:
17
+ config/initializers/maquina_stream.rb
18
+ config/routes.rb mount MaquinaStream::Engine
19
+ config/importmap.rb @hotwired/turbo-rails pin
20
+ app/javascript/controllers/index.js registerMaquinaStreamControllers
21
+ app/views/layouts/application.html.erb the three stylesheet links
22
+
23
+ bin/rails generate maquina_stream:install --deferred-renderers
24
+
25
+ Also pins mermaid and katex with `preload: false`, for the client-deferred
26
+ diagram and math renderers.
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module MaquinaStream
6
+ module Generators
7
+ # Wires the engine into a host: the initializer, the mount, the importmap
8
+ # pins, the Stimulus registration and the stylesheets.
9
+ #
10
+ # ```sh
11
+ # bin/rails generate maquina_stream:install
12
+ # ```
13
+ #
14
+ # Every step is idempotent and none of them overwrites a host file. A step
15
+ # whose ingredient is missing — no importmap, no Stimulus entrypoint, no
16
+ # layout — says what to do by hand instead of failing or silently doing
17
+ # nothing, because a generator that no-ops quietly is worse than one that
18
+ # is not there.
19
+ #
20
+ # Two things it exists to prevent, both learned the hard way:
21
+ #
22
+ # - **Turbo must be pinned by the host.** Repair applies Turbo Stream
23
+ # morphs; with `window.Turbo` undefined every repair fails inside a catch
24
+ # and nothing in the browser says so.
25
+ # - **A deferred-renderer pin needs `preload: false`.** importmap-rails
26
+ # preloads by default, which fetches the library on every page and
27
+ # defeats the lazy import it exists to avoid.
28
+ class InstallGenerator < Rails::Generators::Base
29
+ source_root File.expand_path("templates", __dir__)
30
+
31
+ desc "Wires maquina_stream into this application: initializer, mount, importmap, Stimulus, stylesheets."
32
+
33
+ class_option :deferred_renderers, type: :boolean, default: false,
34
+ desc: "Also pin mermaid and katex (preload: false) for the diagram and math renderers"
35
+
36
+ INITIALIZER = "config/initializers/maquina_stream.rb"
37
+ ROUTES = "config/routes.rb"
38
+ IMPORTMAP = "config/importmap.rb"
39
+ LAYOUT = "app/views/layouts/application.html.erb"
40
+
41
+ # Where `registerMaquinaStreamControllers(application)` can go, best
42
+ # first. Both have an `application` in scope in a stock importmap app.
43
+ ENTRYPOINTS = %w[
44
+ app/javascript/controllers/index.js
45
+ app/javascript/application.js
46
+ ].freeze
47
+
48
+ MOUNT = 'mount MaquinaStream::Engine => "/maquina_stream"'
49
+
50
+ TURBO_PIN = <<~RUBY
51
+ # Required by maquina_stream: repair applies Turbo Stream morphs, and with
52
+ # `window.Turbo` undefined every repair fails silently inside a catch.
53
+ pin "@hotwired/turbo-rails", to: "turbo.min.js"
54
+ RUBY
55
+
56
+ DEFERRED_PINS = <<~RUBY
57
+ # Libraries the client-deferred renderers import lazily. `preload: false` is
58
+ # load-bearing: importmap-rails preloads by default, which would emit a
59
+ # <link rel="modulepreload"> and fetch both on every page — exactly the cost
60
+ # the lazy import inside `ms-deferred#library()` exists to avoid.
61
+ pin "mermaid", to: "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/+esm", preload: false
62
+ pin "katex", to: "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.mjs", preload: false
63
+ RUBY
64
+
65
+ REGISTRATION = <<~JS
66
+ // maquina_stream registers its own Stimulus identifiers rather than relying
67
+ // on an eager-load glob: those identifiers are part of the DOM contract.
68
+ import { registerMaquinaStreamControllers } from "maquina_stream"
69
+ registerMaquinaStreamControllers(application)
70
+ JS
71
+
72
+ STYLESHEETS = <<~ERB
73
+ <%= stylesheet_link_tag "maquina_stream/reveal" %>
74
+ <%= stylesheet_link_tag "maquina_stream/themes/light" %>
75
+ <%= stylesheet_link_tag "maquina_stream/themes/dark" %>
76
+ ERB
77
+
78
+ def create_initializer
79
+ if exists?(INITIALIZER)
80
+ skip INITIALIZER, "already exists — left untouched"
81
+ else
82
+ template "initializer.rb.tt", INITIALIZER
83
+ end
84
+ end
85
+
86
+ def mount_engine
87
+ return by_hand(ROUTES, MOUNT) unless exists?(ROUTES)
88
+ return skip(ROUTES, "engine already mounted") if read(ROUTES).include?("MaquinaStream::Engine")
89
+
90
+ route MOUNT
91
+ end
92
+
93
+ def pin_turbo
94
+ return by_hand(IMPORTMAP, TURBO_PIN, importmap_absent_note) unless exists?(IMPORTMAP)
95
+ return skip(IMPORTMAP, "@hotwired/turbo-rails already pinned") if read(IMPORTMAP).include?("@hotwired/turbo-rails")
96
+ return turbo_missing unless turbo_available?
97
+
98
+ append_to_file IMPORTMAP, "\n#{TURBO_PIN}"
99
+ end
100
+
101
+ def pin_deferred_renderers
102
+ return unless options[:deferred_renderers]
103
+ return by_hand(IMPORTMAP, DEFERRED_PINS) unless exists?(IMPORTMAP)
104
+ return skip(IMPORTMAP, "mermaid and katex already pinned") if read(IMPORTMAP).include?('pin "mermaid"')
105
+
106
+ append_to_file IMPORTMAP, "\n#{DEFERRED_PINS}"
107
+ end
108
+
109
+ def register_controllers
110
+ entrypoint = ENTRYPOINTS.find { |path| exists?(path) && read(path).match?(/\bapplication\b/) }
111
+ return by_hand(ENTRYPOINTS.first, REGISTRATION, entrypoint_absent_note) if entrypoint.nil?
112
+ return skip(entrypoint, "controllers already registered") if read(entrypoint).include?("registerMaquinaStreamControllers")
113
+
114
+ append_to_file entrypoint, "\n#{REGISTRATION}"
115
+ end
116
+
117
+ def link_stylesheets
118
+ return by_hand(LAYOUT, STYLESHEETS) unless exists?(LAYOUT)
119
+ return skip(LAYOUT, "stylesheets already linked") if read(LAYOUT).include?("maquina_stream/reveal")
120
+
121
+ inject_into_file LAYOUT, STYLESHEETS.gsub(/^/, " "), before: %r{^\s*</head>}
122
+ end
123
+
124
+ def report_the_two_seams
125
+ say ""
126
+ say "maquina_stream is wired. Two things are still yours:", :green
127
+ say ""
128
+ say " 1. #{INITIALIZER} — `authorize` is a stub that denies everything."
129
+ say " Until you replace it every repair request is refused, which is safe"
130
+ say " and also broken: the browser can never repair a message."
131
+ say ""
132
+ say " 2. bin/rails generate maquina_stream:streamable Message"
133
+ say " — the migration and the model macro, and it fills in `find_stream`."
134
+ say ""
135
+ end
136
+
137
+ private
138
+ def exists?(path)
139
+ File.exist?(File.join(destination_root, path))
140
+ end
141
+
142
+ def read(path)
143
+ File.read(File.join(destination_root, path))
144
+ end
145
+
146
+ def skip(path, why)
147
+ say_status :skip, "#{path}: #{why}", :yellow
148
+ end
149
+
150
+ # A missing ingredient is reported loudly and with the exact content to
151
+ # paste. Silence here is what left our own dummy app without Turbo.
152
+ def by_hand(path, content, note = nil)
153
+ say_status :"by hand", path, :red
154
+ say note if note
155
+ say ""
156
+ say content.gsub(/^/, " ")
157
+ say ""
158
+ end
159
+
160
+ # Turbo is the host's pin, but pinning it against a gem that is not
161
+ # there produces a 404 on every page — so it is only added when the
162
+ # host's own Gemfile has turbo-rails. The question is about the
163
+ # application being generated into, not about this process.
164
+ def turbo_available?
165
+ gemfile.match?(/^\s*gem ["']turbo-rails["']/)
166
+ end
167
+
168
+ def gemfile
169
+ exists?("Gemfile") ? read("Gemfile") : ""
170
+ end
171
+
172
+ def turbo_missing
173
+ say_status :error, "turbo-rails is not in this application", :red
174
+ say <<~MESSAGE
175
+
176
+ maquina_stream requires Turbo. Repair applies Turbo Stream morphs, and
177
+ with `window.Turbo` undefined every repair fails silently inside a catch:
178
+ the message simply stops being correct and nothing says so.
179
+
180
+ bundle add turbo-rails
181
+ bin/rails turbo:install
182
+ bin/rails generate maquina_stream:install
183
+
184
+ MESSAGE
185
+ end
186
+
187
+ def importmap_absent_note
188
+ "No config/importmap.rb. maquina_stream ships JavaScript by importmap and " \
189
+ "has no build step; with a bundler, import its source from app/javascript " \
190
+ "in the gem. Either way Turbo has to be loaded:"
191
+ end
192
+
193
+ def entrypoint_absent_note
194
+ "No Stimulus entrypoint with an `application` in scope. Add this wherever " \
195
+ "you call `Application.start()`:"
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ # maquina_stream. Every option is listed with the default it already has, so
4
+ # uncomment only what you are changing. The full reference, with the measured
5
+ # cost of each cadence setting, is in docs/configuration.md.
6
+ MaquinaStream.configure do |c|
7
+ # ---------------------------------------------------------------------------
8
+ # Host seams. The engine resolves nothing and authorizes nothing on its own,
9
+ # and the two below are the reason. Both are stubs: fill them in.
10
+ # ---------------------------------------------------------------------------
11
+
12
+ # Turns a stream id into a record. `rails g maquina_stream:streamable Message`
13
+ # rewrites this line for you; until then it raises, which is what an unset
14
+ # `find_stream` does too — a silent nil would look like a missing record
15
+ # rather than a missing seam.
16
+ c.find_stream = ->(sid) { raise NotImplementedError, "maquina_stream: c.find_stream is still the generated stub" }
17
+
18
+ # STUB, AND THE ONE THING THIS GENERATOR WILL NOT GUESS FOR YOU.
19
+ #
20
+ # As written it denies every repair request — exactly what an unset
21
+ # `authorize` does. That is the safe failure and a broken feature both: the
22
+ # browser can never repair a message until you replace it, and a message that
23
+ # loses a frame stays wrong until the page reloads.
24
+ #
25
+ # It receives the record `find_stream` returned and the ActionDispatch
26
+ # request, and answers whether this request may see this record. Something
27
+ # like:
28
+ #
29
+ # c.authorize = ->(record, request) do
30
+ # record.conversation.member?(request.session[:user_id])
31
+ # end
32
+ #
33
+ # Guessing this is how an engine leaks other people's messages, so it guesses
34
+ # nothing.
35
+ c.authorize = ->(record, request) { false }
36
+
37
+ # Which transport Broadcaster's default emitter uses. `:turbo_streams` is the
38
+ # only value the engine ships; the seam exists so SSE is possible without the
39
+ # broadcaster knowing about it.
40
+ # c.transport = :turbo_streams
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Streaming cadence
44
+ # ---------------------------------------------------------------------------
45
+
46
+ # How long appends coalesce before one frame goes out. The one number worth
47
+ # thinking about: if your provider streams token by token you pay 2.34x the
48
+ # document size at 100ms and 1.08x at 250ms, and the reveal animation covers
49
+ # the coarser cadence. If your text arrives in batches or whole steps,
50
+ # raising it buys nothing. Measurements in docs/configuration.md.
51
+ # c.frame_budget_ms = 100
52
+
53
+ # How many blocks must open after a block before it may freeze. Markdown
54
+ # reinterprets backwards — a paragraph becomes a heading when its underline
55
+ # arrives — so a block near the tail is still moving.
56
+ # c.seal_lag = 2
57
+
58
+ # How often the client reconciles its DOM against the manifest, in ms.
59
+ # c.keyframe_interval_ms = 4_000
60
+
61
+ # How many recent sealed blocks a manifest carries in full. Everything older
62
+ # is covered by one rollup digest, which is what bounds the payload by the
63
+ # window instead of by the message.
64
+ # c.manifest_window = 50
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Presentation
68
+ # ---------------------------------------------------------------------------
69
+
70
+ # Fallback locale for the engine's own labels when I18n.locale is unset.
71
+ # Spanish and English both ship complete.
72
+ # c.locale = :es
73
+
74
+ # `:maquina` renders through maquina_components when that gem is installed
75
+ # and defines a component; `:plain` forces the engine's own Tailwind fallback
76
+ # even when the gem is present.
77
+ # c.components = :maquina
78
+
79
+ # Rouge theme names for the two generated highlighting stylesheets. Names are
80
+ # Rouge's own — `github.light`, not `github_light`. Changing them means
81
+ # re-running `bin/rails maquina_stream:themes`.
82
+ # c.themes = {light: "github.light", dark: "github.dark"}
83
+
84
+ # Which interactive affordances render. A hash merges onto the defaults one
85
+ # level deep; `false` turns everything off, `true` turns everything back on.
86
+ # c.controls = {code: {download: false}}
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # URL hardening. Read by the sanitizer, the last pass before any HTML leaves
90
+ # the server. Every one of these loosens or tightens what a *model* may put
91
+ # in an href or a src, and model output is prompt-injectable.
92
+ # ---------------------------------------------------------------------------
93
+
94
+ # Base for resolving relative URLs. nil leaves a relative URL relative.
95
+ # c.default_origin = nil
96
+
97
+ # The only schemes that survive.
98
+ # c.allowed_protocols = %w[http https mailto]
99
+
100
+ # "*" allows any destination. A list of prefixes strips the href off every
101
+ # link not starting with one; the text stays.
102
+ # c.allowed_link_prefixes = ["*"]
103
+
104
+ # The same, for images. An image whose src does not survive is removed
105
+ # entirely: a broken rectangle carrying an attacker-chosen alt is worse than
106
+ # nothing.
107
+ # c.allowed_image_prefixes = ["*"]
108
+
109
+ # Whether data: image URLs survive. Only base64 rasters ever do;
110
+ # data:image/svg+xml is refused whatever this is set to.
111
+ # c.allow_data_images = true
112
+ end
113
+
114
+ # Fences and tags the renderer should know about beyond CommonMark. See
115
+ # docs/registries.md and docs/deferred-renderers.md.
116
+ #
117
+ # MaquinaStream.register_fence "ruby", strategy: :server
118
+ # MaquinaStream.register_fence "mermaid",
119
+ # strategy: :client,
120
+ # controller: "ms-diagram",
121
+ # payload: ->(source, info) { {source: source, info: info} }
@@ -0,0 +1,28 @@
1
+ Description:
2
+ Makes one model streamable: the migration carrying the columns the
3
+ Streamable contract requires, and the `include` plus `maquina_stream` macro
4
+ in the model itself.
5
+
6
+ Per model, because a host may have several — an assistant message and a
7
+ tool call are two streams, not one.
8
+
9
+ The migration's columns come from MaquinaStream::Streamable itself, so they
10
+ cannot drift from the contract `maquina_stream_contract_gaps` checks.
11
+
12
+ Example:
13
+ bin/rails generate maquina_stream:streamable Message
14
+
15
+ Creates or reports:
16
+ db/migrate/XXXXXXXX_add_maquina_stream_to_messages.rb
17
+ app/models/message.rb include + maquina_stream
18
+ config/initializers/maquina_stream.rb fills in the find_stream stub
19
+
20
+ bin/rails generate maquina_stream:streamable Message --buffer=body
21
+
22
+ Names a different column as the markdown buffer.
23
+
24
+ bin/rails generate maquina_stream:streamable Message \
25
+ --stream-for="[:conversation, message.conversation_id, :messages]"
26
+
27
+ Sets the Turbo broadcast target. The engine never guesses one: who may
28
+ subscribe to a stream is your question, not the engine's.