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 +4 -4
- data/.claude/skills/layered-assistant-rails/SKILL.md +4 -1
- data/README.md +16 -0
- data/app/controllers/layered/assistant/messages_controller.rb +8 -14
- data/app/layered_resources/layered/assistant/message_resource.rb +44 -0
- data/app/models/layered/assistant/conversation.rb +5 -2
- data/config/routes.rb +1 -1
- data/db/migrate/20261031000000_prefix_layered_assistant_conversation_uids.rb +14 -0
- data/lib/layered/assistant/version.rb +1 -1
- metadata +3 -2
- data/app/views/layered/assistant/messages/index.html.erb +0 -63
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 801e95c2e899ba36085052a8971f405f2a7d5b12f3129193cf738978d0653c2c
|
|
4
|
+
data.tar.gz: b9fefa9325aaf76a334d7f958a37cdf119f73f59bddf08cfbd77b20a62ffddcd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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.
|
|
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>
|