layered-assistant-rails 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e367d72d8f95ede81ac50a520755aa2afbf1fef6d9d8bf719b5ca8620ec26f3
4
- data.tar.gz: 6525df2eb9d245791ebb5cb761bff293778b1b17cda0a7b4682ea566376f8664
3
+ metadata.gz: 8c4d53502a7e91e56f50420d3d16830d3441a49a3a6a5a399be405d10e0c05b4
4
+ data.tar.gz: 6155fe4297f73702449ac22e099692cc17d6582c697f68c0e7b9df8f0f3bc96f
5
5
  SHA512:
6
- metadata.gz: cfa9d6034e3a92e14b386406b186f6ff5eadce1a053afcde74d816108ab5ebe680eb120aa103cb2027d75a30979876579f7c8e7cb15583e9afda5b2f36c8e599
7
- data.tar.gz: b6d3ff236bf5c748ebd629a9d3e445bc9f67a7216d56290398e29628772183f0f243a4b85dbd0cd82115a48531ce410f2c264b1c9bf90dc4f19e442d07cd5f9e
6
+ metadata.gz: 8131ee6e51b4f9417501de38b88af330e1dbd6b0603e4284e56900e37b41c549265d56821b6518846e1d1c95382d4fe186fca8c2d5a450c7c4a17f2a5ac79266
7
+ data.tar.gz: 9ff9c8b830ee1cabaeed1da03332e57f5622ce79104f3d0f2e760be19d2dc503adcc0195bb3eb0d877b9b4a656a8556e6596301b087eb9fb805d619291978e18
data/AGENTS.md CHANGED
@@ -39,6 +39,10 @@ You can run the tests with: `bundle exec rake test`
39
39
  - Tables prefixed `layered_assistant_`
40
40
  - Models inherit from `Layered::Assistant::ApplicationRecord`
41
41
 
42
+ ### Ownership and scoping
43
+
44
+ Ownership is enforced at the controller layer via `scoped()`, not with model validations. When a controller action accepts a foreign key for a scoped model (e.g. `persona_id`, `assistant_id`), look up the record through `scoped(Model).find(params[:id])` so that out-of-scope IDs return 404. Do not add model-level validations that duplicate this scoping logic.
45
+
42
46
  ### Generators
43
47
 
44
48
  Verify dependencies before modifying host app files. Use `inject_into_file` for safe insertion. Check for existing imports to ensure idempotency.
data/README.md CHANGED
@@ -106,13 +106,7 @@ The `l_assistant_accessible?` helper evaluates the authorize block without side
106
106
 
