envoy_ai 0.0.1

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 (55) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +22 -0
  3. data/README.md +399 -0
  4. data/app/assets/stylesheets/envoy/console.css +371 -0
  5. data/app/controllers/envoy/application_controller.rb +18 -0
  6. data/app/controllers/envoy/conversations_controller.rb +36 -0
  7. data/app/controllers/envoy/messages_controller.rb +14 -0
  8. data/app/controllers/envoy/system_prompts_controller.rb +52 -0
  9. data/app/javascript/envoy/controllers/autoscroll_controller.js +46 -0
  10. data/app/javascript/envoy/controllers/composer_controller.js +30 -0
  11. data/app/jobs/envoy/application_job.rb +4 -0
  12. data/app/jobs/envoy/run_job.rb +65 -0
  13. data/app/models/envoy/application_record.rb +5 -0
  14. data/app/models/envoy/conversation.rb +20 -0
  15. data/app/models/envoy/message.rb +8 -0
  16. data/app/models/envoy/system_prompt.rb +24 -0
  17. data/app/models/envoy/system_prompt_version.rb +14 -0
  18. data/app/models/envoy/tool_call.rb +28 -0
  19. data/app/views/envoy/conversations/_composer_input.html.erb +4 -0
  20. data/app/views/envoy/conversations/index.html.erb +15 -0
  21. data/app/views/envoy/conversations/new.html.erb +19 -0
  22. data/app/views/envoy/conversations/show.html.erb +18 -0
  23. data/app/views/envoy/messages/_message.html.erb +7 -0
  24. data/app/views/envoy/messages/_streaming.html.erb +6 -0
  25. data/app/views/envoy/messages/_tool_call.html.erb +8 -0
  26. data/app/views/envoy/messages/create.turbo_stream.erb +25 -0
  27. data/app/views/envoy/system_prompts/_form.html.erb +14 -0
  28. data/app/views/envoy/system_prompts/edit.html.erb +2 -0
  29. data/app/views/envoy/system_prompts/index.html.erb +14 -0
  30. data/app/views/envoy/system_prompts/new.html.erb +2 -0
  31. data/app/views/envoy/system_prompts/show.html.erb +15 -0
  32. data/app/views/layouts/envoy/application.html.erb +20 -0
  33. data/config/routes.rb +7 -0
  34. data/db/migrate/20260710000001_create_envoy_conversations.rb +14 -0
  35. data/db/migrate/20260710000002_create_envoy_messages.rb +14 -0
  36. data/db/migrate/20260710000003_create_envoy_tool_calls.rb +14 -0
  37. data/db/migrate/20260711000001_create_envoy_system_prompts.rb +10 -0
  38. data/db/migrate/20260711000002_create_envoy_system_prompt_versions.rb +13 -0
  39. data/db/migrate/20260711000003_add_system_prompt_version_to_envoy_conversations.rb +6 -0
  40. data/lib/envoy/badges.rb +22 -0
  41. data/lib/envoy/compiled_tool.rb +43 -0
  42. data/lib/envoy/configuration.rb +21 -0
  43. data/lib/envoy/engine.rb +19 -0
  44. data/lib/envoy/errors.rb +6 -0
  45. data/lib/envoy/guard.rb +28 -0
  46. data/lib/envoy/llm.rb +24 -0
  47. data/lib/envoy/markdown.rb +16 -0
  48. data/lib/envoy/runner.rb +44 -0
  49. data/lib/envoy/testing/fake_llm.rb +41 -0
  50. data/lib/envoy/tool_definition.rb +38 -0
  51. data/lib/envoy/toolset.rb +72 -0
  52. data/lib/envoy/version.rb +3 -0
  53. data/lib/envoy.rb +45 -0
  54. data/lib/envoy_ai.rb +3 -0
  55. metadata +138 -0
