layered-assistant-rails 0.1.0 → 0.1.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 (28) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +2 -0
  3. data/README.md +9 -0
  4. data/app/controllers/concerns/layered/assistant/stoppable_response.rb +13 -0
  5. data/app/controllers/layered/assistant/conversations_controller.rb +3 -1
  6. data/app/controllers/layered/assistant/panel/conversations_controller.rb +2 -1
  7. data/app/controllers/layered/assistant/public/conversations_controller.rb +3 -1
  8. data/app/controllers/layered/assistant/public/panel/conversations_controller.rb +2 -1
  9. data/app/javascript/layered_assistant/composer_controller.js +87 -5
  10. data/app/javascript/layered_assistant/message_streaming.js +9 -0
  11. data/app/jobs/layered/assistant/messages/response_job.rb +3 -0
  12. data/app/models/layered/assistant/conversation.rb +17 -0
  13. data/app/models/layered/assistant/message.rb +6 -0
  14. data/app/services/layered/assistant/chunk_service.rb +12 -0
  15. data/app/views/layered/assistant/messages/_composer.html.erb +2 -14
  16. data/app/views/layered/assistant/messages/_composer_fields.html.erb +14 -0
  17. data/app/views/layered/assistant/messages/_message.html.erb +5 -2
  18. data/app/views/layered/assistant/messages/create.turbo_stream.erb +1 -1
  19. data/app/views/layered/assistant/panel/messages/_composer.html.erb +2 -14
  20. data/app/views/layered/assistant/panel/messages/create.turbo_stream.erb +1 -1
  21. data/app/views/layered/assistant/public/messages/_composer.html.erb +2 -6
  22. data/app/views/layered/assistant/public/messages/create.turbo_stream.erb +1 -1
  23. data/app/views/layered/assistant/public/panel/messages/_composer.html.erb +2 -6
  24. data/app/views/layered/assistant/public/panel/messages/create.turbo_stream.erb +1 -1
  25. data/config/routes.rb +4 -0
  26. data/db/migrate/20260315000000_add_stopped_to_layered_assistant_messages.rb +5 -0
  27. data/lib/layered/assistant/version.rb +1 -1
  28. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7b9f6574ccdd2e1fe605f6f74bcb50feb69c6eca079a5d5add6bc1b615488972
4
- data.tar.gz: cc618f8dde0d9211ae7445dc10f0f75f6e63012a5b3c52a66afbca595ed1aa7f
3
+ metadata.gz: c09f437cf1d50e987e065bab63c5a974c569511d6095a09e0fb5e3072119d08b
4
+ data.tar.gz: 24476722cc3bf5d342cd308974e0fb888f059fd838cb406215e6772211dde587
5
5
  SHA512:
6
- metadata.gz: 1b853a8464bf2eb6507d8287cefc46c51268e908fe0c6045e6d2265084fd829aec1b6ebfb92cf2a35f41ca15f3a85a19bc1c756c91678e6d872c3234888ff443
7
- data.tar.gz: ca37eee8b57612ab350522dec5cbc46c334ed168043d2c8ee581813d1ba224ae42e5c685618669f2af76b61c0c81c46eb044564a3008de3c137ce670cf987c81
6
+ metadata.gz: f26719babe3ba1885f3f96021f5900d202d9aa4ba70353477f6559ac8b04871a5d7bd24d81be0cd3b93866bf8d3e2d7cb83c5fab3b354c618e45555fe96476d4
7
+ data.tar.gz: 25fcf070c6222f1fca1234939f7f283cdabe0fd232ea407c704c2a8f270ae9ac2caaf57fb775f7d35a15d83c4c3a5921d616a847adf200190c5ecaf4e2bf5017
data/AGENTS.md CHANGED
@@ -46,6 +46,8 @@ Verify dependencies before modifying host app files. Use `inject_into_file` for
46
46
  ## Making changes to `layered-ui-rails`
47
47
 
48
48
  The UI kit gem for this app is located at `../layered-ui-rails`.
49
+ Use existing classes where possible to keep the UI project lean.
50
+ If there’s a class missing that would be genuinely useful, let me know before making any edits.
49
51
  You can find and make edits to that project directly if necessary.
