layered-assistant-rails 0.3.0 → 0.3.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +4 -0
  3. data/README.md +1 -7
  4. data/app/controllers/layered/assistant/assistants_controller.rb +28 -4
  5. data/app/controllers/layered/assistant/personas_controller.rb +60 -0
  6. data/app/controllers/layered/assistant/skills_controller.rb +60 -0
  7. data/app/models/layered/assistant/assistant.rb +3 -0
  8. data/app/models/layered/assistant/assistant_skill.rb +12 -0
  9. data/app/models/layered/assistant/conversation.rb +3 -2
  10. data/app/models/layered/assistant/persona.rb +20 -0
  11. data/app/models/layered/assistant/skill.rb +20 -0
  12. data/app/services/layered/assistant/system_prompt_service.rb +24 -0
  13. data/app/views/layered/assistant/assistants/_form.html.erb +22 -3
  14. data/app/views/layered/assistant/assistants/index.html.erb +8 -0
  15. data/app/views/layered/assistant/personas/_form.html.erb +31 -0
  16. data/app/views/layered/assistant/personas/edit.html.erb +2 -0
  17. data/app/views/layered/assistant/personas/index.html.erb +41 -0
  18. data/app/views/layered/assistant/personas/new.html.erb +2 -0
  19. data/app/views/layered/assistant/skills/_form.html.erb +30 -0
  20. data/app/views/layered/assistant/skills/edit.html.erb +2 -0
  21. data/app/views/layered/assistant/skills/index.html.erb +41 -0
  22. data/app/views/layered/assistant/skills/new.html.erb +2 -0
  23. data/app/views/layouts/layered/assistant/application.html.erb +2 -0
  24. data/config/routes.rb +2 -0
  25. data/db/migrate/20260403000000_create_layered_assistant_personas.rb +13 -0
  26. data/db/migrate/20260403000001_add_persona_to_layered_assistant_assistants.rb +5 -0
  27. data/db/migrate/20260406000001_create_layered_assistant_skills.rb +13 -0
  28. data/db/migrate/20260406000002_create_layered_assistant_assistant_skills.rb +13 -0
  29. data/lib/generators/layered/assistant/templates/initializer.rb +2 -4
  30. data/lib/layered/assistant/version.rb +1 -1
  31. metadata +19 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e367d72d8f95ede81ac50a520755aa2afbf1fef6d9d8bf719b5ca8620ec26f3
4
- data.tar.gz: 6525df2eb9d245791ebb5cb761bff293778b1b17cda0a7b4682ea566376f8664
3
+ metadata.gz: d8d828826c0b73a9efaa2324f15d1e7d684c2b3c00c760421d6d18bbf3f88d39
4
+ data.tar.gz: 11f5f52acb971db3b8bee92f6c5739e8cb89c7f5cbbd9411db8570fd53c2be87
5
5
  SHA512:
6
- metadata.gz: cfa9d6034e3a92e14b386406b186f6ff5eadce1a053afcde74d816108ab5ebe680eb120aa103cb2027d75a30979876579f7c8e7cb15583e9afda5b2f36c8e599
7
- data.tar.gz: b6d3ff236bf5c748ebd629a9d3e445bc9f67a7216d56290398e29628772183f0f243a4b85dbd0cd82115a48531ce410f2c264b1c9bf90dc4f19e442d07cd5f9e
6
+ metadata.gz: d0e8c773d9b788562089ca773c615c1871e7440f453f18f8822074b83ea69db544cd747e8c540a21c10d53fb7babe3c37fe70245ce6bfc31a64fe496dff5a2ae
7
+ data.tar.gz: 3d3ee90a358372e72cdadccf200fff09e56adadfefc01c8a010991febc54281466b447d8730b6e00969a29225a56e38e5349187cd23b119274463e610f792fa1
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,10 +3,12 @@ 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]
7
+ before_action :set_skills, only: [:new, :create, :edit, :update]
6
8
 
7
9
  def index
8
10
  @page_title = "Assistants"
9
- @pagy, @assistants = pagy(scoped(Assistant).by_name)
11
+ @pagy, @assistants = pagy(scoped(Assistant).includes(:persona).by_name)
10
12
  end
11
13
 
12
14
  def new
@@ -15,10 +17,12 @@ module Layered
15
17
  end
16
18
 
17
19
  def create
18
- @assistant = Assistant.new(assistant_params)
20
+ @assistant = Assistant.new(assistant_params.except(:persona_id, :skill_ids))
19
21
  @assistant.owner = l_ui_current_user
