layered-assistant-rails 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3c4266f3d5e33f449619fe77a6e9b0819bcc22c038ffe3498c96ea56549faa80
4
- data.tar.gz: 6b4e8a671a99e0e0cfd987b631dc316616743f5b3dbe3b2d3370d8c0a7f1a614
3
+ metadata.gz: 68ea0fb66720732e6e05a139e873ec6eb90912967e1a6e9c956461e76d7e347c
4
+ data.tar.gz: f4e561987280115a3e160a42a747ee036aab7373c8f65fcc33d36eac01dafec7
5
5
  SHA512:
6
- metadata.gz: '0681356f7a030b95dcc62e0b589647b4e4c650fa1683dbf2b98ddceefd89cb74ea1b73578053de064c32420faefd21c2bd28cd7ef5ffc1660d39cd991d954713'
7
- data.tar.gz: df9c6fde84b9897e4833fd7beedcb0557c319f6603b663af10f9fa1b4ce1c69ddf4041dd9aa7aea335c09b2b26764aecf53fdbb3ac21d3871796f11fdcc0bc28
6
+ metadata.gz: 56758769136c2e033813dcf466444c778643483739bcafa5048962bca0786e167a04a6a9de9085eed038a8d492cd08c2328363cc1a98f2ea58024bd6096efd99
7
+ data.tar.gz: 054c1b233c696bbec3d6c7e4e643c28fc69ffc378ce5ffa11b3110de59ed9cf8bbb87c9ed582b442f437a724a7d502609171c3022b67eb43f8b2a759a8190059
data/README.md CHANGED
@@ -434,16 +434,17 @@ Then visit `http://localhost:3000`.
434
434
 
435
435
  ### Deploying the dummy app
436
436
 
