layered-assistant-rails 0.1.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.
Files changed (105) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +94 -0
  3. data/LICENSE +201 -0
  4. data/README.md +176 -0
  5. data/Rakefile +15 -0
  6. data/app/assets/tailwind/layered/assistant/styles.css +13 -0
  7. data/app/controllers/concerns/layered/assistant/message_creation.rb +49 -0
  8. data/app/controllers/concerns/layered/assistant/public/session_conversations.rb +50 -0
  9. data/app/controllers/layered/assistant/application_controller.rb +25 -0
  10. data/app/controllers/layered/assistant/assistants_controller.rb +59 -0
  11. data/app/controllers/layered/assistant/conversations_controller.rb +77 -0
  12. data/app/controllers/layered/assistant/messages_controller.rb +57 -0
  13. data/app/controllers/layered/assistant/models_controller.rb +61 -0
  14. data/app/controllers/layered/assistant/panel/conversations_controller.rb +63 -0
  15. data/app/controllers/layered/assistant/panel/messages_controller.rb +44 -0
  16. data/app/controllers/layered/assistant/providers_controller.rb +55 -0
  17. data/app/controllers/layered/assistant/public/application_controller.rb +16 -0
  18. data/app/controllers/layered/assistant/public/assistants_controller.rb +16 -0
  19. data/app/controllers/layered/assistant/public/conversations_controller.rb +33 -0
  20. data/app/controllers/layered/assistant/public/messages_controller.rb +42 -0
  21. data/app/controllers/layered/assistant/public/panel/conversations_controller.rb +62 -0
  22. data/app/controllers/layered/assistant/public/panel/messages_controller.rb +50 -0
  23. data/app/controllers/layered/assistant/setup_controller.rb +9 -0
  24. data/app/helpers/layered/assistant/access_helper.rb +45 -0
  25. data/app/helpers/layered/assistant/messages_helper.rb +41 -0
  26. data/app/helpers/layered/assistant/panel_helper.rb +38 -0
  27. data/app/javascript/layered_assistant/composer_controller.js +30 -0
  28. data/app/javascript/layered_assistant/index.js +14 -0
  29. data/app/javascript/layered_assistant/message_streaming.js +124 -0
  30. data/app/javascript/layered_assistant/messages_controller.js +62 -0
  31. data/app/javascript/layered_assistant/panel_controller.js +36 -0
  32. data/app/javascript/layered_assistant/panel_nav_controller.js +16 -0
  33. data/app/javascript/layered_assistant/provider_template_controller.js +45 -0
  34. data/app/javascript/layered_assistant/vendor/marked.esm.js +72 -0
  35. data/app/jobs/layered/assistant/application_job.rb +6 -0
  36. data/app/jobs/layered/assistant/messages/response_job.rb +36 -0
  37. data/app/models/layered/assistant/application_record.rb +7 -0
  38. data/app/models/layered/assistant/assistant.rb +22 -0
  39. data/app/models/layered/assistant/conversation.rb +39 -0
  40. data/app/models/layered/assistant/message.rb +56 -0
  41. data/app/models/layered/assistant/model.rb +21 -0
  42. data/app/models/layered/assistant/provider.rb +49 -0
  43. data/app/services/layered/assistant/chunk_service.rb +80 -0
  44. data/app/services/layered/assistant/client_service.rb +18 -0
  45. data/app/services/layered/assistant/clients/anthropic.rb +24 -0
  46. data/app/services/layered/assistant/clients/base.rb +29 -0
  47. data/app/services/layered/assistant/clients/openai.rb +33 -0
  48. data/app/services/layered/assistant/messages_service.rb +58 -0
  49. data/app/services/layered/assistant/models/create_service.rb +50 -0
  50. data/app/services/layered/assistant/token_estimator.rb +11 -0
  51. data/app/views/layered/assistant/assistants/_form.html.erb +42 -0
  52. data/app/views/layered/assistant/assistants/edit.html.erb +6 -0
  53. data/app/views/layered/assistant/assistants/index.html.erb +45 -0
  54. data/app/views/layered/assistant/assistants/new.html.erb +6 -0
  55. data/app/views/layered/assistant/conversations/_form.html.erb +29 -0
  56. data/app/views/layered/assistant/conversations/edit.html.erb +6 -0
  57. data/app/views/layered/assistant/conversations/index.html.erb +63 -0
  58. data/app/views/layered/assistant/conversations/new.html.erb +6 -0
  59. data/app/views/layered/assistant/conversations/show.html.erb +25 -0
  60. data/app/views/layered/assistant/messages/_composer.html.erb +15 -0
  61. data/app/views/layered/assistant/messages/_message.html.erb +25 -0
  62. data/app/views/layered/assistant/messages/_system_prompt.html.erb +10 -0
  63. data/app/views/layered/assistant/messages/create.turbo_stream.erb +9 -0
  64. data/app/views/layered/assistant/messages/index.html.erb +46 -0
  65. data/app/views/layered/assistant/models/_form.html.erb +30 -0
  66. data/app/views/layered/assistant/models/edit.html.erb +6 -0
  67. data/app/views/layered/assistant/models/index.html.erb +54 -0
  68. data/app/views/layered/assistant/models/new.html.erb +6 -0
  69. data/app/views/layered/assistant/panel/conversations/_header.html.erb +23 -0
  70. data/app/views/layered/assistant/panel/conversations/index.html.erb +46 -0
  71. data/app/views/layered/assistant/panel/conversations/new.html.erb +23 -0
  72. data/app/views/layered/assistant/panel/conversations/show.html.erb +24 -0
  73. data/app/views/layered/assistant/panel/messages/_composer.html.erb +15 -0
  74. data/app/views/layered/assistant/panel/messages/create.turbo_stream.erb +9 -0
  75. data/app/views/layered/assistant/providers/_form.html.erb +81 -0
  76. data/app/views/layered/assistant/providers/edit.html.erb +6 -0
  77. data/app/views/layered/assistant/providers/index.html.erb +47 -0
  78. data/app/views/layered/assistant/providers/new.html.erb +6 -0
  79. data/app/views/layered/assistant/public/assistants/index.html.erb +34 -0
  80. data/app/views/layered/assistant/public/assistants/show.html.erb +23 -0
  81. data/app/views/layered/assistant/public/conversations/show.html.erb +24 -0
  82. data/app/views/layered/assistant/public/messages/_composer.html.erb +7 -0
  83. data/app/views/layered/assistant/public/messages/create.turbo_stream.erb +9 -0
  84. data/app/views/layered/assistant/public/panel/conversations/_header.html.erb +16 -0
  85. data/app/views/layered/assistant/public/panel/conversations/index.html.erb +48 -0
  86. data/app/views/layered/assistant/public/panel/conversations/new.html.erb +17 -0
  87. data/app/views/layered/assistant/public/panel/conversations/show.html.erb +23 -0
  88. data/app/views/layered/assistant/public/panel/messages/_composer.html.erb +7 -0
  89. data/app/views/layered/assistant/public/panel/messages/create.turbo_stream.erb +9 -0
  90. data/app/views/layered/assistant/setup/_setup.html.erb +121 -0
  91. data/app/views/layered/assistant/setup/index.html.erb +2 -0
  92. data/app/views/layouts/layered/assistant/_host_navigation.html.erb +0 -0
  93. data/app/views/layouts/layered/assistant/application.html.erb +32 -0
  94. data/config/importmap.rb +8 -0
  95. data/config/routes.rb +31 -0
  96. data/data/models.json +42 -0
  97. data/db/migrate/20260312000000_create_layered_assistant_tables.rb +63 -0
  98. data/lib/generators/layered/assistant/install_generator.rb +113 -0
  99. data/lib/generators/layered/assistant/migrations_generator.rb +47 -0
  100. data/lib/generators/layered/assistant/templates/initializer.rb +26 -0
  101. data/lib/layered/assistant/engine.rb +29 -0
  102. data/lib/layered/assistant/version.rb +5 -0
  103. data/lib/layered/assistant.rb +19 -0
  104. data/lib/layered-assistant-rails.rb +1 -0
  105. metadata +449 -0