22
+ @assistant.persona = scoped(Persona).find(assistant_params[:persona_id]) if assistant_params[:persona_id].present?
20
23
 
21
24
  if @assistant.save
25
+ assign_skills
22
26
  redirect_to layered_assistant.assistants_path, notice: "Assistant was successfully created."
23
27
  else
24
28
  render :new, status: :unprocessable_entity
@@ -30,7 +34,12 @@ module Layered
30
34
  end
31
35
 
32
36
  def update
33
- if @assistant.update(assistant_params)
37
+ if assistant_params.key?(:persona_id)
38
+ @assistant.persona = assistant_params[:persona_id].present? ? scoped(Persona).find(assistant_params[:persona_id]) : nil
39
+ end
40
+
41
+ if @assistant.update(assistant_params.except(:persona_id, :skill_ids))
42
+ assign_skills
34
43
  redirect_to layered_assistant.assistants_path, notice: "Assistant was successfully updated."
35
44
  else
36
45
  render :edit, status: :unprocessable_entity
@@ -52,8 +61,23 @@ module Layered
52
61
  @models = Model.available
53
62
  end
54
63
 
64
+ def set_personas
65
+ @personas = scoped(Persona).by_name
66
+ end
67
+
68
+ def set_skills
69
+ @skills = scoped(Skill).by_name
70
+ end
71
+
72
+ def assign_skills
73
+ if assistant_params.key?(:skill_ids)
74
+ skill_ids = Array(assistant_params[:skill_ids]).compact_blank
75
+ @assistant.skills = scoped(Skill).where(id: skill_ids)
76
+ end
77
+ end
78
+
55
79
  def assistant_params
56
- params.require(:assistant).permit(:name, :description, :instructions, :default_model_id, :public)
80
+ params.require(:assistant).permit(:name, :description, :instructions, :default_model_id, :persona_id, :public, skill_ids: [])
57
81
  end
58
82
  end
59
83
  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
@@ -0,0 +1,60 @@
1
+ module Layered
2
+ module Assistant
3
+ class SkillsController < ApplicationController
4
+ before_action :set_skill, only: [:edit, :update, :destroy]
5
+
6
+ def index
7
+ @page_title = "Skills"
8
+ @pagy, @skills = pagy(scoped(Skill).by_name)
9
+ end
10
+
11
+ def new
12
+ @page_title = "New skill"
13
+ @skill = Skill.new
14
+ end
15
+
16
+ def create
17
+ @skill = Skill.new(skill_params)
18
+ @skill.owner = l_ui_current_user
19
+
20
+ if @skill.save
21
+ redirect_to layered_assistant.skills_path, notice: "Skill was successfully created."
22
+ else
23
+ render :new, status: :unprocessable_entity
24
+ end
25
+ end
26
+
27
+ def edit
28
+ @page_title = "Edit skill"
29
+ end
30
+
31
+ def update
32
+ if @skill.update(skill_params)
33
+ redirect_to layered_assistant.skills_path, notice: "Skill was successfully updated."
34
+ else
35
+ render :edit, status: :unprocessable_entity
36
+ end
37
+ end
38
+
39
+ def destroy
40
+ if @skill.destroy
41
+ redirect_to layered_assistant.skills_path, notice: "Skill was successfully deleted."
42
+ else
43
+ redirect_to layered_assistant.skills_path, alert: "Skill could not be deleted: #{@skill.errors.full_messages.to_sentence}."
44
+ end
45
+ rescue ActiveRecord::InvalidForeignKey
46
+ redirect_to layered_assistant.skills_path, alert: "Skill could not be deleted because it is assigned to assistants."
47
+ end
48
+
49
+ private
50
+
51
+ def set_skill
52
+ @skill = scoped(Skill).find(params[:id])
53
+ end
54
+
55
+ def skill_params
56
+ params.require(:skill).permit(:name, :description, :instructions)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -7,6 +7,9 @@ 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
11
+ has_many :assistant_skills, dependent: :destroy
12
+ has_many :skills, through: :assistant_skills
10
13
  has_many :conversations, dependent: :destroy
11
14
 
12
15
  # Validations
