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.
- checksums.yaml +7 -0
- data/AGENTS.md +94 -0
- data/LICENSE +201 -0
- data/README.md +176 -0
- data/Rakefile +15 -0
- data/app/assets/tailwind/layered/assistant/styles.css +13 -0
- data/app/controllers/concerns/layered/assistant/message_creation.rb +49 -0
- data/app/controllers/concerns/layered/assistant/public/session_conversations.rb +50 -0
- data/app/controllers/layered/assistant/application_controller.rb +25 -0
- data/app/controllers/layered/assistant/assistants_controller.rb +59 -0
- data/app/controllers/layered/assistant/conversations_controller.rb +77 -0
- data/app/controllers/layered/assistant/messages_controller.rb +57 -0
- data/app/controllers/layered/assistant/models_controller.rb +61 -0
- data/app/controllers/layered/assistant/panel/conversations_controller.rb +63 -0
- data/app/controllers/layered/assistant/panel/messages_controller.rb +44 -0
- data/app/controllers/layered/assistant/providers_controller.rb +55 -0
- data/app/controllers/layered/assistant/public/application_controller.rb +16 -0
- data/app/controllers/layered/assistant/public/assistants_controller.rb +16 -0
- data/app/controllers/layered/assistant/public/conversations_controller.rb +33 -0
- data/app/controllers/layered/assistant/public/messages_controller.rb +42 -0
- data/app/controllers/layered/assistant/public/panel/conversations_controller.rb +62 -0
- data/app/controllers/layered/assistant/public/panel/messages_controller.rb +50 -0
- data/app/controllers/layered/assistant/setup_controller.rb +9 -0
- data/app/helpers/layered/assistant/access_helper.rb +45 -0
- data/app/helpers/layered/assistant/messages_helper.rb +41 -0
- data/app/helpers/layered/assistant/panel_helper.rb +38 -0
- data/app/javascript/layered_assistant/composer_controller.js +30 -0
- data/app/javascript/layered_assistant/index.js +14 -0
- data/app/javascript/layered_assistant/message_streaming.js +124 -0
- data/app/javascript/layered_assistant/messages_controller.js +62 -0
- data/app/javascript/layered_assistant/panel_controller.js +36 -0
- data/app/javascript/layered_assistant/panel_nav_controller.js +16 -0
- data/app/javascript/layered_assistant/provider_template_controller.js +45 -0
- data/app/javascript/layered_assistant/vendor/marked.esm.js +72 -0
- data/app/jobs/layered/assistant/application_job.rb +6 -0
- data/app/jobs/layered/assistant/messages/response_job.rb +36 -0
- data/app/models/layered/assistant/application_record.rb +7 -0
- data/app/models/layered/assistant/assistant.rb +22 -0
- data/app/models/layered/assistant/conversation.rb +39 -0
- data/app/models/layered/assistant/message.rb +56 -0
- data/app/models/layered/assistant/model.rb +21 -0
- data/app/models/layered/assistant/provider.rb +49 -0
- data/app/services/layered/assistant/chunk_service.rb +80 -0
- data/app/services/layered/assistant/client_service.rb +18 -0
- data/app/services/layered/assistant/clients/anthropic.rb +24 -0
- data/app/services/layered/assistant/clients/base.rb +29 -0
- data/app/services/layered/assistant/clients/openai.rb +33 -0
- data/app/services/layered/assistant/messages_service.rb +58 -0
- data/app/services/layered/assistant/models/create_service.rb +50 -0
- data/app/services/layered/assistant/token_estimator.rb +11 -0
- data/app/views/layered/assistant/assistants/_form.html.erb +42 -0
- data/app/views/layered/assistant/assistants/edit.html.erb +6 -0
- data/app/views/layered/assistant/assistants/index.html.erb +45 -0
- data/app/views/layered/assistant/assistants/new.html.erb +6 -0
- data/app/views/layered/assistant/conversations/_form.html.erb +29 -0
- data/app/views/layered/assistant/conversations/edit.html.erb +6 -0
- data/app/views/layered/assistant/conversations/index.html.erb +63 -0
- data/app/views/layered/assistant/conversations/new.html.erb +6 -0
- data/app/views/layered/assistant/conversations/show.html.erb +25 -0
- data/app/views/layered/assistant/messages/_composer.html.erb +15 -0
- data/app/views/layered/assistant/messages/_message.html.erb +25 -0
- data/app/views/layered/assistant/messages/_system_prompt.html.erb +10 -0
- data/app/views/layered/assistant/messages/create.turbo_stream.erb +9 -0
- data/app/views/layered/assistant/messages/index.html.erb +46 -0
- data/app/views/layered/assistant/models/_form.html.erb +30 -0
- data/app/views/layered/assistant/models/edit.html.erb +6 -0
- data/app/views/layered/assistant/models/index.html.erb +54 -0
- data/app/views/layered/assistant/models/new.html.erb +6 -0
- data/app/views/layered/assistant/panel/conversations/_header.html.erb +23 -0
- data/app/views/layered/assistant/panel/conversations/index.html.erb +46 -0
- data/app/views/layered/assistant/panel/conversations/new.html.erb +23 -0
- data/app/views/layered/assistant/panel/conversations/show.html.erb +24 -0
- data/app/views/layered/assistant/panel/messages/_composer.html.erb +15 -0
- data/app/views/layered/assistant/panel/messages/create.turbo_stream.erb +9 -0
- data/app/views/layered/assistant/providers/_form.html.erb +81 -0
- data/app/views/layered/assistant/providers/edit.html.erb +6 -0
- data/app/views/layered/assistant/providers/index.html.erb +47 -0
- data/app/views/layered/assistant/providers/new.html.erb +6 -0
- data/app/views/layered/assistant/public/assistants/index.html.erb +34 -0
- data/app/views/layered/assistant/public/assistants/show.html.erb +23 -0
- data/app/views/layered/assistant/public/conversations/show.html.erb +24 -0
- data/app/views/layered/assistant/public/messages/_composer.html.erb +7 -0
- data/app/views/layered/assistant/public/messages/create.turbo_stream.erb +9 -0
- data/app/views/layered/assistant/public/panel/conversations/_header.html.erb +16 -0
- data/app/views/layered/assistant/public/panel/conversations/index.html.erb +48 -0
- data/app/views/layered/assistant/public/panel/conversations/new.html.erb +17 -0
- data/app/views/layered/assistant/public/panel/conversations/show.html.erb +23 -0
- data/app/views/layered/assistant/public/panel/messages/_composer.html.erb +7 -0
- data/app/views/layered/assistant/public/panel/messages/create.turbo_stream.erb +9 -0
- data/app/views/layered/assistant/setup/_setup.html.erb +121 -0
- data/app/views/layered/assistant/setup/index.html.erb +2 -0
- data/app/views/layouts/layered/assistant/_host_navigation.html.erb +0 -0
- data/app/views/layouts/layered/assistant/application.html.erb +32 -0
- data/config/importmap.rb +8 -0
- data/config/routes.rb +31 -0
- data/data/models.json +42 -0
- data/db/migrate/20260312000000_create_layered_assistant_tables.rb +63 -0
- data/lib/generators/layered/assistant/install_generator.rb +113 -0
- data/lib/generators/layered/assistant/migrations_generator.rb +47 -0
- data/lib/generators/layered/assistant/templates/initializer.rb +26 -0
- data/lib/layered/assistant/engine.rb +29 -0
- data/lib/layered/assistant/version.rb +5 -0
- data/lib/layered/assistant.rb +19 -0
- data/lib/layered-assistant-rails.rb +1 -0
- 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,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,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,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) %>
|