layered-assistant-rails 0.1.1 → 0.1.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +1 -1
  3. data/README.md +49 -5
  4. data/app/controllers/concerns/layered/assistant/message_creation.rb +2 -6
  5. data/app/controllers/concerns/layered/assistant/public/session_conversations.rb +13 -14
  6. data/app/controllers/layered/assistant/application_controller.rb +12 -0
  7. data/app/controllers/layered/assistant/assistants_controller.rb +2 -2
  8. data/app/controllers/layered/assistant/conversations_controller.rb +5 -5
  9. data/app/controllers/layered/assistant/messages_controller.rb +4 -4
  10. data/app/controllers/layered/assistant/models_controller.rb +1 -1
  11. data/app/controllers/layered/assistant/panel/conversations_controller.rb +3 -3
  12. data/app/controllers/layered/assistant/panel/messages_controller.rb +3 -3
  13. data/app/controllers/layered/assistant/providers_controller.rb +2 -2
  14. data/app/controllers/layered/assistant/public/assistants_controller.rb +3 -0
  15. data/app/controllers/layered/assistant/public/conversations_controller.rb +8 -0
  16. data/app/controllers/layered/assistant/public/panel/conversations_controller.rb +3 -3
  17. data/app/controllers/layered/assistant/public/panel/messages_controller.rb +1 -1
  18. data/app/helpers/layered/assistant/access_helper.rb +2 -2
  19. data/app/helpers/layered/assistant/messages_helper.rb +18 -1
  20. data/app/javascript/layered_assistant/composer_controller.js +4 -1
  21. data/app/javascript/layered_assistant/conversation_select_controller.js +8 -0
  22. data/app/javascript/layered_assistant/index.js +2 -0
  23. data/app/javascript/layered_assistant/messages_controller.js +31 -1
  24. data/app/jobs/layered/assistant/messages/response_job.rb +1 -0
  25. data/app/models/layered/assistant/conversation.rb +19 -6
  26. data/app/models/layered/assistant/message.rb +1 -1
  27. data/app/models/layered/assistant/provider.rb +3 -3
  28. data/app/services/layered/assistant/chunk_parser.rb +46 -0
  29. data/app/services/layered/assistant/chunk_service.rb +25 -46
  30. data/app/services/layered/assistant/clients/anthropic.rb +3 -2
  31. data/app/services/layered/assistant/clients/openai.rb +3 -2
  32. data/app/services/layered/assistant/response_timer.rb +31 -0
  33. data/app/views/layered/assistant/assistants/_form.html.erb +1 -1
  34. data/app/views/layered/assistant/conversations/_form.html.erb +1 -1
  35. data/app/views/layered/assistant/messages/_message.html.erb +4 -4
  36. data/app/views/layered/assistant/messages/index.html.erb +18 -1
  37. data/app/views/layered/assistant/models/_form.html.erb +1 -1
  38. data/app/views/layered/assistant/providers/_form.html.erb +2 -2
  39. data/app/views/layered/assistant/providers/index.html.erb +1 -1
  40. data/app/views/layered/assistant/public/conversations/show.html.erb +11 -3
  41. data/app/views/layered/assistant/setup/_setup.html.erb +89 -0
  42. data/app/views/layouts/layered/assistant/application.html.erb +24 -14
  43. data/config/importmap.rb +1 -0
  44. data/config/locales/en.yml +5 -0
  45. data/db/migrate/20260315100000_add_response_timing_to_layered_assistant_messages.rb +6 -0
  46. data/db/migrate/20260317000000_normalise_provider_protocol_values.rb +11 -0
  47. data/lib/generators/layered/assistant/templates/initializer.rb +34 -0
  48. data/lib/layered/assistant/version.rb +1 -1
  49. data/lib/layered/assistant.rb +8 -0
  50. metadata +10 -5
  51. data/app/views/layered/assistant/public/assistants/show.html.erb +0 -23
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c09f437cf1d50e987e065bab63c5a974c569511d6095a09e0fb5e3072119d08b
4
- data.tar.gz: 24476722cc3bf5d342cd308974e0fb888f059fd838cb406215e6772211dde587
3
+ metadata.gz: 0c6156a26192a7edd3fcb67ac5a170aa88ed8b52d2e673787d5595f05c420fbd
4
+ data.tar.gz: 79ec02dbdf134098f5ddb28d6dc480d626a27d35abb1151e1b6712e5496f09c9
5
5
  SHA512:
6
- metadata.gz: f26719babe3ba1885f3f96021f5900d202d9aa4ba70353477f6559ac8b04871a5d7bd24d81be0cd3b93866bf8d3e2d7cb83c5fab3b354c618e45555fe96476d4
7
- data.tar.gz: 25fcf070c6222f1fca1234939f7f283cdabe0fd232ea407c704c2a8f270ae9ac2caaf57fb775f7d35a15d83c4c3a5921d616a847adf200190c5ecaf4e2bf5017
6
+ metadata.gz: 3e09ac245b3bbbbb14c7e73540c6bd91d215323de65957b4452d797a6d5e23ddeb03b8fef7da870b781a75b1e18ebd29592563c06eacd3ea1038e7e6aa415447
7
+ data.tar.gz: a7ce50cbdba541810656dfd56fd660a435bb4d6b87b3f77fa50818c65374db059f7a5ba4d1aa5e2c1e2011efc8e4db706e1579ff9e9ea12c11bc68b2a438d3b3
data/AGENTS.md CHANGED
@@ -53,7 +53,7 @@ It has its own `AGENTS.md` file — inspect it for context on the apps's convent
53
53
 
54
54
  ## Conventions
55
55
 
56
- - Use "layered-ui-assistant" not "Layered UI" when referring to the project
56
+ - Use "layered-assistant-rails" not "Layered Assistant Rails" when referring to the project
57
57
  - Use normal dashes '-' not long ones '—'
58
58
  - Locale: Favour en-GB (British English), unless terms are defined by technical standards (e.g. LICENSE, COLOR).
59
59
  - Titles: capitalise first word only (e.g. "This title")
data/README.md CHANGED
@@ -102,6 +102,40 @@ The `l_assistant_accessible?` helper evaluates the authorize block without side
102
102
  <% end %>
103
103
  ```
104
104
 
105
+ ## Record scoping
106
+
107
+ By default, all records are visible to any authorised user. If your application is multi-tenant or you need to restrict which records a user can see, configure a `scope` block in the initialiser.
108
+
109
+ The block receives the model class, runs in controller context, and must return an `ActiveRecord::Relation`. The following models are passed through the scope block:
110
+
111
+ | Model | Description |
112
+ |---|---|
113
+ | `Layered::Assistant::Conversation` | User conversations (has polymorphic `owner`) |
114
+ | `Layered::Assistant::Assistant` | Assistant configurations (has polymorphic `owner`) |
115
+ | `Layered::Assistant::Provider` | API provider credentials (has polymorphic `owner`) |
116
+
117
+ ### Scope all owned resources to the current user
118
+
119
+ ```ruby
120
+ Layered::Assistant.scope do |model_class|
121
+ model_class.where(owner: current_user)
122
+ end
123
+ ```
124
+
125
+ ### Scope conversations only
126
+
127
+ ```ruby
128
+ Layered::Assistant.scope do |model_class|
129
+ if model_class == Layered::Assistant::Conversation
130
+ model_class.where(owner: current_user)
131
+ else
132
+ model_class.all
133
+ end
134
+ end
135
+ ```
136
+
137
+ When no scope block is configured, queries are unscoped. Record-level access control is the host application's responsibility; the scope block is the integration point for it.
138
+
105
139
  ## Panel helpers
106
140
 
107
141
  The engine provides two convenience helpers for wiring the layered-ui panel to the assistant. Use them inside `content_for` blocks in your application layout:
@@ -131,12 +165,22 @@ Both helpers accept keyword arguments that are forwarded as HTML attributes to t
131
165
 
132
166
  ## Configuration
133
167
 
134
- ### Environment Variables
168
+ Optional settings can be added to your initialiser (`config/initializers/layered_assistant.rb`):
169
+
170
+ ```ruby
171
+ # Log API errors to stdout (default: false)
172
+ Layered::Assistant.log_errors = true
173
+
174
+ # Total timeout in seconds for API requests, including the full streaming response (default: 210).
175
+ # Increase for models with high max_tokens limits or slow providers.
176
+ Layered::Assistant.api_request_timeout = 210
177
+
178
+ # Disable Active Record Encryption on Provider#secret.
179
+ # Only use this in development/test environments without encryption keys configured.
180
+ Layered::Assistant.skip_db_encryption = true
181
+ ```
135
182
 
136
- | Variable | Default | Description |
137
- |---|---|---|
138
- | `LAYERED_ASSISTANT_DANGEROUSLY_SKIP_DB_ENCRYPTION` | `nil` | Set to `"yes"` to skip Active Record Encryption on `Provider#secret`. Only for development/test environments without encryption keys configured |
139
- | `LAYERED_ASSISTANT_LOG_ERRORS` | `nil` | Set to `"yes"` to enable error logging from the AI API clients |
183
+ Note: `skip_db_encryption` is read at class load time, so it must be set before `Layered::Assistant::Provider` is first loaded. A standard Rails initialiser satisfies this requirement.
140
184
 