@@ -0,0 +1,12 @@
1
+ module Layered
2
+ module Assistant
3
+ class AssistantSkill < ApplicationRecord
4
+ # Associations
5
+ belongs_to :assistant, counter_cache: :assistant_skills_count
6
+ belongs_to :skill, counter_cache: :assistants_count
7
+
8
+ # Validations
9
+ validates :skill_id, uniqueness: { scope: :assistant_id }
10
+ end
11
+ end
12
+ end
@@ -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,20 @@
1
+ module Layered
2
+ module Assistant
3
+ class Skill < ApplicationRecord
4
+ # UID
5
+ has_secure_token :uid
6
+
7
+ # Associations
8
+ belongs_to :owner, polymorphic: true, optional: true
9
+ has_many :assistant_skills, dependent: :restrict_with_error
10
+ has_many :assistants, through: :assistant_skills
11
+
12
+ # Validations
13
+ validates :name, 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,24 @@
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
+ skill_sections = assistant.skills.filter_map do |s|
12
+ "### #{s.name}\n\n#{s.instructions}" if s.instructions.present?
13
+ end
14
+ if skill_sections.any?
15
+ parts << "## Skills\n\n#{skill_sections.join("\n\n---\n\n")}"
16
+ end
17
+
18
+ parts << assistant.instructions if assistant.instructions.present?
19
+
20
+ parts.join("\n\n").presence
21
+ end
22
+ end
23
+ end
24
+ end
@@ -13,6 +13,12 @@
13
13
  <%= render "layered_ui/shared/field_error", object: assistant, field: :description %>
14
14
  </div>
15
15
 
16
+ <div class="l-ui-form__group">
17
+ <%= render "layered_ui/shared/label", form: f, field: :default_model_id, name: "Default model", required: false %>
18
+ <%= f.select :default_model_id, @models.map { |m| ["#{m.provider.name} - #{m.name}", m.id] }, { include_blank: "None" }, class: "l-ui-select" %>
19
+ <%= render "layered_ui/shared/field_error", object: assistant, field: :default_model_id %>
20
+ </div>
21
+
16
22
  <div class="l-ui-form__group">
17
23
  <%= render "layered_ui/shared/label", form: f, field: :instructions, required: false %>
18
24
  <%= f.text_area :instructions, class: "l-ui-form__field", rows: 5 %>
@@ -20,11 +26,24 @@
20
26
  </div>
21
27
 
22
28
  <div class="l-ui-form__group">
23
- <%= render "layered_ui/shared/label", form: f, field: :default_model_id, name: "Default model", required: false %>
24
- <%= f.select :default_model_id, @models.map { |m| ["#{m.provider.name} - #{m.name}", m.id] }, { include_blank: "None" }, class: "l-ui-select" %>
25
- <%= render "layered_ui/shared/field_error", object: assistant, field: :default_model_id %>
29
+ <%= render "layered_ui/shared/label", form: f, field: :persona_id, name: "Persona", required: false %>
30
+ <%= f.select :persona_id, @personas.map { |p| [p.name, p.id] }, { include_blank: "None" }, class: "l-ui-select" %>
31
+ <%= render "layered_ui/shared/field_error", object: assistant, field: :persona_id %>
26
32
  </div>
27
33
 
34
+ <% if @skills.any? %>
35
+ <fieldset class="l-ui-form__group">
36
+ <legend class="l-ui-label">Skills</legend>
37
+ <%= hidden_field_tag "assistant[skill_ids][]", "" %>
38
+ <% @skills.each do |skill| %>
39
+ <div class="l-ui-container--checkbox">
40
+ <%= f.check_box :skill_ids, { multiple: true, checked: assistant.skill_ids.include?(skill.id), id: "assistant_skill_ids_#{skill.id}" }, skill.id, nil %>
41
+ <label for="assistant_skill_ids_<%= skill.id %>" class="l-ui-label--checkbox"><%= skill.name %></label>
42
+ </div>
43
+ <% end %>
44
+ </fieldset>
45
+ <% end %>
46
+
28
47
  <label class="l-ui-switch l-ui-utility--mt-xl">
29
48
  <%= f.check_box :public, class: "l-ui-switch__input", role: "switch" %>
30
49
  <span class="l-ui-switch__track"></span>
@@ -11,6 +11,8 @@
11
11
  <th scope="col" class="l-ui-table__header-cell">Name</th>
12
12
  <th scope="col" class="l-ui-table__header-cell">Description</th>
13
13
  <th scope="col" class="l-ui-table__header-cell">Default model</th>
14
+ <th scope="col" class="l-ui-table__header-cell">Persona</th>
15
+ <th scope="col" class="l-ui-table__header-cell">Skills</th>
14
16
  <th scope="col" class="l-ui-table__header-cell">Conversations</th>
