layered-assistant-rails 0.1.1 → 0.1.2

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 (43) 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/panel/conversations_controller.rb +3 -3
  15. data/app/controllers/layered/assistant/public/panel/messages_controller.rb +1 -1
  16. data/app/helpers/layered/assistant/access_helper.rb +2 -2
  17. data/app/helpers/layered/assistant/messages_helper.rb +18 -1
  18. data/app/javascript/layered_assistant/composer_controller.js +4 -1
  19. data/app/javascript/layered_assistant/messages_controller.js +31 -1
  20. data/app/jobs/layered/assistant/messages/response_job.rb +1 -0
  21. data/app/models/layered/assistant/conversation.rb +19 -6
  22. data/app/models/layered/assistant/message.rb +1 -1
  23. data/app/models/layered/assistant/provider.rb +3 -3
  24. data/app/services/layered/assistant/chunk_parser.rb +46 -0
  25. data/app/services/layered/assistant/chunk_service.rb +25 -46
  26. data/app/services/layered/assistant/clients/anthropic.rb +3 -2
  27. data/app/services/layered/assistant/clients/openai.rb +3 -2
  28. data/app/services/layered/assistant/response_timer.rb +31 -0
  29. data/app/views/layered/assistant/assistants/_form.html.erb +1 -1
  30. data/app/views/layered/assistant/conversations/_form.html.erb +1 -1
  31. data/app/views/layered/assistant/messages/_message.html.erb +4 -4
  32. data/app/views/layered/assistant/messages/index.html.erb +18 -1
  33. data/app/views/layered/assistant/models/_form.html.erb +1 -1
  34. data/app/views/layered/assistant/providers/_form.html.erb +2 -2
  35. data/app/views/layered/assistant/providers/index.html.erb +1 -1
  36. data/app/views/layered/assistant/setup/_setup.html.erb +89 -0
  37. data/config/locales/en.yml +5 -0
  38. data/db/migrate/20260315100000_add_response_timing_to_layered_assistant_messages.rb +6 -0
  39. data/db/migrate/20260317000000_normalise_provider_protocol_values.rb +11 -0
  40. data/lib/generators/layered/assistant/templates/initializer.rb +34 -0
  41. data/lib/layered/assistant/version.rb +1 -1
  42. data/lib/layered/assistant.rb +8 -0
  43. metadata +7 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c09f437cf1d50e987e065bab63c5a974c569511d6095a09e0fb5e3072119d08b
4
- data.tar.gz: 24476722cc3bf5d342cd308974e0fb888f059fd838cb406215e6772211dde587
3
+ metadata.gz: ff9af0132fa23f11eae8703acf4b0cfd2c893389fa0237e2883f09a6fa3080b3
4
+ data.tar.gz: 5a2e46ba572593219180533800eca8f69e78712a03bb521886f017c07a3b0a15
5
5
  SHA512:
6
- metadata.gz: f26719babe3ba1885f3f96021f5900d202d9aa4ba70353477f6559ac8b04871a5d7bd24d81be0cd3b93866bf8d3e2d7cb83c5fab3b354c618e45555fe96476d4
7
- data.tar.gz: 25fcf070c6222f1fca1234939f7f283cdabe0fd232ea407c704c2a8f270ae9ac2caaf57fb775f7d35a15d83c4c3a5921d616a847adf200190c5ecaf4e2bf5017
6
+ metadata.gz: b418c0f1b88ad608bfc07447b5227f5cdc499267c15810fccc0b7e97650a3d53476c6778fd1921438b280177ce677ccbcd492918738f070647146de51c908f6b
7
+ data.tar.gz: 5b3e1061ab08cd17abc131a909a39127e1adbe98c080e29cd3da4040172fab9e0f6978e72c1760a798b1dcd90a8a09c8608a361591f9632f81bc3a6672f090fe
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
@@ -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
  }
@@ -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
 