@@ -0,0 +1,371 @@
1
+ /*
2
+ * Envoy debug console — self-contained stylesheet.
3
+ *
4
+ * The console ships its own presentation so it renders correctly in ANY
5
+ * host app, Tailwind or not. No Tailwind utilities are used anywhere in the
6
+ * engine's views; every class here is semantic and scoped to `envoy-*`.
7
+ *
8
+ * Dark mode follows the OS preference (there is no in-app theme toggle) via
9
+ * `prefers-color-scheme`. The palette is centralized in CSS custom
10
+ * properties so light/dark stay in one place.
11
+ */
12
+
13
+ :root {
14
+ --envoy-bg: #f9fafb;
15
+ --envoy-surface: #ffffff;
16
+ --envoy-surface-assistant: #eff6ff;
17
+ --envoy-surface-muted: #f9fafb;
18
+ --envoy-ink: #111827;
19
+ --envoy-muted: #6b7280;
20
+ --envoy-border: #e5e7eb;
21
+ --envoy-link: #1d4ed8;
22
+ --envoy-btn-bg: #111827;
23
+ --envoy-btn-ink: #ffffff;
24
+ --envoy-danger: #b91c1c;
25
+ --envoy-header-bg: rgba(255, 255, 255, 0.8);
26
+ }
27
+
28
+ @media (prefers-color-scheme: dark) {
29
+ :root {
30
+ --envoy-bg: #030712;
31
+ --envoy-surface: #111827;
32
+ --envoy-surface-assistant: #1f2937;
33
+ --envoy-surface-muted: #111827;
34
+ --envoy-ink: #f3f4f6;
35
+ --envoy-muted: #9ca3af;
36
+ --envoy-border: #374151;
37
+ --envoy-link: #60a5fa;
38
+ --envoy-btn-bg: #f3f4f6;
39
+ --envoy-btn-ink: #111827;
40
+ --envoy-header-bg: rgba(17, 24, 39, 0.8);
41
+ }
42
+ }
43
+
44
+ /* ---------------------------------------------------------------------- */
45
+ /* Base / layout */
46
+ /* ---------------------------------------------------------------------- */
47
+
48
+ body.envoy-body {
49
+ min-height: 100vh;
50
+ margin: 0;
51
+ background: var(--envoy-bg);
52
+ color: var(--envoy-ink);
53
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
54
+ }
55
+
56
+ .envoy-header {
57
+ position: sticky;
58
+ top: 0;
59
+ z-index: 10;
60
+ border-bottom: 1px solid var(--envoy-border);
61
+ background: var(--envoy-header-bg);
62
+ -webkit-backdrop-filter: blur(8px);
63
+ backdrop-filter: blur(8px);
64
+ }
65
+
66
+ .envoy-header__inner {
67
+ max-width: 48rem;
68
+ margin: 0 auto;
69
+ padding: 0 1rem;
70
+ height: 3rem;
71
+ display: flex;
72
+ align-items: center;
73
+ justify-content: space-between;
74
+ }
75
+
76
+ .envoy-brand {
77
+ font-weight: 600;
78
+ letter-spacing: -0.01em;
79
+ text-decoration: none;
80
+ color: inherit;
81
+ }
82
+ .envoy-brand:hover { opacity: 0.7; }
83
+
84
+ .envoy-navlink {
85
+ font-size: 0.875rem;
86
+ color: var(--envoy-link);
87
+ text-decoration: none;
88
+ }
89
+ .envoy-navlink:hover { text-decoration: underline; }
90
+
91
+ .envoy-main {
92
+ max-width: 48rem;
93
+ margin: 0 auto;
94
+ padding: 1rem;
95
+ }
96
+
97
+ /* ---------------------------------------------------------------------- */
98
+ /* Titles / headings */
99
+ /* ---------------------------------------------------------------------- */
100
+
101
+ .envoy-title {
102
+ font-size: 1.25rem;
103
+ font-weight: 600;
104
+ }
105
+
106
+ .envoy-title--sm {
107
+ font-size: 1.125rem;
108
+ font-weight: 600;
109
+ }
110
+ @media (min-width: 640px) {
111
+ .envoy-title--sm { font-size: 1.25rem; }
112
+ }
113
+
114
+ .envoy-subtitle {
115
+ font-weight: 600;
116
+ margin-bottom: 0.5rem;
117
+ }
118
+
119
+ /* ---------------------------------------------------------------------- */
120
+ /* Header rows / button groups */
121
+ /* ---------------------------------------------------------------------- */
122
+
123
+ .envoy-header-row {
124
+ display: flex;
125
+ flex-wrap: wrap;
126
+ align-items: center;
127
+ justify-content: space-between;
128
+ gap: 0.5rem;
129
+ }
130
+
131
+ .envoy-btn-group {
132
+ display: flex;
133
+ gap: 0.5rem;
134
+ }
135
+
136
+ /* ---------------------------------------------------------------------- */
137
+ /* Spacing helpers */
138
+ /* ---------------------------------------------------------------------- */
139
+
140
+ .envoy-mb-3 { margin-bottom: 0.75rem; }
141
+ .envoy-mb-4 { margin-bottom: 1rem; }
142
+ .envoy-mt-4 { margin-top: 1rem; }
143
+
144
+ .envoy-stack > * + * { margin-top: 1rem; }
145
+ .envoy-stack--sm > * + * { margin-top: 0.75rem; }
146
+
147
+ /* ---------------------------------------------------------------------- */
148
+ /* Messages */
149
+ /* ---------------------------------------------------------------------- */
150
+
151
+ .envoy-msg {
152
+ border: 1px solid var(--envoy-border);
153
+ border-radius: 0.5rem;
154
+ padding: 1rem;
155
+ background: var(--envoy-surface);
156
+ }
157
+ .envoy-msg--assistant { background: var(--envoy-surface-assistant); }
158
+
159
+ .envoy-msg__role {
160
+ font-size: 0.75rem;
161
+ text-transform: uppercase;
162
+ color: var(--envoy-muted);
163
+ margin-bottom: 0.25rem;
164
+ }
165
+
166
+ /* Subtle "working…" shimmer while a reply is being generated. */
167
+ .envoy-working { animation: envoy-working 1.4s ease-in-out infinite; }
168
+ @keyframes envoy-working {
169
+ 0%, 100% { opacity: 0.4; }
170
+ 50% { opacity: 0.95; }
171
+ }
172
+ @media (prefers-reduced-motion: reduce) {
173
+ .envoy-working { animation: none; opacity: 0.7; }
174
+ }
175
+
176
+ /* ---------------------------------------------------------------------- */
177
+ /* Rendered markdown body */
178
+ /* ---------------------------------------------------------------------- */
179
+
180
+ .envoy-md { font-size: 0.9rem; line-height: 1.6; }
181
+ .envoy-md > * + * { margin-top: 0.7rem; }
182
+ .envoy-md h1 { font-size: 1.3em; font-weight: 700; }
183
+ .envoy-md h2 { font-size: 1.15em; font-weight: 700; }
184
+ .envoy-md h3, .envoy-md h4 { font-size: 1em; font-weight: 700; }
185
+ .envoy-md ul { padding-left: 1.25rem; list-style: disc; }
186
+ .envoy-md ol { padding-left: 1.25rem; list-style: decimal; }
187
+ .envoy-md li + li { margin-top: 0.2rem; }
188
+ .envoy-md a { text-decoration: underline; }
189
+ .envoy-md blockquote { border-left: 3px solid rgba(128, 128, 128, 0.4); padding-left: 0.75rem; }
190
+ .envoy-md hr { border: 0; border-top: 1px solid rgba(128, 128, 128, 0.3); }
191
+ /* Fenced code (comrak carries its own dark background + inline styles). */
192
+ .envoy-md pre { padding: 0.75rem 1rem; border-radius: 0.5rem; }
193
+ /* Inline code gets a subtle theme-agnostic chip. */
194
+ .envoy-md :not(pre) > code {
195
+ font-size: 0.9em;
196
+ overflow-wrap: anywhere;
197
+ background: rgba(128, 128, 128, 0.15);
198
+ padding: 0.1em 0.3em;
199
+ border-radius: 0.25rem;
200
+ }
201
+
202
+ /* Code blocks scroll within the message instead of spilling past it, with a
203
+ thin, theme-agnostic overlay scrollbar (mid-gray at 50% reads on both the
204
+ light tool-call panels and the dark highlighted blocks). */
205
+ #envoy_messages pre {
206
+ overflow-x: auto;
207
+ max-width: 100%;
208
+ scrollbar-width: thin;
209
+ scrollbar-color: rgba(128, 128, 128, 0.5) transparent;
210
+ }
211
+ #envoy_messages pre::-webkit-scrollbar { height: 8px; }
212
+ #envoy_messages pre::-webkit-scrollbar-track { background: transparent; }
213
+ #envoy_messages pre::-webkit-scrollbar-thumb {
214
+ background: rgba(128, 128, 128, 0.5);
215
+ border-radius: 9999px;
216
+ }
217
+ #envoy_messages pre::-webkit-scrollbar-thumb:hover { background: rgba(128, 128, 128, 0.8); }
218
+
219
+ /* ---------------------------------------------------------------------- */
220
+ /* Tool calls */
221
+ /* ---------------------------------------------------------------------- */
222
+
223
+ .envoy-tool {
224
+ margin-top: 0.5rem;
225
+ font-size: 0.875rem;
226
+ border: 1px solid var(--envoy-border);
227
+ border-radius: 0.375rem;
228
+ padding: 0.5rem;
229
+ background: var(--envoy-surface-muted);
230
+ }
231
+
232
+ .envoy-tool__summary {
233
+ cursor: pointer;
234
+ display: flex;
235
+ align-items: center;
236
+ gap: 0.5rem;
237
+ }
238
+
239
+ .envoy-tool pre {
240
+ font-size: 0.75rem;
241
+ margin-top: 0.5rem;
242
+ overflow-x: auto;
243
+ max-width: 100%;
244
+ }
245
+
246
+ .envoy-mono {
247
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
248
+ }
249
+
250
+ /* Tool-call outcome badge (rendered by Envoy::Badges.outcome). Colors match
251
+ the original Tailwind red-100/amber-100/green-100 pairs; intentionally
252
+ scheme-independent, as the original badge had no dark variant. */
253
+ .envoy-badge {
254
+ display: inline-block;
255
+ font-size: 0.75rem;
256
+ padding: 0.125rem 0.5rem;
257
+ border-radius: 0.375rem;
258
+ font-weight: 500;
259
+ }
260
+ .envoy-badge--ok { background: #dcfce7; color: #166534; }
261
+ .envoy-badge--failed { background: #fef3c7; color: #92400e; }
262
+ .envoy-badge--denied { background: #fee2e2; color: #991b1b; }
263
+
264
+ /* ---------------------------------------------------------------------- */
265
+ /* Composer */
266
+ /* ---------------------------------------------------------------------- */
267
+
268
+ .envoy-composer {
269
+ display: flex;
270
+ gap: 0.5rem;
271
+ align-items: flex-end;
272
+ }
273
+
274
+ .envoy-input {
275
+ flex: 1;
276
+ border: 1px solid var(--envoy-border);
277
+ border-radius: 0.375rem;
278
+ padding: 0.5rem;
279
+ background: var(--envoy-surface);
280
+ color: var(--envoy-ink);
281
+ }
282
+ .envoy-input::placeholder { color: var(--envoy-muted); opacity: 1; }
283
+
284
+ .envoy-hint {
285
+ font-size: 0.75rem;
286
+ color: var(--envoy-muted);
287
+ margin-top: 0.25rem;
288
+ }
289
+
290
+ /* ---------------------------------------------------------------------- */
291
+ /* Buttons / links */
292
+ /* ---------------------------------------------------------------------- */
293
+
294
+ .envoy-btn {
295
+ padding: 0.375rem 0.75rem;
296
+ border-radius: 0.375rem;
297
+ background: var(--envoy-btn-bg);
298
+ color: var(--envoy-btn-ink);
299
+ border: 0;
300
+ cursor: pointer;
301
+ text-decoration: none;
302
+ display: inline-block;
303
+ flex-shrink: 0;
304
+ font: inherit;
305
+ }
306
+ .envoy-btn--secondary {
307
+ background: transparent;
308
+ color: inherit;
309
+ border: 1px solid var(--envoy-border);
310
+ }
311
+
312
+ .envoy-link {
313
+ color: var(--envoy-link);
314
+ text-decoration: none;
315
+ }
316
+ .envoy-link:hover { text-decoration: underline; }
317
+
318
+ /* ---------------------------------------------------------------------- */
319
+ /* Lists */
320
+ /* ---------------------------------------------------------------------- */
321
+
322
+ .envoy-list { list-style: none; margin: 0; padding: 0; }
323
+ .envoy-list > * + * { border-top: 1px solid var(--envoy-border); }
324
+ .envoy-list__item { padding: 0.5rem 0; }
325
+
326
+ /* ---------------------------------------------------------------------- */
327
+ /* Forms */
328
+ /* ---------------------------------------------------------------------- */
329
+
330
+ .envoy-form > * + * { margin-top: 0.75rem; }
331
+
332
+ .envoy-label {
333
+ display: block;
334
+ font-size: 0.875rem;
335
+ color: var(--envoy-muted);
336
+ margin-bottom: 0.25rem;
337
+ }
338
+
339
+ .envoy-field {
340
+ width: 100%;
341
+ border: 1px solid var(--envoy-border);
342
+ border-radius: 0.375rem;
343
+ padding: 0.5rem;
344
+ background: var(--envoy-surface);
345
+ color: var(--envoy-ink);
346
+ }
347
+ .envoy-field::placeholder { color: var(--envoy-muted); opacity: 1; }
348
+ .envoy-field--mono {
349
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
350
+ font-size: 0.875rem;
351
+ }
352
+
353
+ .envoy-error {
354
+ color: var(--envoy-danger);
355
+ font-size: 0.875rem;
356
+ margin-bottom: 0.75rem;
357
+ }
358
+
359
+ .envoy-prose-box {
360
+ border: 1px solid var(--envoy-border);
361
+ border-radius: 0.375rem;
362
+ padding: 0.75rem;
363
+ background: var(--envoy-surface-muted);
364
+ white-space: pre-wrap;
365
+ font-size: 0.875rem;
366
+ }
367
+
368
+ .envoy-muted {
369
+ color: var(--envoy-muted);
370
+ font-size: 0.875rem;
371
+ }
@@ -0,0 +1,18 @@
1
+ module Envoy
2
+ class ApplicationController < ActionController::Base
3
+ protect_from_forgery with: :exception
4
+ before_action :envoy_authenticate!
5
+
6
+ helper_method :envoy_current_actor
7
+
8
+ private
9
+
10
+ def envoy_authenticate!
11
+ instance_exec(self, &Envoy.config.authenticate)
12
+ end
13
+
14
+ def envoy_current_actor
15
+ @envoy_current_actor ||= Envoy.config.actor_resolver.call(self)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,36 @@
1
+ module Envoy
2
+ class ConversationsController < ApplicationController
3
+ def index
4
+ @conversations = Conversation.where(actor: envoy_current_actor).order(created_at: :desc)
5
+ end
6
+
7
+ def new
8
+ @conversation = Conversation.new
9
+ end
10
+
11
+ def create
12
+ @conversation = Conversation.new(conversation_params)
13
+ @conversation.actor = envoy_current_actor
14
+ @conversation.model_id ||= Envoy.config.default_model
15
+ @conversation.provider = Envoy.config.provider.to_s
16
+ if (pid = params.dig(:conversation, :system_prompt_id)).present?
17
+ @conversation.system_prompt_version = Envoy::SystemPrompt.find(pid).latest_version
18
+ end
19
+ if @conversation.save
20
+ redirect_to conversation_path(@conversation)
21
+ else
22
+ render :new, status: :unprocessable_entity
23
+ end
24
+ end
25
+
26
+ def show
27
+ @conversation = Conversation.where(actor: envoy_current_actor).find(params[:id])
28
+ end
29
+
30
+ private
31
+
32
+ def conversation_params
33
+ params.require(:conversation).permit(:title, :model_id, :toolset_key, :system_prompt)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,14 @@
1
+ module Envoy
2
+ class MessagesController < ApplicationController
3
+ def create
4
+ @conversation = Conversation.where(actor: envoy_current_actor).find(params[:conversation_id])
5
+ content = params.require(:message).fetch(:content)
6
+ Envoy::RunJob.perform_later(@conversation, content)
7
+
8
+ respond_to do |format|
9
+ format.turbo_stream { @user_content = content }
10
+ format.html { redirect_to conversation_path(@conversation) }
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,52 @@
1
+ module Envoy
2
+ class SystemPromptsController < ApplicationController
3
+ def index
4
+ @system_prompts = SystemPrompt.order(:name)
5
+ end
6
+
7
+ def show
8
+ @system_prompt = SystemPrompt.find(params[:id])
9
+ end
10
+
11
+ def new
12
+ @system_prompt = SystemPrompt.new
13
+ end
14
+
15
+ def create
16
+ @system_prompt = SystemPrompt.new(prompt_params.except(:body))
17
+ body = prompt_params[:body].to_s
18
+ if body.blank?
19
+ @system_prompt.errors.add(:base, "Body can't be blank")
20
+ return render :new, status: :unprocessable_entity
21
+ end
22
+ SystemPrompt.transaction do
23
+ @system_prompt.save!
24
+ @system_prompt.add_version!(body)
25
+ end
26
+ redirect_to system_prompt_path(@system_prompt), notice: "Prompt created."
27
+ rescue ActiveRecord::RecordInvalid
28
+ render :new, status: :unprocessable_entity
29
+ end
30
+
31
+ def edit
32
+ @system_prompt = SystemPrompt.find(params[:id])
33
+ end
34
+
35
+ def update
36
+ @system_prompt = SystemPrompt.find(params[:id])
37
+ if @system_prompt.update(prompt_params.except(:body))
38
+ body = prompt_params[:body].to_s
39
+ @system_prompt.add_version!(body) if body.present? && body != @system_prompt.current_body
40
+ redirect_to system_prompt_path(@system_prompt), notice: "Prompt updated."
41
+ else
42
+ render :edit, status: :unprocessable_entity
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def prompt_params
49
+ params.require(:system_prompt).permit(:name, :description, :body)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,46 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Wraps the message region. Keeps the window scrolled to the newest content as
4
+ // it streams in. If the user scrolls away from the bottom, autoscroll pauses;
5
+ // it resumes when they scroll back to the bottom or send a new message.
6
+ export default class extends Controller {
7
+ connect() {
8
+ this.stick = true
9
+
10
+ // Content changes (appends + streaming replaces) trigger a scroll-to-bottom.
11
+ this.observer = new MutationObserver(() => {
12
+ if (this.stick) this.toBottom()
13
+ })
14
+ this.observer.observe(this.element, {
15
+ childList: true, subtree: true, characterData: true
16
+ })
17
+
18
+ // wheel/touch are unambiguously user-initiated (our own scrollTo doesn't
19
+ // fire them), so use them to decide whether to keep sticking to the bottom.
20
+ this.onUserScroll = () => { this.stick = this.atBottom() }
21
+ window.addEventListener("wheel", this.onUserScroll, { passive: true })
22
+ window.addEventListener("touchmove", this.onUserScroll, { passive: true })
23
+
24
+ // A new message re-enables autoscroll for that response.
25
+ this.onSubmit = () => { this.stick = true; this.toBottom() }
26
+ document.addEventListener("turbo:submit-end", this.onSubmit)
27
+
28
+ this.toBottom()
29
+ }
30
+
31
+ disconnect() {
32
+ this.observer?.disconnect()
33
+ window.removeEventListener("wheel", this.onUserScroll)
34
+ window.removeEventListener("touchmove", this.onUserScroll)
35
+ document.removeEventListener("turbo:submit-end", this.onSubmit)
36
+ }
37
+
38
+ atBottom() {
39
+ const doc = document.documentElement
40
+ return window.innerHeight + window.scrollY >= doc.scrollHeight - 48
41
+ }
42
+
43
+ toBottom() {
44
+ window.scrollTo({ top: document.documentElement.scrollHeight })
45
+ }
46
+ }
@@ -0,0 +1,30 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Mounted on the composer textarea. Enter submits; Shift+Enter (native) and
4
+ // Option/Alt+Enter insert a newline instead — matching common chat interfaces.
5
+ // Clearing the textarea after send is handled server-side by the
6
+ // create.turbo_stream response, so sending still works without this controller.
7
+ export default class extends Controller {
8
+ submit(event) {
9
+ if (event.key !== "Enter") return
10
+ if (event.shiftKey) return // native newline
11
+
12
+ if (event.altKey) {
13
+ // Option/Alt+Enter has no reliable native newline in a textarea; do it.
14
+ event.preventDefault()
15
+ this.insertNewline()
16
+ return
17
+ }
18
+
19
+ event.preventDefault()
20
+ this.element.closest("form").requestSubmit()
21
+ }
22
+
23
+ insertNewline() {
24
+ const el = this.element
25
+ const { selectionStart: start, selectionEnd: end, value } = el
26
+ el.value = `${value.slice(0, start)}\n${value.slice(end)}`
27
+ el.selectionStart = el.selectionEnd = start + 1
28
+ el.dispatchEvent(new Event("input", { bubbles: true }))
29
+ }
30
+ }
@@ -0,0 +1,4 @@
1
+ module Envoy
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,65 @@
1
+ module Envoy
2
+ class RunJob < ApplicationJob
3
+ STREAM_TARGET = "envoy_streaming_reply"
4
+
5
+ # GlobalID serialization of the Conversation (and its polymorphic actor)
6
+ # is handled by ActiveJob automatically.
7
+ def perform(conversation, content)
8
+ # RubyLLM yields incremental deltas; accumulate them so each broadcast
9
+ # carries the full text so far (broadcast_replace_to replaces, not appends).
10
+ # This also makes streaming race-proof: a missed early broadcast is harmless
11
+ # because every later one contains the whole reply.
12
+ @streamed = +""
13
+ Envoy::Runner.new(conversation: conversation).call(content: content) do |event|
14
+ stream(conversation, event)
15
+ end
16
+ finalize(conversation)
17
+ end
18
+
19
+ private
20
+
21
+ # Live streaming: accumulate deltas and replace the streaming target (which
22
+ # lives at the bottom of the message list) with the growing assistant bubble.
23
+ # Tool events aren't forwarded by the LLM seam in production; the completed
24
+ # tool calls appear when the turn finalizes.
25
+ def stream(conversation, (kind, payload))
26
+ return unless kind == :delta
27
+
28
+ @streamed << payload.to_s
29
+ Turbo::StreamsChannel.broadcast_replace_to(
30
+ conversation, "messages",
31
+ target: STREAM_TARGET,
32
+ partial: "envoy/messages/streaming",
33
+ locals: { content: @streamed }
34
+ )
35
+ end
36
+
37
+ # Replace the streaming target with the persisted assistant messages
38
+ # (authoritative text + tool calls, and no streaming id). This "commits" the
39
+ # reply into the history so the next turn's fresh target appends AFTER it and
40
+ # message order stays correct across turns.
41
+ def finalize(conversation)
42
+ messages = committed_messages(conversation)
43
+ return if messages.empty?
44
+
45
+ Turbo::StreamsChannel.broadcast_replace_to(
46
+ conversation, "messages",
47
+ target: STREAM_TARGET,
48
+ partial: "envoy/messages/message",
49
+ collection: messages,
50
+ as: :message
51
+ )
52
+ end
53
+
54
+ # Every assistant message the turn produced — those created after the last
55
+ # user message. A tool-using turn spans multiple assistant messages (the
56
+ # tool-call request, which carries the chips, then the final text answer);
57
+ # committing only the last dropped the chips from the live view until reload.
58
+ def committed_messages(conversation)
59
+ last_user_id = conversation.messages.where(role: "user").maximum(:id)
60
+ scope = conversation.messages.where(role: "assistant").order(:id)
61
+ scope = scope.where("id > ?", last_user_id) if last_user_id
62
+ scope.to_a
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,5 @@
1
+ module Envoy
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,20 @@
1
+ module Envoy
2
+ class Conversation < ApplicationRecord
3
+ acts_as_chat message_class: "Envoy::Message", tool_call_class: "Envoy::ToolCall"
4
+
5
+ belongs_to :actor, polymorphic: true
6
+ belongs_to :system_prompt_version, class_name: "Envoy::SystemPromptVersion", optional: true
7
+
8
+ validates :model_id, :toolset_key, presence: true
9
+
10
+ def toolset
11
+ Envoy.toolset(toolset_key)
12
+ end
13
+
14
+ # Version body (if pinned) is primary; custom text (if any) is appended as
15
+ # chat-specific context. Either stands alone; both compose; neither -> nil.
16
+ def effective_system_prompt
17
+ [ system_prompt_version&.body, system_prompt ].compact_blank.join("\n\n").presence
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,8 @@
1
+ module Envoy
2
+ class Message < ApplicationRecord
3
+ acts_as_message chat_class: "Envoy::Conversation",
4
+ chat_foreign_key: "conversation_id",
5
+ tool_call_class: "Envoy::ToolCall"
6
+ # Do NOT validate content presence — it is null during streaming init.
7
+ end
8
+ end