141
185
  ## Demo
142
186
 
@@ -15,10 +15,7 @@ module Layered
15
15
  return { message: message } unless message.persisted?
16
16
 
17
17
  conversation.update_name_from_content!(content)
18
- message.broadcast_created
19
18
 
20
- models = Model.available
21
- selected_model_id = model_id
22
19
  assistant_message = nil
23
20
  error = nil
24
21
 
@@ -28,8 +25,9 @@ module Layered
28
25
  content: nil,
29
26
  model_id: model_id
30
27
  )
31
- assistant_message.broadcast_created
32
28
 
29
+ message.broadcast_created
30
+ assistant_message.broadcast_created
33
31
  Messages::ResponseJob.perform_later(assistant_message.id)
34
32
  rescue => e
35
33
  Rails.logger.error("Assistant response failed: #{e.message}")
@@ -39,8 +37,6 @@ module Layered
39
37
  {
40
38
  message: message,
41
39
  assistant_message: assistant_message,
42
- models: models,
43
- selected_model_id: selected_model_id,
44
40
  error: error
45
41
  }
46
42
  end
@@ -6,41 +6,40 @@ module Layered
6
6
 
7
7
  private
8
8
 
9
- def session_conversation_ids
10
- session[:layered_assistant_conversation_ids] ||= []
9
+ def session_conversation_uids
10
+ session[:layered_assistant_conversation_uids] ||= []
11
11
  end
12
12
 
13
13
  MAX_SESSION_CONVERSATIONS = 50
14
14
 
15
15
  def add_conversation_to_session(conversation)
16
- ids = session_conversation_ids
17
- ids << conversation.id unless ids.include?(conversation.id)
18
- ids.shift while ids.size > MAX_SESSION_CONVERSATIONS
16
+ uids = session_conversation_uids
17
+ uids << conversation.uid unless uids.include?(conversation.uid)
18
+ uids.shift while uids.size > MAX_SESSION_CONVERSATIONS
19
19
  end
20
20
 
21
- def find_session_conversation(id)
22
- id = id.to_i
23
- unless session_conversation_ids.include?(id)
21
+ def find_session_conversation(uid)
22
+ unless session_conversation_uids.include?(uid)
24
23
  raise ActiveRecord::RecordNotFound, "Conversation not found in session"
25
24
  end
26
25
 
27
- Conversation.joins(:assistant).merge(Assistant.publicly_available).find(id)
26
+ Conversation.joins(:assistant).merge(Assistant.publicly_available).find_by!(uid: uid)
28
27
  rescue ActiveRecord::RecordNotFound
29
- remove_conversation_from_session(id)
28
+ remove_conversation_from_session(uid)
30
29
  raise
31
30
  end
32
31
 
33
- def remove_conversation_from_session(id)
34
- session_conversation_ids.delete(id.to_i)
32
+ def remove_conversation_from_session(uid)
33
+ session_conversation_uids.delete(uid)
35
34
  end
36
35
 
37
36
  def existing_session_conversation_for(assistant)
38
- return nil if session_conversation_ids.empty?
37
+ return nil if session_conversation_uids.empty?
39
38
 
40
39
  Conversation
41
40
  .joins(:assistant)
42
41
  .merge(Assistant.publicly_available)
43
- .where(id: session_conversation_ids, assistant: assistant)
42
+ .where(uid: session_conversation_uids, assistant: assistant)
44
43
  .order(created_at: :desc)
45
44
  .first
46
45
  end
@@ -20,6 +20,18 @@ module Layered
20
20
 
21
21
  instance_exec(&block)
22
22
  end
