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.
- checksums.yaml +7 -0
- data/.rdoc_options +30 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE.txt +21 -0
- data/README.md +163 -0
- data/app/assets/stylesheets/maquina_stream/components/attachment.css +35 -0
- data/app/assets/stylesheets/maquina_stream/components/code_block.css +30 -0
- data/app/assets/stylesheets/maquina_stream/components/shimmer.css +31 -0
- data/app/assets/stylesheets/maquina_stream/components/snippet.css +22 -0
- data/app/assets/stylesheets/maquina_stream/components/source_citation.css +14 -0
- data/app/assets/stylesheets/maquina_stream/components/suggestion.css +32 -0
- data/app/assets/stylesheets/maquina_stream/reveal.css +48 -0
- data/app/assets/stylesheets/maquina_stream/themes/dark.css +235 -0
- data/app/assets/stylesheets/maquina_stream/themes/light.css +117 -0
- data/app/controllers/maquina_stream/application_controller.rb +21 -0
- data/app/controllers/maquina_stream/blocks_controller.rb +41 -0
- data/app/controllers/maquina_stream/manifests_controller.rb +15 -0
- data/app/helpers/maquina_stream/components_helper.rb +80 -0
- data/app/javascript/maquina_stream/controllers/application_controller.js +169 -0
- data/app/javascript/maquina_stream/controllers/ms_autoscroll_controller.js +110 -0
- data/app/javascript/maquina_stream/controllers/ms_code_controller.js +96 -0
- data/app/javascript/maquina_stream/controllers/ms_deferred_controller.js +223 -0
- data/app/javascript/maquina_stream/controllers/ms_diagram_controller.js +40 -0
- data/app/javascript/maquina_stream/controllers/ms_link_safety_controller.js +196 -0
- data/app/javascript/maquina_stream/controllers/ms_math_controller.js +32 -0
- data/app/javascript/maquina_stream/controllers/ms_repair_controller.js +167 -0
- data/app/javascript/maquina_stream/controllers/ms_reveal_controller.js +320 -0
- data/app/javascript/maquina_stream/controllers/ms_table_controller.js +183 -0
- data/app/javascript/maquina_stream/index.js +59 -0
- data/app/views/maquina_stream/components/_attachment.html.erb +139 -0
- data/app/views/maquina_stream/components/_code_block.html.erb +74 -0
- data/app/views/maquina_stream/components/_shimmer.html.erb +36 -0
- data/app/views/maquina_stream/components/_snippet.html.erb +50 -0
- data/app/views/maquina_stream/components/_source_citation.html.erb +42 -0
- data/app/views/maquina_stream/components/_suggestion.html.erb +73 -0
- data/config/importmap.rb +10 -0
- data/config/locales/en.yml +79 -0
- data/config/locales/es.yml +82 -0
- data/config/routes.rb +11 -0
- data/docs/configuration.md +219 -0
- data/docs/deferred-renderers.md +184 -0
- data/docs/getting-started.md +356 -0
- data/docs/javascript.md +298 -0
- data/docs/registries.md +283 -0
- data/docs/repair.md +162 -0
- data/docs/security.md +247 -0
- data/docs/streaming.md +308 -0
- data/lib/generators/maquina_stream/install/USAGE +26 -0
- data/lib/generators/maquina_stream/install/install_generator.rb +199 -0
- data/lib/generators/maquina_stream/install/templates/initializer.rb.tt +121 -0
- data/lib/generators/maquina_stream/streamable/USAGE +28 -0
- data/lib/generators/maquina_stream/streamable/streamable_generator.rb +187 -0
- data/lib/generators/maquina_stream/streamable/templates/migration.rb.tt +21 -0
- data/lib/generators/maquina_stream/streamable/templates/model.rb.tt +4 -0
- data/lib/maquina_stream/block.rb +99 -0
- data/lib/maquina_stream/broadcaster.rb +233 -0
- data/lib/maquina_stream/component_cache.rb +0 -0
- data/lib/maquina_stream/components/contract.rb +184 -0
- data/lib/maquina_stream/components.rb +135 -0
- data/lib/maquina_stream/configuration.rb +240 -0
- data/lib/maquina_stream/document.rb +296 -0
- data/lib/maquina_stream/engine.rb +46 -0
- data/lib/maquina_stream/errors.rb +17 -0
- data/lib/maquina_stream/export.rb +66 -0
- data/lib/maquina_stream/frame.rb +73 -0
- data/lib/maquina_stream/manifest.rb +137 -0
- data/lib/maquina_stream/registries.rb +116 -0
- data/lib/maquina_stream/renderer/fence.rb +115 -0
- data/lib/maquina_stream/renderer/post_pass.rb +363 -0
- data/lib/maquina_stream/renderer/tag_blocks.rb +276 -0
- data/lib/maquina_stream/renderer/view_context.rb +72 -0
- data/lib/maquina_stream/renderer.rb +128 -0
- data/lib/maquina_stream/sanitizer.rb +392 -0
- data/lib/maquina_stream/streamable.rb +281 -0
- data/lib/maquina_stream/text_direction.rb +56 -0
- data/lib/maquina_stream/themes.rb +84 -0
- data/lib/maquina_stream/version.rb +5 -0
- data/lib/maquina_stream.rb +175 -0
- 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.
|