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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +22 -0
- data/README.md +399 -0
- data/app/assets/stylesheets/envoy/console.css +371 -0
- data/app/controllers/envoy/application_controller.rb +18 -0
- data/app/controllers/envoy/conversations_controller.rb +36 -0
- data/app/controllers/envoy/messages_controller.rb +14 -0
- data/app/controllers/envoy/system_prompts_controller.rb +52 -0
- data/app/javascript/envoy/controllers/autoscroll_controller.js +46 -0
- data/app/javascript/envoy/controllers/composer_controller.js +30 -0
- data/app/jobs/envoy/application_job.rb +4 -0
- data/app/jobs/envoy/run_job.rb +65 -0
- data/app/models/envoy/application_record.rb +5 -0
- data/app/models/envoy/conversation.rb +20 -0
- data/app/models/envoy/message.rb +8 -0
- data/app/models/envoy/system_prompt.rb +24 -0
- data/app/models/envoy/system_prompt_version.rb +14 -0
- data/app/models/envoy/tool_call.rb +28 -0
- data/app/views/envoy/conversations/_composer_input.html.erb +4 -0
- data/app/views/envoy/conversations/index.html.erb +15 -0
- data/app/views/envoy/conversations/new.html.erb +19 -0
- data/app/views/envoy/conversations/show.html.erb +18 -0
- data/app/views/envoy/messages/_message.html.erb +7 -0
- data/app/views/envoy/messages/_streaming.html.erb +6 -0
- data/app/views/envoy/messages/_tool_call.html.erb +8 -0
- data/app/views/envoy/messages/create.turbo_stream.erb +25 -0
- data/app/views/envoy/system_prompts/_form.html.erb +14 -0
- data/app/views/envoy/system_prompts/edit.html.erb +2 -0
- data/app/views/envoy/system_prompts/index.html.erb +14 -0
- data/app/views/envoy/system_prompts/new.html.erb +2 -0
- data/app/views/envoy/system_prompts/show.html.erb +15 -0
- data/app/views/layouts/envoy/application.html.erb +20 -0
- data/config/routes.rb +7 -0
- data/db/migrate/20260710000001_create_envoy_conversations.rb +14 -0
- data/db/migrate/20260710000002_create_envoy_messages.rb +14 -0
- data/db/migrate/20260710000003_create_envoy_tool_calls.rb +14 -0
- data/db/migrate/20260711000001_create_envoy_system_prompts.rb +10 -0
- data/db/migrate/20260711000002_create_envoy_system_prompt_versions.rb +13 -0
- data/db/migrate/20260711000003_add_system_prompt_version_to_envoy_conversations.rb +6 -0
- data/lib/envoy/badges.rb +22 -0
- data/lib/envoy/compiled_tool.rb +43 -0
- data/lib/envoy/configuration.rb +21 -0
- data/lib/envoy/engine.rb +19 -0
- data/lib/envoy/errors.rb +6 -0
- data/lib/envoy/guard.rb +28 -0
- data/lib/envoy/llm.rb +24 -0
- data/lib/envoy/markdown.rb +16 -0
- data/lib/envoy/runner.rb +44 -0
- data/lib/envoy/testing/fake_llm.rb +41 -0
- data/lib/envoy/tool_definition.rb +38 -0
- data/lib/envoy/toolset.rb +72 -0
- data/lib/envoy/version.rb +3 -0
- data/lib/envoy.rb +45 -0
- data/lib/envoy_ai.rb +3 -0
- 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,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,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
|