23
+
24
+ def scoped(model_class)
25
+ block = Layered::Assistant.scope_block
26
+ return model_class.all unless block
27
+
28
+ result = instance_exec(model_class, &block)
29
+ unless result.is_a?(ActiveRecord::Relation)
30
+ raise ArgumentError,
31
+ "Layered::Assistant.scope must return an ActiveRecord::Relation, got #{result.class}"
32
+ end
33
+ result
34
+ end
23
35
  end
24
36
  end
25
37
  end
@@ -6,7 +6,7 @@ module Layered
6
6
 
7
7
  def index
8
8
  @page_title = "Assistants"
9
- @pagy, @assistants = pagy(Assistant.by_name)
9
+ @pagy, @assistants = pagy(scoped(Assistant).by_name)
10
10
  end
11
11
 
12
12
  def new
@@ -44,7 +44,7 @@ module Layered
44
44
  private
45
45
 
46
46
  def set_assistant
47
- @assistant = Assistant.find(params[:id])
47
+ @assistant = scoped(Assistant).find(params[:id])
48
48
  end
49
49
 
50
50
  def set_models
@@ -8,12 +8,12 @@ module Layered
8
8
 
9
9
  def index
10
10
  if params[:assistant_id]
11
- @assistant = Assistant.find(params[:assistant_id])
11
+ @assistant = scoped(Assistant).find(params[:assistant_id])
12
12
  @page_title = "Conversations - #{@assistant.name}"
13
- @pagy, @conversations = pagy(@assistant.conversations.includes(:owner).by_created_at)
13
+ @pagy, @conversations = pagy(@assistant.conversations.merge(scoped(Conversation)).includes(:owner).by_created_at)
14
14
  else
15
15
  @page_title = "Conversations"
16
- @pagy, @conversations = pagy(Conversation.includes(:assistant, :owner).by_created_at)
16
+ @pagy, @conversations = pagy(scoped(Conversation).includes(:assistant, :owner).by_created_at)
17
17
  end
18
18
  end
19
19
 
@@ -60,11 +60,11 @@ module Layered
60
60
  private
61
61
 
62
62
  def set_conversation
63
- @conversation = Conversation.find(params[:id])
63
+ @conversation = scoped(Conversation).find_by!(uid: params[:id])
64
64
  end
65
65
 
66
66
  def set_assistants
67
- @assistants = Assistant.by_name
67
+ @assistants = scoped(Assistant).by_name
68
68
  end
69
69
 
70
70
  def conversation_params
@@ -8,7 +8,7 @@ module Layered
8
8
 
9
9
  def index
10
10
  @page_title = "Messages"
11
- @pagy, @messages = pagy(@conversation.messages.by_created_at)
11
+ @pagy, @messages = pagy(@conversation.messages.includes(:model).by_created_at)
12
12
  end
13
13
 
14
14
  def create
@@ -24,8 +24,8 @@ module Layered
24
24
  end
25
25
 
26
26
  @assistant_message = result[:assistant_message]
27
- @models = result[:models]
28
- @selected_model_id = result[:selected_model_id]
27
+ @models = Model.available
28
+ @selected_model_id = message_params[:model_id]
29
29
  @error = result[:error]
30
30
 
31
31
  respond_to do |format|
@@ -42,7 +42,7 @@ module Layered
42
42
  private
43
43
 
44
44
  def set_conversation
45
- @conversation = Conversation.find(params[:conversation_id])
45
+ @conversation = scoped(Conversation).find_by!(uid: params[:conversation_id])
46
46
  end
47
47
 
48
48
  def set_message
@@ -46,7 +46,7 @@ module Layered
46
46
  private
47
47
 
48
48
  def set_provider
49
- @provider = Provider.find(params[:provider_id])
49
+ @provider = scoped(Provider).find(params[:provider_id])
50
50
  end
51
51
 
52
52
  def set_model
@@ -42,15 +42,15 @@ module Layered
42
42
  private
43
43
 
44
44
  def set_conversation
45
- @conversation = Conversation.find(params[:id])
45
+ @conversation = scoped(Conversation).find_by!(uid: params[:id])
46
46
  end
47
47
 
48
48
  def set_assistants
49
- @assistants = Assistant.by_name
49
+ @assistants = scoped(Assistant).by_name
50
50
  end
51
51
 
52
52
  def set_conversations
53
- scope = Conversation.by_created_at
53
+ scope = scoped(Conversation).by_created_at
54
54
  scope = scope.where(assistant_id: params[:assistant_id]) if params[:assistant_id].present?
