envoy_ai 0.0.1 → 0.0.3
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 +4 -4
- data/README.md +181 -2
- data/app/assets/stylesheets/envoy/chat.css +236 -0
- data/app/assets/stylesheets/envoy/console.css +50 -176
- data/app/controllers/envoy/conversations_controller.rb +1 -4
- data/app/controllers/envoy/panels_controller.rb +30 -0
- data/app/javascript/envoy/controllers/autoscroll_controller.js +26 -32
- data/app/jobs/envoy/run_job.rb +25 -4
- data/app/models/envoy/conversation.rb +33 -6
- data/app/views/envoy/conversations/_composer_input.html.erb +1 -1
- data/app/views/envoy/conversations/index.html.erb +0 -2
- data/app/views/envoy/conversations/new.html.erb +1 -7
- data/app/views/envoy/conversations/show.html.erb +1 -17
- data/app/views/envoy/messages/_error.html.erb +6 -0
- data/app/views/envoy/messages/_streaming.html.erb +1 -1
- data/app/views/envoy/messages/create.turbo_stream.erb +5 -5
- data/app/views/envoy/panels/_panel.html.erb +3 -0
- data/app/views/envoy/panels/_transcript.html.erb +21 -0
- data/config/routes.rb +1 -1
- data/db/migrate/20260716000001_drop_envoy_system_prompts.rb +12 -0
- data/db/migrate/20260716000002_add_surface_to_envoy_conversations.rb +15 -0
- data/lib/envoy/engine.rb +6 -0
- data/lib/envoy/errors.rb +4 -0
- data/lib/envoy/guard.rb +13 -1
- data/lib/envoy/library.rb +82 -0
- data/lib/envoy/llm.rb +1 -1
- data/lib/envoy/page_surface.rb +34 -0
- data/lib/envoy/prompt.rb +55 -0
- data/lib/envoy/runner.rb +17 -3
- data/lib/envoy/surface.rb +90 -0
- data/lib/envoy/tool_definition.rb +12 -2
- data/lib/envoy/version.rb +1 -1
- data/lib/envoy.rb +59 -0
- metadata +12 -12
- data/app/controllers/envoy/system_prompts_controller.rb +0 -52
- data/app/models/envoy/system_prompt.rb +0 -24
- data/app/models/envoy/system_prompt_version.rb +0 -14
- data/app/views/envoy/system_prompts/_form.html.erb +0 -14
- data/app/views/envoy/system_prompts/edit.html.erb +0 -2
- data/app/views/envoy/system_prompts/index.html.erb +0 -14
- data/app/views/envoy/system_prompts/new.html.erb +0 -2
- data/app/views/envoy/system_prompts/show.html.erb +0 -15
- data/db/migrate/20260711000001_create_envoy_system_prompts.rb +0 -10
- data/db/migrate/20260711000002_create_envoy_system_prompt_versions.rb +0 -13
- data/db/migrate/20260711000003_add_system_prompt_version_to_envoy_conversations.rb +0 -6
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/* Console chrome only. The reusable chat rules live in chat.css so a host can
|
|
2
|
+
* load them without inheriting body-level styling.
|
|
3
|
+
*
|
|
4
|
+
* url(), not a bare string: Propshaft only fingerprints CSS url(...)
|
|
5
|
+
* references (lib/propshaft/compiler/css_asset_urls.rb), not @import
|
|
6
|
+
* strings. A plain `@import "chat.css"` would ship the literal, undigested
|
|
7
|
+
* filename straight into the served CSS, and Propshaft's asset server 404s
|
|
8
|
+
* on undigested paths -- so the browser would silently never load chat.css.
|
|
9
|
+
* The path is relative to this file's own directory (both live in
|
|
10
|
+
* app/assets/stylesheets/envoy/), which is what the compiler's resolver
|
|
11
|
+
* expects.
|
|
12
|
+
*/
|
|
13
|
+
@import url("chat.css");
|
|
14
|
+
|
|
1
15
|
/*
|
|
2
16
|
* Envoy debug console — self-contained stylesheet.
|
|
3
17
|
*
|
|
@@ -23,6 +37,23 @@
|
|
|
23
37
|
--envoy-btn-ink: #ffffff;
|
|
24
38
|
--envoy-danger: #b91c1c;
|
|
25
39
|
--envoy-header-bg: rgba(255, 255, 255, 0.8);
|
|
40
|
+
|
|
41
|
+
/* chat.css's public vars (see its header comment). These mirror the
|
|
42
|
+
* console-only names above one-for-one -- chat.css was split out of this
|
|
43
|
+
* file and renamed its custom properties in the process, so without these
|
|
44
|
+
* the chat rules fall back to chat.css's own light-mode literals even
|
|
45
|
+
* under `prefers-color-scheme: dark` below. --envoy-radius is deliberately
|
|
46
|
+
* NOT defined: pre-split, radius was hardcoded per rule (0.5rem for
|
|
47
|
+
* .envoy-msg, 0.375rem everywhere else) rather than themed, so a single
|
|
48
|
+
* value here would change one or the other instead of leaving both as
|
|
49
|
+
* they were. Radius doesn't vary with color scheme, so it needs no dark
|
|
50
|
+
* override either.
|
|
51
|
+
*/
|
|
52
|
+
--envoy-line: #e5e7eb; /* = --envoy-border */
|
|
53
|
+
--envoy-subtle: #6b7280; /* = --envoy-muted */
|
|
54
|
+
--envoy-accent: #111827; /* = --envoy-btn-bg */
|
|
55
|
+
--envoy-accent-ink: #ffffff; /* = --envoy-btn-ink */
|
|
56
|
+
--envoy-warn: #b91c1c; /* = --envoy-danger (same in both schemes) */
|
|
26
57
|
}
|
|
27
58
|
|
|
28
59
|
@media (prefers-color-scheme: dark) {
|
|
@@ -38,7 +69,25 @@
|
|
|
38
69
|
--envoy-btn-bg: #f3f4f6;
|
|
39
70
|
--envoy-btn-ink: #111827;
|
|
40
71
|
--envoy-header-bg: rgba(17, 24, 39, 0.8);
|
|
72
|
+
|
|
73
|
+
--envoy-line: #374151; /* = --envoy-border (dark) */
|
|
74
|
+
--envoy-subtle: #9ca3af; /* = --envoy-muted (dark) */
|
|
75
|
+
--envoy-accent: #f3f4f6; /* = --envoy-btn-bg (dark) */
|
|
76
|
+
--envoy-accent-ink: #111827; /* = --envoy-btn-ink (dark) */
|
|
77
|
+
/* --envoy-warn is not redefined here: --envoy-danger, its predecessor,
|
|
78
|
+
* was never overridden for dark mode either, so it keeps its light
|
|
79
|
+
* value in both schemes. */
|
|
41
80
|
}
|
|
81
|
+
|
|
82
|
+
/* --envoy-surface-alt has two distinct pre-split predecessors --
|
|
83
|
+
* --envoy-surface-assistant for chat bubbles, --envoy-surface-muted for
|
|
84
|
+
* tool panels -- that chat.css's rename collapsed into one property (see
|
|
85
|
+
* its differing per-rule fallbacks, #eff6ff vs #f9fafb). One :root value
|
|
86
|
+
* can't restore both original colors at once, so scope the override to
|
|
87
|
+
* each element that chat.css actually uses it on instead.
|
|
88
|
+
*/
|
|
89
|
+
.envoy-msg--assistant { --envoy-surface-alt: #1f2937; } /* = --envoy-surface-assistant (dark) */
|
|
90
|
+
.envoy-tool { --envoy-surface-alt: #111827; } /* = --envoy-surface-muted (dark) */
|
|
42
91
|
}
|
|
43
92
|
|
|
44
93
|
/* ---------------------------------------------------------------------- */
|
|
@@ -141,174 +190,10 @@ body.envoy-body {
|
|
|
141
190
|
.envoy-mb-4 { margin-bottom: 1rem; }
|
|
142
191
|
.envoy-mt-4 { margin-top: 1rem; }
|
|
143
192
|
|
|
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
193
|
/* ---------------------------------------------------------------------- */
|
|
291
|
-
/*
|
|
194
|
+
/* Links */
|
|
292
195
|
/* ---------------------------------------------------------------------- */
|
|
293
196
|
|
|
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
197
|
.envoy-link {
|
|
313
198
|
color: var(--envoy-link);
|
|
314
199
|
text-decoration: none;
|
|
@@ -350,12 +235,6 @@ body.envoy-body {
|
|
|
350
235
|
font-size: 0.875rem;
|
|
351
236
|
}
|
|
352
237
|
|
|
353
|
-
.envoy-error {
|
|
354
|
-
color: var(--envoy-danger);
|
|
355
|
-
font-size: 0.875rem;
|
|
356
|
-
margin-bottom: 0.75rem;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
238
|
.envoy-prose-box {
|
|
360
239
|
border: 1px solid var(--envoy-border);
|
|
361
240
|
border-radius: 0.375rem;
|
|
@@ -364,8 +243,3 @@ body.envoy-body {
|
|
|
364
243
|
white-space: pre-wrap;
|
|
365
244
|
font-size: 0.875rem;
|
|
366
245
|
}
|
|
367
|
-
|
|
368
|
-
.envoy-muted {
|
|
369
|
-
color: var(--envoy-muted);
|
|
370
|
-
font-size: 0.875rem;
|
|
371
|
-
}
|
|
@@ -13,9 +13,6 @@ module Envoy
|
|
|
13
13
|
@conversation.actor = envoy_current_actor
|
|
14
14
|
@conversation.model_id ||= Envoy.config.default_model
|
|
15
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
16
|
if @conversation.save
|
|
20
17
|
redirect_to conversation_path(@conversation)
|
|
21
18
|
else
|
|
@@ -30,7 +27,7 @@ module Envoy
|
|
|
30
27
|
private
|
|
31
28
|
|
|
32
29
|
def conversation_params
|
|
33
|
-
params.require(:conversation).permit(:title, :model_id, :toolset_key, :system_prompt)
|
|
30
|
+
params.require(:conversation).permit(:title, :model_id, :toolset_key, :system_prompt, :prompt_key)
|
|
34
31
|
end
|
|
35
32
|
end
|
|
36
33
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Envoy
|
|
2
|
+
class PanelsController < ApplicationController
|
|
3
|
+
# POST, not GET behind a lazy frame: this creates a record. The semantics
|
|
4
|
+
# genuinely are "create or resume", so one path serves open and reopen and a
|
|
5
|
+
# reopen always re-fetches a current transcript.
|
|
6
|
+
def create
|
|
7
|
+
surface = Envoy.surface(params.require(:surface))
|
|
8
|
+
conversation = find_or_resume(surface)
|
|
9
|
+
render partial: "envoy/panels/panel", locals: { conversation: conversation }
|
|
10
|
+
rescue Envoy::UnknownSurface
|
|
11
|
+
# surface and context_key arrive from the browser; only registered
|
|
12
|
+
# surfaces are addressable. Whether this ACTOR may see this SUBJECT is the
|
|
13
|
+
# surface's context block to enforce.
|
|
14
|
+
head :not_found
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def find_or_resume(surface)
|
|
20
|
+
Conversation.find_or_create_by!(
|
|
21
|
+
actor: envoy_current_actor,
|
|
22
|
+
surface_key: surface.key,
|
|
23
|
+
context_key: params[:context_key].presence
|
|
24
|
+
) do |conversation|
|
|
25
|
+
conversation.provider = Envoy.config.provider.to_s
|
|
26
|
+
conversation.model_id = surface.model_id || Envoy.config.default_model
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -1,46 +1,40 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Sticks the transcript to the bottom as messages stream in, unless the reader
|
|
4
|
+
// has scrolled up to read back.
|
|
5
|
+
//
|
|
6
|
+
// Scrolls its own log container rather than the window: in an embedded panel
|
|
7
|
+
// the window is the host page, so scrolling it jumps the page the user is
|
|
8
|
+
// reading AND never moves the panel.
|
|
6
9
|
export default class extends Controller {
|
|
7
|
-
|
|
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 })
|
|
10
|
+
static targets = ["log"]
|
|
23
11
|
|
|
24
|
-
|
|
25
|
-
this.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
this.toBottom()
|
|
12
|
+
connect() {
|
|
13
|
+
this.observer = new MutationObserver(() => this.stick())
|
|
14
|
+
this.observer.observe(this.scroller, { childList: true, subtree: true, characterData: true })
|
|
15
|
+
this.scrollToBottom()
|
|
29
16
|
}
|
|
30
17
|
|
|
31
18
|
disconnect() {
|
|
32
19
|
this.observer?.disconnect()
|
|
33
|
-
window.removeEventListener("wheel", this.onUserScroll)
|
|
34
|
-
window.removeEventListener("touchmove", this.onUserScroll)
|
|
35
|
-
document.removeEventListener("turbo:submit-end", this.onSubmit)
|
|
36
20
|
}
|
|
37
21
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
22
|
+
stick() {
|
|
23
|
+
if (this.atBottom) this.scrollToBottom()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
scrollToBottom() {
|
|
27
|
+
this.scroller.scrollTop = this.scroller.scrollHeight
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A tolerance, because a streaming reply grows under the reader; without it
|
|
31
|
+
// one stray pixel would strand them mid-transcript for the rest of the turn.
|
|
32
|
+
get atBottom() {
|
|
33
|
+
const { scrollTop, clientHeight, scrollHeight } = this.scroller
|
|
34
|
+
return scrollHeight - (scrollTop + clientHeight) < 48
|
|
41
35
|
}
|
|
42
36
|
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
get scroller() {
|
|
38
|
+
return this.hasLogTarget ? this.logTarget : this.element
|
|
45
39
|
}
|
|
46
40
|
}
|
data/app/jobs/envoy/run_job.rb
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
module Envoy
|
|
2
2
|
class RunJob < ApplicationJob
|
|
3
|
-
|
|
3
|
+
extend ActionView::RecordIdentifier
|
|
4
|
+
|
|
5
|
+
# Scoped per conversation: a flat constant meant two panels — or a panel and
|
|
6
|
+
# a console tab — would fight over the same target on one page.
|
|
7
|
+
def self.stream_target(conversation)
|
|
8
|
+
dom_id(conversation, :streaming)
|
|
9
|
+
end
|
|
4
10
|
|
|
5
11
|
# GlobalID serialization of the Conversation (and its polymorphic actor)
|
|
6
12
|
# is handled by ActiveJob automatically.
|
|
@@ -14,6 +20,12 @@ module Envoy
|
|
|
14
20
|
stream(conversation, event)
|
|
15
21
|
end
|
|
16
22
|
finalize(conversation)
|
|
23
|
+
rescue StandardError
|
|
24
|
+
# Without this the panel sits on "working…" forever: nothing else ever
|
|
25
|
+
# replaces the streaming target. Re-raise so the queue still records the
|
|
26
|
+
# failure and any retry policy applies.
|
|
27
|
+
broadcast_error(conversation)
|
|
28
|
+
raise
|
|
17
29
|
end
|
|
18
30
|
|
|
19
31
|
private
|
|
@@ -28,9 +40,9 @@ module Envoy
|
|
|
28
40
|
@streamed << payload.to_s
|
|
29
41
|
Turbo::StreamsChannel.broadcast_replace_to(
|
|
30
42
|
conversation, "messages",
|
|
31
|
-
target:
|
|
43
|
+
target: self.class.stream_target(conversation),
|
|
32
44
|
partial: "envoy/messages/streaming",
|
|
33
|
-
locals: { content: @streamed }
|
|
45
|
+
locals: { content: @streamed, conversation: conversation }
|
|
34
46
|
)
|
|
35
47
|
end
|
|
36
48
|
|
|
@@ -44,7 +56,7 @@ module Envoy
|
|
|
44
56
|
|
|
45
57
|
Turbo::StreamsChannel.broadcast_replace_to(
|
|
46
58
|
conversation, "messages",
|
|
47
|
-
target:
|
|
59
|
+
target: self.class.stream_target(conversation),
|
|
48
60
|
partial: "envoy/messages/message",
|
|
49
61
|
collection: messages,
|
|
50
62
|
as: :message
|
|
@@ -61,5 +73,14 @@ module Envoy
|
|
|
61
73
|
scope = scope.where("id > ?", last_user_id) if last_user_id
|
|
62
74
|
scope.to_a
|
|
63
75
|
end
|
|
76
|
+
|
|
77
|
+
def broadcast_error(conversation)
|
|
78
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
79
|
+
conversation, "messages",
|
|
80
|
+
target: self.class.stream_target(conversation),
|
|
81
|
+
partial: "envoy/messages/error",
|
|
82
|
+
locals: { conversation: conversation }
|
|
83
|
+
)
|
|
84
|
+
end
|
|
64
85
|
end
|
|
65
86
|
end
|
|
@@ -3,18 +3,45 @@ module Envoy
|
|
|
3
3
|
acts_as_chat message_class: "Envoy::Message", tool_call_class: "Envoy::ToolCall"
|
|
4
4
|
|
|
5
5
|
belongs_to :actor, polymorphic: true
|
|
6
|
-
belongs_to :system_prompt_version, class_name: "Envoy::SystemPromptVersion", optional: true
|
|
7
6
|
|
|
8
|
-
validates :model_id,
|
|
7
|
+
validates :model_id, presence: true
|
|
8
|
+
validate :toolset_or_surface_present
|
|
9
|
+
|
|
10
|
+
# Embed conversations resolve everything through their surface, so editing a
|
|
11
|
+
# surface reaches chats that already exist. Console conversations have no
|
|
12
|
+
# surface and fall back to their own columns.
|
|
13
|
+
def surface
|
|
14
|
+
surface_key.present? ? Envoy.surface(surface_key) : nil
|
|
15
|
+
end
|
|
9
16
|
|
|
10
17
|
def toolset
|
|
11
|
-
Envoy.toolset(toolset_key)
|
|
18
|
+
Envoy.toolset(surface&.toolset_key || toolset_key)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolved_model_id
|
|
22
|
+
surface&.model_id || model_id || Envoy.config.default_model
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def prompt_body
|
|
26
|
+
return surface.prompt_body if surface
|
|
27
|
+
prompt_key.present? ? Envoy.prompt(prompt_key).full_body : nil
|
|
12
28
|
end
|
|
13
29
|
|
|
14
|
-
#
|
|
15
|
-
|
|
30
|
+
# A surface may pin a chat read-only; so may the conversation's own status.
|
|
31
|
+
def read_only?
|
|
32
|
+
status == "read_only" || !!surface&.read_only?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Per-conversation ad-hoc instructions, appended last by Runner.
|
|
16
36
|
def effective_system_prompt
|
|
17
|
-
|
|
37
|
+
system_prompt.presence
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def toolset_or_surface_present
|
|
43
|
+
return if toolset_key.present? || surface_key.present?
|
|
44
|
+
errors.add(:base, "toolset_key or surface_key must be present")
|
|
18
45
|
end
|
|
19
46
|
end
|
|
20
47
|
end
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
<%= text_area_tag "message[content]", nil,
|
|
2
|
-
id:
|
|
2
|
+
id: dom_id(conversation, :composer), rows: 2, autofocus: true,
|
|
3
3
|
class: "envoy-input",
|
|
4
4
|
data: { controller: "envoy-composer", action: "keydown.enter->envoy-composer#submit" } %>
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
<div class="envoy-header-row envoy-mb-4">
|
|
2
2
|
<h1 class="envoy-title">Envoy chats</h1>
|
|
3
3
|
<div class="envoy-btn-group">
|
|
4
|
-
<%= link_to "System prompts", system_prompts_path,
|
|
5
|
-
class: "envoy-btn envoy-btn--secondary" %>
|
|
6
4
|
<%= link_to "New chat", new_conversation_path,
|
|
7
5
|
class: "envoy-btn" %>
|
|
8
6
|
</div>
|
|
@@ -5,14 +5,8 @@
|
|
|
5
5
|
<%= f.text_field :title, placeholder: "Title (optional)", class: field %>
|
|
6
6
|
<%= f.select :toolset_key, Envoy.toolsets.keys, {}, class: field %>
|
|
7
7
|
<%= f.select :model_id, Envoy.config.available_models, {}, class: field %>
|
|
8
|
-
<div>
|
|
9
|
-
<label class="envoy-label">Saved system prompt</label>
|
|
10
|
-
<%= select_tag "conversation[system_prompt_id]",
|
|
11
|
-
options_for_select([["None", ""]] + Envoy::SystemPrompt.order(:name).map { |p| [p.name, p.id] }),
|
|
12
|
-
class: field %>
|
|
13
|
-
</div>
|
|
14
8
|
<%= f.text_area :system_prompt, rows: 4,
|
|
15
|
-
placeholder: "Additional instructions (optional)
|
|
9
|
+
placeholder: "Additional instructions (optional)",
|
|
16
10
|
class: field %>
|
|
17
11
|
<%= f.submit "Start", class: "envoy-btn" %>
|
|
18
12
|
</div>
|
|
@@ -1,18 +1,2 @@
|
|
|
1
|
-
<%= turbo_stream_from @conversation, "messages" %>
|
|
2
1
|
<h1 class="envoy-title--sm envoy-mb-3"><%= @conversation.title.presence || "Chat ##{@conversation.id}" %></h1>
|
|
3
|
-
|
|
4
|
-
<div data-controller="envoy-autoscroll">
|
|
5
|
-
<div id="envoy_messages" class="envoy-stack envoy-mb-4">
|
|
6
|
-
<% @conversation.messages.where(role: %w[user assistant]).each do |message| %>
|
|
7
|
-
<%= render "envoy/messages/message", message: message %>
|
|
8
|
-
<% end %>
|
|
9
|
-
</div>
|
|
10
|
-
</div>
|
|
11
|
-
|
|
12
|
-
<%= form_with url: conversation_messages_path(@conversation), method: :post do |f| %>
|
|
13
|
-
<div class="envoy-composer">
|
|
14
|
-
<%= render "envoy/conversations/composer_input" %>
|
|
15
|
-
<%= f.submit "Send", class: "envoy-btn" %>
|
|
16
|
-
</div>
|
|
17
|
-
<p class="envoy-hint">Enter to send · Shift/Option+Enter for a new line</p>
|
|
18
|
-
<% end %>
|
|
2
|
+
<%= render "envoy/panels/transcript", conversation: @conversation %>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<div id="<%= Envoy::RunJob.stream_target(conversation) %>">
|
|
2
|
+
<div class="envoy-msg envoy-msg--assistant envoy-error">
|
|
3
|
+
<div class="envoy-msg__role">assistant</div>
|
|
4
|
+
<div class="envoy-md">Something went wrong on my side — your message was not answered. Try again.</div>
|
|
5
|
+
</div>
|
|
6
|
+
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<%# Append the user message, then a fresh streaming target — both into the ordered
|
|
2
2
|
message list so the assistant reply commits in place and the next turn appends
|
|
3
3
|
after it (fixes cross-turn ordering). %>
|
|
4
|
-
<%= turbo_stream.append
|
|
4
|
+
<%= turbo_stream.append dom_id(@conversation, :messages) do %>
|
|
5
5
|
<div class="envoy-msg">
|
|
6
6
|
<div class="envoy-msg__role">user</div>
|
|
7
7
|
<div class="envoy-md"><%= Envoy::Markdown.render(@user_content) %></div>
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
<%# The streaming target starts with a subtle "working…" indicator so there is a
|
|
11
11
|
live sign of activity before the first token (e.g. during tool calls, which
|
|
12
12
|
emit no text deltas). The first delta / finalize replaces this. %>
|
|
13
|
-
<%= turbo_stream.append
|
|
14
|
-
<div id="
|
|
13
|
+
<%= turbo_stream.append dom_id(@conversation, :messages) do %>
|
|
14
|
+
<div id="<%= Envoy::RunJob.stream_target(@conversation) %>">
|
|
15
15
|
<div class="envoy-msg envoy-msg--assistant">
|
|
16
16
|
<div class="envoy-msg__role">assistant</div>
|
|
17
17
|
<div class="envoy-working envoy-muted">working…</div>
|
|
@@ -20,6 +20,6 @@
|
|
|
20
20
|
<% end %>
|
|
21
21
|
<%# Clear the composer by replacing it with a fresh, empty textarea (server-driven,
|
|
22
22
|
so it works even if the Stimulus controller didn't load). %>
|
|
23
|
-
<%= turbo_stream.replace
|
|
24
|
-
<%= render "envoy/conversations/composer_input" %>
|
|
23
|
+
<%= turbo_stream.replace dom_id(@conversation, :composer) do %>
|
|
24
|
+
<%= render "envoy/conversations/composer_input", conversation: @conversation %>
|
|
25
25
|
<% end %>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<%# The single definition of a chat transcript, used by both the embedded panel
|
|
2
|
+
and the full-page console. Ids are conversation-scoped so more than one may
|
|
3
|
+
live on a page. Styling is CSS-var driven — the host owns the chrome. %>
|
|
4
|
+
<%= turbo_stream_from conversation, "messages" %>
|
|
5
|
+
|
|
6
|
+
<div class="envoy-chat" data-controller="envoy-autoscroll">
|
|
7
|
+
<div id="<%= dom_id(conversation, :messages) %>" class="envoy-stack envoy-chat__log"
|
|
8
|
+
data-envoy-autoscroll-target="log">
|
|
9
|
+
<% conversation.messages.where(role: %w[user assistant]).each do |message| %>
|
|
10
|
+
<%= render "envoy/messages/message", message: message %>
|
|
11
|
+
<% end %>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<%= form_with url: conversation_messages_path(conversation), method: :post do |f| %>
|
|
15
|
+
<div class="envoy-composer">
|
|
16
|
+
<%= render "envoy/conversations/composer_input", conversation: conversation %>
|
|
17
|
+
<%= f.submit "Send", class: "envoy-btn" %>
|
|
18
|
+
</div>
|
|
19
|
+
<p class="envoy-hint">Enter to send · Shift/Option+Enter for a new line</p>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|
data/config/routes.rb
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
class DropEnvoySystemPrompts < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
# The DB-backed prompt store never held a row and its controller was the one
|
|
4
|
+
# unscoped surface in the engine. Prompts are program behaviour and now live
|
|
5
|
+
# in the DSL registry, versioned by git.
|
|
6
|
+
remove_reference :envoy_conversations, :system_prompt_version, foreign_key: false, index: true
|
|
7
|
+
add_column :envoy_conversations, :prompt_key, :string
|
|
8
|
+
|
|
9
|
+
drop_table :envoy_system_prompt_versions
|
|
10
|
+
drop_table :envoy_system_prompts
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class AddSurfaceToEnvoyConversations < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
add_column :envoy_conversations, :surface_key, :string
|
|
4
|
+
add_column :envoy_conversations, :context_key, :string
|
|
5
|
+
change_column_null :envoy_conversations, :toolset_key, true
|
|
6
|
+
|
|
7
|
+
# Resumption is a uniqueness rule, so let the database hold it rather than a
|
|
8
|
+
# racy find_or_create_by. Partial: console conversations have no surface and
|
|
9
|
+
# must stay unconstrained.
|
|
10
|
+
add_index :envoy_conversations,
|
|
11
|
+
%i[actor_type actor_id surface_key context_key],
|
|
12
|
+
unique: true, where: "surface_key IS NOT NULL",
|
|
13
|
+
name: "index_envoy_conversations_on_surface_subject"
|
|
14
|
+
end
|
|
15
|
+
end
|