15
17
  <th scope="col" class="l-ui-table__header-cell--action">Actions</th>
16
18
  </tr>
@@ -28,6 +30,12 @@
28
30
  <td class="l-ui-table__cell">
29
31
  <%= assistant.default_model&.name %>
30
32
  </td>
33
+ <td class="l-ui-table__cell">
34
+ <%= assistant.persona&.name || "None" %>
35
+ </td>
36
+ <td class="l-ui-table__cell">
37
+ <%= assistant.assistant_skills_count %>
38
+ </td>
31
39
  <td class="l-ui-table__cell">
32
40
  <%= link_to assistant.conversations_count, layered_assistant.assistant_conversations_path(assistant) %>
33
41
  </td>
@@ -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 %>
@@ -0,0 +1,30 @@
1
+ <%= form_with(model: skill, url: url, html: { class: "l-ui-form" }) do |f| %>
2
+ <%= render "layered_ui/shared/form_errors", item: skill %>
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: skill, 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: skill, field: :description %>
14
+ </div>
15
+
16
+ <div class="l-ui-form__group">
17
+ <%= render "layered_ui/shared/label", form: f, field: :instructions, required: false %>
18
+ <%= f.text_area :instructions, class: "l-ui-form__field", rows: 5 %>
19
+ <%= render "layered_ui/shared/field_error", object: skill, field: :instructions %>
20
+ </div>
21
+
22
+ <div class="l-ui-container--spread l-ui-utility--mt-2xl">
23
+ <% if skill.persisted? %>
24
+ <%= link_to "Delete", layered_assistant.skill_path(skill), class: "l-ui-button--outline-danger", data: { turbo_method: :delete, turbo_confirm: "Are you sure?" } %>
25
+ <% else %>
26
+ <span></span>
27
+ <% end %>
28
+ <%= f.submit skill.new_record? ? "Create" : "Update", class: "l-ui-button--primary" %>
29
+ </div>
30
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <h1>Edit skill</h1>
2
+ <%= render "form", skill: @skill, url: layered_assistant.skill_path(@skill) %>
@@ -0,0 +1,41 @@
1
+ <div class="l-ui-container--spread">
2
+ <h1>Skills</h1>
3
+ <%= link_to "New", layered_assistant.new_skill_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">Skills</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
+ <% @skills.each do |skill| %>
20
+ <tr>
21
+ <th scope="row" class="l-ui-table__cell--primary">
22
+ <%= skill.name %>
23
+ </th>
24
+ <td class="l-ui-table__cell">
25
+ <%= truncate(skill.description, length: 60) %>
26
+ </td>
27
+ <td class="l-ui-table__cell">
28
+ <%= skill.assistants_count %>
29
+ </td>
30
+ <td class="l-ui-table__cell--action">
31
+ <%= link_to "Edit", layered_assistant.edit_skill_path(skill) %>
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 skill</h1>
2
+ <%= render "form", skill: @skill, url: layered_assistant.skills_path %>
@@ -9,6 +9,8 @@
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 %>
13
+ <%= l_ui_navigation_item "Skills", layered_assistant.skills_path %>
12
14
  <%= l_ui_navigation_item "Assistants", layered_assistant.assistants_path %>
13
15
  <%= l_ui_navigation_item "Conversations", layered_assistant.conversations_path %>
14
16
  <% else %>
data/config/routes.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  Layered::Assistant::Engine.routes.draw do
2
2
  root "setup#index"
3
+ resources :personas, except: [:show]
4
+ resources :skills, except: [:show]
3
5
  resources :assistants, except: [:show] do
4
6
  resources :conversations, only: [:index]
5
7
  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
@@ -0,0 +1,13 @@
1
+ class CreateLayeredAssistantSkills < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :layered_assistant_skills, 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,13 @@
1
+ class CreateLayeredAssistantAssistantSkills < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :layered_assistant_assistant_skills, if_not_exists: true do |t|
4
+ t.references :assistant, null: false, foreign_key: { to_table: :layered_assistant_assistants }
5
+ t.references :skill, null: false, foreign_key: { to_table: :layered_assistant_skills }
6
+ t.timestamps
7
+ end
8
+
9
+ add_index :layered_assistant_assistant_skills, [:assistant_id, :skill_id], unique: true, name: "idx_assistant_skills_on_assistant_and_skill"
10
+
11
+ add_column :layered_assistant_assistants, :assistant_skills_count, :bigint, default: 0, null: false
12
+ end
13
+ 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.2"
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.2
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
@@ -321,6 +322,7 @@ files:
321
322
  - app/controllers/layered/assistant/public/panel/conversations_controller.rb