437
- The dummy app can be deployed with [Kamal](https://kamal-deploy.org). Set the required environment variables and deploy from `test/dummy`:
437
+ The dummy app can be deployed with [Kamal](https://kamal-deploy.org). Copy `.env.deploy.example` to `.env.deploy` and fill in the target, then deploy with `bin/deploy`:
438
438
 
439
439
  ```bash
440
- cd test/dummy
441
- export KAMAL_DEPLOY_IP=<server-ip>
442
- export KAMAL_DEPLOY_DOMAIN=<domain>
443
- export KAMAL_SSH_KEY=<path-to-ssh-key>
444
- kamal deploy
440
+ cp .env.deploy.example .env.deploy
441
+ bin/deploy # kamal deploy
442
+ bin/deploy setup # first run on a new server
443
+ bin/deploy logs # any other kamal subcommand passes straight through
445
444
  ```
446
445
 
446
+ `.env.deploy` is gitignored, so the server address stays out of the repo. The script reads it, checks the three `KAMAL_*` variables are set, and runs Kamal from `test/dummy`, where `config/deploy.yml` lives.
447
+
447
448
  ## Testing
448
449
 
449
450
  Run the gem tests from the root directory:
@@ -1,80 +1,60 @@
1
1
  module Layered
2
2
  module Assistant
3
- class ConversationsController < ApplicationController
3
+ # Conversations are a layered resource for listing, starting, renaming
4
+ # and deleting them; only `show` - the conversation itself - and `stop`
5
+ # are this engine's own.
6
+ class ConversationsController < ResourcesController
4
7
  include StoppableResponse
5
8
 
6
- before_action :set_conversation, only: [ :show, :edit, :update, :destroy, :stop ]
7
- before_action :set_assistants, only: [ :new, :create ]
8
-
9
- def index
10
- if params[:assistant_id]
11
- @assistant = scoped(Assistant).find(params[:assistant_id])
12
- @page_title = "Conversations - #{@assistant.name}"
13
- @pagy, @conversations = pagy(@assistant.conversations.merge(scoped(Conversation)).includes(:owner, :user).by_created_at)
14
- else
15
- @page_title = "Conversations"
16
- @pagy, @conversations = pagy(scoped(Conversation).includes(:assistant, :owner, :user).by_created_at)
17
- end
18
- end
9
+ before_action :set_conversation, only: [ :stop ]
10
+ before_action :drop_assistant_column, only: [ :index ], if: -> { params[:assistant_id] }
11
+ before_action :scope_choice_fields, only: [ :new, :create, :edit, :update ]
19
12
 
20
13
  def show
21
- @page_title = @conversation.name
14
+ super
15
+ @conversation = @record
22
16
  @messages = @conversation.messages.includes(:model).by_created_at
23
17
  @models = scoped_models
24
18
  @selected_model_id = @messages.last&.model_id || @conversation.assistant.default_model_id || @models.first&.id
25
19
  end
26
20
 
27
- def new
28
- @page_title = "New conversation"
29
- @conversation = Conversation.new(params.permit(conversation: [ :assistant_id ])[:conversation])
30
- end
21
+ private
31
22
 
32
- def create
33
- @conversation = Conversation.new(conversation_params)
34
- @conversation.owner = current_owner!
35
- @conversation.user = current_conversation_user
36
- @conversation.assistant = scoped(Assistant).find(conversation_params[:assistant_id]) if conversation_params[:assistant_id].present?
37
- @conversation.name = Conversation.default_name if @conversation.name.blank?
38
- if @conversation.save
39
- redirect_to layered_assistant.conversation_path(@conversation)
40
- else
41
- render :new, status: :unprocessable_entity
42
- end
23
+ # `stop` is a custom member action, so the gem has loaded @record.
24
+ def set_conversation
25
+ @conversation = @record
43
26
  end
44
27
 
45
- def edit
46
- @page_title = "Edit conversation"
28
+ # Listed under an assistant, every row would name the same one.
29
+ def drop_assistant_column
30
+ @columns = @columns.reject { |column| column[:attribute] == :assistant_id }
47
31
  end
48
32
 
49
- def update
50
- if @conversation.update(conversation_params)
51
- redirect_to layered_assistant.conversations_path, notice: "Conversation was successfully updated."
33
+ # The assistant is chosen once, when the conversation is started, and
34
+ # only from the owner's own.
35
+ def scope_choice_fields
36
+ if action_name.in?(%w[new create])
37
+ assistants = scoped(Assistant).by_name.map { |assistant| [ assistant.name, assistant.id ] }
38
+ @fields = @fields.map { |field| field[:attribute] == :assistant_id ? field.merge(collection: assistants) : field }
52
39
  else
53
- render :edit, status: :unprocessable_entity
40
+ @fields = @fields.reject { |field| field[:attribute] == :assistant_id }
54
41
  end
55
42
  end
56
43
 
57
- def destroy
58
- @conversation.destroy
59
- redirect_to layered_assistant.conversations_path, notice: "Conversation was successfully deleted."
60
- end
61
-
62
- private
63
-
64
- def set_conversation
65
- @conversation = scoped(Conversation).find_by!(uid: params[:id])
66
- end
67
-
68
- def set_assistants
69
- @assistants = scoped(Assistant).by_name
70
- end
44
+ # An out-of-scope assistant 404s rather than being silently dropped,
45
+ # matching how a record itself is looked up. A blank one leaves any
46
+ # assistant the route already chose in place.
47
+ def layered_resource_params
48
+ attributes = super
49
+ return attributes.except(:assistant_id) unless action_name == "create"
71
50
 
72
- def conversation_params
73
- if action_name == "create"
74
- params.require(:conversation).permit(:name, :assistant_id)
51
+ if attributes[:assistant_id].present?
52
+ attributes[:assistant_id] = scoped(Assistant).find(attributes[:assistant_id]).id
75
53
  else
76
- params.require(:conversation).permit(:name)
54
+ attributes.delete(:assistant_id)
77
55
  end
56
+ attributes[:name] = Conversation.default_name if attributes[:name].blank?
57
+ attributes
78
58
  end
79
59
  end
80
60
  end
@@ -0,0 +1,87 @@
1
+ module Layered
2
+ module Assistant
3
+ class ConversationResource < Layered::Resource::Base
4
+ model Conversation
5
+
6
+ # Conversations are addressed by uid, not id.
7
+ lookup_attribute :uid
8
+
9
+ columns [
10
+ # The name links to the conversation itself, not its edit form.
11
+ { attribute: :name, primary: true, link: false,
12
+ render: ->(record, view) {
13
+ view.link_to(record.name, view.layered_assistant.conversation_path(record),
14
+ data: { turbo_frame: "_top" })
15
+ } },
16
+ { attribute: :assistant_id, label: "Assistant", sortable: false,
17
+ render: ->(record, view) {
18
+ view.link_to(record.assistant.name,
19
+ view.layered_assistant.assistant_conversations_path(record.assistant),
20
+ data: { turbo_frame: "_top" })
21
+ } },
22
+ { attribute: :messages_count, label: "Messages",
23
+ render: ->(record, view) {
24
+ view.link_to(record.messages_count.to_i,
25
+ view.layered_assistant.conversation_messages_path(record),
26
+ data: { turbo_frame: "_top" })
27
+ } },
28
+ { attribute: :token_estimate, label: "Tokens",
29
+ render: ->(record, view) { view.number_with_delimiter(record.token_estimate.to_i) } },
30
+ # The person who did the talking, which is the owner itself until an
31
+ # owner block scopes ownership elsewhere. Neither is set for an
32
+ # anonymous visitor on a public assistant.
33
+ { attribute: :user_id, label: "User", sortable: false,
34
+ render: ->(record) { record.user.try(:name) || record.owner.try(:name) || "Guest" } },
35
+ { attribute: :created_at, label: "Created",
36
+ render: ->(record) { record.created_at.to_fs(:short) } }
37
+ ]
38
+
39
+ search_fields [ :name ]
40
+
41
+ default_sort attribute: :created_at, direction: :desc
42
+
43
+ # `assistant_id` carries no collection here: the options have to be
44
+ # scoped to the owner, which only the controller can resolve.
45
+ # ConversationsController fills them in per request, and drops the
46
+ # field once the conversation exists - its assistant is fixed.
47
+ fields [
48
+ { attribute: :assistant_id, label: "Assistant", as: :select, include_blank: "Select an assistant:", required: true },
49
+ # Left blank, the name is set from the first message, so the form
50
+ # must not insist on one even though the model does.
51
+ { attribute: :name, required: false, hint: "Auto-set from first message if left blank" }
52
+ ]
53
+
54
+ # Nested under an assistant, only that assistant's conversations are
55
+ # listed, and an out-of-scope assistant 404s before any are reached.
56
+ def self.scope(controller)
57
+ scope = controller.send(:scoped, Conversation)
58
+ if (assistant_id = controller.params[:assistant_id])
59
+ scope = scope.where(assistant: controller.send(:scoped, Assistant).find(assistant_id))
60
+ end
61
+ scope.includes(:assistant, :owner, :user)
62
+ end
63
+
64
+ # Started from an assistant's conversations, the assistant is chosen
65
+ # already - resolved through `scoped` so an out-of-scope one 404s.
66
+ def self.build_record(controller)
67
+ assistant_id = controller.params[:assistant_id]
68
+
69
+ Conversation.new(
70
+ owner: controller.send(:current_owner!),
71
+ user: controller.send(:current_conversation_user),
72
+ assistant: (controller.send(:scoped, Assistant).find(assistant_id) if assistant_id)
73
+ )
74
+ end
75
+
76
+ # A new conversation opens straight into the chat; edits and deletes
77
+ # return to the list they were made from.
78
+ def self.after_save_path(controller, record)
79
+ if controller.action_name == "create"
80
+ controller.layered_assistant.conversation_path(record)
81
+ else
82
+ controller.layered_assistant.conversations_path
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -38,8 +38,9 @@ module Layered
38
38
  { key: "openai", name: "OpenAI", description: "GPT family of models. Requires an API key.", protocol: "openai", url: "https://api.openai.com/v1", keys_url: "https://platform.openai.com/api-keys" },
39
39
  { key: "gemini", name: "Gemini", description: "Google Gemini family of models. Requires an API key.", protocol: "openai", url: "https://generativelanguage.googleapis.com/v1beta/openai/", keys_url: "https://aistudio.google.com/api-keys" },
40
40
  { key: "mistral", name: "Mistral", description: "Mistral's own frontier models. Requires an API key.", protocol: "openai", url: "https://api.mistral.ai/v1", keys_url: "https://admin.mistral.ai/organization/api-keys" },
41
- { key: "groq", name: "Groq", description: "Low-latency inference for popular open-weight models. Requires an API key.", protocol: "openai", url: "https://api.groq.com/openai/v1", keys_url: "https://console.groq.com/keys" },
42
41
  { key: "openrouter", name: "OpenRouter", description: "Access hundreds of models through a single API. Requires an API key.", protocol: "openai", url: "https://openrouter.ai/api/v1/", keys_url: "https://openrouter.ai/settings/keys" },
42
+ { key: "together", name: "Together AI", description: "Serverless inference for open-weight models. Requires an API key.", protocol: "openai", url: "https://api.together.ai/v1", keys_url: "https://api.together.ai/settings/projects/~current/api-keys" },
43
+ { key: "groq", name: "Groq", description: "Low-latency inference for popular open-weight models. Requires an API key.", protocol: "openai", url: "https://api.groq.com/openai/v1", keys_url: "https://console.groq.com/keys" },
43
44
  { key: "fireworks", name: "Fireworks", description: "Serverless inference for open-weight models. Requires an API key.", protocol: "openai", url: "https://api.fireworks.ai/inference/v1", keys_url: "https://app.fireworks.ai/settings/users/api-keys" }
44
45
  ],
45
46
  "Local" => [
@@ -1,46 +1,15 @@
1
1
  <%= render "layered/assistant/panel/conversations/header", assistants: @assistants, conversations: @conversations, selected: layered_assistant.panel_conversations_path %>
2
2
 
3
3
  <%= turbo_frame_tag "assistant_panel" do %>
4
- <div class="l-ui-table-container">
5
- <table class="l-ui-table l-ui-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 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 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 l-ui-table__cell--action">
34
- <%= button_to "Delete", layered_assistant.panel_conversation_path(conversation), method: :delete, class: "l-ui-table__action 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>
4
+ <%= l_ui_table(@conversations,
5
+ caption: "Conversations in panel",
6
+ columns: [
7
+ { attribute: :created_at, label: "Created", render: ->(c) { c.created_at.to_fs(:short) } },
8
+ { attribute: :name, primary: true, render: ->(c) { link_to c.name, layered_assistant.panel_conversation_path(c), data: { turbo_frame: "assistant_panel", turbo_prefetch: false } } },
9
+ { attribute: :messages_count, label: "Messages", render: ->(c) { link_to c.messages_count || 0, layered_assistant.conversation_messages_path(c), data: { turbo_frame: "_top", turbo_prefetch: false } } },
10
+ { attribute: :token_estimate, label: "Tokens", render: ->(c) { number_with_delimiter(c.token_estimate.to_i) } }
11
+ ],
12
+ actions: ->(c) { button_to "Delete", layered_assistant.panel_conversation_path(c), method: :delete, class: "l-ui-table__action l-ui-table__action--danger", data: { turbo_confirm: "Delete this conversation?" } },
13
+ floating_actions: true
14
+ ) %>
46
15
  <% end %>
@@ -37,7 +37,8 @@
37
37
  <div class="l-ui-spread">
38
38
  <div>
39
39
  <%= l_ui_breadcrumbs do %>
40
- <%= l_ui_breadcrumb_item @model.model_name.human.pluralize, layered_collection_path %>
40
+ <%= layered_breadcrumb_item @model.model_name.human.pluralize, layered_collection_path %>
41
+ <%= layered_breadcrumb_item "New", current: true %>
41
42
  <% end %>
42
43
 
43
44
  <h1 class="l-ui-heading">New <%= @model.model_name.human.downcase %></h1>
@@ -9,34 +9,14 @@
9
9
  <% end %>
10
10
 
11
11
  <% if @conversations.any? %>
12
- <div class="l-ui-table-container">
13
- <table class="l-ui-table l-ui-mt-0">
14
- <caption class="l-ui-sr-only">Your conversations</caption>
15
- <thead class="l-ui-table__header">
16
- <tr>
17
- <th scope="col" class="l-ui-table__header-cell">Created</th>
18
- <th scope="col" class="l-ui-table__header-cell">Name</th>
19
- <th scope="col" class="l-ui-table__header-cell">Messages</th>
20
- </tr>
21
- </thead>
22
-
23
- <tbody class="l-ui-table__body">
24
- <% @conversations.each do |conversation| %>
25
- <tr>
26
- <td class="l-ui-table__cell">
27
- <%= conversation.created_at.to_fs(:short) %>
28
- </td>
29
- <th scope="row" class="l-ui-table__cell l-ui-table__cell--primary">
30
- <%= link_to conversation.name, layered_assistant.public_panel_conversation_path(conversation), data: { turbo_frame: "assistant_panel" } %>
31
- </th>
32
- <td class="l-ui-table__cell">
33
- <%= conversation.messages_count || 0 %>
34
- </td>
35
- </tr>
36
- <% end %>
37
- </tbody>
38
- </table>
39
- </div>
12
+ <%= l_ui_table(@conversations,
13
+ caption: "Your conversations",
14
+ columns: [
15
+ { attribute: :created_at, label: "Created", render: ->(c) { c.created_at.to_fs(:short) } },
16
+ { attribute: :name, primary: true, render: ->(c) { link_to c.name, layered_assistant.public_panel_conversation_path(c), data: { turbo_frame: "assistant_panel", turbo_prefetch: false } } },
17
+ { attribute: :messages_count, label: "Messages", render: ->(c) { c.messages_count || 0 } }
18
+ ]
19
+ ) %>
40
20
  <% end %>
41
21
 
42
22
  <%= form_with url: layered_assistant.public_panel_conversations_path(assistant_id: @assistant.id), method: :post do |f| %>
data/config/routes.rb CHANGED
@@ -4,14 +4,18 @@ Layered::Assistant::Engine.routes.draw do
4
4
  layered_resources :skills, namespace: "Layered::Assistant", except: [ :show ]
5
5
  layered_resources :assistants, namespace: "Layered::Assistant", controller: "assistants", except: [ :show ]
6
6
  resources :assistants, only: [] do
7
- resources :conversations, only: [ :index ]
7
+ layered_resources :conversations, namespace: "Layered::Assistant", controller: "conversations", only: [ :index, :new, :create ]
8
8
  end
9
9
  layered_resources :providers, namespace: "Layered::Assistant", controller: "providers", except: [ :show ]
10
10
  resources :providers, only: [] do
11
11
  layered_resources :models, namespace: "Layered::Assistant", except: [ :show ]
12
12
  end
13
- resources :conversations, only: [ :index, :show, :new, :create, :edit, :update, :destroy ] do
14
- patch :stop, on: :member
13
+ layered_resources :conversations, namespace: "Layered::Assistant", controller: "conversations" do
14
+ member do
15
+ patch :stop
16
+ end
17
+ end
18
+ resources :conversations, only: [] do
15
19
  resources :messages, only: [ :index, :create, :destroy ]
16
20
  # Approving a tool call is the same act wherever it is rendered, and the
17
21
  # partial carrying the buttons is broadcast rather than requested, so it
data/data/models.json CHANGED
@@ -1,55 +1,66 @@
1
1
  {
2
2
  "Anthropic": [
3
+ { "name": "Claude Opus 5.5", "identifier": "claude-opus-5-5" },
4
+ { "name": "Claude Sonnet 5.5", "identifier": "claude-sonnet-5-5" },
5
+ { "name": "Claude Fable 5.1", "identifier": "claude-fable-5-1" },
3
6
  { "name": "Claude Opus 5", "identifier": "claude-opus-5" },
4
- { "name": "Claude Sonnet 5", "identifier": "claude-sonnet-5" },
5
- { "name": "Claude Fable 5", "identifier": "claude-fable-5" },
6
- { "name": "Claude Opus 4.8", "identifier": "claude-opus-4-8" },
7
7
  { "name": "Claude Haiku 4.5", "identifier": "claude-haiku-4-5" }
8
8
  ],
9
9
  "OpenAI": [
10
- { "name": "GPT-5.6 Sol", "identifier": "gpt-5.6-sol" },
11
- { "name": "GPT-5.6 Terra", "identifier": "gpt-5.6-terra" },
12
- { "name": "GPT-5.6 Luna", "identifier": "gpt-5.6-luna" },
13
- { "name": "GPT-5.5 Pro", "identifier": "gpt-5.5-pro" }
10
+ { "name": "GPT-6 Astra", "identifier": "gpt-6-astra" },
11
+ { "name": "GPT-6.1 Sol", "identifier": "gpt-6.1-sol" },
12
+ { "name": "GPT-6 Luna", "identifier": "gpt-6-luna" },
13
+ { "name": "GPT-5.6 Sol", "identifier": "gpt-5.6-sol" }
14
14
  ],
15
15
  "Gemini": [
16
+ { "name": "Gemini 3.8 Flash", "identifier": "gemini-3.8-flash" },
17
+ { "name": "Gemini 3.7 Flash", "identifier": "gemini-3.7-flash" },
16
18
  { "name": "Gemini 3.6 Flash", "identifier": "gemini-3.6-flash" },
17
19
  { "name": "Gemini 3.5 Flash", "identifier": "gemini-3.5-flash" },
18
20
  { "name": "Gemini 3.5 Flash-Lite", "identifier": "gemini-3.5-flash-lite" },
19
21
  { "name": "Gemini 3.1 Pro Preview", "identifier": "gemini-3.1-pro-preview" },
20
- { "name": "Gemini 3.1 Flash-Lite", "identifier": "gemini-3.1-flash-lite" },
21
- { "name": "Gemini 2.5 Pro", "identifier": "gemini-2.5-pro" },
22
- { "name": "Gemini 2.5 Flash", "identifier": "gemini-2.5-flash" },
23
- { "name": "Gemini 2.5 Flash-Lite", "identifier": "gemini-2.5-flash-lite" }
22
+ { "name": "Gemini 3.1 Flash-Lite", "identifier": "gemini-3.1-flash-lite" }
24
23
  ],
25
24
  "OpenRouter": [
26
25
  { "name": "Auto Router (Beta)", "identifier": "openrouter/auto-beta" },
27
- { "name": "Qwen3.8 Max", "identifier": "qwen/qwen3.8-max" },
28
- { "name": "DeepSeek V4 Flash 0731", "identifier": "deepseek/deepseek-v4-flash-0731" },
26
+ { "name": "Qwen3.8 Max (0902)", "identifier": "qwen/qwen3.8-max-0902" },
27
+ { "name": "DeepSeek V4.1 Flash", "identifier": "deepseek/deepseek-v4.1-flash" },
28
+ { "name": "Grok 4.7", "identifier": "x-ai/grok-4.7" },
29
+ { "name": "GLM 5.3", "identifier": "z-ai/glm-5.3" },
29
30
  { "name": "Kimi K3", "identifier": "moonshotai/kimi-k3" },
30
- { "name": "Grok 4.5", "identifier": "x-ai/grok-4.5" },
31
- { "name": "GLM-5.2", "identifier": "z-ai/glm-5.2" },
32
31
  { "name": "Kimi K2.7 Code", "identifier": "moonshotai/kimi-k2.7-code" },
33
32
  { "name": "MiniMax M3", "identifier": "minimax/minimax-m3" },
34
- { "name": "DeepSeek V4 Pro", "identifier": "deepseek/deepseek-v4-pro" }
33
+ { "name": "DeepSeek V4 Pro 0813", "identifier": "deepseek/deepseek-v4-pro-0813" }
35
34
  ],
36
35
  "Groq": [
37
- { "name": "Qwen3.6 27B", "identifier": "qwen/qwen3.6-27b" },
36
+ { "name": "Qwen3.8 27B", "identifier": "qwen/qwen3.8-27b" },
38
37
  { "name": "GPT-OSS 120B", "identifier": "openai/gpt-oss-120b" },
39
38
  { "name": "GPT-OSS 20B", "identifier": "openai/gpt-oss-20b" },
40
- { "name": "Compound", "identifier": "groq/compound" },
41
- { "name": "Compound Mini", "identifier": "groq/compound-mini" }
39
+ { "name": "Llama 3.3 70B", "identifier": "llama-3.3-70b-versatile" }
42
40
  ],
43
41
  "Fireworks": [
44
- { "name": "DeepSeek V4 Flash 0731", "identifier": "accounts/fireworks/models/deepseek-v4-flash-0731" },
42
+ { "name": "Ember-1", "identifier": "accounts/fireworks/models/ember-1" },
43
+ { "name": "DeepSeek V4.1 Flash", "identifier": "accounts/fireworks/models/deepseek-v4p1-flash" },
44
+ { "name": "GLM 5.3", "identifier": "accounts/fireworks/models/glm-5p3" },
45
+ { "name": "GLM 5.3 Flash", "identifier": "accounts/fireworks/models/glm-5p3-flash" },
45
46
  { "name": "Kimi K3", "identifier": "accounts/fireworks/models/kimi-k3" },
46
- { "name": "GLM 5.2", "identifier": "accounts/fireworks/models/glm-5p2" },
47
- { "name": "Qwen3.7 Plus", "identifier": "accounts/fireworks/models/qwen3p7-plus" },
47
+ { "name": "Qwen3.8 Max", "identifier": "accounts/fireworks/models/qwen3p8-max" },
48
48
  { "name": "Kimi K2.7 Code", "identifier": "accounts/fireworks/models/kimi-k2p7-code" },
49
49
  { "name": "MiniMax M3", "identifier": "accounts/fireworks/models/minimax-m3" },
50
- { "name": "DeepSeek V4 Pro", "identifier": "accounts/fireworks/models/deepseek-v4-pro" },
51
50
  { "name": "GPT-OSS 120B", "identifier": "accounts/fireworks/models/gpt-oss-120b" }
52
51
  ],
52
+ "Together AI": [
53
+ { "name": "Qwen3.8 2.4T A95B", "identifier": "Qwen/Qwen3.8-2.4T-A95B" },
54
+ { "name": "DeepSeek V4.1 Flash", "identifier": "deepseek-ai/DeepSeek-V4.1-Flash" },
55
+ { "name": "Kimi K3", "identifier": "moonshotai/Kimi-K3" },
56
+ { "name": "GLM 5.3", "identifier": "zai-org/GLM-5.3" },
57
+ { "name": "GLM 5.3 Flash", "identifier": "zai-org/GLM-5.3-Flash" },
58
+ { "name": "Qwen3.7 Max", "identifier": "Qwen/Qwen3.7-Max" },
59
+ { "name": "MiniMax M3", "identifier": "MiniMaxAI/MiniMax-M3" },
60
+ { "name": "DeepSeek V4 Pro 0813", "identifier": "deepseek-ai/DeepSeek-V4-Pro-0813" },
61
+ { "name": "GPT-OSS 120B", "identifier": "openai/gpt-oss-120b" },
62
+ { "name": "Llama 3.3 70B Instruct Turbo", "identifier": "meta-llama/Llama-3.3-70B-Instruct-Turbo" }
63
+ ],
53
64
  "Mistral": [
54
65
  { "name": "Mistral Medium 3.5", "identifier": "mistral-medium-2604" },
55
66
  { "name": "Mistral Small 4", "identifier": "mistral-small-2603" },
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Assistant
3
- VERSION = "0.8.0"
3
+ VERSION = "0.9.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: layered-assistant-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai
@@ -85,28 +85,28 @@ dependencies:
85
85
  requirements:
86
86
  - - "~>"
87
87
  - !ruby/object:Gem::Version
88
- version: '0.25'
88
+ version: '0.27'
89
89
  type: :runtime
90
90
  prerelease: false
91
91
  version_requirements: !ruby/object:Gem::Requirement
92
92
  requirements:
93
93
  - - "~>"
94
94
  - !ruby/object:Gem::Version
95
- version: '0.25'
95
+ version: '0.27'
96
96
  - !ruby/object:Gem::Dependency
97
97
  name: layered-resource-rails
98
98
  requirement: !ruby/object:Gem::Requirement
99
99
  requirements:
100
100
  - - "~>"
101
101
  - !ruby/object:Gem::Version
102
- version: '0.2'
102
+ version: '0.4'
103
103
  type: :runtime
104
104
  prerelease: false
105
105
  version_requirements: !ruby/object:Gem::Requirement
106
106
  requirements:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
- version: '0.2'
109
+ version: '0.4'
110
110
  - !ruby/object:Gem::Dependency
111
111
  name: propshaft
112
112
  requirement: !ruby/object:Gem::Requirement
@@ -349,6 +349,7 @@ files:
349
349
  - app/jobs/layered/assistant/messages/response_job.rb
350
350
  - app/jobs/layered/assistant/messages/tool_call_job.rb
351
351
  - app/layered_resources/layered/assistant/assistant_resource.rb
352
+ - app/layered_resources/layered/assistant/conversation_resource.rb
352
353
  - app/layered_resources/layered/assistant/model_resource.rb
353
354
  - app/layered_resources/layered/assistant/persona_resource.rb
354
355
  - app/layered_resources/layered/assistant/provider_resource.rb
@@ -380,10 +381,6 @@ files:
380
381
  - app/services/layered/assistant/tool_registry.rb
381
382
  - app/services/layered/assistant/tool_runner_service.rb
382
383
  - app/tools/layered/assistant/tool.rb
383
- - app/views/layered/assistant/conversations/_form.html.erb
384
- - app/views/layered/assistant/conversations/edit.html.erb
385
- - app/views/layered/assistant/conversations/index.html.erb
386
- - app/views/layered/assistant/conversations/new.html.erb
387
384
  - app/views/layered/assistant/conversations/show.html.erb
388
385
  - app/views/layered/assistant/messages/_composer.html.erb
389
386
  - app/views/layered/assistant/messages/_composer_fields.html.erb
@@ -480,7 +477,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
480
477
  - !ruby/object:Gem::Version
481
478
  version: '0'
482
479
  requirements: []
483
- rubygems_version: 4.0.6
480
+ rubygems_version: 3.6.9
484
481
  specification_version: 4
485
482
  summary: Open source, multi-provider, streaming AI assistant engine for Rails 8+ built
486
483
  on `layered-ui-rails`.
@@ -1,29 +0,0 @@
1
- <%= form_with(model: conversation, url: url, html: { class: "l-ui-form" }) do |f| %>
2
- <%= render "layered_ui/shared/form_errors", item: conversation %>
3
-
4
- <% if conversation.new_record? %>
5
- <div class="l-ui-form__group">
6
- <%= render "layered_ui/shared/label", form: f, field: :assistant_id, name: "Assistant", required: true %>
7
- <%= f.select :assistant_id, @assistants.map { |a| [a.name, a.id] }, { include_blank: "Select an assistant:" }, class: "l-ui-select" %>
8
- <%= render "layered_ui/shared/field_error", object: conversation, field: :assistant %>
9
- </div>
10
- <% end %>
11
-
12
- <div class="l-ui-form__group">
13
- <%= render "layered_ui/shared/label", form: f, field: :name, required: false %>
14
- <%= f.text_field :name, class: "l-ui-form__field" %>
15
- <% if conversation.new_record? %>
16
- <p class="l-ui-form__hint">Auto-set from first message if left blank</p>
17
- <% end %>
18
- <%= render "layered_ui/shared/field_error", object: conversation, field: :name %>
19
- </div>
20
-
21
- <div class="l-ui-spread l-ui-mt-8">
22
- <% if conversation.persisted? %>
23
- <%= link_to "Delete", layered_assistant.conversation_path(conversation), class: "l-ui-button l-ui-button--outline-danger", data: { turbo_method: :delete, turbo_confirm: "Are you sure?" } %>
24
- <% else %>
25
- <span></span>
26
- <% end %>
27
- <%= f.submit conversation.new_record? ? "Create" : "Update", class: "l-ui-button l-ui-button--primary" %>
28
- </div>
29
- <% end %>
@@ -1,6 +0,0 @@
1
- <%= l_ui_title_bar(
2
- title: "Edit conversation",
3
- breadcrumbs: [["Conversations", layered_assistant.conversations_path]]
4
- ) %>
5
-
6
- <%= render "form", conversation: @conversation, url: layered_assistant.conversation_path(@conversation) %>
@@ -1,62 +0,0 @@
1
- <%= l_ui_title_bar(
2
- title: @assistant ? "Conversations - #{@assistant.name}" : "Conversations",
3
- breadcrumbs: @assistant ? [["Assistants", layered_assistant.assistants_path]] : []
4
- ) do %>
5
- <%= link_to "New", layered_assistant.new_conversation_path(conversation: @assistant ? { assistant_id: @assistant.id } : {}), class: "l-ui-button l-ui-button--primary" %>
6
- <% end %>
7
-
8
- <div class="l-ui-table-container l-ui-mt-4">
9
- <table class="l-ui-table">
10
- <caption class="l-ui-sr-only"><%= @assistant ? "Conversations for #{@assistant.name}" : "Conversations" %></caption>
11
- <thead class="l-ui-table__header">
12
- <tr>
13
- <th scope="col" class="l-ui-table__header-cell">Name</th>
14
- <% unless @assistant %>
15
- <th scope="col" class="l-ui-table__header-cell">Assistant</th>
16
- <% end %>
17
- <th scope="col" class="l-ui-table__header-cell">Messages</th>
18
- <th scope="col" class="l-ui-table__header-cell">Tokens</th>
19
- <th scope="col" class="l-ui-table__header-cell">User</th>
20
- <th scope="col" class="l-ui-table__header-cell">Created</th>
21
- <th scope="col" class="l-ui-table__header-cell l-ui-table__header-cell--action">Actions</th>
22
- </tr>
23
- </thead>
24
-
25
- <tbody class="l-ui-table__body">
26
- <% @conversations.each do |conversation| %>
27
- <tr>
28
- <th scope="row" class="l-ui-table__cell l-ui-table__cell--primary">
29
- <%= link_to conversation.name, layered_assistant.conversation_path(conversation) %>
30
- </th>
31
- <% unless @assistant %>
32
- <td class="l-ui-table__cell">
33
- <%= link_to conversation.assistant.name, layered_assistant.assistant_conversations_path(conversation.assistant) %>
34
- </td>
35
- <% end %>
36
- <td class="l-ui-table__cell">
37
- <%= link_to conversation.messages_count.to_i, layered_assistant.conversation_messages_path(conversation) %>
38
- </td>
39
- <td class="l-ui-table__cell">
40
- <%= number_with_delimiter(conversation.token_estimate.to_i) %>
41
- </td>
42
- <td class="l-ui-table__cell">
43
- <%# The person who did the talking, which is the owner itself
44
- until an owner block scopes ownership elsewhere. Neither is
45
- set for an anonymous visitor on a public assistant. %>
46
- <%= conversation.user.try(:name) || conversation.owner.try(:name) || "Guest" %>
47
- </td>
48
- <td class="l-ui-table__cell">
49
- <%= conversation.created_at.to_fs(:short) %>
50
- </td>
51
- <td class="l-ui-table__cell l-ui-table__cell--action">
52
- <%= link_to "Edit", layered_assistant.edit_conversation_path(conversation), class: "l-ui-table__action" %>
53
- </td>
54
- </tr>
55
- <% end %>
56
- </tbody>
57
- </table>
58
-
59
- <div class="l-ui-mt-4">
60
- <%= l_ui_pagy(@pagy) %>
61
- </div>
62
- </div>
@@ -1,6 +0,0 @@
1
- <%= l_ui_title_bar(
2
- title: "New conversation",
3
- breadcrumbs: [["Conversations", layered_assistant.conversations_path]]
4
- ) %>
5
-
6
- <%= render "form", conversation: @conversation, url: layered_assistant.conversations_path %>