@@ -0,0 +1,46 @@
1
+ module Layered
2
+ module Assistant
3
+ class ChunkParser
4
+ def initialize(protocol)
5
+ @openai = protocol == "openai"
6
+ end
7
+
8
+ def text(chunk)
9
+ t = if @openai
10
+ chunk.dig("choices", 0, "delta", "content")
11
+ else
12
+ chunk.dig("delta", "text") if chunk["type"] == "content_block_delta"
13
+ end
14
+ t unless t.nil? || t.empty?
15
+ end
16
+
17
+ def finished?(chunk)
18
+ if @openai
19
+ chunk.dig("choices", 0, "finish_reason").present?
20
+ else
21
+ chunk["type"] == "message_stop"
22
+ end
23
+ end
24
+
25
+ def usage_ready?(chunk)
26
+ @openai && chunk["usage"].present? && chunk.dig("choices")&.empty?
27
+ end
28
+
29
+ def input_tokens(chunk)
30
+ if @openai
31
+ chunk.dig("usage", "prompt_tokens")&.to_i if usage_ready?(chunk)
32
+ elsif chunk["type"] == "message_start"
33
+ chunk.dig("message", "usage", "input_tokens")&.to_i
34
+ end
35
+ end
36
+
37
+ def output_tokens(chunk)
38
+ if @openai
39
+ chunk.dig("usage", "completion_tokens")&.to_i if usage_ready?(chunk)
40
+ elsif chunk["type"] == "message_delta"
41
+ chunk.dig("usage", "output_tokens")&.to_i
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -3,34 +3,49 @@ module Layered
3
3
  class ChunkService
4
4
  STOP_CHECK_INTERVAL = 25
5
5
 
6
- def initialize(message, provider:)
6
+ def initialize(message, provider:, started_at: nil)
7
7
  @message = message
8
- @provider = provider
8
+ @parser = ChunkParser.new(provider.protocol)
9
+ @timer = ResponseTimer.new
10
+ @timer.start! if started_at
9
11
  @input_tokens = 0
10
12
  @output_tokens = 0
11
13
  @chunk_count = 0
12
14
  @stopped = false
13
15
  end
14
16
 
17
+ def mark_started!
18
+ @timer.start!
19
+ end
20
+
15
21
  def call(chunk)
16
22
  return if @stopped
17
23
 
18
24
  @chunk_count += 1
19
25
  if @chunk_count % STOP_CHECK_INTERVAL == 0
20
26
  @stopped = @message.reload.stopped?
21
- return if @stopped
27
+ if @stopped
28
+ attrs = @timer.timing_attrs
29
+ unless attrs.empty?
30
+ @message.update!(attrs)
31
+ @message.broadcast_updated
32
+ end
33
+ return
34
+ end
22
35
  end
23
36
 
24
37
  Rails.logger.debug { "[ChunkService] #{chunk.inspect}" }
25
- text = extract_text(chunk)
26
- extract_usage(chunk)
38
+ text = @parser.text(chunk)
39
+ @input_tokens = @parser.input_tokens(chunk) || @input_tokens
40
+ @output_tokens = @parser.output_tokens(chunk) || @output_tokens
27
41
 
28
42
  if text
29
- @message.update(content: (@message.content || "") + text)
43
+ @timer.record_first_token!
44
+ @message.update!(content: (@message.content || "") + text)
30
45
  @message.broadcast_chunk(text)
31
46
  end
32
47
 
33
- if chunk_finished?(chunk) || usage_chunk?(chunk)
48
+ if @parser.finished?(chunk) || @parser.usage_ready?(chunk)
34
49
  save_token_usage
35
50
  @message.broadcast_updated
36
51
  end
@@ -38,51 +53,15 @@ module Layered
38
53
 
39
54
  private
40
55
 
41
- def extract_text(chunk)
42
- text = if @provider.protocol == "openai"
43
- chunk.dig("choices", 0, "delta", "content")
44
- else
45
- chunk.dig("delta", "text") if chunk["type"] == "content_block_delta"
46
- end
47
- text unless text.nil? || text.empty?
48
- end
49
-
50
- def chunk_finished?(chunk)
51
- if @provider.protocol == "openai"
52
- chunk.dig("choices", 0, "finish_reason").present?
53
- else
54
- chunk["type"] == "message_stop"
55
- end
56
- end
57
-
58
- def usage_chunk?(chunk)
59
- @provider.protocol == "openai" && chunk["usage"].present? && chunk.dig("choices")&.empty?
60
- end
61
-
62
- def extract_usage(chunk)
63
- if @provider.protocol == "openai"
64
- if (usage = chunk["usage"])
65
- @input_tokens = usage["prompt_tokens"].to_i
66
- @output_tokens = usage["completion_tokens"].to_i
67
- end
68
- else
69
- if chunk["type"] == "message_start" && (usage = chunk.dig("message", "usage"))
70
- @input_tokens = usage["input_tokens"].to_i
71
- end
72
- if chunk["type"] == "message_delta" && (usage = chunk["usage"])
73
- @output_tokens = usage["output_tokens"].to_i
74
- end
75
- end
76
- end
77
-
78
56
  def save_token_usage