322
323
  - app/controllers/layered/assistant/public/panel/messages_controller.rb
323
324
  - app/controllers/layered/assistant/setup_controller.rb
325
+ - app/controllers/layered/assistant/skills_controller.rb
324
326
  - app/helpers/layered/assistant/access_helper.rb
325
327
  - app/helpers/layered/assistant/messages_helper.rb
326
328
  - app/helpers/layered/assistant/panel_helper.rb
@@ -336,10 +338,13 @@ files:
336
338
  - app/jobs/layered/assistant/messages/response_job.rb
337
339
  - app/models/layered/assistant/application_record.rb
338
340
  - app/models/layered/assistant/assistant.rb
341
+ - app/models/layered/assistant/assistant_skill.rb
339
342
  - app/models/layered/assistant/conversation.rb
340
343
  - app/models/layered/assistant/message.rb
341
344
  - app/models/layered/assistant/model.rb
345
+ - app/models/layered/assistant/persona.rb
342
346
  - app/models/layered/assistant/provider.rb
347
+ - app/models/layered/assistant/skill.rb
343
348
  - app/services/layered/assistant/chunk_parser.rb
344
349
  - app/services/layered/assistant/chunk_service.rb
345
350
  - app/services/layered/assistant/client_service.rb
@@ -349,6 +354,7 @@ files:
349
354
  - app/services/layered/assistant/messages_service.rb
350
355
  - app/services/layered/assistant/models/create_service.rb
351
356
  - app/services/layered/assistant/response_timer.rb
357
+ - app/services/layered/assistant/system_prompt_service.rb
352
358
  - app/services/layered/assistant/token_estimator.rb
353
359
  - app/views/layered/assistant/assistants/_form.html.erb
354
360
  - app/views/layered/assistant/assistants/edit.html.erb
@@ -374,6 +380,10 @@ files:
374
380
  - app/views/layered/assistant/panel/conversations/show.html.erb
375
381
  - app/views/layered/assistant/panel/messages/_composer.html.erb
376
382
  - app/views/layered/assistant/panel/messages/create.turbo_stream.erb
383
+ - app/views/layered/assistant/personas/_form.html.erb
384
+ - app/views/layered/assistant/personas/edit.html.erb
385
+ - app/views/layered/assistant/personas/index.html.erb
386
+ - app/views/layered/assistant/personas/new.html.erb
377
387
  - app/views/layered/assistant/providers/_form.html.erb
378
388
  - app/views/layered/assistant/providers/edit.html.erb
379
389
  - app/views/layered/assistant/providers/index.html.erb
@@ -390,6 +400,10 @@ files:
390
400
  - app/views/layered/assistant/public/panel/messages/create.turbo_stream.erb
391
401
  - app/views/layered/assistant/setup/_setup.html.erb
392
402
  - app/views/layered/assistant/setup/index.html.erb
403
+ - app/views/layered/assistant/skills/_form.html.erb
404
+ - app/views/layered/assistant/skills/edit.html.erb
405
+ - app/views/layered/assistant/skills/index.html.erb
406
+ - app/views/layered/assistant/skills/new.html.erb
393
407
  - app/views/layouts/layered/assistant/_host_navigation.html.erb
394
408
  - app/views/layouts/layered/assistant/application.html.erb
395
409
  - config/importmap.rb
@@ -400,7 +414,11 @@ files:
400
414
  - db/migrate/20260315000000_add_stopped_to_layered_assistant_messages.rb
401
415
  - db/migrate/20260315100000_add_response_timing_to_layered_assistant_messages.rb
402
416
  - db/migrate/20260317000000_normalise_provider_protocol_values.rb
417
+ - db/migrate/20260403000000_create_layered_assistant_personas.rb
418
+ - db/migrate/20260403000001_add_persona_to_layered_assistant_assistants.rb
403
419
  - db/migrate/20260406000000_rename_system_prompt_to_instructions.rb
420
+ - db/migrate/20260406000001_create_layered_assistant_skills.rb
421
+ - db/migrate/20260406000002_create_layered_assistant_assistant_skills.rb
404
422
  - lib/generators/layered/assistant/install_generator.rb
405
423
  - lib/generators/layered/assistant/migrations_generator.rb
406
424
  - lib/generators/layered/assistant/templates/initializer.rb