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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +181 -2
  3. data/app/assets/stylesheets/envoy/chat.css +236 -0
  4. data/app/assets/stylesheets/envoy/console.css +50 -176
  5. data/app/controllers/envoy/conversations_controller.rb +1 -4
  6. data/app/controllers/envoy/panels_controller.rb +30 -0
  7. data/app/javascript/envoy/controllers/autoscroll_controller.js +26 -32
  8. data/app/jobs/envoy/run_job.rb +25 -4
  9. data/app/models/envoy/conversation.rb +33 -6
  10. data/app/views/envoy/conversations/_composer_input.html.erb +1 -1
  11. data/app/views/envoy/conversations/index.html.erb +0 -2
  12. data/app/views/envoy/conversations/new.html.erb +1 -7
  13. data/app/views/envoy/conversations/show.html.erb +1 -17
  14. data/app/views/envoy/messages/_error.html.erb +6 -0
  15. data/app/views/envoy/messages/_streaming.html.erb +1 -1
  16. data/app/views/envoy/messages/create.turbo_stream.erb +5 -5
  17. data/app/views/envoy/panels/_panel.html.erb +3 -0
  18. data/app/views/envoy/panels/_transcript.html.erb +21 -0
  19. data/config/routes.rb +1 -1
  20. data/db/migrate/20260716000001_drop_envoy_system_prompts.rb +12 -0
  21. data/db/migrate/20260716000002_add_surface_to_envoy_conversations.rb +15 -0
  22. data/lib/envoy/engine.rb +6 -0
  23. data/lib/envoy/errors.rb +4 -0
  24. data/lib/envoy/guard.rb +13 -1
  25. data/lib/envoy/library.rb +82 -0
  26. data/lib/envoy/llm.rb +1 -1
  27. data/lib/envoy/page_surface.rb +34 -0
  28. data/lib/envoy/prompt.rb +55 -0
  29. data/lib/envoy/runner.rb +17 -3
  30. data/lib/envoy/surface.rb +90 -0
  31. data/lib/envoy/tool_definition.rb +12 -2
  32. data/lib/envoy/version.rb +1 -1
  33. data/lib/envoy.rb +59 -0
  34. metadata +12 -12
  35. data/app/controllers/envoy/system_prompts_controller.rb +0 -52
  36. data/app/models/envoy/system_prompt.rb +0 -24
  37. data/app/models/envoy/system_prompt_version.rb +0 -14
  38. data/app/views/envoy/system_prompts/_form.html.erb +0 -14
  39. data/app/views/envoy/system_prompts/edit.html.erb +0 -2
  40. data/app/views/envoy/system_prompts/index.html.erb +0 -14
  41. data/app/views/envoy/system_prompts/new.html.erb +0 -2
  42. data/app/views/envoy/system_prompts/show.html.erb +0 -15
  43. data/db/migrate/20260711000001_create_envoy_system_prompts.rb +0 -10
  44. data/db/migrate/20260711000002_create_envoy_system_prompt_versions.rb +0 -13
  45. 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
- /* Buttons / links */
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
- // 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.
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
- 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 })
10
+ static targets = ["log"]
23
11
 
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()
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
- atBottom() {
39
- const doc = document.documentElement
40
- return window.innerHeight + window.scrollY >= doc.scrollHeight - 48
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
- toBottom() {
44
- window.scrollTo({ top: document.documentElement.scrollHeight })
37
+ get scroller() {
38
+ return this.hasLogTarget ? this.logTarget : this.element
45
39
  }
46
40
  }
@@ -1,6 +1,12 @@
1
1
  module Envoy
2
2
  class RunJob < ApplicationJob
3
- STREAM_TARGET = "envoy_streaming_reply"
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: STREAM_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: STREAM_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, :toolset_key, presence: true
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
- # Version body (if pinned) is primary; custom text (if any) is appended as
15
- # chat-specific context. Either stands alone; both compose; neither -> nil.
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
- [ system_prompt_version&.body, system_prompt ].compact_blank.join("\n\n").presence
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: "envoy_composer_input", rows: 2, autofocus: true,
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) — appended to the selected saved prompt, or used alone as a custom prompt",
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,4 +1,4 @@
1
- <div id="envoy_streaming_reply">
1
+ <div id="<%= Envoy::RunJob.stream_target(conversation) %>">
2
2
  <div class="envoy-msg envoy-msg--assistant">
3
3
  <div class="envoy-msg__role">assistant</div>
4
4
  <div class="envoy-md"><%= Envoy::Markdown.render(content) %></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 "envoy_messages" do %>
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 "envoy_messages" do %>
14
- <div id="envoy_streaming_reply">
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 "envoy_composer_input" do %>
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,3 @@
1
+ <%= turbo_frame_tag "envoy_panel" do %>
2
+ <%= render "envoy/panels/transcript", conversation: conversation %>
3
+ <% 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
@@ -1,7 +1,7 @@
1
1
  Envoy::Engine.routes.draw do
2
+ resource :panel, only: :create
2
3
  resources :conversations, only: %i[index new create show] do
3
4
  resources :messages, only: :create
4
5
  end
5
- resources :system_prompts, except: :destroy
6
6
  root to: "conversations#index"
7
7
  end
@@ -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