50
52
  It has its own `AGENTS.md` file — inspect it for context on the apps's conventions, architecture, and development workflows.
51
53
 
data/README.md CHANGED
@@ -51,6 +51,15 @@ This will:
51
51
 
52
52
  All steps are idempotent - re-running the generator will not duplicate imports, routes, or migrations.
53
53
 
54
+ ### Upgrading
55
+
56
+ After updating the gem, copy any new migrations and run them:
57
+
58
+ ```bash
59
+ bin/rails generate layered:assistant:migrations
60
+ bin/rails db:migrate
61
+ ```
62
+
54
63
  ## Authorization
55
64
 
56
65
  All non-public engine routes are **blocked by default** (403 Forbidden) until you configure an `authorize` block. The install generator creates a starter initialiser at `config/initializers/layered_assistant.rb` - uncomment one of the examples to get started.
@@ -0,0 +1,13 @@
1
+ module Layered
2
+ module Assistant
3
+ module StoppableResponse
4
+ def stop
5
+ if @conversation.stop_response!
6
+ head :ok
7
+ else
8
+ head :no_content
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,7 +1,9 @@
1
1
  module Layered
2
2
  module Assistant
3
3
  class ConversationsController < ApplicationController
4
- before_action :set_conversation, only: [:show, :edit, :update, :destroy]
4
+ include StoppableResponse
5
+
6
+ before_action :set_conversation, only: [:show, :edit, :update, :destroy, :stop]
5
7
  before_action :set_assistants, only: [:new, :create]
6
8
 
7
9
  def index
@@ -2,9 +2,10 @@ module Layered
2
2
  module Assistant
3
3
  module Panel
4
4
  class ConversationsController < ApplicationController
5
+ include StoppableResponse
5
6
  layout false
6
7
 
7
- before_action :set_conversation, only: [:show, :destroy]
8
+ before_action :set_conversation, only: [:show, :destroy, :stop]
8
9
  before_action :set_conversations, only: [:index, :show]
9
10
  before_action :set_assistants, only: [:index, :show, :new, :create]
10
11
 
@@ -2,8 +2,10 @@ module Layered
2
2
  module Assistant
3
3
  module Public
4
4
  class ConversationsController < ApplicationController
5
+ include StoppableResponse
6
+
5
7
  before_action :set_public_assistant, only: [:create]
6
- before_action :set_conversation, only: [:show]
8
+ before_action :set_conversation, only: [:show, :stop]
7
9
 
8
10
  def create
9
11
  @conversation = @assistant.conversations.new(name: Conversation.default_name)
@@ -3,10 +3,11 @@ module Layered
3
3
  module Public
4
4
  module Panel
5
5
  class ConversationsController < Public::ApplicationController
6
+ include StoppableResponse
6
7
  layout false
7
8
 
8
9
  before_action :set_public_assistant, only: [:index, :new, :create]
9
- before_action :set_conversation, only: [:show]
10
+ before_action :set_conversation, only: [:show, :stop]
10
11
  before_action :set_session_conversations, only: [:index, :show]
11
12
 
12
13
  def index