57
+ timing = @timer.timing_attrs
79
58
  if @input_tokens == 0 && @output_tokens == 0
80
59
  estimated = TokenEstimator.estimate(@message.content)
81
60
  return unless estimated
82
61
 
83
- @message.update!(output_tokens: estimated, tokens_estimated: true)
62
+ @message.update!(output_tokens: estimated, tokens_estimated: true, **timing)
84
63
  else
85
- @message.update!(input_tokens: @input_tokens, output_tokens: @output_tokens)
64
+ @message.update!(input_tokens: @input_tokens, output_tokens: @output_tokens, **timing)
86
65
  end
87
66
 
88
67
  @message.conversation.update_token_totals!
@@ -8,14 +8,15 @@ module Layered
8
8
  parameters = {
9
9
  model: model,
10
10
  messages: formatted[:messages],
11
- max_tokens: 4096,
11
+ max_tokens: 8192,
12
12
  stream: stream_proc
13
13
  }
14
14
  parameters[:system] = formatted[:system] if formatted[:system].present?
15
15
 
16
16
  ::Anthropic::Client.new(
17
17
  access_token: @api_key,
18
- log_errors: ENV.fetch("LAYERED_ASSISTANT_LOG_ERRORS", "no") == "yes"
18
+ log_errors: Layered::Assistant.log_errors,
19
+ request_timeout: Layered::Assistant.api_request_timeout
19
20
  ).messages(parameters: parameters)
20
21
  end
21
22
  end
@@ -7,7 +7,8 @@ module Layered
7
7
 
8
8
  client_options = {
9
9
  access_token: @api_key,
10
- log_errors: ENV.fetch("LAYERED_ASSISTANT_LOG_ERRORS", "no") == "yes"
10
+ log_errors: Layered::Assistant.log_errors,
11
+ request_timeout: Layered::Assistant.api_request_timeout
11
12
  }
12
13
  if @provider.url.present?
13
14
  client_options[:uri_base] = @provider.url.sub(/\/\z/, "")
@@ -15,7 +16,7 @@ module Layered
15
16
  end
16
17
 
17
18
  ::OpenAI::Client.new(**client_options) do |f|
18
- if ENV.fetch("LAYERED_ASSISTANT_LOG_ERRORS", "no") == "yes"
19
+ if Layered::Assistant.log_errors
19
20
  f.response :logger, Logger.new($stdout), bodies: true
20
21
  end
