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,39 @@
1
+ module Layered
2
+ module Assistant
3
+ class Conversation < ApplicationRecord
4
+ # UID
5
+ has_secure_token :uid
6
+
7
+ # Associations
8
+ belongs_to :assistant, counter_cache: true
9
+ belongs_to :owner, polymorphic: true, optional: true
10
+ belongs_to :subject, polymorphic: true, optional: true
11
+ has_many :messages, dependent: :destroy
12
+
13
+ # Validations
14
+ validates :name, presence: true
15
+
16
+ # Scopes
17
+ scope :by_name, -> { order(name: :asc, created_at: :desc) }
18
+ scope :by_created_at, -> { order(created_at: :desc) }
19
+
20
+ # Name
21
+ def update_token_totals!
22
+ input = messages.sum(:input_tokens)
23
+ output = messages.sum(:output_tokens)
24
+ update!(input_tokens: input, output_tokens: output, token_estimate: input + output)
25
+ end
26
+
27
+ def self.default_name
28
+ "New conversation"
29
+ end
30
+
31
+ def update_name_from_content!(content)
32
+ return unless name == self.class.default_name
33
+ return if content.blank?
34
+
35
+ update!(name: content.truncate(60))
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,56 @@
1
+ module Layered
2
+ module Assistant
3
+ class Message < ApplicationRecord
4
+ # Includes
5
+ include ActionView::RecordIdentifier
6
+
7
+ # UID
8
+ has_secure_token :uid
9
+
10
+ # Enums
11
+ enum :role, {
12
+ system: "system",
13
+ assistant: "assistant",
14
+ user: "user"
15
+ }
16
+
17
+ # Validations
18
+ validates :content, presence: true, unless: :assistant?
19
+
20
+ # Associations
21
+ belongs_to :conversation, counter_cache: true
22
+ belongs_to :model, optional: true, counter_cache: true
23
+
24
+ # Scopes
25
+ scope :by_created_at, -> { order(created_at: :asc) }
26
+
27
+ # Broadcasting
28
+ def broadcast_created
29
+ broadcast_append_to conversation,
30
+ targets: ".#{dom_id(conversation)}_messages",
31
+ partial: "layered/assistant/messages/message",
32
+ locals: { message: self }
33
+ end
34
+
35
+ def broadcast_updated
36
+ broadcast_replace_to conversation,
37
+ targets: ".#{dom_id(self)}",
38
+ partial: "layered/assistant/messages/message",
39
+ locals: { message: self }
40
+ end
41
+
42
+ def broadcast_chunk(text)
43
+ broadcast_action_to conversation,
44
+ action: :append_chunk,
45
+ targets: ".#{dom_id(self)}_body",
46
+ content: helpers.content_tag(:span, text, class: "l-ui-token-fade")
47
+ end
48
+
49
+ private
50
+
51
+ def helpers
52
+ ApplicationController.helpers
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,21 @@
1
+ module Layered
2
+ module Assistant
3
+ class Model < ApplicationRecord
4
+ # Positioning
5
+ positioned on: :provider
6
+
7
+ # Associations
8
+ belongs_to :provider, counter_cache: true
9
+ has_many :messages, dependent: :restrict_with_error
10
+ has_many :assistants, foreign_key: :default_model_id, dependent: :restrict_with_error, inverse_of: :default_model
11
+
12
+ # Validations
13
+ validates :name, :identifier, presence: true
14
+
15
+ # Scopes
16
+ scope :enabled, -> { where(enabled: true) }
17
+ scope :available, -> { enabled.eager_load(:provider).merge(Provider.enabled).merge(Provider.sorted).sorted }
18
+ scope :sorted, -> { order(position: :asc, name: :asc) }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,49 @@
1
+ module Layered
2
+ module Assistant
3
+ class Provider < ApplicationRecord
4
+ # Virtual attributes
5
+ attr_accessor :create_models
6
+
7
+ # Positioning
8
+ positioned
9
+
10
+ # Associations
11
+ has_many :models, dependent: :destroy
12
+ belongs_to :owner, polymorphic: true, optional: true
13
+
14
+ # Enums
15
+ enum :protocol, {
16
+ anthropic: "Anthropic",
17
+ openai: "OpenAI"
18
+ }
19
+
20
+ # Encryption
21
+ unless ENV["LAYERED_ASSISTANT_DANGEROUSLY_SKIP_DB_ENCRYPTION"] == "yes"
22
+ encrypts :secret
23
+ end
24
+
25
+ # Validations
26
+ validates :name, :protocol, presence: true
27
+ validates :url, format: { with: /\Ahttps?:\/\//i, message: "must start with http:// or https://" }, allow_blank: true
28
+
29
+ TEMPLATES = {
30
+ "Cloud" => [
31
+ { key: "anthropic", name: "Anthropic", description: "Claude family of models. Requires an API key.", protocol: "anthropic", url: "https://api.anthropic.com/v1", keys_url: "https://console.anthropic.com/settings/keys" },
32
+ { 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" },
33
+ { 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" },
34
+ { 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" },
35
+ { 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" },
36
+ { 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" }
37
+ ],
38
+ "Local" => [
39
+ { key: "ollama", name: "Ollama", description: "Run open-weight models locally via the Ollama CLI. No API key required.", protocol: "openai", url: "http://localhost:11434/v1" },
40
+ { key: "lm_studio", name: "LM Studio", description: "Run open-weight models locally via the LM Studio desktop app. No API key required.", protocol: "openai", url: "http://localhost:1234/v1" }
41
+ ]
42
+ }.freeze
43
+
44
+ # Scopes
45
+ scope :enabled, -> { where(enabled: true) }
46
+ scope :sorted, -> { order(position: :asc, name: :asc) }
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,80 @@
1
+ module Layered
2
+ module Assistant
3
+ class ChunkService
4
+ def initialize(message, provider:)
5
+ @message = message
6
+ @provider = provider
7
+ @input_tokens = 0
8
+ @output_tokens = 0
9
+ end
10
+
11
+ def call(chunk)
12
+ Rails.logger.debug { "[ChunkService] #{chunk.inspect}" }
13
+ text = extract_text(chunk)
14
+ extract_usage(chunk)
15
+
16
+ if text
17
+ @message.update(content: (@message.content || "") + text)
18
+ @message.broadcast_chunk(text)
19
+ end
20
+
21
+ if chunk_finished?(chunk) || usage_chunk?(chunk)
22
+ save_token_usage
23
+ @message.broadcast_updated
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def extract_text(chunk)
30
+ text = if @provider.protocol == "openai"
31
+ chunk.dig("choices", 0, "delta", "content")
32
+ else
33
+ chunk.dig("delta", "text") if chunk["type"] == "content_block_delta"
34
+ end
35
+ text unless text.nil? || text.empty?
36
+ end
37
+
38
+ def chunk_finished?(chunk)
39
+ if @provider.protocol == "openai"
40
+ chunk.dig("choices", 0, "finish_reason").present?
41
+ else
42
+ chunk["type"] == "message_stop"
43
+ end
44
+ end
45
+
46
+ def usage_chunk?(chunk)
47
+ @provider.protocol == "openai" && chunk["usage"].present? && chunk.dig("choices")&.empty?
48
+ end
49
+
50
+ def extract_usage(chunk)
51
+ if @provider.protocol == "openai"
52
+ if (usage = chunk["usage"])
53
+ @input_tokens = usage["prompt_tokens"].to_i
54
+ @output_tokens = usage["completion_tokens"].to_i
55
+ end
56
+ else
57
+ if chunk["type"] == "message_start" && (usage = chunk.dig("message", "usage"))
58
+ @input_tokens = usage["input_tokens"].to_i
59
+ end
60
+ if chunk["type"] == "message_delta" && (usage = chunk["usage"])
61
+ @output_tokens = usage["output_tokens"].to_i
62
+ end
63
+ end
64
+ end
65
+
66
+ def save_token_usage
67
+ if @input_tokens == 0 && @output_tokens == 0
68
+ estimated = TokenEstimator.estimate(@message.content)
69
+ return unless estimated
70
+
71
+ @message.update!(output_tokens: estimated, tokens_estimated: true)
72
+ else
73
+ @message.update!(input_tokens: @input_tokens, output_tokens: @output_tokens)
74
+ end
75
+
76
+ @message.conversation.update_token_totals!
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,18 @@
1
+ module Layered
2
+ module Assistant
3
+ class ClientService
4
+ def call(message:, stream_proc:)
5
+ provider = message.model.provider
6
+ client = Clients::Base.for(provider)
7
+ system_prompt = message.conversation.assistant.system_prompt
8
+
9
+ client.chat(
10
+ messages: message.conversation.messages,
11
+ model: message.model.identifier,
12
+ stream_proc: stream_proc,
13
+ system_prompt: system_prompt
14
+ )
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,24 @@
1
+ module Layered
2
+ module Assistant
3
+ module Clients
4
+ class Anthropic < Base
5
+ def chat(messages:, model:, stream_proc:, system_prompt: nil)
6
+ formatted = MessagesService.new.format(messages, provider: @provider, system_prompt: system_prompt)
7
+
8
+ parameters = {
9
+ model: model,
10
+ messages: formatted[:messages],
11
+ max_tokens: 4096,
12
+ stream: stream_proc
13
+ }
14
+ parameters[:system] = formatted[:system] if formatted[:system].present?
15
+
16
+ ::Anthropic::Client.new(
17
+ access_token: @api_key,
18
+ log_errors: ENV.fetch("LAYERED_ASSISTANT_LOG_ERRORS", "no") == "yes"
19
+ ).messages(parameters: parameters)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,29 @@
1
+ module Layered
2
+ module Assistant
3
+ module Clients
4
+ class Base
5
+ def initialize(provider)
6
+ @provider = provider
7
+ @api_key = provider.secret
8
+
9
+ raise StandardError, "API key is not set for provider #{provider.name}" if @api_key.blank?
10
+ end
11
+
12
+ def chat(messages:, model:, stream_proc:, system_prompt: nil)
13
+ raise NotImplementedError
14
+ end
15
+
16
+ def self.for(provider)
17
+ case provider.protocol
18
+ when "anthropic"
19
+ Clients::Anthropic.new(provider)
20
+ when "openai"
21
+ Clients::OpenAI.new(provider)
22
+ else
23
+ raise StandardError, "Unsupported provider protocol: #{provider.protocol}"
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,33 @@
1
+ module Layered
2
+ module Assistant
3
+ module Clients
4
+ class OpenAI < Base
5
+ def chat(messages:, model:, stream_proc:, system_prompt: nil)
6
+ formatted = MessagesService.new.format(messages, provider: @provider, system_prompt: system_prompt)
7
+
8
+ client_options = {
9
+ access_token: @api_key,
10
+ log_errors: ENV.fetch("LAYERED_ASSISTANT_LOG_ERRORS", "no") == "yes"
11
+ }
12
+ if @provider.url.present?
13
+ client_options[:uri_base] = @provider.url.sub(/\/\z/, "")
14
+ client_options[:api_version] = "" # Gemini and other OpenAI-compatible APIs use their own path
15
+ end
16
+
17
+ ::OpenAI::Client.new(**client_options) do |f|
18
+ if ENV.fetch("LAYERED_ASSISTANT_LOG_ERRORS", "no") == "yes"
19
+ f.response :logger, Logger.new($stdout), bodies: true
20
+ end
21
+ end.chat(
22
+ parameters: {
23
+ model: model,
24
+ messages: formatted[:messages],
25
+ stream: stream_proc,
26
+ stream_options: { include_usage: true }
27
+ }
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,58 @@
1
+ module Layered
2
+ module Assistant
3
+ class MessagesService
4
+ def format(messages, provider: nil, system_prompt: nil)
5
+ protocol = provider&.protocol
6
+
7
+ if protocol == "openai"
8
+ format_openai(messages, system_prompt: system_prompt)
9
+ else
10
+ format_anthropic(messages, system_prompt: system_prompt)
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ def format_anthropic(messages, system_prompt: nil)
17
+ system_messages = []
18
+ system_messages << system_prompt if system_prompt.present?
19
+ regular_messages = []
20
+
21
+ messages.by_created_at.each do |message|
22
+ case message.role
23
+ when "system"
24
+ system_messages << message.content
25
+ when "user"
26
+ regular_messages << { role: "user", content: [{ type: "text", text: message.content }] }
27
+ when "assistant"
28
+ next if message.content.blank?
29
+ regular_messages << { role: "assistant", content: [{ type: "text", text: message.content }] }
30
+ end
31
+ end
32
+
33
+ result = { messages: regular_messages }
34
+ result[:system] = system_messages.join("\n\n") if system_messages.any?
35
+ result
36
+ end
37
+
38
+ def format_openai(messages, system_prompt: nil)
39
+ formatted = []
40
+ formatted << { role: "system", content: system_prompt } if system_prompt.present?
41
+
42
+ messages.by_created_at.each do |message|
43
+ case message.role
44
+ when "system"
45
+ formatted << { role: "system", content: message.content }
46
+ when "user"
47
+ formatted << { role: "user", content: message.content }
48
+ when "assistant"
49
+ next if message.content.blank?
50
+ formatted << { role: "assistant", content: message.content }
51
+ end
52
+ end
53
+
54
+ { messages: formatted }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,50 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Layered
5
+ module Assistant
6
+ module Models
7
+ class CreateService
8
+ MODELS_URL = "https://raw.githubusercontent.com/layered-ai-public/layered-assistant-rails/main/data/models.json".freeze
9
+
10
+ def initialize(provider)
11
+ @provider = provider
12
+ end
13
+
14
+ def call
15
+ models_data = fetch_models
16
+ return if models_data.nil?
17
+
18
+ entries = models_data[@provider.name]
19
+ if entries.nil?
20
+ Rails.logger.info "[layered-ui-assistant] No models found for provider #{@provider.name.inspect} in remote catalogue"
21
+ return
22
+ end
23
+
24
+ entries.each do |entry|
25
+ @provider.models.find_or_create_by!(identifier: entry["identifier"]) do |model|
26
+ model.name = entry["name"]
27
+ end
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def fetch_models
34
+ uri = URI(MODELS_URL)
35
+ response = Net::HTTP.get_response(uri)
36
+
37
+ unless response.is_a?(Net::HTTPSuccess)
38
+ Rails.logger.info "[layered-ui-assistant] Could not fetch model catalogue (HTTP #{response.code}) - skipping model sync"
39
+ return nil
40
+ end
41
+
42
+ JSON.parse(response.body)
43
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout => e
44
+ Rails.logger.info "[layered-ui-assistant] Could not reach GitHub to fetch model catalogue (#{e.class}) - skipping model sync"
45
+ nil
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,11 @@
1
+ module Layered
2
+ module Assistant
3
+ class TokenEstimator
4
+ def self.estimate(text)
5
+ return nil if text.blank?
6
+
7
+ OpenAI.rough_token_count(text)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,42 @@
1
+ <%= form_with(model: assistant, url: url, html: { class: "l-ui-form l-ui-utility--mt-2xl" }) do |f| %>
2
+ <%= render "layered_ui/shared/form_errors", item: assistant %>
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: assistant, 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: assistant, field: :description %>
14
+ </div>
15
+
16
+ <div class="l-ui-form__group">
17
+ <%= render "layered_ui/shared/label", form: f, field: :system_prompt, name: "System prompt", required: false %>
18
+ <%= f.text_area :system_prompt, class: "l-ui-form__field", rows: 5 %>
19
+ <%= render "layered_ui/shared/field_error", object: assistant, field: :system_prompt %>
20
+ </div>
21
+
22
+ <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 %>
26
+ </div>
27
+
28
+ <label class="l-ui-switch l-ui-utility--mt-xl">
29
+ <%= f.check_box :public, class: "l-ui-switch__input", role: "switch" %>
30
+ <span class="l-ui-switch__track"></span>
31
+ Public
32
+ </label>
33
+
34
+ <div class="l-ui-container--spread l-ui-utility--mt-2xl">
35
+ <% if assistant.persisted? %>
36
+ <%= link_to "Delete", layered_assistant.assistant_path(assistant), class: "l-ui-button--outline-danger", data: { turbo_method: :delete, turbo_confirm: "Are you sure?" } %>
37
+ <% else %>
38
+ <span></span>
39
+ <% end %>
40
+ <%= f.submit assistant.new_record? ? "Create" : "Update", class: "l-ui-button--primary" %>
41
+ </div>
42
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <div class="l-ui-container--spread">
2
+ <h1>Edit assistant</h1>
3
+ <%= link_to "Back", layered_assistant.assistants_path, class: "l-ui-button--outline" %>
4
+ </div>
5
+
6
+ <%= render "form", assistant: @assistant, url: layered_assistant.assistant_path(@assistant) %>
@@ -0,0 +1,45 @@
1
+ <div class="l-ui-container--spread">
2
+ <h1>Assistants</h1>
3
+ <%= link_to "New", layered_assistant.new_assistant_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">Assistants</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">Default model</th>
14
+ <th scope="col" class="l-ui-table__header-cell">Conversations</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
+ <% @assistants.each do |assistant| %>
21
+ <tr>
22
+ <th scope="row" class="l-ui-table__cell--primary">
23
+ <%= link_to assistant.name, layered_assistant.assistant_conversations_path(assistant) %>
24
+ </th>
25
+ <td class="l-ui-table__cell">
26
+ <%= truncate(assistant.description, length: 60) %>
27
+ </td>
28
+ <td class="l-ui-table__cell">
29
+ <%= assistant.default_model&.name %>
30
+ </td>
31
+ <td class="l-ui-table__cell">
32
+ <%= link_to assistant.conversations_count, layered_assistant.assistant_conversations_path(assistant) %>
33
+ </td>
34
+ <td class="l-ui-table__cell--action">
35
+ <%= link_to "Edit", layered_assistant.edit_assistant_path(assistant) %>
36
+ </td>
37
+ </tr>
38
+ <% end %>
39
+ </tbody>
40
+ </table>
41
+
42
+ <div class="l-ui-utility--mt-lg">
43
+ <%= l_ui_pagy(@pagy) %>
44
+ </div>
45
+ </div>
@@ -0,0 +1,6 @@
1
+ <div class="l-ui-container--spread">
2
+ <h1>New assistant</h1>
3
+ <%= link_to "Back", layered_assistant.assistants_path, class: "l-ui-button--outline" %>
4
+ </div>
5
+
6
+ <%= render "form", assistant: @assistant, url: layered_assistant.assistants_path %>
@@ -0,0 +1,29 @@
1
+ <%= form_with(model: conversation, url: url, html: { class: "l-ui-form l-ui-utility--mt-2xl" }) 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-container--spread l-ui-utility--mt-2xl">
22
+ <% if conversation.persisted? %>
23
+ <%= link_to "Delete", layered_assistant.conversation_path(conversation), class: "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--primary" %>
28
+ </div>
29
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <div class="l-ui-container--spread">
2
+ <h1>Edit 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.conversation_path(@conversation) %>