@@ -0,0 +1,63 @@
1
+ <div class="l-ui-container--spread">
2
+ <h1><%= @assistant ? "Conversations - #{@assistant.name}" : "Conversations" %></h1>
3
+ <div class="l-ui-container--spread">
4
+ <% if @assistant %>
5
+ <%= link_to "Back", layered_assistant.assistants_path, class: "l-ui-button--outline" %>
6
+ <% end %>
7
+ <%= link_to "New", layered_assistant.new_conversation_path(conversation: @assistant ? { assistant_id: @assistant.id } : {}), class: "l-ui-button--primary" %>
8
+ </div>
9
+ </div>
10
+
11
+ <div class="l-ui-container--table l-ui-utility--mt-lg">
12
+ <table class="l-ui-table">
13
+ <caption class="l-ui-sr-only"><%= @assistant ? "Conversations for #{@assistant.name}" : "Conversations" %></caption>
14
+ <thead class="l-ui-table__header">
15
+ <tr>
16
+ <th scope="col" class="l-ui-table__header-cell">Name</th>
17
+ <% unless @assistant %>
18
+ <th scope="col" class="l-ui-table__header-cell">Assistant</th>
19
+ <% end %>
20
+ <th scope="col" class="l-ui-table__header-cell">Messages</th>
21
+ <th scope="col" class="l-ui-table__header-cell">Tokens</th>
22
+ <th scope="col" class="l-ui-table__header-cell">User</th>
23
+ <th scope="col" class="l-ui-table__header-cell">Created</th>
24
+ <th scope="col" class="l-ui-table__header-cell--action">Actions</th>
25
+ </tr>
26
+ </thead>
27
+
28
+ <tbody class="l-ui-table__body">
29
+ <% @conversations.each do |conversation| %>
30
+ <tr>
31
+ <th scope="row" class="l-ui-table__cell--primary">
32
+ <%= link_to conversation.name, layered_assistant.conversation_path(conversation) %>
33
+ </th>
34
+ <% unless @assistant %>
35
+ <td class="l-ui-table__cell">
36
+ <%= link_to conversation.assistant.name, layered_assistant.assistant_conversations_path(conversation.assistant) %>
37
+ </td>
38
+ <% end %>
39
+ <td class="l-ui-table__cell">
40
+ <%= link_to conversation.messages_count.to_i, layered_assistant.conversation_messages_path(conversation) %>
41
+ </td>
42
+ <td class="l-ui-table__cell">
43
+ <%= number_with_delimiter(conversation.token_estimate.to_i) %>
44
+ </td>
45
+ <td class="l-ui-table__cell">
46
+ <%# nil owner = public conversation (guest user) %>
47
+ <%= conversation.owner.try(:name) || "Guest" %>
48
+ </td>
49
+ <td class="l-ui-table__cell">
50
+ <%= conversation.created_at.to_fs(:short) %>
51
+ </td>
52
+ <td class="l-ui-table__cell--action">
53
+ <%= link_to "Edit", layered_assistant.edit_conversation_path(conversation) %>
54
+ </td>
55
+ </tr>
56
+ <% end %>
57
+ </tbody>
58
+ </table>
59
+
60
+ <div class="l-ui-utility--mt-lg">
61
+ <%= l_ui_pagy(@pagy) %>
62
+ </div>
63
+ </div>
@@ -0,0 +1,6 @@
1
+ <div class="l-ui-container--spread">
2
+ <h1>New Conversation</h1>
3
+ <%= link_to "Back", layered_assistant.conversations_path, class: "l-ui-button--outline" %>
4
+ </div>
5
+
6
+ <%= render "form", conversation: @conversation, url: layered_assistant.conversations_path %>
@@ -0,0 +1,25 @@
1
+ <%= turbo_stream_from @conversation %>
2
+
3
+ <div class="l-ui-conversation__container">
4
+ <div class="l-ui-container--spread">
5
+ <h1><%= @conversation.name %></h1>
6
+ <%= link_to "Back", layered_assistant.conversations_path, class: "l-ui-button--outline" %>
7
+ </div>
8
+
9
+ <div class="l-ui-conversation__messages" data-controller="messages">
10
+ <div id="<%= dom_id(@conversation) %>_messages" class="<%= dom_id(@conversation) %>_messages l-ui-conversation" aria-live="polite" data-messages-target="list">
11
+ <%= render "layered/assistant/messages/system_prompt", conversation: @conversation %>
12
+ <%= render partial: "layered/assistant/messages/message", collection: @messages, as: :message %>
13
+ </div>
14
+
15
+ <button class="l-ui-scroll-to-bottom"
16
+ data-messages-target="scrollButton"
17
+ data-action="click->messages#jumpToBottom"
18
+ aria-label="Scroll to bottom"
19
+ type="button">
20
+ <%= image_tag "layered_ui/icon_chevron_down.svg", class: "l-ui-icon--sm l-ui-icon--dark-invert", alt: "" %>
21
+ </button>
22
+ </div>
23
+
24
+ <%= render "layered/assistant/messages/composer", conversation: @conversation, models: @models, selected_model_id: @selected_model_id %>
25
+ </div>
@@ -0,0 +1,15 @@
1
+ <%= form_with url: layered_assistant.conversation_messages_path(conversation), id: "composer-form", data: { controller: "composer", composer_target: "form", action: "submit->composer#submit" } do |f| %>
2
+ <div class="l-ui-conversation__composer">
3
+ <%= f.text_area :content, name: "message[content]", class: "l-ui-conversation__composer-input", placeholder: "Type a message...", "aria-label": "Message", rows: 1, data: { composer_target: "input", action: "keydown->composer#submitOnEnter" } %>
4
+
5
+ <button type="submit" class="l-ui-button--primary" title="Send (Enter)" data-composer-target="button">Send</button>
6
+ </div>
7
+
8
+ <% if models.any? %>
9
+ <div class="l-ui-form__group">
10
+ <div class="l-ui-select-wrapper">
11
+ <%= f.select :model_id, options_for_select(models.map { |m| ["#{m.provider.name}: #{m.name}", m.id] }, selected_model_id), {}, name: "message[model_id]", class: "l-ui-select", "aria-label": "AI model" %>
12
+ </div>
13
+ </div>
14
+ <% end %>
15
+ <% end %>
@@ -0,0 +1,25 @@
1
+ <%= tag.div id: dom_id(message), class: "#{dom_id(message)} #{message.user? ? "l-ui-message--sent" : "l-ui-message"}" do %>
2
+ <div class="l-ui-message__bubble">
3
+ <% unless message.user? %>
4
+ <div class="l-ui-message__author"><%= message.role.capitalize %></div>
5
+ <% end %>
6
+ <div class="<%= dom_id(message) %>_body l-ui-message__body<%= " l-ui-markdown" unless message.assistant? && message.content.blank? %>">
7
+ <% if message.assistant? && message.content.blank? %>
8
+ <div class="l-ui-typing-indicator" role="status" aria-label="Assistant is typing">
9
+ <span class="l-ui-typing-indicator__dot"></span>
10
+ <span class="l-ui-typing-indicator__dot"></span>
11
+ <span class="l-ui-typing-indicator__dot"></span>
12
+ </div>
13
+ <% else %>
14
+ <%= render_message_content(message) %>
15
+ <% end %>
16
+ </div>
17
+ <div class="l-ui-message__footer">
18
+ <% total_tokens = message.input_tokens.to_i + message.output_tokens.to_i %>
19
+ <% if message.model || total_tokens > 0 %>
20
+ <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
+ <% end %>
22
+ <span class="l-ui-message__timestamp"><%= message.created_at.to_fs(:short) %></span>
23
+ </div>
24
+ </div>
25
+ <% end %>
@@ -0,0 +1,10 @@
1
+ <% if conversation.assistant.system_prompt.present? %>
2
+ <div class="l-ui-message">
3
+ <div class="l-ui-message__bubble">
4
+ <div class="l-ui-message__author">System</div>
5
+ <div class="l-ui-message__body">
6
+ <%= conversation.assistant.system_prompt %>
7
+ </div>
8
+ </div>
9
+ </div>
10
+ <% end %>
@@ -0,0 +1,9 @@
1
+ <% if @error %>
2
+ <%= turbo_stream.append "#{dom_id(@conversation)}_messages" do %>
3
+ <div class="l-ui-notice--error l-ui-utility--mb-0" role="alert"><%= @error %></div>
4
+ <% end %>
5
+ <% end %>
6
+
7
+ <%= turbo_stream.replace "composer-form" do %>
8
+ <%= render "layered/assistant/messages/composer", conversation: @conversation, models: @models, selected_model_id: @selected_model_id %>
9
+ <% end %>
@@ -0,0 +1,46 @@
1
+ <div class="l-ui-container--spread">
2
+ <h1>Messages</h1>
3
+ <%= link_to "Back", layered_assistant.conversations_path, class: "l-ui-button--outline" %>
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">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">Tokens</th>
14
+ <th scope="col" class="l-ui-table__header-cell">Created</th>
15
+ <th scope="col" class="l-ui-table__header-cell--action">Actions</th>
16
+ </tr>
17
+ </thead>
18
+
19
+ <tbody class="l-ui-table__body">
20
+ <% @messages.each do |message| %>
21
+ <tr>
22
+ <th scope="row" class="l-ui-table__cell--primary">
23
+ <%= message.role %>
24
+ </th>
25
+ <td class="l-ui-table__cell">
26
+ <%= truncate(message.content, length: 100) %>
27
+ </td>
28
+ <td class="l-ui-table__cell">
29
+ <% total = message.input_tokens.to_i + message.output_tokens.to_i %>
30
+ <%= number_with_delimiter(total) || 0 %>
31
+ </td>
32
+ <td class="l-ui-table__cell">
33
+ <%= message.created_at.to_fs(:short) %>
34
+ </td>
35
+ <td class="l-ui-table__cell--action">
36
+ <%= link_to "Delete", layered_assistant.conversation_message_path(@conversation, message), data: { turbo_method: :delete, turbo_confirm: "Are you sure?" } %>
37
+ </td>
38
+ </tr>
39
+ <% end %>
40
+ </tbody>
41
+ </table>
42
+
43
+ <div class="l-ui-utility--mt-lg">
44
+ <%= l_ui_pagy(@pagy) %>
45
+ </div>
46
+ </div>
@@ -0,0 +1,30 @@
1
+ <%= form_with(model: model, url: url, html: { class: "l-ui-form l-ui-utility--mt-2xl" }) do |f| %>
2
+ <%= render "layered_ui/shared/form_errors", item: model %>
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: model, field: :name %>
8
+ </div>
9
+
10
+ <div class="l-ui-form__group">
11
+ <%= render "layered_ui/shared/label", form: f, field: :identifier, required: true %>
12
+ <%= f.text_field :identifier, class: "l-ui-form__field" %>
13
+ <%= render "layered_ui/shared/field_error", object: model, field: :identifier %>
14
+ </div>
15
+
16
+ <label class="l-ui-switch l-ui-utility--mt-xl">
17
+ <%= f.check_box :enabled, class: "l-ui-switch__input", role: "switch" %>
18
+ <span class="l-ui-switch__track"></span>
19
+ Enabled
20
+ </label>
21
+
22
+ <div class="l-ui-container--spread l-ui-utility--mt-2xl">
23
+ <% if model.persisted? %>
24
+ <%= link_to "Delete", layered_assistant.provider_model_path(@provider, model), 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 f.object.new_record? ? 'Create' : 'Update', class: "l-ui-button--primary" %>
29
+ </div>
30
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <div class="l-ui-container--spread">
2
+ <h1>Edit Model</h1>
3
+ <%= link_to "Back", layered_assistant.provider_models_path(@provider), class: "l-ui-button--outline" %>
4
+ </div>
5
+
6
+ <%= render "form", model: @model, url: layered_assistant.provider_model_path(@provider, @model) %>
@@ -0,0 +1,54 @@
1
+ <div class="l-ui-container--spread">
2
+ <h1>Models</h1>
3
+ <div class="l-ui-container--spread">
4
+ <%= link_to "Back", layered_assistant.providers_path, class: "l-ui-button--outline" %>
5
+ <%= link_to "New", layered_assistant.new_provider_model_path(@provider), class: "l-ui-button--primary" %>
6
+ </div>
7
+ </div>
8
+
9
+ <div class="l-ui-container--table l-ui-utility--mt-lg">
10
+ <table class="l-ui-table">
11
+ <caption class="l-ui-sr-only">Models</caption>
12
+ <thead class="l-ui-table__header">
13
+ <tr>
14
+ <th scope="col" class="l-ui-table__header-cell">Name</th>
15
+ <th scope="col" class="l-ui-table__header-cell">Identifier</th>
16
+ <th scope="col" class="l-ui-table__header-cell">Enabled</th>
17
+ <th scope="col" class="l-ui-table__header-cell">Assistants</th>
18
+ <th scope="col" class="l-ui-table__header-cell">Messages</th>
19
+ <th scope="col" class="l-ui-table__header-cell--action">Actions</th>
20
+ </tr>
21
+ </thead>
22
+
23
+ <tbody class="l-ui-table__body">
24
+ <% @models.each do |model| %>
25
+ <tr>
26
+ <td class="l-ui-table__cell--primary">
27
+ <%= model.name %>
28
+ </td>
29
+ <td class="l-ui-table__cell">
30
+ <%= model.identifier %>
31
+ </td>
32
+ <td class="l-ui-table__cell">
33
+ <span class="<%= model.enabled? ? "l-ui-badge--success" : "l-ui-badge--danger" %>">
34
+ <%= model.enabled? ? "Enabled" : "Disabled" %>
35
+ </span>
36
+ </td>
37
+ <td class="l-ui-table__cell">
38
+ <%= model.assistants_count %>
39
+ </td>
40
+ <td class="l-ui-table__cell">
41
+ <%= model.messages_count %>
42
+ </td>
43
+ <td class="l-ui-table__cell--action">
44
+ <%= link_to "Edit", layered_assistant.edit_provider_model_path(@provider, model) %>
45
+ </td>
46
+ </tr>
47
+ <% end %>
48
+ </tbody>
49
+ </table>
50
+
51
+ <div class="l-ui-utility--mt-lg">
52
+ <%= l_ui_pagy(@pagy) %>
53
+ </div>
54
+ </div>
@@ -0,0 +1,6 @@
1
+ <div class="l-ui-container--spread">
2
+ <h1>New Model</h1>
3
+ <%= link_to "Back", layered_assistant.provider_models_path(@provider), class: "l-ui-button--outline" %>
4
+ </div>
5
+
6
+ <%= render "form", model: @model, url: layered_assistant.provider_models_path(@provider) %>
@@ -0,0 +1,23 @@
1
+ <%= turbo_frame_tag "assistant_panel_header" do %>
2
+ <div class="l-ui-form__group l-ui-utility--mt-0">
3
+ <div class="l-ui-select-wrapper l-ui-utility--mt-0 l-ui-utility---mr-2">
4
+ <select name="conversation_id" class="l-ui-select" aria-label="Select conversation"
5
+ data-controller="panel-nav"
6
+ data-action="change->panel-nav#navigate"
7
+ data-panel-nav-new-url-value="<%= layered_assistant.new_panel_conversation_path %>">
8
+ <option value="<%= layered_assistant.panel_conversations_path %>" <%= "selected" if selected == layered_assistant.panel_conversations_path %>>Conversations</option>
9
+ <option value="new" <%= "selected" if selected == "new" %>>+ New</option>
10
+ <% assistants.each do |assistant| %>
11
+ <% assistant_conversations = conversations.select { |c| c.assistant_id == assistant.id } %>
12
+ <% if assistant_conversations.any? %>
13
+ <optgroup label="<%= assistant.name %>">
14
+ <% assistant_conversations.each do |c| %>
15
+ <option value="<%= layered_assistant.panel_conversation_path(c) %>" <%= "selected" if selected == layered_assistant.panel_conversation_path(c) %>><%= c.name %></option>
16
+ <% end %>
17
+ </optgroup>
18
+ <% end %>
19
+ <% end %>
20
+ </select>
21
+ </div>
22
+ </div>
23
+ <% end %>
@@ -0,0 +1,46 @@
1
+ <%= render "layered/assistant/panel/conversations/header", assistants: @assistants, conversations: @conversations, selected: layered_assistant.panel_conversations_path %>
2
+
3
+ <%= turbo_frame_tag "assistant_panel" do %>
4
+ <div class="l-ui-container--table">
5
+ <table class="l-ui-table l-ui-utility--mt-0">
6
+ <caption class="l-ui-sr-only">Conversations in panel</caption>
7
+ <thead class="l-ui-table__header">
8
+ <tr>
9
+ <th scope="col" class="l-ui-table__header-cell">Created</th>
10
+ <th scope="col" class="l-ui-table__header-cell">Name</th>
11
+ <th scope="col" class="l-ui-table__header-cell">Messages</th>
12
+ <th scope="col" class="l-ui-table__header-cell">Tokens</th>
13
+ <th scope="col" class="l-ui-table__header-cell--action">Actions</th>
14
+ </tr>
15
+ </thead>
16
+
17
+ <tbody class="l-ui-table__body">
18
+ <% if @conversations.any? %>
19
+ <% @conversations.each do |conversation| %>
20
+ <tr>
21
+ <td class="l-ui-table__cell">
22
+ <%= conversation.created_at.to_fs(:short) %>
23
+ </td>
24
+ <th scope="row" class="l-ui-table__cell--primary">
25
+ <%= link_to conversation.name, layered_assistant.panel_conversation_path(conversation), data: { turbo_frame: "assistant_panel" } %>
26
+ </th>
27
+ <td class="l-ui-table__cell">
28
+ <%= link_to conversation.messages_count || 0, layered_assistant.conversation_messages_path(conversation), data: { turbo_frame: "_top" } %>
29
+ </td>
30
+ <td class="l-ui-table__cell">
31
+ <%= number_with_delimiter(conversation.token_estimate.to_i) %>
32
+ </td>
33
+ <td class="l-ui-table__cell--action">
34
+ <%= button_to "Delete", layered_assistant.panel_conversation_path(conversation), method: :delete, class: "l-ui-table__action--danger", data: { turbo_confirm: "Delete this conversation?" } %>
35
+ </td>
36
+ </tr>
37
+ <% end %>
38
+ <% else %>
39
+ <tr>
40
+ <td class="l-ui-table__cell" colspan="5">No conversations yet.</td>
41
+ </tr>
42
+ <% end %>
43
+ </tbody>
44
+ </table>
45
+ </div>
46
+ <% end %>
@@ -0,0 +1,23 @@
1
+ <%= render "layered/assistant/panel/conversations/header", assistants: @assistants, conversations: [], selected: "new" %>
2
+
3
+ <%= turbo_frame_tag "assistant_panel" do %>
4
+ <%= form_with(model: @conversation, url: layered_assistant.panel_conversations_path, html: { class: "l-ui-form l-ui-utility--mt-0" }) do |f| %>
5
+ <%= render "layered_ui/shared/form_errors", item: @conversation %>
6
+
7
+ <div class="l-ui-form__group l-ui-utility--mt-0">
8
+ <%= render "layered_ui/shared/label", form: f, field: :assistant_id, name: "Assistant", required: true %>
9
+ <%= f.select :assistant_id, @assistants.map { |a| [a.name, a.id] }, { include_blank: "Select:" }, class: "l-ui-select" %>
10
+ <%= render "layered_ui/shared/field_error", object: @conversation, field: :assistant %>
11
+ </div>
12
+
13
+ <div class="l-ui-form__group">
14
+ <%= render "layered_ui/shared/label", form: f, field: :name, required: false %>
15
+ <%= f.text_field :name, class: "l-ui-form__field" %>
16
+ <p class="l-ui-form__hint">Auto-set from first message if left blank</p>
17
+ </div>
18
+
19
+ <div class="l-ui-utility--mt-lg">
20
+ <%= f.submit "Create conversation", class: "l-ui-button--primary" %>
21
+ </div>
22
+ <% end %>
23
+ <% end %>
@@ -0,0 +1,24 @@
1
+ <%= render "layered/assistant/panel/conversations/header", assistants: @assistants, conversations: @conversations, selected: layered_assistant.panel_conversation_path(@conversation) %>
2
+
3
+ <%= turbo_frame_tag "assistant_panel" do %>
4
+ <div class="l-ui-conversation__container">
5
+ <%= turbo_stream_from @conversation %>
6
+
7
+ <div class="l-ui-conversation__messages" data-controller="messages">
8
+ <div id="panel_<%= dom_id(@conversation) %>_messages" class="<%= dom_id(@conversation) %>_messages l-ui-conversation" aria-live="polite" data-messages-target="list">
9
+ <%= render "layered/assistant/messages/system_prompt", conversation: @conversation %>
10
+ <%= render partial: "layered/assistant/messages/message", collection: @messages, as: :message %>
11
+ </div>
12
+
13
+ <button class="l-ui-scroll-to-bottom"
14
+ data-messages-target="scrollButton"
15
+ data-action="click->messages#jumpToBottom"
16
+ aria-label="Scroll to bottom"
17
+ type="button">
18
+ <%= image_tag "layered_ui/icon_chevron_down.svg", class: "l-ui-icon--sm l-ui-icon--dark-invert", alt: "" %>
19
+ </button>
20
+ </div>
21
+
22
+ <%= render "layered/assistant/panel/messages/composer", conversation: @conversation, models: @models, selected_model_id: @selected_model_id %>
23
+ </div>
24
+ <% end %>
@@ -0,0 +1,15 @@
1
+ <%= form_with url: layered_assistant.panel_conversation_messages_path(conversation), id: "panel-composer-form", data: { controller: "composer", composer_target: "form", turbo_frame: "_top", action: "submit->composer#submit" } do |f| %>
2
+ <div class="l-ui-conversation__composer">
3
+ <%= f.text_area :content, name: "message[content]", class: "l-ui-conversation__composer-input", placeholder: "Type a message...", "aria-label": "Message", rows: 1, data: { composer_target: "input", action: "keydown->composer#submitOnEnter" } %>
4
+
5
+ <button type="submit" class="l-ui-button--primary" title="Send (Enter)" data-composer-target="button">Send</button>
6
+ </div>
7
+
8
+ <% if models.any? %>
9
+ <div class="l-ui-form__group">
10
+ <div class="l-ui-select-wrapper">
11
+ <%= f.select :model_id, options_for_select(models.map { |m| ["#{m.provider.name}: #{m.name}", m.id] }, selected_model_id), {}, name: "message[model_id]", class: "l-ui-select", "aria-label": "AI model" %>
12
+ </div>
13
+ </div>
14
+ <% end %>
15
+ <% end %>
@@ -0,0 +1,9 @@
1
+ <% if @error %>
2
+ <%= turbo_stream.append_all ".#{dom_id(@conversation)}_messages" do %>
3
+ <div class="l-ui-notice--error l-ui-utility--mb-0" role="alert"><%= @error %></div>
4
+ <% end %>
5
+ <% end %>
6
+
7
+ <%= turbo_stream.replace "panel-composer-form" do %>
8
+ <%= render "layered/assistant/panel/messages/composer", conversation: @conversation, models: @models, selected_model_id: @selected_model_id %>
9
+ <% end %>
@@ -0,0 +1,81 @@
1
+ <%= form_with(model: provider, url: url, html: { class: "l-ui-form l-ui-utility--mt-2xl" }) do |f| %>
2
+ <%= render "layered_ui/shared/form_errors", item: provider %>
3
+
4
+ <div data-controller="provider-template" data-provider-template-templates-value="<%= Layered::Assistant::Provider::TEMPLATES.values.flatten.to_json %>">
5
+ <% unless provider.persisted? %>
6
+ <div class="l-ui-form__group">
7
+ <label class="l-ui-label" for="provider_template">Template</label>
8
+ <select id="provider_template" class="l-ui-select" data-action="provider-template#apply">
9
+ <option value="">Manual</option>
10
+ <% Layered::Assistant::Provider::TEMPLATES.each do |group, templates| %>
11
+ <optgroup label="<%= group %>">
12
+ <% templates.each do |t| %>
13
+ <option value="<%= t[:key] %>"><%= t[:name] %></option>
14
+ <% end %>
15
+ </optgroup>
16
+ <% end %>
17
+ </select>
18
+ <div class="l-ui-form__hint" hidden data-provider-template-target="description"></div>
19
+ </div>
20
+ <% end %>
21
+
22
+ <div class="l-ui-form__group">
23
+ <%= render "layered_ui/shared/label", form: f, field: :name, required: true %>
24
+ <%= f.text_field :name, class: "l-ui-form__field", data: { provider_template_target: "name" } %>
25
+ <%= render "layered_ui/shared/field_error", object: provider, field: :name %>
26
+ </div>
27
+
28
+ <div class="l-ui-form__group">
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" } } %>
31
+ <%= render "layered_ui/shared/field_error", object: provider, field: :protocol %>
32
+ </div>
33
+
34
+ <div class="l-ui-form__group">
35
+ <%= render "layered_ui/shared/label", form: f, field: :url, name: "URL", required: false %>
36
+ <%= f.url_field :url, class: "l-ui-form__field", data: { provider_template_target: "url" } %>
37
+ <%= render "layered_ui/shared/field_error", object: provider, field: :url %>
38
+ </div>
39
+
40
+ <div class="l-ui-form__group">
41
+ <%= render "layered_ui/shared/label", form: f, field: :secret, name: "Secret", required: false %>
42
+ <%= f.password_field :secret, class: "l-ui-form__field" %>
43
+ <%= render "layered_ui/shared/field_error", object: provider, field: :secret %>
44
+ <div class="l-ui-form__hint" hidden data-provider-template-target="secretHint"></div>
45
+ </div>
46
+ </div>
47
+
48
+ <label class="l-ui-switch l-ui-utility--mt-xl">
49
+ <%= f.check_box :enabled, class: "l-ui-switch__input", role: "switch" %>
50
+ <span class="l-ui-switch__track"></span>
51
+ Enabled
52
+ </label>
53
+
54
+ <% unless provider.persisted? %>
55
+ <div class="l-ui-form__group">
56
+ <label class="l-ui-label">Models</label>
57
+ <div class="l-ui-radio__group">
58
+ <div class="l-ui-radio__item">
59
+ <%= f.radio_button :create_models, "1", class: "l-ui-radio__input", checked: true %>
60
+ <%= f.label :create_models_1, "If available, automatically create popular models for this provider", class: "l-ui-radio__label" %>
61
+ </div>
62
+ <div class="l-ui-radio__item">
63
+ <%= f.radio_button :create_models, "0", class: "l-ui-radio__input" %>
64
+ <%= f.label :create_models_0, "Add your own models manually", class: "l-ui-radio__label" %>
65
+ </div>
66
+ </div>
67
+ <div class="l-ui-form__hint">
68
+ Popular model identifiers are sourced from: <%= link_to "https://github.com/layered-ai-public/layered-assistant-rails/blob/main/data/models.json", "https://github.com/layered-ai-public/layered-assistant-rails/blob/main/data/models.json", target: "_blank", rel: "noopener noreferrer" %>.
69
+ </div>
70
+ </div>
71
+ <% end %>
72
+
73
+ <div class="l-ui-container--spread l-ui-utility--mt-2xl">
74
+ <% if provider.persisted? %>
75
+ <%= link_to "Delete", layered_assistant.provider_path(provider), class: "l-ui-button--outline-danger", data: { turbo_method: :delete, turbo_confirm: "Are you sure?" } %>
76
+ <% else %>
77
+ <span></span>
78
+ <% end %>
79
+ <%= f.submit f.object.new_record? ? 'Create' : 'Update', class: "l-ui-button--primary" %>
80
+ </div>
81
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <div class="l-ui-container--spread">
2
+ <h1>Edit Provider</h1>
3
+ <%= link_to "Back", layered_assistant.providers_path, class: "l-ui-button--outline" %>
4
+ </div>
5
+
6
+ <%= render "form", provider: @provider, url: layered_assistant.provider_path(@provider) %>
@@ -0,0 +1,47 @@
1
+ <div class="l-ui-container--spread">
2
+ <h1>Providers</h1>
3
+ <%= link_to "New", layered_assistant.new_provider_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">Providers</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">Protocol</th>
13
+ <th scope="col" class="l-ui-table__header-cell">Models</th>
14
+ <th scope="col" class="l-ui-table__header-cell">Enabled</th>
15
+ <th scope="col" class="l-ui-table__header-cell--action">Actions</th>
16
+ </tr>
17
+ </thead>
18
+
19
+ <tbody class="l-ui-table__body">
20
+ <% @providers.each do |provider| %>
21
+ <tr>
22
+ <td class="l-ui-table__cell--primary">
23
+ <%= provider.name %>
24
+ </td>
25
+ <td class="l-ui-table__cell">
26
+ <%= Layered::Assistant::Provider.protocols[provider.protocol] %>
27
+ </td>
28
+ <td class="l-ui-table__cell">
29
+ <%= link_to provider.models_count, layered_assistant.provider_models_path(provider) %>
30
+ </td>
31
+ <td class="l-ui-table__cell">
32
+ <span class="<%= provider.enabled? ? "l-ui-badge--success" : "l-ui-badge--danger" %>">
33
+ <%= provider.enabled? ? "Enabled" : "Disabled" %>
34
+ </span>
35
+ </td>
36
+ <td class="l-ui-table__cell--action">
37
+ <%= link_to "Edit", layered_assistant.edit_provider_path(provider) %>
38
+ </td>
39
+ </tr>
40
+ <% end %>
41
+ </tbody>
42
+ </table>
43
+
44
+ <div class="l-ui-utility--mt-lg">
45
+ <%= l_ui_pagy(@pagy) %>
46
+ </div>
47
+ </div>
@@ -0,0 +1,6 @@
1
+ <div class="l-ui-container--spread">
2
+ <h1>New Provider</h1>
3
+ <%= link_to "Back", layered_assistant.providers_path, class: "l-ui-button--outline" %>
4
+ </div>
5
+
6
+ <%= render "form", provider: @provider, url: layered_assistant.providers_path %>