layered-assistant-rails 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 68ea0fb66720732e6e05a139e873ec6eb90912967e1a6e9c956461e76d7e347c
4
- data.tar.gz: f4e561987280115a3e160a42a747ee036aab7373c8f65fcc33d36eac01dafec7
3
+ metadata.gz: 801e95c2e899ba36085052a8971f405f2a7d5b12f3129193cf738978d0653c2c
4
+ data.tar.gz: b9fefa9325aaf76a334d7f958a37cdf119f73f59bddf08cfbd77b20a62ffddcd
5
5
  SHA512:
6
- metadata.gz: 56758769136c2e033813dcf466444c778643483739bcafa5048962bca0786e167a04a6a9de9085eed038a8d492cd08c2328363cc1a98f2ea58024bd6096efd99
7
- data.tar.gz: 054c1b233c696bbec3d6c7e4e643c28fc69ffc378ce5ffa11b3110de59ed9cf8bbb87c9ed582b442f437a724a7d502609171c3022b67eb43f8b2a759a8190059
6
+ metadata.gz: cff5a257d775b91f808b1b92cf52a5c9f25be2fe5885edb8557b074c6994f1d664645807c783464552f0b409e7115f65e598fde7618b90f5f34b29c8b8d48dc7
7
+ data.tar.gz: 5e7bf84dafd4756af9bfa208a9711a16411a7543e7bc3b657e56f591643d2d9e748bb32b80cf8bbc3fc469a719ec79382b95847f30dd1ef13926bb8b71c672fd
@@ -27,12 +27,14 @@ The install generator:
27
27
 
28
28
  1. Verifies `layered-ui-rails` is installed - invokes its installer if not.
29
29
  2. Adds `import "layered_assistant"` to `app/javascript/application.js` (after the `layered_ui` import).
30
- 3. Creates `config/initializers/layered_assistant.rb` with example `authorize` and `scope` blocks.
30
+ 3. Creates `config/initializers/layered_assistant.rb` with example `authorize` blocks, notes on record ownership with an optional `owner` block, and tool configuration examples.
31
31
  4. Mounts the engine at `/layered/assistant` in `config/routes.rb`.
32
32
  5. Copies migrations into the host app via the `layered:assistant:migrations` generator.
33
33
 
34
34
  After installation, configure the initializer (see below) - **routes return 403 until an `authorize` block is set**. Then visit `/layered/assistant` to set up providers, models, personas, skills and assistants.
35
35
 