55
55
  @conversations = scope.limit(20)
56
56
  end
@@ -20,8 +20,8 @@ module Layered
20
20
  end
21
21
 
22
22
  @assistant_message = result[:assistant_message]
23
- @models = result[:models]
24
- @selected_model_id = result[:selected_model_id]
23
+ @models = Model.available
24
+ @selected_model_id = message_params[:model_id]
25
25
  @error = result[:error]
26
26
 
27
27
  respond_to do |format|
@@ -32,7 +32,7 @@ module Layered
32
32
  private
33
33
 
34
34
  def set_conversation
35
- @conversation = Conversation.find(params[:conversation_id])
35
+ @conversation = scoped(Conversation).find_by!(uid: params[:conversation_id])
36
36
  end
37
37
 
38
38
  def message_params
@@ -5,7 +5,7 @@ module Layered
5
5
 
6
6
  def index
7
7
  @page_title = "Providers"
8
- @pagy, @providers = pagy(Provider.sorted)
8
+ @pagy, @providers = pagy(scoped(Provider).sorted)
9
9
  end
10
10
 
11
11
  def new
@@ -44,7 +44,7 @@ module Layered
44
44
  private
45
45
 
46
46
  def set_provider
47
- @provider = Provider.find(params[:id])
47
+ @provider = scoped(Provider).find(params[:id])
48
48
  end
49
49
 
50
50
  def provider_params
@@ -9,6 +9,9 @@ module Layered
9
9
  end
10
10
 
11
11
  def show
12
+ conversation = @assistant.conversations.create!(name: Conversation.default_name)
13
+ add_conversation_to_session(conversation)
14
+ redirect_to layered_assistant.public_conversation_path(conversation)
12
15
  end
13
16
  end
14
17
  end
@@ -20,6 +20,14 @@ module Layered
20
20
 
21
21
  def show
22
22
  @messages = @conversation.messages.includes(:model).by_created_at
23
+ @conversations = if session_conversation_uids.any?
24
+ Conversation.joins(:assistant).merge(Assistant.publicly_available)
25
+ .where(uid: session_conversation_uids, assistant: @conversation.assistant)
26
+ .by_created_at
27
+ .limit(20)
28
+ else
29
+ Conversation.none
30
+ end
23
31
  end
24
32
 
25
33
  private
@@ -36,7 +36,7 @@ module Layered
36
36
  def set_conversation
37
37
  @conversation = find_session_conversation(params[:id])
38
38
  rescue ActiveRecord::RecordNotFound
39
- assistant = Conversation.find_by(id: params[:id])&.assistant
39
+ assistant = Conversation.find_by(uid: params[:id])&.assistant
40
40
  if assistant&.public?
41
41
  redirect_to layered_assistant.public_panel_conversations_path(assistant_id: assistant.id)
42
42
  else
@@ -46,9 +46,9 @@ module Layered
46
46
 
47
47
  def set_session_conversations
48
48
  assistant_id = @conversation&.assistant_id || @assistant&.id
49
- @conversations = if session_conversation_ids.any?
49
+ @conversations = if session_conversation_uids.any?
50
50
  scope = Conversation.joins(:assistant).merge(Assistant.publicly_available)
51
- .where(id: session_conversation_ids)
51
+ .where(uid: session_conversation_uids)
52
52
  .by_created_at
53
53
  scope = scope.where(assistant_id: assistant_id) if assistant_id
54
54
  scope.limit(20)
@@ -32,7 +32,7 @@ module Layered
32
32
  def set_conversation
33
33
  @conversation = find_session_conversation(params[:conversation_id])
34
34
  rescue ActiveRecord::RecordNotFound
35
- assistant = Conversation.find_by(id: params[:conversation_id])&.assistant
35
+ assistant = Conversation.find_by(uid: params[:conversation_id])&.assistant
36
36
  if assistant&.public?
37
37
  redirect_to layered_assistant.new_public_panel_conversation_path(assistant_id: assistant.id)
38
38
  else
@@ -31,11 +31,11 @@ module Layered
31
31
  private
32
32
 
33
33
  def method_missing(method, ...)
34
- @context.send(method, ...)
34
+ @context.public_send(method, ...)
35
35
  end
36
36
 
37
37
  def respond_to_missing?(method, include_private = false)
38
- @context.respond_to?(method, include_private)
38
+ @context.respond_to?(method, false)
39
39
  end
40
40
  end