@@ -1,14 +1,33 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
+ // Manages the message composer form. Toggles the submit button between
4
+ // "Send" and "Stop" depending on whether the assistant is responding,
5
+ // and disables "Send" when the input is empty.
3
6
  export default class extends Controller {
4
- static targets = ["form", "input", "button"]
7
+ static targets = ["form", "input", "sendButton", "stopButton"]
8
+ static values = {
9
+ responding: { type: Boolean, default: false },
10
+ stopUrl: { type: String, default: "" }
11
+ }
5
12
 
6
13
  connect() {
7
- if (!this.element.closest("turbo-frame")) {
8
- this.inputTarget.focus()
9
- }
14
+ this._onChunkReceived = () => this._resetRespondingTimeout()
15
+ document.addEventListener("assistant:chunk-received", this._onChunkReceived)
16
+ this._applyRespondingState()
17
+ this.updateButtonDisabled()
18
+ }
19
+
20
+ disconnect() {
21
+ document.removeEventListener("assistant:chunk-received", this._onChunkReceived)
22
+ clearTimeout(this._respondingTimeout)
23
+ }
24
+
25
+ respondingValueChanged() {
26
+ this._applyRespondingState()
10
27
  }
11
28
 
29
+ // Enter submits, Shift+Enter is a no-op (default newline),
30
+ // Alt+Enter inserts a newline without submitting.
12
31
  submitOnEnter(event) {
13
32
  if (event.key !== "Enter") return
14
33
  if (event.shiftKey) return
@@ -17,14 +36,77 @@ export default class extends Controller {
17
36
 
18
37
  if (event.altKey) {
19
38
  this.inputTarget.setRangeText("\n", this.inputTarget.selectionStart, this.inputTarget.selectionEnd, "end")
20
- } else if (this.inputTarget.value.trim() !== "") {
39
+ } else if (!this.respondingValue && this.inputTarget.value.trim() !== "") {
21
40
  this.formTarget.requestSubmit()
22
41
  }
23
42
  }
24
43
 
25
44
  submit() {
45
+ this.respondingValue = true
46
+
47
+ // Dismiss the on-screen keyboard on touch devices
26
48
  if ("ontouchstart" in window) {
27
49
  this.inputTarget.blur()
28
50
  }
29
51
  }
52
+
53
+ // Ask the server to stop the current response, then reset the composer.
54
+ // On failure the composer resets anyway so the user is not stuck.
55
+ stop(event) {
56
+ event.preventDefault()
57
+
58
+ if (!this.stopUrlValue) return
59
+
60
+ const token = document.querySelector('meta[name="csrf-token"]')?.content
61
+ fetch(this.stopUrlValue, {
62
+ method: "PATCH",
63
+ headers: {
64
+ "X-CSRF-Token": token,
65
+ "Accept": "text/vnd.turbo-stream.html"
66
+ }
67
+ }).then(response => {
68
+ if (response.ok) this.respondingValue = false
69
+ }).catch(() => {
70
+ this.respondingValue = false
71
+ })
72
+ }
73
+
74
+ // Disable the send button when the input is empty. Called from the
75
+ // `input` event on the textarea and after responding state changes.
76
+ updateButtonDisabled() {
77
+ if (this.respondingValue) return
78
+
79
+ const empty = this.inputTarget.value.trim() === ""
80
+ this.sendButtonTarget.disabled = empty
81
+ this.sendButtonTarget.classList.toggle("l-ui-button--disabled", empty)
82
+ }
83
+
84
+ // Toggle visibility of the Send and Stop buttons. While responding a
85
+ // 60-second safety timeout resets the composer in case the server
86
+ // never signals completion. The timeout is reset each time a chunk
87
+ // is received so long-running responses are not interrupted.
88
+ _applyRespondingState() {
89
+ clearTimeout(this._respondingTimeout)
90
+
91
+ if (this.respondingValue) {
92
+ this.sendButtonTarget.hidden = true
93
+ this.stopButtonTarget.hidden = false
94
+
95
+ this._resetRespondingTimeout()
96
+ } else {
97
+ this.stopButtonTarget.hidden = true
98
+ this.sendButtonTarget.hidden = false
99
+ this.updateButtonDisabled()
100
+
101
+ if (!this.element.closest("turbo-frame")) {
102
+ this.inputTarget.focus()
103
+ }
104
+ }
105
+ }
106
+
107
+ _resetRespondingTimeout() {
108
+ if (!this.respondingValue) return
109
+ clearTimeout(this._respondingTimeout)
110
+ this._respondingTimeout = setTimeout(() => { this.respondingValue = false }, 60000)
111
+ }
30
112
  }
@@ -105,6 +105,12 @@ function scheduleRender(target) {
105
105
  renderTimers.set(target, id)
106
106
  }
107
107
 
108
+ Turbo.StreamActions.enable_composer = function () {
109
+ this.targetElements.forEach((form) => {
110
+ form.setAttribute("data-composer-responding-value", "false")
111
+ })
112
+ }
113
+
108
114
  Turbo.StreamActions.append_chunk = function () {
109
115
  this.targetElements.forEach((target) => {
110
116
  const text = this.templateContent.textContent || ""
@@ -121,4 +127,7 @@ Turbo.StreamActions.append_chunk = function () {
121
127
  // Schedule debounced render
122
128
  scheduleRender(target)
123
129
  })
130
+
131
+ // Notify the composer so it can reset its safety timeout
132
+ document.dispatchEvent(new CustomEvent("assistant:chunk-received"))
124
133
  }
@@ -10,6 +10,7 @@ module Layered
10
10
  unless message.model&.provider
11
11
  message.update(content: "No provider is configured for this model.")
12
12
  message.broadcast_updated
13
+ message.broadcast_response_complete
13
14
  return
14
15
  end
15
16
 
@@ -29,6 +30,8 @@ module Layered
29
30
  message.update(content: existing.present? ? "#{existing}\n\n---\n\n#{error_note}" : error_note)
30
31
  message.broadcast_updated
31
32
  end
33
+
34
+ message.broadcast_response_complete unless message.reload.stopped?
32
35
  end
33
36
  end
34
37
  end
@@ -28,6 +28,23 @@ module Layered
28
28
  "New conversation"
29
29
  end
30
30
 
31
+ def stop_response!
32
+ message = messages.where(role: :assistant, stopped: false).order(created_at: :desc).first
33
+ return false unless message
34
+
35
+ message.with_lock do
36
+ return false if message.stopped?
37
+
38
+ estimated = TokenEstimator.estimate(message.content) || 0
39
+ message.update!(stopped: true, output_tokens: estimated, tokens_estimated: true)
40
+ update_token_totals!
41
+ message.broadcast_updated
42
+ message.broadcast_response_complete
43
+ end
44
+
45
+ true
46
+ end
47
+
31
48
  def update_name_from_content!(content)
32
49
  return unless name == self.class.default_name
33
50
  return if content.blank?
@@ -39,6 +39,12 @@ module Layered
39
39
  locals: { message: self }
40
40
  end
41
41
 
42
+ def broadcast_response_complete
43
+ broadcast_action_to conversation,
44
+ action: :enable_composer,
45
+ targets: ".#{dom_id(conversation)}_composer"
46
+ end
47
+
42
48
  def broadcast_chunk(text)
43
49
  broadcast_action_to conversation,
44
50
  action: :append_chunk,
@@ -1,14 +1,26 @@
1
1
  module Layered
2
2
  module Assistant
3
3
  class ChunkService
4
+ STOP_CHECK_INTERVAL = 25
5
+
4
6
  def initialize(message, provider:)
5
7
  @message = message
6
8
  @provider = provider
7
9
  @input_tokens = 0
8
10
  @output_tokens = 0
11
+ @chunk_count = 0
12
+ @stopped = false
9
13
  end
10
14
 
11
15
  def call(chunk)
16
+ return if @stopped
17
+
18
+ @chunk_count += 1
19
+ if @chunk_count % STOP_CHECK_INTERVAL == 0
20
+ @stopped = @message.reload.stopped?
21
+ return if @stopped
22
+ end
23
+
12
24
  Rails.logger.debug { "[ChunkService] #{chunk.inspect}" }
13
25
  text = extract_text(chunk)
14
26
  extract_usage(chunk)
@@ -1,15 +1,3 @@
1
- <%= form_with url: layered_assistant.conversation_messages_path(conversation), id: "composer-form", data: { controller: "composer", composer_target: "form", action: "submit->composer#submit" } do |f| %>
2
- <div class="l-ui-conversation__composer">
3
- <%= f.text_area :content, name: "message[content]", class: "l-ui-conversation__composer-input", placeholder: "Type a message...", "aria-label": "Message", rows: 1, data: { composer_target: "input", action: "keydown->composer#submitOnEnter" } %>
4
-
5
- <button type="submit" class="l-ui-button--primary" title="Send (Enter)" data-composer-target="button">Send</button>
6
- </div>
7
-
8
- <% if models.any? %>
9
- <div class="l-ui-form__group">
10
- <div class="l-ui-select-wrapper">
11
- <%= f.select :model_id, options_for_select(models.map { |m| ["#{m.provider.name}: #{m.name}", m.id] }, selected_model_id), {}, name: "message[model_id]", class: "l-ui-select", "aria-label": "AI model" %>
12
- </div>
13
- </div>
14
- <% end %>
1
+ <%= form_with url: layered_assistant.conversation_messages_path(conversation), id: "composer-form", class: "#{dom_id(conversation)}_composer", data: { controller: "composer", composer_target: "form", action: "submit->composer#submit", composer_responding_value: local_assigns.fetch(:responding, false), composer_stop_url_value: layered_assistant.stop_conversation_path(conversation) } do |f| %>
2
+ <%= render "layered/assistant/messages/composer_fields", f: f, models: models, selected_model_id: selected_model_id %>
15
3
  <% end %>
@@ -0,0 +1,14 @@
1
+ <div class="l-ui-conversation__composer">
2
+ <%= f.text_area :content, name: "message[content]", class: "l-ui-conversation__composer-input", placeholder: "Type a message...", "aria-label": "Message", rows: 1, data: { composer_target: "input", action: "keydown->composer#submitOnEnter input->composer#updateButtonDisabled" } %>
3
+
4
+ <button type="submit" class="l-ui-button--primary" title="Send (Enter)" data-composer-target="sendButton">Send</button>
5
+ <button type="button" class="l-ui-button--primary" title="Stop" data-composer-target="stopButton" data-action="click->composer#stop" hidden>Stop</button>
6
+ </div>
7
+
8
+ <% if local_assigns[:models]&.any? %>
9
+ <div class="l-ui-form__group">
10
+ <div class="l-ui-select-wrapper">
11
+ <%= f.select :model_id, options_for_select(models.map { |m| ["#{m.provider.name}: #{m.name}", m.id] }, selected_model_id), {}, name: "message[model_id]", class: "l-ui-select", "aria-label": "AI model" %>
12
+ </div>
13
+ </div>
14
+ <% end %>
@@ -3,8 +3,8 @@
3
3
  <% unless message.user? %>
4
4
  <div class="l-ui-message__author"><%= message.role.capitalize %></div>
5
5
  <% end %>
6
- <div class="<%= dom_id(message) %>_body l-ui-message__body<%= " l-ui-markdown" unless message.assistant? && message.content.blank? %>">
7
- <% if message.assistant? && message.content.blank? %>
6
+ <div class="<%= dom_id(message) %>_body l-ui-message__body<%= " l-ui-markdown" unless message.assistant? && message.content.blank? && !message.stopped? %>">
7
+ <% if message.assistant? && message.content.blank? && !message.stopped? %>
8
8
  <div class="l-ui-typing-indicator" role="status" aria-label="Assistant is typing">
9
9
  <span class="l-ui-typing-indicator__dot"></span>
10
10
  <span class="l-ui-typing-indicator__dot"></span>
@@ -13,6 +13,9 @@
13
13
  <% else %>
14
14
  <%= render_message_content(message) %>
15
15
  <% end %>
16
+ <% if message.stopped? %>
17
+ <div class="l-ui-notice--warning" role="status">The response was stopped</div>
18
+ <% end %>
16
19
  </div>
17
20
  <div class="l-ui-message__footer">
18
21
  <% total_tokens = message.input_tokens.to_i + message.output_tokens.to_i %>
@@ -5,5 +5,5 @@
5
5
  <% end %>
6
6
 
7
7
  <%= turbo_stream.replace "composer-form" do %>
8
- <%= render "layered/assistant/messages/composer", conversation: @conversation, models: @models, selected_model_id: @selected_model_id %>
8
+ <%= render "layered/assistant/messages/composer", conversation: @conversation, models: @models, selected_model_id: @selected_model_id, responding: @error.nil? %>
9
9
  <% end %>
@@ -1,15 +1,3 @@
1
- <%= form_with url: layered_assistant.panel_conversation_messages_path(conversation), id: "panel-composer-form", data: { controller: "composer", composer_target: "form", turbo_frame: "_top", action: "submit->composer#submit" } do |f| %>
2
- <div class="l-ui-conversation__composer">
3
- <%= f.text_area :content, name: "message[content]", class: "l-ui-conversation__composer-input", placeholder: "Type a message...", "aria-label": "Message", rows: 1, data: { composer_target: "input", action: "keydown->composer#submitOnEnter" } %>
4
-
5
- <button type="submit" class="l-ui-button--primary" title="Send (Enter)" data-composer-target="button">Send</button>
6
- </div>
7
-
8
- <% if models.any? %>
9
- <div class="l-ui-form__group">
10
- <div class="l-ui-select-wrapper">
11
- <%= f.select :model_id, options_for_select(models.map { |m| ["#{m.provider.name}: #{m.name}", m.id] }, selected_model_id), {}, name: "message[model_id]", class: "l-ui-select", "aria-label": "AI model" %>
12
- </div>
13
- </div>
14
- <% end %>
1
+ <%= form_with url: layered_assistant.panel_conversation_messages_path(conversation), id: "panel-composer-form", class: "#{dom_id(conversation)}_composer", data: { controller: "composer", composer_target: "form", turbo_frame: "_top", action: "submit->composer#submit", composer_responding_value: local_assigns.fetch(:responding, false), composer_stop_url_value: layered_assistant.stop_panel_conversation_path(conversation) } do |f| %>
2
+ <%= render "layered/assistant/messages/composer_fields", f: f, models: models, selected_model_id: selected_model_id %>
15
3
  <% end %>
@@ -5,5 +5,5 @@
5
5
  <% end %>
6
6
 
7
7
  <%= turbo_stream.replace "panel-composer-form" do %>
8
- <%= render "layered/assistant/panel/messages/composer", conversation: @conversation, models: @models, selected_model_id: @selected_model_id %>
8
+ <%= render "layered/assistant/panel/messages/composer", conversation: @conversation, models: @models, selected_model_id: @selected_model_id, responding: @error.nil? %>
9
9
  <% end %>
@@ -1,7 +1,3 @@
1
- <%= form_with url: layered_assistant.public_conversation_messages_path(conversation), id: "public-composer-form", data: { controller: "composer", composer_target: "form", turbo_frame: "_top", action: "submit->composer#submit" } do |f| %>
2
- <div class="l-ui-conversation__composer">
3
- <%= f.text_area :content, name: "message[content]", class: "l-ui-conversation__composer-input", placeholder: "Type a message...", "aria-label": "Message", rows: 1, data: { composer_target: "input", action: "keydown->composer#submitOnEnter" } %>
4
-
5
- <button type="submit" class="l-ui-button--primary" title="Send (Enter)" data-composer-target="button">Send</button>
6
- </div>
1
+ <%= form_with url: layered_assistant.public_conversation_messages_path(conversation), id: "public-composer-form", class: "#{dom_id(conversation)}_composer", data: { controller: "composer", composer_target: "form", turbo_frame: "_top", action: "submit->composer#submit", composer_responding_value: local_assigns.fetch(:responding, false), composer_stop_url_value: layered_assistant.stop_public_conversation_path(conversation) } do |f| %>
2
+ <%= render "layered/assistant/messages/composer_fields", f: f %>
7
3
  <% end %>
@@ -5,5 +5,5 @@
5
5
  <% end %>
6
6
 
7
7
  <%= turbo_stream.replace "public-composer-form" do %>
8
- <%= render "layered/assistant/public/messages/composer", conversation: @conversation %>
8
+ <%= render "layered/assistant/public/messages/composer", conversation: @conversation, responding: @error.nil? %>
9
9
  <% end %>
@@ -1,7 +1,3 @@
1
- <%= form_with url: layered_assistant.public_panel_conversation_messages_path(conversation), id: "public-panel-composer-form", data: { controller: "composer", composer_target: "form", turbo_frame: "_top", action: "submit->composer#submit" } do |f| %>
2
- <div class="l-ui-conversation__composer">
3
- <%= f.text_area :content, name: "message[content]", class: "l-ui-conversation__composer-input", placeholder: "Type a message...", "aria-label": "Message", rows: 1, data: { composer_target: "input", action: "keydown->composer#submitOnEnter" } %>
4
-
5
- <button type="submit" class="l-ui-button--primary" title="Send (Enter)" data-composer-target="button">Send</button>
6
- </div>
1
+ <%= form_with url: layered_assistant.public_panel_conversation_messages_path(conversation), id: "public-panel-composer-form", class: "#{dom_id(conversation)}_composer", data: { controller: "composer", composer_target: "form", turbo_frame: "_top", action: "submit->composer#submit", composer_responding_value: local_assigns.fetch(:responding, false), composer_stop_url_value: layered_assistant.stop_public_panel_conversation_path(conversation) } do |f| %>
2
+ <%= render "layered/assistant/messages/composer_fields", f: f %>
7
3
  <% end %>
@@ -5,5 +5,5 @@
5
5
  <% end %>
6
6
 
7
7
  <%= turbo_stream.replace "public-panel-composer-form" do %>
8
- <%= render "layered/assistant/public/panel/messages/composer", conversation: @conversation %>
8
+ <%= render "layered/assistant/public/panel/messages/composer", conversation: @conversation, responding: @error.nil? %>
9
9
  <% end %>
data/config/routes.rb CHANGED
@@ -7,11 +7,13 @@ Layered::Assistant::Engine.routes.draw do
7
7
  resources :models, only: [:index, :new, :create, :edit, :update, :destroy]
8
8
  end
9
9
  resources :conversations, only: [:index, :show, :new, :create, :edit, :update, :destroy] do
10
+ patch :stop, on: :member
10
11
  resources :messages, only: [:index, :create, :destroy]
11
12
  end
12
13
 
13
14
  namespace :panel do
14
15
  resources :conversations, only: [:index, :show, :new, :create, :destroy] do
16
+ patch :stop, on: :member
15
17
  resources :messages, only: [:create]
16
18
  end
17
19
  end
@@ -19,11 +21,13 @@ Layered::Assistant::Engine.routes.draw do
19
21
  namespace :public do
20
22
  resources :assistants, only: [:index, :show]
21
23
  resources :conversations, only: [:show, :create] do
24
+ patch :stop, on: :member
22
25
  resources :messages, only: [:create]
23
26
  end
24
27
 
25
28
  namespace :panel do
26
29
  resources :conversations, only: [:index, :show, :new, :create] do
30
+ patch :stop, on: :member
27
31
  resources :messages, only: [:create]
28
32
  end
29
33
  end
@@ -0,0 +1,5 @@
1
+ class AddStoppedToLayeredAssistantMessages < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :layered_assistant_messages, :stopped, :boolean, default: false, null: false
4
+ end
5
+ end
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Assistant
3
- VERSION = "0.1.0"
3
+ VERSION = "0.1.1"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: layered-assistant-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai
@@ -305,6 +305,7 @@ files:
305
305
  - app/assets/tailwind/layered/assistant/styles.css
306
306
  - app/controllers/concerns/layered/assistant/message_creation.rb
307
307
  - app/controllers/concerns/layered/assistant/public/session_conversations.rb
308
+ - app/controllers/concerns/layered/assistant/stoppable_response.rb
308
309
  - app/controllers/layered/assistant/application_controller.rb
309
310
  - app/controllers/layered/assistant/assistants_controller.rb
310
311
  - app/controllers/layered/assistant/conversations_controller.rb
@@ -357,6 +358,7 @@ files:
357
358
  - app/views/layered/assistant/conversations/new.html.erb
358
359
  - app/views/layered/assistant/conversations/show.html.erb
359
360
  - app/views/layered/assistant/messages/_composer.html.erb
361
+ - app/views/layered/assistant/messages/_composer_fields.html.erb
360
362
  - app/views/layered/assistant/messages/_message.html.erb
361
363
  - app/views/layered/assistant/messages/_system_prompt.html.erb
362
364
  - app/views/layered/assistant/messages/create.turbo_stream.erb
@@ -394,6 +396,7 @@ files:
394
396
  - config/routes.rb
395
397
  - data/models.json
396
398
  - db/migrate/20260312000000_create_layered_assistant_tables.rb
399
+ - db/migrate/20260315000000_add_stopped_to_layered_assistant_messages.rb
397
400
  - lib/generators/layered/assistant/install_generator.rb
398
401
  - lib/generators/layered/assistant/migrations_generator.rb
399
402
  - lib/generators/layered/assistant/templates/initializer.rb