36
+ **Streaming:** replies are generated in an Active Job and broadcast as Turbo Streams over Action Cable. The queue adapter must run jobs in the background (not `:inline`), and if jobs run in a separate worker process (Solid Queue's `bin/jobs`, Sidekiq), `config/cable.yml` must use `solid_cable` or `redis` rather than `async`. The Rails defaults (`:async` jobs, `async` cable) work in a single development process.
37
+
36
38
  ## Authorization
37
39
 
38
40
  All non-public engine routes are gated by an `authorize` block. The block runs in controller context, so you have access to `current_user`, `request`, `redirect_to`, `head`, `main_app`, etc.
@@ -229,6 +231,7 @@ The engine renders inside `layered-ui-rails` layouts and uses only `l-ui-` class
229
231
  - **Provider creation fails with encryption error** - run `bin/rails db:encryption:init` and add the keys to credentials, or set `Layered::Assistant.skip_db_encryption = true` for dev/test.
230
232
  - **Panel body never loads** - `turbo-rails` must be installed and `layered-ui-rails` must be mounted in the layout. Check `import "@hotwired/turbo-rails"` is present.
231
233
  - **`layered_assistant` JS controllers missing** - ensure `import "layered_assistant"` is in `app/javascript/application.js` (added by the install generator, after the `layered_ui` import).
234
+ - **Replies don't stream, or only appear after a refresh** - the job's broadcasts aren't reaching the browser. Avoid the `:inline` queue adapter, and if jobs run in a separate worker, switch `config/cable.yml` from `async` to `solid_cable` or `redis`.
232
235
  - **No records visible, or `MissingOwnerError` on create** - `current_owner` is nil. Check the authorize block only admits signed-in users, and that any `Layered::Assistant.owner` block returns a record.
233
236
 
234
237
  ## Further reference
data/README.md CHANGED
@@ -76,6 +76,22 @@ bin/rails generate layered:assistant:migrations
76
76
  bin/rails db:migrate
77
77
  ```
78
78
 
79
+ ### Background jobs and Action Cable
80
+
81
+ Assistant replies are generated in an Active Job and streamed to the browser
82
+ as Turbo Streams over Action Cable. Streaming needs two things from the host
83
+ app:
84
+
85
+ - **A queue adapter that runs jobs in the background.** With `:inline`, the job
86
+ runs inside the request that sent the message, so the whole reply arrives at
87
+ once instead of streaming. The Rails default (`:async`) and Solid Queue both
88
+ work.
89
+ - **A cable adapter the job process can reach.** The `async` cable adapter only
90
+ works within one process. It is fine when jobs also run in the web process
91
+ (the `:async` queue adapter), but if jobs run in a separate worker (Solid
92
+ Queue's `bin/jobs`, Sidekiq, etc.), their broadcasts never reach the browser.
93
+ Use `solid_cable` or `redis` in `config/cable.yml` instead.
94
+
79
95
  ## Authorization
80
96
 
81
97
  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.
@@ -1,15 +1,14 @@
1
1
  module Layered
2
2
  module Assistant
3
- class MessagesController < ApplicationController
3
+ # Messages are a layered resource for listing and deleting them; only
4
+ # `create` - the composer posting a message - is this engine's own.
5
+ class MessagesController < ResourcesController
4
6
  include MessageCreation
5
7
 
6
- before_action :set_conversation
7
- before_action :set_message, only: [ :destroy ]
8
-
9
- def index
10
- @page_title = "Messages"
11
- @pagy, @messages = pagy(@conversation.messages.includes(:model).by_created_at)
12
- end
8
+ # Messages have no fields, which the gem reads as having no form, but
9
+ # `create` is the composer's rather than the gem's.
10
+ skip_before_action :require_layered_fields, only: [ :create ]
11
+ before_action :set_conversation, only: [ :create, :destroy ]
13
12
 
14
13
  def create
15
14
  model_id = scoped_model_id(message_params[:model_id])
@@ -37,9 +36,8 @@ module Layered
37
36
  end
38
37
 
39
38
  def destroy
40
- @message.destroy
39
+ super
41
40
  @conversation.update_token_totals!
42
- redirect_to layered_assistant.conversation_messages_path(@conversation), notice: "Message was successfully deleted."
43
41
  end
44
42
 
45
43
  private
@@ -48,10 +46,6 @@ module Layered
48
46
  @conversation = scoped(Conversation).find_by!(uid: params[:conversation_id])
49
47
  end
50
48
 
51
- def set_message
52
- @message = @conversation.messages.find(params[:id])
53
- end
54
-
55
49
  def message_params
56
50
  params.require(:message).permit(:content, :model_id)
57
51
  end
@@ -0,0 +1,44 @@
1
+ module Layered
2
+ module Assistant
3
+ # Messages are listed and deleted here; they are written through the
4
+ # composer, so there are no fields and no form.
5
+ class MessageResource < Layered::Resource::Base
6
+ model Message
7
+
8
+ columns [
9
+ { attribute: :role, primary: true },
10
+ { attribute: :content, sortable: false,
11
+ render: ->(record, view) { view.truncate(record.content, length: 100) } },
12
+ { attribute: :model_id, label: "Model", sortable: false,
13
+ render: ->(record) { record.model&.name } },
14
+ { attribute: :tokens, label: "Tokens", sortable: false,
15
+ render: ->(record, view) {
16
+ total = record.input_tokens.to_i + record.output_tokens.to_i
17
+ "#{'~' if record.tokens_estimated?}#{view.number_with_delimiter(total)}" if total > 0
18
+ } },
19
+ { attribute: :tokens_per_second, label: "Tok/s", sortable: false,
20
+ render: ->(record) {
21
+ if record.output_tokens.to_i > 0 && record.response_ms.to_i >= MessagesHelper::MIN_RESPONSE_MS_FOR_TPS
22
+ (record.output_tokens * 1000.0 / record.response_ms).round(1)
23
+ end
24
+ } },
25
+ { attribute: :ttft_ms, label: "TTFT",
26
+ render: ->(record) { "#{record.ttft_ms}ms" if record.ttft_ms } },
27
+ { attribute: :created_at, label: "Created",
28
+ render: ->(record) { record.created_at.to_fs(:short) } }
29
+ ]
30
+
31
+ search_fields [ :content ]
32
+
33
+ default_sort attribute: :created_at, direction: :asc
34
+
35
+ # The conversation is resolved through the engine's own scoping, so an
36
+ # out-of-scope one 404s before any message is reached.
37
+ def self.scope(controller)
38
+ controller.send(:scoped, Conversation)
39
+ .find_by!(uid: controller.params[:conversation_id])
40
+ .messages.includes(:model)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -3,8 +3,10 @@ module Layered
3
3
  class Conversation < ApplicationRecord
4
4
  include Ownable
5
5
 
6
- # UID
7
- has_secure_token :uid
6
+ # UID: the public identifier, in the form conversation_<uuid>
7
+ UID_FORMAT = /\Aconversation_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
8
+
9
+ attribute :uid, :string, default: -> { "conversation_#{SecureRandom.uuid}" }
8
10
 
9
11
  # Associations
10
12
  belongs_to :assistant, counter_cache: true
@@ -22,6 +24,7 @@ module Layered
22
24
 
23
25
  # Validations
24
26
  validates :name, presence: true
27
+ validates :uid, format: { with: UID_FORMAT }, uniqueness: true
25
28
 
26
29
  # Scopes
27
30
  scope :by_name, -> { order(name: :asc, created_at: :desc) }
data/config/routes.rb CHANGED
@@ -16,7 +16,7 @@ Layered::Assistant::Engine.routes.draw do
16
16
  end
17
17
  end
18
18
  resources :conversations, only: [] do
19
- resources :messages, only: [ :index, :create, :destroy ]
19
+ layered_resources :messages, namespace: "Layered::Assistant", controller: "messages", only: [ :index, :create, :destroy ]
20
20
  # Approving a tool call is the same act wherever it is rendered, and the
21
21
  # partial carrying the buttons is broadcast rather than requested, so it
22
22
  # cannot know which namespace it landed in. One route serves them all.
@@ -0,0 +1,14 @@
1
+ class PrefixLayeredAssistantConversationUids < ActiveRecord::Migration[8.0]
2
+ # Conversation uids become conversation_<uuid>. Existing tokens are
3
+ # replaced, so old conversation URLs stop resolving; the integer primary
4
+ # key is untouched.
5
+ def up
6
+ select_values("SELECT id FROM layered_assistant_conversations").each do |id|
7
+ execute "UPDATE layered_assistant_conversations SET uid = #{quote("conversation_#{SecureRandom.uuid}")} WHERE id = #{id.to_i}"
8
+ end
9
+ end
10
+
11
+ def down
12
+ # Irreversible data change; the prefixed uids remain valid.
13
+ end
14
+ end
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Assistant
3
- VERSION = "0.9.0"
3
+ VERSION = "0.10.0"
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.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai
@@ -350,6 +350,7 @@ files:
350
350
  - app/jobs/layered/assistant/messages/tool_call_job.rb
351
351
  - app/layered_resources/layered/assistant/assistant_resource.rb
352
352
  - app/layered_resources/layered/assistant/conversation_resource.rb
353
+ - app/layered_resources/layered/assistant/message_resource.rb
353
354
  - app/layered_resources/layered/assistant/model_resource.rb
354
355
  - app/layered_resources/layered/assistant/persona_resource.rb
355
356
  - app/layered_resources/layered/assistant/provider_resource.rb
@@ -387,7 +388,6 @@ files:
387
388
  - app/views/layered/assistant/messages/_message.html.erb
388
389
  - app/views/layered/assistant/messages/_tool_message.html.erb
389
390
  - app/views/layered/assistant/messages/create.turbo_stream.erb
390
- - app/views/layered/assistant/messages/index.html.erb
391
391
  - app/views/layered/assistant/panel/conversations/_header.html.erb
392
392
  - app/views/layered/assistant/panel/conversations/index.html.erb
393
393
  - app/views/layered/assistant/panel/conversations/new.html.erb
@@ -427,6 +427,7 @@ files:
427
427
  - db/migrate/20260901000000_create_layered_assistant_assistant_tools.rb
428
428
  - db/migrate/20260901000001_add_user_to_layered_assistant_conversations.rb
429
429
  - db/migrate/20260919000000_add_tool_status_to_layered_assistant_messages.rb
430
+ - db/migrate/20261031000000_prefix_layered_assistant_conversation_uids.rb
430
431
  - lib/generators/layered/assistant/install_agent_skill_generator.rb
431
432
  - lib/generators/layered/assistant/install_generator.rb
432
433
  - lib/generators/layered/assistant/migrations_generator.rb
@@ -1,63 +0,0 @@
1
- <%= l_ui_title_bar(
2
- title: "Messages",
3
- breadcrumbs: [["Conversations", layered_assistant.conversations_path]]
4
- ) %>
5
-
6
- <div class="l-ui-table-container l-ui-mt-4">
7
- <table class="l-ui-table">
8
- <caption class="l-ui-sr-only">Messages</caption>
9
- <thead class="l-ui-table__header">
10
- <tr>
11
- <th scope="col" class="l-ui-table__header-cell">Role</th>
12
- <th scope="col" class="l-ui-table__header-cell">Content</th>
13
- <th scope="col" class="l-ui-table__header-cell">Model</th>
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>
17
- <th scope="col" class="l-ui-table__header-cell">Created</th>
18
- <th scope="col" class="l-ui-table__header-cell l-ui-table__header-cell--action">Actions</th>
19
- </tr>
20
- </thead>
21
-
22
- <tbody class="l-ui-table__body">
23
- <% @messages.each do |message| %>
24
- <tr>
25
- <th scope="row" class="l-ui-table__cell l-ui-table__cell--primary">
26
- <%= message.role %>
27
- </th>
28
- <td class="l-ui-table__cell">
29
- <%= truncate(message.content, length: 100) %>
30
- </td>
31
- <td class="l-ui-table__cell">
32
- <%= message.model&.name %>
33
- </td>
34
- <td class="l-ui-table__cell">
35
- <% total = message.input_tokens.to_i + message.output_tokens.to_i %>
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 %>
48
- </td>
49
- <td class="l-ui-table__cell">
50
- <%= message.created_at.to_fs(:short) %>
51
- </td>
52
- <td class="l-ui-table__cell l-ui-table__cell--action">
53
- <%= link_to "Delete", layered_assistant.conversation_message_path(@conversation, message), class: "l-ui-table__action l-ui-table__action--danger", data: { turbo_method: :delete, turbo_confirm: "Are you sure?" } %>
54
- </td>
55
- </tr>
56
- <% end %>
57
- </tbody>
58
- </table>
59
-
60
- <div class="l-ui-mt-4">
61
- <%= l_ui_pagy(@pagy) %>
62
- </div>
63
- </div>