41
41
 
@@ -15,6 +15,23 @@ module Layered
15
15
 
16
16
  ALLOWED_ATTRIBUTES = %w[href title class].freeze
17
17
 
18
+ MIN_RESPONSE_MS_FOR_TPS = 100
19
+
20
+ def message_metadata_title(message)
21
+ total_tokens = message.input_tokens.to_i + message.output_tokens.to_i
22
+ parts = []
23
+ if total_tokens > 0
24
+ prefix = message.tokens_estimated? ? "~" : ""
25
+ parts << "#{prefix}#{number_with_delimiter(total_tokens)} tokens"
26
+ end
27
+ if message.output_tokens.to_i > 0 && message.response_ms.to_i >= MIN_RESPONSE_MS_FOR_TPS
28
+ tps = (message.output_tokens * 1000.0 / message.response_ms).round(1)
29
+ parts << "#{tps} tok/s"
30
+ end
31
+ parts << "#{message.ttft_ms}ms TTFT" if message.ttft_ms
32
+ parts.join(" · ")
33
+ end
34
+
18
35
  def render_message_content(message)
19
36
  return if message.content.blank?
20
37
 
@@ -24,7 +41,7 @@ module Layered
24
41
  syntax_highlighter: nil
25
42
  ).to_html
26
43
 
27
- sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES).html_safe
44
+ sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
28
45
  end
29
46
 
30
47
  private
@@ -107,6 +107,9 @@ export default class extends Controller {
107
107
  _resetRespondingTimeout() {
108
108
  if (!this.respondingValue) return
109
109
  clearTimeout(this._respondingTimeout)
110
- this._respondingTimeout = setTimeout(() => { this.respondingValue = false }, 60000)
110
+ this._respondingTimeout = setTimeout(() => {
111
+ this.respondingValue = false
112
+ document.dispatchEvent(new CustomEvent("assistant:response-timeout"))
113
+ }, 30000)
111
114
  }
112
115
  }
@@ -0,0 +1,8 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ navigate(event) {
5
+ const value = event.target.value
6
+ if (value) Turbo.visit(value)
7
+ }
8
+ }
@@ -1,11 +1,13 @@
1
1
  import { application } from "controllers/application"
2
2
  import ComposerController from "layered_assistant/composer_controller"
3
+ import ConversationSelectController from "layered_assistant/conversation_select_controller"
3
4
  import MessagesController from "layered_assistant/messages_controller"
4
5
  import PanelController from "layered_assistant/panel_controller"
5
6
  import PanelNavController from "layered_assistant/panel_nav_controller"
6
7
  import ProviderTemplateController from "layered_assistant/provider_template_controller"
7
8
 
8
9
  application.register("composer", ComposerController)
10
+ application.register("conversation-select", ConversationSelectController)
9
11
  application.register("messages", MessagesController)
10
12
  application.register("panel", PanelController)
11
13
  application.register("panel-nav", PanelNavController)