107
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
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`) |
109
+ The block receives the model class, runs in controller context, and must return an `ActiveRecord::Relation`. All engine models with a polymorphic `owner` association are passed through the scope block.
116
110
 
117
111
  ### Scope all owned resources to the current user
118
112
 
@@ -3,6 +3,7 @@ module Layered
3
3
  class AssistantsController < ApplicationController
4
4
  before_action :set_assistant, only: [:edit, :update, :destroy]
5
5
  before_action :set_models, only: [:new, :create, :edit, :update]
6
+ before_action :set_personas, only: [:new, :create, :edit, :update]
6
7
 
7
8
  def index
8
9
  @page_title = "Assistants"
@@ -15,8 +16,9 @@ module Layered
15
16
  end
16
17
 
17
18
  def create
18
- @assistant = Assistant.new(assistant_params)
19
+ @assistant = Assistant.new(assistant_params.except(:persona_id))
19
20
  @assistant.owner = l_ui_current_user
21
+ @assistant.persona = scoped(Persona).find(assistant_params[:persona_id]) if assistant_params[:persona_id].present?
20
22
 
21
23
  if @assistant.save
22
24
  redirect_to layered_assistant.assistants_path, notice: "Assistant was successfully created."
@@ -30,7 +32,11 @@ module Layered
30
32
  end
31
33
 
32
34
  def update
33
- if @assistant.update(assistant_params)
35
+ if assistant_params.key?(:persona_id)
36
+ @assistant.persona = assistant_params[:persona_id].present? ? scoped(Persona).find(assistant_params[:persona_id]) : nil
37
+ end
38
+
39
+ if @assistant.update(assistant_params.except(:persona_id))
34
40
  redirect_to layered_assistant.assistants_path, notice: "Assistant was successfully updated."
35
41
  else
36
42
  render :edit, status: :unprocessable_entity
@@ -52,8 +58,12 @@ module Layered
52
58
  @models = Model.available
53
59
  end
54
60
 
61
+ def set_personas
62
+ @personas = scoped(Persona).by_name
63
+ end
64
+
55
65
  def assistant_params
56
- params.require(:assistant).permit(:name, :description, :instructions, :default_model_id, :public)
66
+ params.require(:assistant).permit(:name, :description, :instructions, :default_model_id, :persona_id, :public)
57
67
  end
58
68
  end
59
69
  end
@@ -0,0 +1,60 @@
1
+ module Layered
2
+ module Assistant
3
+ class PersonasController < ApplicationController
4
+ before_action :set_persona, only: [:edit, :update, :destroy]
5
+
6
+ def index
7
+ @page_title = "Personas"
8
+ @pagy, @personas = pagy(scoped(Persona).by_name)
9
+ end
10
+
11
+ def new
12
+ @page_title = "New persona"
13
+ @persona = Persona.new
14
+ end
15
+
16
+ def create
17
+ @persona = Persona.new(persona_params)
18
+ @persona.owner = l_ui_current_user
19
+
20
+ if @persona.save
21
+ redirect_to layered_assistant.personas_path, notice: "Persona was successfully created."
22
+ else
23
+ render :new, status: :unprocessable_entity
24
+ end
25
+ end
26
+
27
+ def edit
28
+ @page_title = "Edit persona"
29
+ end
30
+
31
+ def update
32
+ if @persona.update(persona_params)
33
+ redirect_to layered_assistant.personas_path, notice: "Persona was successfully updated."
34
+ else
35
+ render :edit, status: :unprocessable_entity
36
+ end
37
+ end
38
+
39
+ def destroy
40
+ if @persona.destroy
41
+ redirect_to layered_assistant.personas_path, notice: "Persona was successfully deleted."
42
+ else
43
+ redirect_to layered_assistant.personas_path, alert: "Persona could not be deleted: #{@persona.errors.full_messages.to_sentence}."
44
+ end
45
+ rescue ActiveRecord::InvalidForeignKey
46
+ redirect_to layered_assistant.personas_path, alert: "Persona could not be deleted because it is assigned to assistants."
47
+ end
48
+
49
+ private
50
+
51
+ def set_persona
52
+ @persona = scoped(Persona).find(params[:id])
53
+ end
54
+
55
+ def persona_params
56
+ params.require(:persona).permit(:name, :description, :instructions)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -7,6 +7,7 @@ module Layered
7
7
  # Associations
8
8
  belongs_to :owner, polymorphic: true, optional: true
9
9
  belongs_to :default_model, class_name: "Layered::Assistant::Model", optional: true, counter_cache: :assistants_count
10
+ belongs_to :persona, optional: true, counter_cache: :assistants_count
10
11
  has_many :conversations, dependent: :destroy
11
12
 
12
13
  # Validations
@@ -77,9 +77,10 @@ module Layered
77
77
  private
78
78
 
79
79
  def create_system_message
80
- return if assistant.instructions.blank?
80
+ prompt = SystemPromptService.new.call(assistant: assistant)
81
+ return if prompt.blank?
81
82
 
82
- messages.create!(role: :system, content: assistant.instructions)
83
+ messages.create!(role: :system, content: prompt)
83
84
  end
84
85
 
85
86
  def broadcast_name_updated(old_name)
@@ -0,0 +1,20 @@
1
+ module Layered
2
+ module Assistant
3
+ class Persona < ApplicationRecord
4
+ # UID
5
+ has_secure_token :uid
6
+
7
+ # Associations
8
+ belongs_to :owner, polymorphic: true, optional: true
9
+ has_many :assistants, dependent: :restrict_with_error
10
+
11
+ # Validations
12
+ validates :name, presence: true
13
+ validates :instructions, presence: true
14
+
15
+ # Scopes
16
+ scope :by_name, -> { order(name: :asc, created_at: :desc) }
17
+ scope :by_created_at, -> { order(created_at: :desc) }
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ module Layered
2
+ module Assistant
3
+ class SystemPromptService
4
+ def call(assistant:)
5
+ parts = []
6
+
7
+ if assistant.persona&.instructions.present?
8
+ parts << "**Persona**\n\n#{assistant.persona.instructions}"
9
+ end
10
+
11
+ parts << assistant.instructions if assistant.instructions.present?
12
+
13
+ parts.join("\n\n").presence
14
+ end
15
+ end
16
+ end
17
+ end
@@ -19,6 +19,12 @@
19
19
  <%= render "layered_ui/shared/field_error", object: assistant, field: :instructions %>
20
20
  </div>
21
21
 
22
+ <div class="l-ui-form__group">
23
+ <%= render "layered_ui/shared/label", form: f, field: :persona_id, name: "Persona", required: false %>
24
+ <%= f.select :persona_id, @personas.map { |p| [p.name, p.id] }, { include_blank: "None" }, class: "l-ui-select" %>
25
+ <%= render "layered_ui/shared/field_error", object: assistant, field: :persona_id %>
26
+ </div>
27
+
22
28
  <div class="l-ui-form__group">
23
29
  <%= render "layered_ui/shared/label", form: f, field: :default_model_id, name: "Default model", required: false %>
24
30
  <%= f.select :default_model_id, @models.map { |m| ["#{m.provider.name} - #{m.name}", m.id] }, { include_blank: "None" }, class: "l-ui-select" %>
@@ -0,0 +1,31 @@
1
+ <%= form_with(model: persona, url: url, html: { class: "l-ui-form" }) do |f| %>
2
+ <%= render "layered_ui/shared/form_errors", item: persona %>
3
+
4
+ <div class="l-ui-form__group">
5
+ <%= render "layered_ui/shared/label", form: f, field: :name, required: true %>
6
+ <%= f.text_field :name, class: "l-ui-form__field" %>
7
+ <%= render "layered_ui/shared/field_error", object: persona, field: :name %>
8
+ </div>
9
+
10
+ <div class="l-ui-form__group">
11
+ <%= render "layered_ui/shared/label", form: f, field: :description, required: false %>
12
+ <%= f.text_area :description, class: "l-ui-form__field", rows: 3 %>
13
+ <%= render "layered_ui/shared/field_error", object: persona, field: :description %>
14
+ </div>
15
+
16
+ <div class="l-ui-form__group">
17
+ <%= render "layered_ui/shared/label", form: f, field: :instructions, required: true %>
18
+ <%= f.text_area :instructions, class: "l-ui-form__field", rows: 5 %>
19
+ <p class="l-ui-form__hint">Shape the personality and tone of conversations, used alongside the assistant's own instructions. e.g. "Reply in British English with a formal tone"</p>
20
+ <%= render "layered_ui/shared/field_error", object: persona, field: :instructions %>
21
+ </div>
22
+
23
+ <div class="l-ui-container--spread l-ui-utility--mt-2xl">
24
+ <% if persona.persisted? %>
25
+ <%= link_to "Delete", layered_assistant.persona_path(persona), class: "l-ui-button--outline-danger", data: { turbo_method: :delete, turbo_confirm: "Are you sure?" } %>
26
+ <% else %>
27
+ <span></span>
28
+ <% end %>
29
+ <%= f.submit persona.new_record? ? "Create" : "Update", class: "l-ui-button--primary" %>
30
+ </div>
31
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <h1>Edit persona</h1>
2
+ <%= render "form", persona: @persona, url: layered_assistant.persona_path(@persona) %>
@@ -0,0 +1,41 @@
1
+ <div class="l-ui-container--spread">
2
+ <h1>Personas</h1>
3
+ <%= link_to "New", layered_assistant.new_persona_path, class: "l-ui-button--primary" %>
4
+ </div>
5
+
6
+ <div class="l-ui-container--table l-ui-utility--mt-lg">
7
+ <table class="l-ui-table">
8
+ <caption class="l-ui-sr-only">Personas</caption>
9
+ <thead class="l-ui-table__header">
10
+ <tr>
11
+ <th scope="col" class="l-ui-table__header-cell">Name</th>
12
+ <th scope="col" class="l-ui-table__header-cell">Description</th>
13
+ <th scope="col" class="l-ui-table__header-cell">Assistants</th>
14
+ <th scope="col" class="l-ui-table__header-cell--action">Actions</th>
15
+ </tr>
16
+ </thead>
17
+
18
+ <tbody class="l-ui-table__body">
19
+ <% @personas.each do |persona| %>
20
+ <tr>
21
+ <th scope="row" class="l-ui-table__cell--primary">
22
+ <%= persona.name %>
23
+ </th>
24
+ <td class="l-ui-table__cell">
25
+ <%= truncate(persona.description, length: 60) %>
26
+ </td>
27
+ <td class="l-ui-table__cell">
28
+ <%= persona.assistants_count %>
29
+ </td>
30
+ <td class="l-ui-table__cell--action">
31
+ <%= link_to "Edit", layered_assistant.edit_persona_path(persona) %>
32
+ </td>
33
+ </tr>
34
+ <% end %>
35
+ </tbody>
36
+ </table>
37
+
38
+ <div class="l-ui-utility--mt-lg">
39
+ <%= l_ui_pagy(@pagy) %>
40
+ </div>
41
+ </div>
@@ -0,0 +1,2 @@
1
+ <h1>New persona</h1>
2
+ <%= render "form", persona: @persona, url: layered_assistant.personas_path %>
@@ -9,6 +9,7 @@
9
9
  <% if l_assistant_accessible? %>
10
10
  <%= l_ui_navigation_item "Setup", layered_assistant.root_path %>
11
11
  <%= l_ui_navigation_item "Providers", layered_assistant.providers_path %>
12
+ <%= l_ui_navigation_item "Personas", layered_assistant.personas_path %>
12
13
  <%= l_ui_navigation_item "Assistants", layered_assistant.assistants_path %>
13
14
  <%= l_ui_navigation_item "Conversations", layered_assistant.conversations_path %>
14
15
  <% else %>
data/config/routes.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  Layered::Assistant::Engine.routes.draw do
2
2
  root "setup#index"
3
+ resources :personas, except: [:show]
3
4
  resources :assistants, except: [:show] do
4
5
  resources :conversations, only: [:index]
5
6
  end
@@ -0,0 +1,13 @@
1
+ class CreateLayeredAssistantPersonas < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :layered_assistant_personas, if_not_exists: true do |t|
4
+ t.string :uid, null: false, index: { unique: true }
5
+ t.references :owner, polymorphic: true
6
+ t.string :name, null: false
7
+ t.text :description
8
+ t.text :instructions
9
+ t.bigint :assistants_count, default: 0, null: false
10
+ t.timestamps
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ class AddPersonaToLayeredAssistantAssistants < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_reference :layered_assistant_assistants, :persona, foreign_key: { to_table: :layered_assistant_personas }
4
+ end
5
+ end
@@ -33,10 +33,8 @@
33
33
  # have access to current_user and other helpers. Return an ActiveRecord
34
34
  # relation (e.g. model_class.where(...) or model_class.all).
35
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)
36
+ # All engine models with a polymorphic owner association are passed through
37
+ # the scope block.
40
38
  #
41
39
  # Scope all owned resources to the current user:
42
40
  #
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Assistant
3
- VERSION = "0.3.0"
3
+ VERSION = "0.3.1"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: layered-assistant-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai
@@ -313,6 +313,7 @@ files:
313
313
  - app/controllers/layered/assistant/models_controller.rb
314
314
  - app/controllers/layered/assistant/panel/conversations_controller.rb
315
315
  - app/controllers/layered/assistant/panel/messages_controller.rb
316
+ - app/controllers/layered/assistant/personas_controller.rb
316
317
  - app/controllers/layered/assistant/providers_controller.rb
317
318
  - app/controllers/layered/assistant/public/application_controller.rb
318
319
  - app/controllers/layered/assistant/public/assistants_controller.rb
@@ -339,6 +340,7 @@ files:
339
340
  - app/models/layered/assistant/conversation.rb
340
341
  - app/models/layered/assistant/message.rb
341
342
  - app/models/layered/assistant/model.rb
343
+ - app/models/layered/assistant/persona.rb
342
344
  - app/models/layered/assistant/provider.rb
343
345
  - app/services/layered/assistant/chunk_parser.rb
344
346
  - app/services/layered/assistant/chunk_service.rb
@@ -349,6 +351,7 @@ files:
349
351
  - app/services/layered/assistant/messages_service.rb
350
352
  - app/services/layered/assistant/models/create_service.rb
351
353
  - app/services/layered/assistant/response_timer.rb
354
+ - app/services/layered/assistant/system_prompt_service.rb
352
355
  - app/services/layered/assistant/token_estimator.rb
353
356
  - app/views/layered/assistant/assistants/_form.html.erb
354
357
  - app/views/layered/assistant/assistants/edit.html.erb
@@ -374,6 +377,10 @@ files:
374
377
  - app/views/layered/assistant/panel/conversations/show.html.erb
375
378
  - app/views/layered/assistant/panel/messages/_composer.html.erb
376
379
  - app/views/layered/assistant/panel/messages/create.turbo_stream.erb
380
+ - app/views/layered/assistant/personas/_form.html.erb
381
+ - app/views/layered/assistant/personas/edit.html.erb
382
+ - app/views/layered/assistant/personas/index.html.erb
383
+ - app/views/layered/assistant/personas/new.html.erb
377
384
  - app/views/layered/assistant/providers/_form.html.erb
378
385
  - app/views/layered/assistant/providers/edit.html.erb
379
386
  - app/views/layered/assistant/providers/index.html.erb
@@ -400,6 +407,8 @@ files:
400
407
  - db/migrate/20260315000000_add_stopped_to_layered_assistant_messages.rb
401
408
  - db/migrate/20260315100000_add_response_timing_to_layered_assistant_messages.rb
402
409
  - db/migrate/20260317000000_normalise_provider_protocol_values.rb
410
+ - db/migrate/20260403000000_create_layered_assistant_personas.rb
411
+ - db/migrate/20260403000001_add_persona_to_layered_assistant_assistants.rb
403
412
  - db/migrate/20260406000000_rename_system_prompt_to_instructions.rb
404
413
  - lib/generators/layered/assistant/install_generator.rb
405
414
  - lib/generators/layered/assistant/migrations_generator.rb