21
22
  end.chat(
@@ -0,0 +1,31 @@
1
+ module Layered
2
+ module Assistant
3
+ class ResponseTimer
4
+ def initialize
5
+ @started_at = nil
6
+ @first_token_at = nil
7
+ end
8
+
9
+ def start!
10
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
11
+ end
12
+
13
+ def record_first_token!
14
+ @first_token_at ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
15
+ end
16
+
17
+ def timing_attrs
18
+ return {} unless @started_at
19
+
20
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
21
+ attrs = { response_ms: ((now - @started_at) * 1000).round }
22
+ attrs[:ttft_ms] = ((@first_token_at - @started_at) * 1000).round if @first_token_at
23
+ attrs
24
+ end
25
+
26
+ def started?
27
+ !@started_at.nil?
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,4 +1,4 @@
1
- <%= form_with(model: assistant, url: url, html: { class: "l-ui-form l-ui-utility--mt-2xl" }) do |f| %>
1
+ <%= form_with(model: assistant, url: url, html: { class: "l-ui-form" }) do |f| %>
2
2
  <%= render "layered_ui/shared/form_errors", item: assistant %>
3
3
 
4
4
  <div class="l-ui-form__group">
@@ -1,4 +1,4 @@
1
- <%= form_with(model: conversation, url: url, html: { class: "l-ui-form l-ui-utility--mt-2xl" }) do |f| %>
1
+ <%= form_with(model: conversation, url: url, html: { class: "l-ui-form" }) do |f| %>
2
2
  <%= render "layered_ui/shared/form_errors", item: conversation %>
3
3
 
4
4
  <% if conversation.new_record? %>
@@ -1,4 +1,4 @@
1
- <%= tag.div id: dom_id(message), class: "#{dom_id(message)} #{message.user? ? "l-ui-message--sent" : "l-ui-message"}" do %>
1
+ <%= tag.div id: dom_id(message), data: { created_at: (message.created_at.to_f * 1000).to_i }, class: "#{dom_id(message)} #{message.user? ? "l-ui-message--sent" : "l-ui-message"}" do %>
2
2
  <div class="l-ui-message__bubble">
3
3
  <% unless message.user? %>
4
4
  <div class="l-ui-message__author"><%= message.role.capitalize %></div>
@@ -18,9 +18,9 @@
18
18
  <% end %>
19
19
  </div>
20
20
  <div class="l-ui-message__footer">
21
- <% total_tokens = message.input_tokens.to_i + message.output_tokens.to_i %>
22
- <% if message.model || total_tokens > 0 %>
23
- <span class="l-ui-message__metadata"><%= [message.model&.name, total_tokens > 0 ? "#{message.tokens_estimated? ? "~" : ""}#{number_with_delimiter(total_tokens)} tokens" : nil].compact.join(" · ") %></span>
21
+ <% if message.model %>
22
+ <% title = message_metadata_title(message) %>
23
+ <span class="l-ui-message__metadata"<%= " title=\"#{title}\"".html_safe if title.present? %>><%= message.model.name %></span>
24
24
  <% end %>
25
25
  <span class="l-ui-message__timestamp"><%= message.created_at.to_fs(:short) %></span>
26
26
  </div>
@@ -10,7 +10,10 @@
10
10
  <tr>
11
11
  <th scope="col" class="l-ui-table__header-cell">Role</th>
12
12
  <th scope="col" class="l-ui-table__header-cell">Content</th>
13
+ <th scope="col" class="l-ui-table__header-cell">Model</th>
13
14
  <th scope="col" class="l-ui-table__header-cell">Tokens</th>
15
+ <th scope="col" class="l-ui-table__header-cell">Tok/s</th>
16
+ <th scope="col" class="l-ui-table__header-cell">TTFT</th>
14
17
  <th scope="col" class="l-ui-table__header-cell">Created</th>
15
18
  <th scope="col" class="l-ui-table__header-cell--action">Actions</th>
16
19
  </tr>
@@ -25,9 +28,23 @@
25
28
  <td class="l-ui-table__cell">
26
29
  <%= truncate(message.content, length: 100) %>
27
30
  </td>
31
+ <td class="l-ui-table__cell">
32
+ <%= message.model&.name %>
33
+ </td>
28
34
  <td class="l-ui-table__cell">
29
35
  <% total = message.input_tokens.to_i + message.output_tokens.to_i %>
30
- <%= number_with_delimiter(total) || 0 %>
36
+ <% if total > 0 %>
37
+ <% prefix = message.tokens_estimated? ? "~" : "" %>
38
+ <%= "#{prefix}#{number_with_delimiter(total)}" %>
39
+ <% end %>
40
+ </td>
41
+ <td class="l-ui-table__cell">
42
+ <% if message.output_tokens.to_i > 0 && message.response_ms.to_i >= Layered::Assistant::MessagesHelper::MIN_RESPONSE_MS_FOR_TPS %>
43
+ <%= (message.output_tokens * 1000.0 / message.response_ms).round(1) %>
44
+ <% end %>
45
+ </td>
46
+ <td class="l-ui-table__cell">
47
+ <%= "#{message.ttft_ms}ms" if message.ttft_ms %>
31
48
  </td>
32
49
  <td class="l-ui-table__cell">
33
50
  <%= message.created_at.to_fs(:short) %>
@@ -1,4 +1,4 @@
1
- <%= form_with(model: model, url: url, html: { class: "l-ui-form l-ui-utility--mt-2xl" }) do |f| %>
1
+ <%= form_with(model: model, url: url, html: { class: "l-ui-form" }) do |f| %>
2
2
  <%= render "layered_ui/shared/form_errors", item: model %>
3
3
 
4
4
  <div class="l-ui-form__group">
@@ -1,4 +1,4 @@
1
- <%= form_with(model: provider, url: url, html: { class: "l-ui-form l-ui-utility--mt-2xl" }) do |f| %>
1
+ <%= form_with(model: provider, url: url, html: { class: "l-ui-form" }) do |f| %>
2
2
  <%= render "layered_ui/shared/form_errors", item: provider %>
3
3
 
4
4
  <div data-controller="provider-template" data-provider-template-templates-value="<%= Layered::Assistant::Provider::TEMPLATES.values.flatten.to_json %>">
@@ -27,7 +27,7 @@
27
27
 
28
28
  <div class="l-ui-form__group">
29
29
  <%= render "layered_ui/shared/label", form: f, field: :protocol, required: true %>
30
- <%= f.select :protocol, Layered::Assistant::Provider.protocols.map { |k, v| [v, k] }, { include_blank: "Select:" }, { class: "l-ui-select", data: { provider_template_target: "protocol" } } %>
30
+ <%= f.select :protocol, Layered::Assistant::Provider.protocols.keys.map { |k| [I18n.t("layered_assistant.protocols.#{k}"), k] }, { include_blank: "Select:" }, { class: "l-ui-select", data: { provider_template_target: "protocol" } } %>
31
31
  <%= render "layered_ui/shared/field_error", object: provider, field: :protocol %>
32
32
  </div>
33
33
 
@@ -23,7 +23,7 @@
23
23
  <%= provider.name %>
24
24
  </td>
25
25
  <td class="l-ui-table__cell">
26
- <%= Layered::Assistant::Provider.protocols[provider.protocol] %>
26
+ <%= I18n.t("layered_assistant.protocols.#{provider.protocol}") %>
27
27
  </td>
28
28
  <td class="l-ui-table__cell">
29
29
  <%= link_to provider.models_count, layered_assistant.provider_models_path(provider) %>
@@ -51,6 +51,62 @@ end</code></pre>
51
51
  &lt;%= link_to "Assistant", layered_assistant.root_path %&gt;
52
52
  &lt;% end %&gt;</code></pre>
53
53
 
54
+ <h2 class="l-ui-utility--mt-xl">Record scoping</h2>
55
+ <p class="l-ui-utility--mt-md">
56
+ By default, all records are visible to any authorised user. If your application is
57
+ multi-tenant or you need to restrict which records a user can see, configure a
58
+ <code>scope</code> block in the initialiser.
59
+ </p>
60
+ <p class="l-ui-utility--mt-md">
61
+ The block receives the model class, runs in controller context, and must return an
62
+ <code>ActiveRecord::Relation</code>. The following models are passed through the scope block:
63
+ </p>
64
+
65
+ <div class="l-ui-container--table l-ui-utility--mt-lg">
66
+ <table class="l-ui-table">
67
+ <caption class="l-ui-sr-only">Scopeable models reference</caption>
68
+ <thead class="l-ui-table__header">
69
+ <tr>
70
+ <th class="l-ui-table__header-cell" scope="col">Model</th>
71
+ <th class="l-ui-table__header-cell" scope="col">Description</th>
72
+ </tr>
73
+ </thead>
74
+ <tbody class="l-ui-table__body">
75
+ <tr>
76
+ <th class="l-ui-table__cell--primary" scope="row"><code>Layered::Assistant::Conversation</code></th>
77
+ <td class="l-ui-table__cell">User conversations (has polymorphic <code>owner</code>)</td>
78
+ </tr>
79
+ <tr>
80
+ <th class="l-ui-table__cell--primary" scope="row"><code>Layered::Assistant::Assistant</code></th>
81
+ <td class="l-ui-table__cell">Assistant configurations (has polymorphic <code>owner</code>)</td>
82
+ </tr>
83
+ <tr>
84
+ <th class="l-ui-table__cell--primary" scope="row"><code>Layered::Assistant::Provider</code></th>
85
+ <td class="l-ui-table__cell">API provider credentials (has polymorphic <code>owner</code>)</td>
86
+ </tr>
87
+ </tbody>
88
+ </table>
89
+ </div>
90
+
91
+ <h3 class="l-ui-utility--mt-lg">Scope all owned resources to the current user</h3>
92
+ <pre class="l-ui-surface l-ui-utility--mt-md"><code>Layered::Assistant.scope do |model_class|
93
+ model_class.where(owner: current_user)
94
+ end</code></pre>
95
+
96
+ <h3 class="l-ui-utility--mt-lg">Scope conversations only</h3>
97
+ <pre class="l-ui-surface l-ui-utility--mt-md"><code>Layered::Assistant.scope do |model_class|
98
+ if model_class == Layered::Assistant::Conversation
99
+ model_class.where(owner: current_user)
100
+ else
101
+ model_class.all
102
+ end
103
+ end</code></pre>
104
+
105
+ <p class="l-ui-utility--mt-md">
106
+ When no scope block is configured, queries are unscoped. Record-level access control
107
+ is the host application's responsibility; the scope block is the integration point for it.
108
+ </p>
109
+
54
110
  <h2 class="l-ui-utility--mt-xl">Panel helpers</h2>
55
111
  <p class="l-ui-utility--mt-md">
56
112
  Two helpers are available to wire the <code>layered-ui</code> panel to the assistant engine.
@@ -90,6 +146,39 @@ end</code></pre>
90
146
  </table>
91
147
  </div>
92
148
 
149
+ <h2 class="l-ui-utility--mt-xl">Configuration</h2>
150
+ <p class="l-ui-utility--mt-md">
151
+ Optional settings can be added to your initialiser (<code>config/initializers/layered_assistant.rb</code>):
152
+ </p>
153
+
154
+ <div class="l-ui-container--table l-ui-utility--mt-lg">
155
+ <table class="l-ui-table">
156
+ <caption class="l-ui-sr-only">Configuration options reference</caption>
157
+ <thead class="l-ui-table__header">
158
+ <tr>
159
+ <th class="l-ui-table__header-cell" scope="col">Option</th>
160
+ <th class="l-ui-table__header-cell" scope="col">Default</th>
161
+ <th class="l-ui-table__header-cell" scope="col">Description</th>
162
+ </tr>
163
+ </thead>
164
+ <tbody class="l-ui-table__body">
165
+ <tr>
166
+ <th class="l-ui-table__cell--primary" scope="row"><code>log_errors</code></th>
167
+ <td class="l-ui-table__cell"><code>false</code></td>
168
+ <td class="l-ui-table__cell">Log API errors to stdout from the AI API clients</td>
169
+ </tr>
170
+ <tr>
171
+ <th class="l-ui-table__cell--primary" scope="row"><code>skip_db_encryption</code></th>
172
+ <td class="l-ui-table__cell"><code>false</code></td>
173
+ <td class="l-ui-table__cell">Disable Active Record Encryption on <code>Provider#secret</code>. Only for development/test environments without encryption keys configured</td>
174
+ </tr>
175
+ </tbody>
176
+ </table>
177
+ </div>
178
+
179
+ <pre class="l-ui-surface l-ui-utility--mt-lg"><code>Layered::Assistant.log_errors = true
180
+ Layered::Assistant.skip_db_encryption = true</code></pre>
181
+
93
182
  <h2 class="l-ui-utility--mt-xl">Getting started</h2>
94
183
  <ol class="l-ui-list l-ui-utility--mt-md">
95
184
  <li><%= link_to "Create a provider", layered_assistant.new_provider_path %> (e.g. Anthropic or OpenAI) with your API key</li>
@@ -0,0 +1,5 @@
1
+ en:
2
+ layered_assistant:
3
+ protocols:
4
+ anthropic: "Anthropic"
5
+ openai: "OpenAI"
@@ -0,0 +1,6 @@
1
+ class AddResponseTimingToLayeredAssistantMessages < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :layered_assistant_messages, :ttft_ms, :integer
4
+ add_column :layered_assistant_messages, :response_ms, :integer
5
+ end
6
+ end
@@ -0,0 +1,11 @@
1
+ class NormaliseProviderProtocolValues < ActiveRecord::Migration[8.1]
2
+ def up
3
+ execute "UPDATE layered_assistant_providers SET protocol = 'anthropic' WHERE protocol = 'Anthropic'"
4
+ execute "UPDATE layered_assistant_providers SET protocol = 'openai' WHERE protocol = 'OpenAI'"
5
+ end
6
+
7
+ def down
8
+ execute "UPDATE layered_assistant_providers SET protocol = 'Anthropic' WHERE protocol = 'anthropic'"
9
+ execute "UPDATE layered_assistant_providers SET protocol = 'OpenAI' WHERE protocol = 'openai'"
10
+ end
11
+ end
@@ -24,3 +24,37 @@
24
24
  # Layered::Assistant.authorize do
25
25
  # head :forbidden unless current_user&.admin?
26
26
  # end
27
+
28
+ # Configure record scoping for layered-assistant-rails.
29
+ # By default, all records are visible to any authorised user. Use the scope
30
+ # block to restrict which records are returned from the engine's controllers.
31
+ #
32
+ # The block receives the model class and runs in controller context, so you
33
+ # have access to current_user and other helpers. Return an ActiveRecord
34
+ # relation (e.g. model_class.where(...) or model_class.all).
35
+ #
36
+ # Models passed through the scope block:
37
+ # - Layered::Assistant::Conversation (has polymorphic owner)
38
+ # - Layered::Assistant::Assistant (has polymorphic owner)
39
+ # - Layered::Assistant::Provider (has polymorphic owner)
40
+ #
41
+ # Scope all owned resources to the current user:
42
+ #
43
+ # Layered::Assistant.scope do |model_class|
44
+ # model_class.where(owner: current_user)
45
+ # end
46
+ #
47
+ # Scope conversations only, leave others unscoped:
48
+ #
49
+ # Layered::Assistant.scope do |model_class|
50
+ # if model_class == Layered::Assistant::Conversation
51
+ # model_class.where(owner: current_user)
52
+ # else
53
+ # model_class.all
54
+ # end
55
+ # end
56
+
57
+ # Optional settings (uncomment to enable):
58
+ # Layered::Assistant.log_errors = true # log API errors to stdout
59
+ # Layered::Assistant.api_request_timeout = 210 # total API timeout in seconds, including full streaming response (default: 210)
60
+ # Layered::Assistant.skip_db_encryption = true # skip encryption on Provider#secret (dev/test only)
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Assistant
3
- VERSION = "0.1.1"
3
+ VERSION = "0.1.2"
4
4
  end
5
5
  end
@@ -11,9 +11,17 @@ require "layered/assistant/engine"
11
11
  module Layered
12
12
  module Assistant
13
13
  mattr_reader :authorize_block
14
+ mattr_reader :scope_block
15
+ mattr_accessor :log_errors, default: false
16
+ mattr_accessor :api_request_timeout, default: 210
17
+ mattr_accessor :skip_db_encryption, default: false
14
18
 
15
19
  def self.authorize(&block)
16
20
  @@authorize_block = block
17
21
  end
22
+
23
+ def self.scope(&block)
24
+ @@scope_block = block
25
+ end
18
26
  end
19
27
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: layered-assistant-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-15 00:00:00.000000000 Z
11
+ date: 2026-03-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -340,6 +340,7 @@ files:
340
340
  - app/models/layered/assistant/message.rb
341
341
  - app/models/layered/assistant/model.rb
342
342
  - app/models/layered/assistant/provider.rb
343
+ - app/services/layered/assistant/chunk_parser.rb
343
344
  - app/services/layered/assistant/chunk_service.rb
344
345
  - app/services/layered/assistant/client_service.rb
345
346
  - app/services/layered/assistant/clients/anthropic.rb
@@ -347,6 +348,7 @@ files:
347
348
  - app/services/layered/assistant/clients/openai.rb
348
349
  - app/services/layered/assistant/messages_service.rb
349
350
  - app/services/layered/assistant/models/create_service.rb
351
+ - app/services/layered/assistant/response_timer.rb
350
352
  - app/services/layered/assistant/token_estimator.rb
351
353
  - app/views/layered/assistant/assistants/_form.html.erb
352
354
  - app/views/layered/assistant/assistants/edit.html.erb
@@ -393,10 +395,13 @@ files:
393
395
  - app/views/layouts/layered/assistant/_host_navigation.html.erb
394
396
  - app/views/layouts/layered/assistant/application.html.erb
395
397
  - config/importmap.rb
398
+ - config/locales/en.yml
396
399
  - config/routes.rb
397
400
  - data/models.json
398
401
  - db/migrate/20260312000000_create_layered_assistant_tables.rb
399
402
  - db/migrate/20260315000000_add_stopped_to_layered_assistant_messages.rb
403
+ - db/migrate/20260315100000_add_response_timing_to_layered_assistant_messages.rb
404
+ - db/migrate/20260317000000_normalise_provider_protocol_values.rb
400
405
  - lib/generators/layered/assistant/install_generator.rb
401
406
  - lib/generators/layered/assistant/migrations_generator.rb
402
407
  - lib/generators/layered/assistant/templates/initializer.rb