@@ -9,14 +9,29 @@ export default class extends Controller {
9
9
  this._userInitiated = false
10
10
 
11
11
  this._markUser = () => { this._userInitiated = true }
12
+ this._onTimeout = () => {
13
+ this.listTarget.querySelectorAll(".l-ui-typing-indicator").forEach(el => {
14
+ const body = el.closest(".l-ui-message__body")
15
+ el.remove()
16
+ if (body && body.children.length === 0) {
17
+ body.insertAdjacentHTML("beforeend", '<div class="l-ui-notice--error" role="status">The response could not be completed.</div>')
18
+ }
19
+ })
20
+ }
12
21
  this.element.addEventListener("wheel", this._markUser, { passive: true })
13
22
  this.element.addEventListener("touchmove", this._markUser, { passive: true })
14
23
  this.element.addEventListener("scroll", this._onScroll, { passive: true })
24
+ document.addEventListener("assistant:response-timeout", this._onTimeout)
15
25
 
16
26
  this.scrollToBottom()
17
27
 
18
- this.observer = new MutationObserver(() => {
28
+ this.observer = new MutationObserver((mutations) => {
19
29
  if (this._pinned) this.scrollToBottom()
30
+
31
+ const hasNewChildren = mutations.some(m =>
32
+ m.type === "childList" && m.target === this.listTarget && m.addedNodes.length > 0
33
+ )
34
+ if (hasNewChildren) this._sortMessages()
20
35
  })
21
36
  this.observer.observe(this.listTarget, { childList: true, subtree: true })
22
37
  }
@@ -25,6 +40,7 @@ export default class extends Controller {
25
40
  this.element.removeEventListener("wheel", this._markUser)
26
41
  this.element.removeEventListener("touchmove", this._markUser)
27
42
  this.element.removeEventListener("scroll", this._onScroll)
43
+ document.removeEventListener("assistant:response-timeout", this._onTimeout)
28
44
  this.observer.disconnect()
29
45
  }
30
46
 
@@ -59,4 +75,18 @@ export default class extends Controller {
59
75
  isNearBottom() {
60
76
  return this.element.scrollHeight - this.element.scrollTop - this.element.clientHeight <= 32
61
77
  }
78
+
79
+ _sortMessages() {
80
+ const children = Array.from(this.listTarget.children)
81
+ const sorted = children.slice().sort((a, b) => {
82
+ const aTime = parseInt(a.dataset.createdAt || "0", 10)
83
+ const bTime = parseInt(b.dataset.createdAt || "0", 10)
84
+ return aTime - bTime
85
+ })
86
+ if (sorted.some((el, i) => el !== children[i])) {
87
+ this.observer.disconnect()
88
+ sorted.forEach(el => this.listTarget.appendChild(el))
89
+ this.observer.observe(this.listTarget, { childList: true, subtree: true })
90
+ }
91
+ }
62
92
  }
@@ -22,6 +22,7 @@ module Layered
22
22
  end
23
23
 
24
24
  begin
25
+ chunk_service.mark_started!
25
26
  ClientService.new.call(message: message, stream_proc: stream_proc)
26
27
  rescue => e
27
28
  Rails.logger.error("Response generation failed: #{e.message}")
@@ -17,6 +17,10 @@ module Layered
17
17
  scope :by_name, -> { order(name: :asc, created_at: :desc) }
18
18
  scope :by_created_at, -> { order(created_at: :desc) }
19
19
 
20
+ def to_param
21
+ uid
22
+ end
23
+
20
24
  # Name
21
25
  def update_token_totals!
22
26
  input = messages.sum(:input_tokens)
@@ -29,15 +33,24 @@ module Layered
29
33
  end
30
34
 
31
35
  def stop_response!
32
- message = messages.where(role: :assistant, stopped: false).order(created_at: :desc).first
33
- return false unless message
36
+ with_lock do
37
+ message = messages.where(role: :assistant, stopped: false).order(created_at: :desc).first
38
+ return false unless message
39
+
40
+ attrs = {
41
+ stopped: true,
42
+ output_tokens: TokenEstimator.estimate(message.content) || 0,
43
+ tokens_estimated: true
44
+ }
34
45
 
35
- message.with_lock do
36
- return false if message.stopped?
46
+ if message.input_tokens.nil?
47
+ prior_content = messages.where("created_at < ?", message.created_at).pluck(:content).compact.join(" ")
48
+ attrs[:input_tokens] = TokenEstimator.estimate(prior_content) || 0
49
+ end
37
50
 
38
- estimated = TokenEstimator.estimate(message.content) || 0
39
- message.update!(stopped: true, output_tokens: estimated, tokens_estimated: true)
51
+ message.update!(attrs)
40
52
  update_token_totals!
53
+ message.reload
41
54
  message.broadcast_updated
42
55
  message.broadcast_response_complete
43
56
  end
@@ -22,7 +22,7 @@ module Layered
22
22
  belongs_to :model, optional: true, counter_cache: true
23
23
 
24
24
  # Scopes
25
- scope :by_created_at, -> { order(created_at: :asc) }
25
+ scope :by_created_at, -> { order(created_at: :asc, id: :asc) }
26
26
 
27
27
  # Broadcasting
28
28
  def broadcast_created
@@ -13,12 +13,12 @@ module Layered
13
13
 
14
14
  # Enums
15
15
  enum :protocol, {
16
- anthropic: "Anthropic",
17
- openai: "OpenAI"
16
+ anthropic: "anthropic",
17
+ openai: "openai"
18
18
  }
19
19
 
20
20
  # Encryption
21
- unless ENV["LAYERED_ASSISTANT_DANGEROUSLY_SKIP_DB_ENCRYPTION"] == "yes"
21
+ unless Layered::Assistant.skip_db_encryption
22
22
  encrypts :secret
23
23
  end
24
24