layered-assistant-rails 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENTS.md +1 -1
- data/README.md +49 -5
- data/app/controllers/concerns/layered/assistant/message_creation.rb +2 -6
- data/app/controllers/concerns/layered/assistant/public/session_conversations.rb +13 -14
- data/app/controllers/layered/assistant/application_controller.rb +12 -0
- data/app/controllers/layered/assistant/assistants_controller.rb +2 -2
- data/app/controllers/layered/assistant/conversations_controller.rb +5 -5
- data/app/controllers/layered/assistant/messages_controller.rb +4 -4
- data/app/controllers/layered/assistant/models_controller.rb +1 -1
- data/app/controllers/layered/assistant/panel/conversations_controller.rb +3 -3
- data/app/controllers/layered/assistant/panel/messages_controller.rb +3 -3
- data/app/controllers/layered/assistant/providers_controller.rb +2 -2
- data/app/controllers/layered/assistant/public/panel/conversations_controller.rb +3 -3
- data/app/controllers/layered/assistant/public/panel/messages_controller.rb +1 -1
- data/app/helpers/layered/assistant/access_helper.rb +2 -2
- data/app/helpers/layered/assistant/messages_helper.rb +18 -1
- data/app/javascript/layered_assistant/composer_controller.js +4 -1
- data/app/javascript/layered_assistant/messages_controller.js +31 -1
- data/app/jobs/layered/assistant/messages/response_job.rb +1 -0
- data/app/models/layered/assistant/conversation.rb +19 -6
- data/app/models/layered/assistant/message.rb +1 -1
- data/app/models/layered/assistant/provider.rb +3 -3
- data/app/services/layered/assistant/chunk_parser.rb +46 -0
- data/app/services/layered/assistant/chunk_service.rb +25 -46
- data/app/services/layered/assistant/clients/anthropic.rb +3 -2
- data/app/services/layered/assistant/clients/openai.rb +3 -2
- data/app/services/layered/assistant/response_timer.rb +31 -0
- data/app/views/layered/assistant/assistants/_form.html.erb +1 -1
- data/app/views/layered/assistant/conversations/_form.html.erb +1 -1
- data/app/views/layered/assistant/messages/_message.html.erb +4 -4
- data/app/views/layered/assistant/messages/index.html.erb +18 -1
- data/app/views/layered/assistant/models/_form.html.erb +1 -1
- data/app/views/layered/assistant/providers/_form.html.erb +2 -2
- data/app/views/layered/assistant/providers/index.html.erb +1 -1
- data/app/views/layered/assistant/setup/_setup.html.erb +89 -0
- data/config/locales/en.yml +5 -0
- data/db/migrate/20260315100000_add_response_timing_to_layered_assistant_messages.rb +6 -0
- data/db/migrate/20260317000000_normalise_provider_protocol_values.rb +11 -0
- data/lib/generators/layered/assistant/templates/initializer.rb +34 -0
- data/lib/layered/assistant/version.rb +1 -1
- data/lib/layered/assistant.rb +8 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ff9af0132fa23f11eae8703acf4b0cfd2c893389fa0237e2883f09a6fa3080b3
|
|
4
|
+
data.tar.gz: 5a2e46ba572593219180533800eca8f69e78712a03bb521886f017c07a3b0a15
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b418c0f1b88ad608bfc07447b5227f5cdc499267c15810fccc0b7e97650a3d53476c6778fd1921438b280177ce677ccbcd492918738f070647146de51c908f6b
|
|
7
|
+
data.tar.gz: 5b3e1061ab08cd17abc131a909a39127e1adbe98c080e29cd3da4040172fab9e0f6978e72c1760a798b1dcd90a8a09c8608a361591f9632f81bc3a6672f090fe
|
data/AGENTS.md
CHANGED
|
@@ -53,7 +53,7 @@ It has its own `AGENTS.md` file — inspect it for context on the apps's convent
|
|
|
53
53
|
|
|
54
54
|
## Conventions
|
|
55
55
|
|
|
56
|
-
- Use "layered-
|
|
56
|
+
- Use "layered-assistant-rails" not "Layered Assistant Rails" when referring to the project
|
|
57
57
|
- Use normal dashes '-' not long ones '—'
|
|
58
58
|
- Locale: Favour en-GB (British English), unless terms are defined by technical standards (e.g. LICENSE, COLOR).
|
|
59
59
|
- Titles: capitalise first word only (e.g. "This title")
|
data/README.md
CHANGED
|
@@ -102,6 +102,40 @@ The `l_assistant_accessible?` helper evaluates the authorize block without side
|
|
|
102
102
|
<% end %>
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
+
## Record scoping
|
|
106
|
+
|
|
107
|
+
By default, all records are visible to any authorised user. If your application is multi-tenant or you need to restrict which records a user can see, configure a `scope` block in the initialiser.
|
|
108
|
+
|
|
109
|
+
The block receives the model class, runs in controller context, and must return an `ActiveRecord::Relation`. The following models are passed through the scope block:
|
|
110
|
+
|
|
111
|
+
| Model | Description |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `Layered::Assistant::Conversation` | User conversations (has polymorphic `owner`) |
|
|
114
|
+
| `Layered::Assistant::Assistant` | Assistant configurations (has polymorphic `owner`) |
|
|
115
|
+
| `Layered::Assistant::Provider` | API provider credentials (has polymorphic `owner`) |
|
|
116
|
+
|
|
117
|
+
### Scope all owned resources to the current user
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
Layered::Assistant.scope do |model_class|
|
|
121
|
+
model_class.where(owner: current_user)
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Scope conversations only
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
Layered::Assistant.scope do |model_class|
|
|
129
|
+
if model_class == Layered::Assistant::Conversation
|
|
130
|
+
model_class.where(owner: current_user)
|
|
131
|
+
else
|
|
132
|
+
model_class.all
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
When no scope block is configured, queries are unscoped. Record-level access control is the host application's responsibility; the scope block is the integration point for it.
|
|
138
|
+
|
|
105
139
|
## Panel helpers
|
|
106
140
|
|
|
107
141
|
The engine provides two convenience helpers for wiring the layered-ui panel to the assistant. Use them inside `content_for` blocks in your application layout:
|
|
@@ -131,12 +165,22 @@ Both helpers accept keyword arguments that are forwarded as HTML attributes to t
|
|
|
131
165
|
|
|
132
166
|
## Configuration
|
|
133
167
|
|
|
134
|
-
|
|
168
|
+
Optional settings can be added to your initialiser (`config/initializers/layered_assistant.rb`):
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
# Log API errors to stdout (default: false)
|
|
172
|
+
Layered::Assistant.log_errors = true
|
|
173
|
+
|
|
174
|
+
# Total timeout in seconds for API requests, including the full streaming response (default: 210).
|
|
175
|
+
# Increase for models with high max_tokens limits or slow providers.
|
|
176
|
+
Layered::Assistant.api_request_timeout = 210
|
|
177
|
+
|
|
178
|
+
# Disable Active Record Encryption on Provider#secret.
|
|
179
|
+
# Only use this in development/test environments without encryption keys configured.
|
|
180
|
+
Layered::Assistant.skip_db_encryption = true
|
|
181
|
+
```
|
|
135
182
|
|
|
136
|
-
|
|
137
|
-
|---|---|---|
|
|
138
|
-
| `LAYERED_ASSISTANT_DANGEROUSLY_SKIP_DB_ENCRYPTION` | `nil` | Set to `"yes"` to skip Active Record Encryption on `Provider#secret`. Only for development/test environments without encryption keys configured |
|
|
139
|
-
| `LAYERED_ASSISTANT_LOG_ERRORS` | `nil` | Set to `"yes"` to enable error logging from the AI API clients |
|
|
183
|
+
Note: `skip_db_encryption` is read at class load time, so it must be set before `Layered::Assistant::Provider` is first loaded. A standard Rails initialiser satisfies this requirement.
|
|
140
184
|
|
|
141
185
|
## Demo
|
|
142
186
|
|
|
@@ -15,10 +15,7 @@ module Layered
|
|
|
15
15
|
return { message: message } unless message.persisted?
|
|
16
16
|
|
|
17
17
|
conversation.update_name_from_content!(content)
|
|
18
|
-
message.broadcast_created
|
|
19
18
|
|
|
20
|
-
models = Model.available
|
|
21
|
-
selected_model_id = model_id
|
|
22
19
|
assistant_message = nil
|
|
23
20
|
error = nil
|
|
24
21
|
|
|
@@ -28,8 +25,9 @@ module Layered
|
|
|
28
25
|
content: nil,
|
|
29
26
|
model_id: model_id
|
|
30
27
|
)
|
|
31
|
-
assistant_message.broadcast_created
|
|
32
28
|
|
|
29
|
+
message.broadcast_created
|
|
30
|
+
assistant_message.broadcast_created
|
|
33
31
|
Messages::ResponseJob.perform_later(assistant_message.id)
|
|
34
32
|
rescue => e
|
|
35
33
|
Rails.logger.error("Assistant response failed: #{e.message}")
|
|
@@ -39,8 +37,6 @@ module Layered
|
|
|
39
37
|
{
|
|
40
38
|
message: message,
|
|
41
39
|
assistant_message: assistant_message,
|
|
42
|
-
models: models,
|
|
43
|
-
selected_model_id: selected_model_id,
|
|
44
40
|
error: error
|
|
45
41
|
}
|
|
46
42
|
end
|
|
@@ -6,41 +6,40 @@ module Layered
|
|
|
6
6
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
|
-
def
|
|
10
|
-
session[:
|
|
9
|
+
def session_conversation_uids
|
|
10
|
+
session[:layered_assistant_conversation_uids] ||= []
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
MAX_SESSION_CONVERSATIONS = 50
|
|
14
14
|
|
|
15
15
|
def add_conversation_to_session(conversation)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
uids = session_conversation_uids
|
|
17
|
+
uids << conversation.uid unless uids.include?(conversation.uid)
|
|
18
|
+
uids.shift while uids.size > MAX_SESSION_CONVERSATIONS
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
def find_session_conversation(
|
|
22
|
-
|
|
23
|
-
unless session_conversation_ids.include?(id)
|
|
21
|
+
def find_session_conversation(uid)
|
|
22
|
+
unless session_conversation_uids.include?(uid)
|
|
24
23
|
raise ActiveRecord::RecordNotFound, "Conversation not found in session"
|
|
25
24
|
end
|
|
26
25
|
|
|
27
|
-
Conversation.joins(:assistant).merge(Assistant.publicly_available).
|
|
26
|
+
Conversation.joins(:assistant).merge(Assistant.publicly_available).find_by!(uid: uid)
|
|
28
27
|
rescue ActiveRecord::RecordNotFound
|
|
29
|
-
remove_conversation_from_session(
|
|
28
|
+
remove_conversation_from_session(uid)
|
|
30
29
|
raise
|
|
31
30
|
end
|
|
32
31
|
|
|
33
|
-
def remove_conversation_from_session(
|
|
34
|
-
|
|
32
|
+
def remove_conversation_from_session(uid)
|
|
33
|
+
session_conversation_uids.delete(uid)
|
|
35
34
|
end
|
|
36
35
|
|
|
37
36
|
def existing_session_conversation_for(assistant)
|
|
38
|
-
return nil if
|
|
37
|
+
return nil if session_conversation_uids.empty?
|
|
39
38
|
|
|
40
39
|
Conversation
|
|
41
40
|
.joins(:assistant)
|
|
42
41
|
.merge(Assistant.publicly_available)
|
|
43
|
-
.where(
|
|
42
|
+
.where(uid: session_conversation_uids, assistant: assistant)
|
|
44
43
|
.order(created_at: :desc)
|
|
45
44
|
.first
|
|
46
45
|
end
|
|
@@ -20,6 +20,18 @@ module Layered
|
|
|
20
20
|
|
|
21
21
|
instance_exec(&block)
|
|
22
22
|
end
|
|
23
|
+
|
|
24
|
+
def scoped(model_class)
|
|
25
|
+
block = Layered::Assistant.scope_block
|
|
26
|
+
return model_class.all unless block
|
|
27
|
+
|
|
28
|
+
result = instance_exec(model_class, &block)
|
|
29
|
+
unless result.is_a?(ActiveRecord::Relation)
|
|
30
|
+
raise ArgumentError,
|
|
31
|
+
"Layered::Assistant.scope must return an ActiveRecord::Relation, got #{result.class}"
|
|
32
|
+
end
|
|
33
|
+
result
|
|
34
|
+
end
|
|
23
35
|
end
|
|
24
36
|
end
|
|
25
37
|
end
|
|
@@ -6,7 +6,7 @@ module Layered
|
|
|
6
6
|
|
|
7
7
|
def index
|
|
8
8
|
@page_title = "Assistants"
|
|
9
|
-
@pagy, @assistants = pagy(Assistant.by_name)
|
|
9
|
+
@pagy, @assistants = pagy(scoped(Assistant).by_name)
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def new
|
|
@@ -44,7 +44,7 @@ module Layered
|
|
|
44
44
|
private
|
|
45
45
|
|
|
46
46
|
def set_assistant
|
|
47
|
-
@assistant = Assistant.find(params[:id])
|
|
47
|
+
@assistant = scoped(Assistant).find(params[:id])
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def set_models
|
|
@@ -8,12 +8,12 @@ module Layered
|
|
|
8
8
|
|
|
9
9
|
def index
|
|
10
10
|
if params[:assistant_id]
|
|
11
|
-
@assistant = Assistant.find(params[:assistant_id])
|
|
11
|
+
@assistant = scoped(Assistant).find(params[:assistant_id])
|
|
12
12
|
@page_title = "Conversations - #{@assistant.name}"
|
|
13
|
-
@pagy, @conversations = pagy(@assistant.conversations.includes(:owner).by_created_at)
|
|
13
|
+
@pagy, @conversations = pagy(@assistant.conversations.merge(scoped(Conversation)).includes(:owner).by_created_at)
|
|
14
14
|
else
|
|
15
15
|
@page_title = "Conversations"
|
|
16
|
-
@pagy, @conversations = pagy(Conversation.includes(:assistant, :owner).by_created_at)
|
|
16
|
+
@pagy, @conversations = pagy(scoped(Conversation).includes(:assistant, :owner).by_created_at)
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
|
@@ -60,11 +60,11 @@ module Layered
|
|
|
60
60
|
private
|
|
61
61
|
|
|
62
62
|
def set_conversation
|
|
63
|
-
@conversation = Conversation.
|
|
63
|
+
@conversation = scoped(Conversation).find_by!(uid: params[:id])
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
def set_assistants
|
|
67
|
-
@assistants = Assistant.by_name
|
|
67
|
+
@assistants = scoped(Assistant).by_name
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
def conversation_params
|
|
@@ -8,7 +8,7 @@ module Layered
|
|
|
8
8
|
|
|
9
9
|
def index
|
|
10
10
|
@page_title = "Messages"
|
|
11
|
-
@pagy, @messages = pagy(@conversation.messages.by_created_at)
|
|
11
|
+
@pagy, @messages = pagy(@conversation.messages.includes(:model).by_created_at)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def create
|
|
@@ -24,8 +24,8 @@ module Layered
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
@assistant_message = result[:assistant_message]
|
|
27
|
-
@models =
|
|
28
|
-
@selected_model_id =
|
|
27
|
+
@models = Model.available
|
|
28
|
+
@selected_model_id = message_params[:model_id]
|
|
29
29
|
@error = result[:error]
|
|
30
30
|
|
|
31
31
|
respond_to do |format|
|
|
@@ -42,7 +42,7 @@ module Layered
|
|
|
42
42
|
private
|
|
43
43
|
|
|
44
44
|
def set_conversation
|
|
45
|
-
@conversation = Conversation.
|
|
45
|
+
@conversation = scoped(Conversation).find_by!(uid: params[:conversation_id])
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def set_message
|
|
@@ -42,15 +42,15 @@ module Layered
|
|
|
42
42
|
private
|
|
43
43
|
|
|
44
44
|
def set_conversation
|
|
45
|
-
@conversation = Conversation.
|
|
45
|
+
@conversation = scoped(Conversation).find_by!(uid: params[:id])
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def set_assistants
|
|
49
|
-
@assistants = Assistant.by_name
|
|
49
|
+
@assistants = scoped(Assistant).by_name
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
def set_conversations
|
|
53
|
-
scope = Conversation.by_created_at
|
|
53
|
+
scope = scoped(Conversation).by_created_at
|
|
54
54
|
scope = scope.where(assistant_id: params[:assistant_id]) if params[:assistant_id].present?
|
|
55
55
|
@conversations = scope.limit(20)
|
|
56
56
|
end
|
|
@@ -20,8 +20,8 @@ module Layered
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
@assistant_message = result[:assistant_message]
|
|
23
|
-
@models =
|
|
24
|
-
@selected_model_id =
|
|
23
|
+
@models = Model.available
|
|
24
|
+
@selected_model_id = message_params[:model_id]
|
|
25
25
|
@error = result[:error]
|
|
26
26
|
|
|
27
27
|
respond_to do |format|
|
|
@@ -32,7 +32,7 @@ module Layered
|
|
|
32
32
|
private
|
|
33
33
|
|
|
34
34
|
def set_conversation
|
|
35
|
-
@conversation = Conversation.
|
|
35
|
+
@conversation = scoped(Conversation).find_by!(uid: params[:conversation_id])
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def message_params
|
|
@@ -5,7 +5,7 @@ module Layered
|
|
|
5
5
|
|
|
6
6
|
def index
|
|
7
7
|
@page_title = "Providers"
|
|
8
|
-
@pagy, @providers = pagy(Provider.sorted)
|
|
8
|
+
@pagy, @providers = pagy(scoped(Provider).sorted)
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def new
|
|
@@ -44,7 +44,7 @@ module Layered
|
|
|
44
44
|
private
|
|
45
45
|
|
|
46
46
|
def set_provider
|
|
47
|
-
@provider = Provider.find(params[:id])
|
|
47
|
+
@provider = scoped(Provider).find(params[:id])
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def provider_params
|
|
@@ -36,7 +36,7 @@ module Layered
|
|
|
36
36
|
def set_conversation
|
|
37
37
|
@conversation = find_session_conversation(params[:id])
|
|
38
38
|
rescue ActiveRecord::RecordNotFound
|
|
39
|
-
assistant = Conversation.find_by(
|
|
39
|
+
assistant = Conversation.find_by(uid: params[:id])&.assistant
|
|
40
40
|
if assistant&.public?
|
|
41
41
|
redirect_to layered_assistant.public_panel_conversations_path(assistant_id: assistant.id)
|
|
42
42
|
else
|
|
@@ -46,9 +46,9 @@ module Layered
|
|
|
46
46
|
|
|
47
47
|
def set_session_conversations
|
|
48
48
|
assistant_id = @conversation&.assistant_id || @assistant&.id
|
|
49
|
-
@conversations = if
|
|
49
|
+
@conversations = if session_conversation_uids.any?
|
|
50
50
|
scope = Conversation.joins(:assistant).merge(Assistant.publicly_available)
|
|
51
|
-
.where(
|
|
51
|
+
.where(uid: session_conversation_uids)
|
|
52
52
|
.by_created_at
|
|
53
53
|
scope = scope.where(assistant_id: assistant_id) if assistant_id
|
|
54
54
|
scope.limit(20)
|
|
@@ -32,7 +32,7 @@ module Layered
|
|
|
32
32
|
def set_conversation
|
|
33
33
|
@conversation = find_session_conversation(params[:conversation_id])
|
|
34
34
|
rescue ActiveRecord::RecordNotFound
|
|
35
|
-
assistant = Conversation.find_by(
|
|
35
|
+
assistant = Conversation.find_by(uid: params[:conversation_id])&.assistant
|
|
36
36
|
if assistant&.public?
|
|
37
37
|
redirect_to layered_assistant.new_public_panel_conversation_path(assistant_id: assistant.id)
|
|
38
38
|
else
|
|
@@ -31,11 +31,11 @@ module Layered
|
|
|
31
31
|
private
|
|
32
32
|
|
|
33
33
|
def method_missing(method, ...)
|
|
34
|
-
@context.
|
|
34
|
+
@context.public_send(method, ...)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def respond_to_missing?(method, include_private = false)
|
|
38
|
-
@context.respond_to?(method,
|
|
38
|
+
@context.respond_to?(method, false)
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
|
|
@@ -15,6 +15,23 @@ module Layered
|
|
|
15
15
|
|
|
16
16
|
ALLOWED_ATTRIBUTES = %w[href title class].freeze
|
|
17
17
|
|
|
18
|
+
MIN_RESPONSE_MS_FOR_TPS = 100
|
|
19
|
+
|
|
20
|
+
def message_metadata_title(message)
|
|
21
|
+
total_tokens = message.input_tokens.to_i + message.output_tokens.to_i
|
|
22
|
+
parts = []
|
|
23
|
+
if total_tokens > 0
|
|
24
|
+
prefix = message.tokens_estimated? ? "~" : ""
|
|
25
|
+
parts << "#{prefix}#{number_with_delimiter(total_tokens)} tokens"
|
|
26
|
+
end
|
|
27
|
+
if message.output_tokens.to_i > 0 && message.response_ms.to_i >= MIN_RESPONSE_MS_FOR_TPS
|
|
28
|
+
tps = (message.output_tokens * 1000.0 / message.response_ms).round(1)
|
|
29
|
+
parts << "#{tps} tok/s"
|
|
30
|
+
end
|
|
31
|
+
parts << "#{message.ttft_ms}ms TTFT" if message.ttft_ms
|
|
32
|
+
parts.join(" · ")
|
|
33
|
+
end
|
|
34
|
+
|
|
18
35
|
def render_message_content(message)
|
|
19
36
|
return if message.content.blank?
|
|
20
37
|
|
|
@@ -24,7 +41,7 @@ module Layered
|
|
|
24
41
|
syntax_highlighter: nil
|
|
25
42
|
).to_html
|
|
26
43
|
|
|
27
|
-
sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
|
|
44
|
+
sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
|
|
28
45
|
end
|
|
29
46
|
|
|
30
47
|
private
|
|
@@ -107,6 +107,9 @@ export default class extends Controller {
|
|
|
107
107
|
_resetRespondingTimeout() {
|
|
108
108
|
if (!this.respondingValue) return
|
|
109
109
|
clearTimeout(this._respondingTimeout)
|
|
110
|
-
this._respondingTimeout = setTimeout(() => {
|
|
110
|
+
this._respondingTimeout = setTimeout(() => {
|
|
111
|
+
this.respondingValue = false
|
|
112
|
+
document.dispatchEvent(new CustomEvent("assistant:response-timeout"))
|
|
113
|
+
}, 30000)
|
|
111
114
|
}
|
|
112
115
|
}
|
|
@@ -9,14 +9,29 @@ export default class extends Controller {
|
|
|
9
9
|
this._userInitiated = false
|
|
10
10
|
|
|
11
11
|
this._markUser = () => { this._userInitiated = true }
|
|
12
|
+
this._onTimeout = () => {
|
|
13
|
+
this.listTarget.querySelectorAll(".l-ui-typing-indicator").forEach(el => {
|
|
14
|
+
const body = el.closest(".l-ui-message__body")
|
|
15
|
+
el.remove()
|
|
16
|
+
if (body && body.children.length === 0) {
|
|
17
|
+
body.insertAdjacentHTML("beforeend", '<div class="l-ui-notice--error" role="status">The response could not be completed.</div>')
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
}
|
|
12
21
|
this.element.addEventListener("wheel", this._markUser, { passive: true })
|
|
13
22
|
this.element.addEventListener("touchmove", this._markUser, { passive: true })
|
|
14
23
|
this.element.addEventListener("scroll", this._onScroll, { passive: true })
|
|
24
|
+
document.addEventListener("assistant:response-timeout", this._onTimeout)
|
|
15
25
|
|
|
16
26
|
this.scrollToBottom()
|
|
17
27
|
|
|
18
|
-
this.observer = new MutationObserver(() => {
|
|
28
|
+
this.observer = new MutationObserver((mutations) => {
|
|
19
29
|
if (this._pinned) this.scrollToBottom()
|
|
30
|
+
|
|
31
|
+
const hasNewChildren = mutations.some(m =>
|
|
32
|
+
m.type === "childList" && m.target === this.listTarget && m.addedNodes.length > 0
|
|
33
|
+
)
|
|
34
|
+
if (hasNewChildren) this._sortMessages()
|
|
20
35
|
})
|
|
21
36
|
this.observer.observe(this.listTarget, { childList: true, subtree: true })
|
|
22
37
|
}
|
|
@@ -25,6 +40,7 @@ export default class extends Controller {
|
|
|
25
40
|
this.element.removeEventListener("wheel", this._markUser)
|
|
26
41
|
this.element.removeEventListener("touchmove", this._markUser)
|
|
27
42
|
this.element.removeEventListener("scroll", this._onScroll)
|
|
43
|
+
document.removeEventListener("assistant:response-timeout", this._onTimeout)
|
|
28
44
|
this.observer.disconnect()
|
|
29
45
|
}
|
|
30
46
|
|
|
@@ -59,4 +75,18 @@ export default class extends Controller {
|
|
|
59
75
|
isNearBottom() {
|
|
60
76
|
return this.element.scrollHeight - this.element.scrollTop - this.element.clientHeight <= 32
|
|
61
77
|
}
|
|
78
|
+
|
|
79
|
+
_sortMessages() {
|
|
80
|
+
const children = Array.from(this.listTarget.children)
|
|
81
|
+
const sorted = children.slice().sort((a, b) => {
|
|
82
|
+
const aTime = parseInt(a.dataset.createdAt || "0", 10)
|
|
83
|
+
const bTime = parseInt(b.dataset.createdAt || "0", 10)
|
|
84
|
+
return aTime - bTime
|
|
85
|
+
})
|
|
86
|
+
if (sorted.some((el, i) => el !== children[i])) {
|
|
87
|
+
this.observer.disconnect()
|
|
88
|
+
sorted.forEach(el => this.listTarget.appendChild(el))
|
|
89
|
+
this.observer.observe(this.listTarget, { childList: true, subtree: true })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
62
92
|
}
|
|
@@ -17,6 +17,10 @@ module Layered
|
|
|
17
17
|
scope :by_name, -> { order(name: :asc, created_at: :desc) }
|
|
18
18
|
scope :by_created_at, -> { order(created_at: :desc) }
|
|
19
19
|
|
|
20
|
+
def to_param
|
|
21
|
+
uid
|
|
22
|
+
end
|
|
23
|
+
|
|
20
24
|
# Name
|
|
21
25
|
def update_token_totals!
|
|
22
26
|
input = messages.sum(:input_tokens)
|
|
@@ -29,15 +33,24 @@ module Layered
|
|
|
29
33
|
end
|
|
30
34
|
|
|
31
35
|
def stop_response!
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
with_lock do
|
|
37
|
+
message = messages.where(role: :assistant, stopped: false).order(created_at: :desc).first
|
|
38
|
+
return false unless message
|
|
39
|
+
|
|
40
|
+
attrs = {
|
|
41
|
+
stopped: true,
|
|
42
|
+
output_tokens: TokenEstimator.estimate(message.content) || 0,
|
|
43
|
+
tokens_estimated: true
|
|
44
|
+
}
|
|
34
45
|
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
if message.input_tokens.nil?
|
|
47
|
+
prior_content = messages.where("created_at < ?", message.created_at).pluck(:content).compact.join(" ")
|
|
48
|
+
attrs[:input_tokens] = TokenEstimator.estimate(prior_content) || 0
|
|
49
|
+
end
|
|
37
50
|
|
|
38
|
-
|
|
39
|
-
message.update!(stopped: true, output_tokens: estimated, tokens_estimated: true)
|
|
51
|
+
message.update!(attrs)
|
|
40
52
|
update_token_totals!
|
|
53
|
+
message.reload
|
|
41
54
|
message.broadcast_updated
|
|
42
55
|
message.broadcast_response_complete
|
|
43
56
|
end
|
|
@@ -13,12 +13,12 @@ module Layered
|
|
|
13
13
|
|
|
14
14
|
# Enums
|
|
15
15
|
enum :protocol, {
|
|
16
|
-
anthropic: "
|
|
17
|
-
openai: "
|
|
16
|
+
anthropic: "anthropic",
|
|
17
|
+
openai: "openai"
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
# Encryption
|
|
21
|
-
unless
|
|
21
|
+
unless Layered::Assistant.skip_db_encryption
|
|
22
22
|
encrypts :secret
|
|
23
23
|
end
|
|
24
24
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Layered
|
|
2
|
+
module Assistant
|
|
3
|
+
class ChunkParser
|
|
4
|
+
def initialize(protocol)
|
|
5
|
+
@openai = protocol == "openai"
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def text(chunk)
|
|
9
|
+
t = if @openai
|
|
10
|
+
chunk.dig("choices", 0, "delta", "content")
|
|
11
|
+
else
|
|
12
|
+
chunk.dig("delta", "text") if chunk["type"] == "content_block_delta"
|
|
13
|
+
end
|
|
14
|
+
t unless t.nil? || t.empty?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def finished?(chunk)
|
|
18
|
+
if @openai
|
|
19
|
+
chunk.dig("choices", 0, "finish_reason").present?
|
|
20
|
+
else
|
|
21
|
+
chunk["type"] == "message_stop"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def usage_ready?(chunk)
|
|
26
|
+
@openai && chunk["usage"].present? && chunk.dig("choices")&.empty?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def input_tokens(chunk)
|
|
30
|
+
if @openai
|
|
31
|
+
chunk.dig("usage", "prompt_tokens")&.to_i if usage_ready?(chunk)
|
|
32
|
+
elsif chunk["type"] == "message_start"
|
|
33
|
+
chunk.dig("message", "usage", "input_tokens")&.to_i
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def output_tokens(chunk)
|
|
38
|
+
if @openai
|
|
39
|
+
chunk.dig("usage", "completion_tokens")&.to_i if usage_ready?(chunk)
|
|
40
|
+
elsif chunk["type"] == "message_delta"
|
|
41
|
+
chunk.dig("usage", "output_tokens")&.to_i
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -3,34 +3,49 @@ module Layered
|
|
|
3
3
|
class ChunkService
|
|
4
4
|
STOP_CHECK_INTERVAL = 25
|
|
5
5
|
|
|
6
|
-
def initialize(message, provider:)
|
|
6
|
+
def initialize(message, provider:, started_at: nil)
|
|
7
7
|
@message = message
|
|
8
|
-
@
|
|
8
|
+
@parser = ChunkParser.new(provider.protocol)
|
|
9
|
+
@timer = ResponseTimer.new
|
|
10
|
+
@timer.start! if started_at
|
|
9
11
|
@input_tokens = 0
|
|
10
12
|
@output_tokens = 0
|
|
11
13
|
@chunk_count = 0
|
|
12
14
|
@stopped = false
|
|
13
15
|
end
|
|
14
16
|
|
|
17
|
+
def mark_started!
|
|
18
|
+
@timer.start!
|
|
19
|
+
end
|
|
20
|
+
|
|
15
21
|
def call(chunk)
|
|
16
22
|
return if @stopped
|
|
17
23
|
|
|
18
24
|
@chunk_count += 1
|
|
19
25
|
if @chunk_count % STOP_CHECK_INTERVAL == 0
|
|
20
26
|
@stopped = @message.reload.stopped?
|
|
21
|
-
|
|
27
|
+
if @stopped
|
|
28
|
+
attrs = @timer.timing_attrs
|
|
29
|
+
unless attrs.empty?
|
|
30
|
+
@message.update!(attrs)
|
|
31
|
+
@message.broadcast_updated
|
|
32
|
+
end
|
|
33
|
+
return
|
|
34
|
+
end
|
|
22
35
|
end
|
|
23
36
|
|
|
24
37
|
Rails.logger.debug { "[ChunkService] #{chunk.inspect}" }
|
|
25
|
-
text =
|
|
26
|
-
|
|
38
|
+
text = @parser.text(chunk)
|
|
39
|
+
@input_tokens = @parser.input_tokens(chunk) || @input_tokens
|
|
40
|
+
@output_tokens = @parser.output_tokens(chunk) || @output_tokens
|
|
27
41
|
|
|
28
42
|
if text
|
|
29
|
-
@
|
|
43
|
+
@timer.record_first_token!
|
|
44
|
+
@message.update!(content: (@message.content || "") + text)
|
|
30
45
|
@message.broadcast_chunk(text)
|
|
31
46
|
end
|
|
32
47
|
|
|
33
|
-
if
|
|
48
|
+
if @parser.finished?(chunk) || @parser.usage_ready?(chunk)
|
|
34
49
|
save_token_usage
|
|
35
50
|
@message.broadcast_updated
|
|
36
51
|
end
|
|
@@ -38,51 +53,15 @@ module Layered
|
|
|
38
53
|
|
|
39
54
|
private
|
|
40
55
|
|
|
41
|
-
def extract_text(chunk)
|
|
42
|
-
text = if @provider.protocol == "openai"
|
|
43
|
-
chunk.dig("choices", 0, "delta", "content")
|
|
44
|
-
else
|
|
45
|
-
chunk.dig("delta", "text") if chunk["type"] == "content_block_delta"
|
|
46
|
-
end
|
|
47
|
-
text unless text.nil? || text.empty?
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def chunk_finished?(chunk)
|
|
51
|
-
if @provider.protocol == "openai"
|
|
52
|
-
chunk.dig("choices", 0, "finish_reason").present?
|
|
53
|
-
else
|
|
54
|
-
chunk["type"] == "message_stop"
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def usage_chunk?(chunk)
|
|
59
|
-
@provider.protocol == "openai" && chunk["usage"].present? && chunk.dig("choices")&.empty?
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def extract_usage(chunk)
|
|
63
|
-
if @provider.protocol == "openai"
|
|
64
|
-
if (usage = chunk["usage"])
|
|
65
|
-
@input_tokens = usage["prompt_tokens"].to_i
|
|
66
|
-
@output_tokens = usage["completion_tokens"].to_i
|
|
67
|
-
end
|
|
68
|
-
else
|
|
69
|
-
if chunk["type"] == "message_start" && (usage = chunk.dig("message", "usage"))
|
|
70
|
-
@input_tokens = usage["input_tokens"].to_i
|
|
71
|
-
end
|
|
72
|
-
if chunk["type"] == "message_delta" && (usage = chunk["usage"])
|
|
73
|
-
@output_tokens = usage["output_tokens"].to_i
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
56
|
def save_token_usage
|
|
57
|
+
timing = @timer.timing_attrs
|
|
79
58
|
if @input_tokens == 0 && @output_tokens == 0
|
|
80
59
|
estimated = TokenEstimator.estimate(@message.content)
|
|
81
60
|
return unless estimated
|
|
82
61
|
|
|
83
|
-
@message.update!(output_tokens: estimated, tokens_estimated: true)
|
|
62
|
+
@message.update!(output_tokens: estimated, tokens_estimated: true, **timing)
|
|
84
63
|
else
|
|
85
|
-
@message.update!(input_tokens: @input_tokens, output_tokens: @output_tokens)
|
|
64
|
+
@message.update!(input_tokens: @input_tokens, output_tokens: @output_tokens, **timing)
|
|
86
65
|
end
|
|
87
66
|
|
|
88
67
|
@message.conversation.update_token_totals!
|
|
@@ -8,14 +8,15 @@ module Layered
|
|
|
8
8
|
parameters = {
|
|
9
9
|
model: model,
|
|
10
10
|
messages: formatted[:messages],
|
|
11
|
-
max_tokens:
|
|
11
|
+
max_tokens: 8192,
|
|
12
12
|
stream: stream_proc
|
|
13
13
|
}
|
|
14
14
|
parameters[:system] = formatted[:system] if formatted[:system].present?
|
|
15
15
|
|
|
16
16
|
::Anthropic::Client.new(
|
|
17
17
|
access_token: @api_key,
|
|
18
|
-
log_errors:
|
|
18
|
+
log_errors: Layered::Assistant.log_errors,
|
|
19
|
+
request_timeout: Layered::Assistant.api_request_timeout
|
|
19
20
|
).messages(parameters: parameters)
|
|
20
21
|
end
|
|
21
22
|
end
|
|
@@ -7,7 +7,8 @@ module Layered
|
|
|
7
7
|
|
|
8
8
|
client_options = {
|
|
9
9
|
access_token: @api_key,
|
|
10
|
-
log_errors:
|
|
10
|
+
log_errors: Layered::Assistant.log_errors,
|
|
11
|
+
request_timeout: Layered::Assistant.api_request_timeout
|
|
11
12
|
}
|
|
12
13
|
if @provider.url.present?
|
|
13
14
|
client_options[:uri_base] = @provider.url.sub(/\/\z/, "")
|
|
@@ -15,7 +16,7 @@ module Layered
|
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
::OpenAI::Client.new(**client_options) do |f|
|
|
18
|
-
if
|
|
19
|
+
if Layered::Assistant.log_errors
|
|
19
20
|
f.response :logger, Logger.new($stdout), bodies: true
|
|
20
21
|
end
|
|
21
22
|
end.chat(
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Layered
|
|
2
|
+
module Assistant
|
|
3
|
+
class ResponseTimer
|
|
4
|
+
def initialize
|
|
5
|
+
@started_at = nil
|
|
6
|
+
@first_token_at = nil
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def start!
|
|
10
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def record_first_token!
|
|
14
|
+
@first_token_at ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def timing_attrs
|
|
18
|
+
return {} unless @started_at
|
|
19
|
+
|
|
20
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
21
|
+
attrs = { response_ms: ((now - @started_at) * 1000).round }
|
|
22
|
+
attrs[:ttft_ms] = ((@first_token_at - @started_at) * 1000).round if @first_token_at
|
|
23
|
+
attrs
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def started?
|
|
27
|
+
!@started_at.nil?
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%= form_with(model: assistant, url: url, html: { class: "l-ui-form
|
|
1
|
+
<%= form_with(model: assistant, url: url, html: { class: "l-ui-form" }) do |f| %>
|
|
2
2
|
<%= render "layered_ui/shared/form_errors", item: assistant %>
|
|
3
3
|
|
|
4
4
|
<div class="l-ui-form__group">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%= form_with(model: conversation, url: url, html: { class: "l-ui-form
|
|
1
|
+
<%= form_with(model: conversation, url: url, html: { class: "l-ui-form" }) do |f| %>
|
|
2
2
|
<%= render "layered_ui/shared/form_errors", item: conversation %>
|
|
3
3
|
|
|
4
4
|
<% if conversation.new_record? %>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%= tag.div id: dom_id(message), class: "#{dom_id(message)} #{message.user? ? "l-ui-message--sent" : "l-ui-message"}" do %>
|
|
1
|
+
<%= tag.div id: dom_id(message), data: { created_at: (message.created_at.to_f * 1000).to_i }, class: "#{dom_id(message)} #{message.user? ? "l-ui-message--sent" : "l-ui-message"}" do %>
|
|
2
2
|
<div class="l-ui-message__bubble">
|
|
3
3
|
<% unless message.user? %>
|
|
4
4
|
<div class="l-ui-message__author"><%= message.role.capitalize %></div>
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
<% end %>
|
|
19
19
|
</div>
|
|
20
20
|
<div class="l-ui-message__footer">
|
|
21
|
-
<%
|
|
22
|
-
|
|
23
|
-
<span class="l-ui-message__metadata"
|
|
21
|
+
<% if message.model %>
|
|
22
|
+
<% title = message_metadata_title(message) %>
|
|
23
|
+
<span class="l-ui-message__metadata"<%= " title=\"#{title}\"".html_safe if title.present? %>><%= message.model.name %></span>
|
|
24
24
|
<% end %>
|
|
25
25
|
<span class="l-ui-message__timestamp"><%= message.created_at.to_fs(:short) %></span>
|
|
26
26
|
</div>
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
<tr>
|
|
11
11
|
<th scope="col" class="l-ui-table__header-cell">Role</th>
|
|
12
12
|
<th scope="col" class="l-ui-table__header-cell">Content</th>
|
|
13
|
+
<th scope="col" class="l-ui-table__header-cell">Model</th>
|
|
13
14
|
<th scope="col" class="l-ui-table__header-cell">Tokens</th>
|
|
15
|
+
<th scope="col" class="l-ui-table__header-cell">Tok/s</th>
|
|
16
|
+
<th scope="col" class="l-ui-table__header-cell">TTFT</th>
|
|
14
17
|
<th scope="col" class="l-ui-table__header-cell">Created</th>
|
|
15
18
|
<th scope="col" class="l-ui-table__header-cell--action">Actions</th>
|
|
16
19
|
</tr>
|
|
@@ -25,9 +28,23 @@
|
|
|
25
28
|
<td class="l-ui-table__cell">
|
|
26
29
|
<%= truncate(message.content, length: 100) %>
|
|
27
30
|
</td>
|
|
31
|
+
<td class="l-ui-table__cell">
|
|
32
|
+
<%= message.model&.name %>
|
|
33
|
+
</td>
|
|
28
34
|
<td class="l-ui-table__cell">
|
|
29
35
|
<% total = message.input_tokens.to_i + message.output_tokens.to_i %>
|
|
30
|
-
|
|
36
|
+
<% if total > 0 %>
|
|
37
|
+
<% prefix = message.tokens_estimated? ? "~" : "" %>
|
|
38
|
+
<%= "#{prefix}#{number_with_delimiter(total)}" %>
|
|
39
|
+
<% end %>
|
|
40
|
+
</td>
|
|
41
|
+
<td class="l-ui-table__cell">
|
|
42
|
+
<% if message.output_tokens.to_i > 0 && message.response_ms.to_i >= Layered::Assistant::MessagesHelper::MIN_RESPONSE_MS_FOR_TPS %>
|
|
43
|
+
<%= (message.output_tokens * 1000.0 / message.response_ms).round(1) %>
|
|
44
|
+
<% end %>
|
|
45
|
+
</td>
|
|
46
|
+
<td class="l-ui-table__cell">
|
|
47
|
+
<%= "#{message.ttft_ms}ms" if message.ttft_ms %>
|
|
31
48
|
</td>
|
|
32
49
|
<td class="l-ui-table__cell">
|
|
33
50
|
<%= message.created_at.to_fs(:short) %>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%= form_with(model: model, url: url, html: { class: "l-ui-form
|
|
1
|
+
<%= form_with(model: model, url: url, html: { class: "l-ui-form" }) do |f| %>
|
|
2
2
|
<%= render "layered_ui/shared/form_errors", item: model %>
|
|
3
3
|
|
|
4
4
|
<div class="l-ui-form__group">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%= form_with(model: provider, url: url, html: { class: "l-ui-form
|
|
1
|
+
<%= form_with(model: provider, url: url, html: { class: "l-ui-form" }) do |f| %>
|
|
2
2
|
<%= render "layered_ui/shared/form_errors", item: provider %>
|
|
3
3
|
|
|
4
4
|
<div data-controller="provider-template" data-provider-template-templates-value="<%= Layered::Assistant::Provider::TEMPLATES.values.flatten.to_json %>">
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
<div class="l-ui-form__group">
|
|
29
29
|
<%= render "layered_ui/shared/label", form: f, field: :protocol, required: true %>
|
|
30
|
-
<%= f.select :protocol, Layered::Assistant::Provider.protocols.map { |k
|
|
30
|
+
<%= f.select :protocol, Layered::Assistant::Provider.protocols.keys.map { |k| [I18n.t("layered_assistant.protocols.#{k}"), k] }, { include_blank: "Select:" }, { class: "l-ui-select", data: { provider_template_target: "protocol" } } %>
|
|
31
31
|
<%= render "layered_ui/shared/field_error", object: provider, field: :protocol %>
|
|
32
32
|
</div>
|
|
33
33
|
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
<%= provider.name %>
|
|
24
24
|
</td>
|
|
25
25
|
<td class="l-ui-table__cell">
|
|
26
|
-
<%=
|
|
26
|
+
<%= I18n.t("layered_assistant.protocols.#{provider.protocol}") %>
|
|
27
27
|
</td>
|
|
28
28
|
<td class="l-ui-table__cell">
|
|
29
29
|
<%= link_to provider.models_count, layered_assistant.provider_models_path(provider) %>
|
|
@@ -51,6 +51,62 @@ end</code></pre>
|
|
|
51
51
|
<%= link_to "Assistant", layered_assistant.root_path %>
|
|
52
52
|
<% end %></code></pre>
|
|
53
53
|
|
|
54
|
+
<h2 class="l-ui-utility--mt-xl">Record scoping</h2>
|
|
55
|
+
<p class="l-ui-utility--mt-md">
|
|
56
|
+
By default, all records are visible to any authorised user. If your application is
|
|
57
|
+
multi-tenant or you need to restrict which records a user can see, configure a
|
|
58
|
+
<code>scope</code> block in the initialiser.
|
|
59
|
+
</p>
|
|
60
|
+
<p class="l-ui-utility--mt-md">
|
|
61
|
+
The block receives the model class, runs in controller context, and must return an
|
|
62
|
+
<code>ActiveRecord::Relation</code>. The following models are passed through the scope block:
|
|
63
|
+
</p>
|
|
64
|
+
|
|
65
|
+
<div class="l-ui-container--table l-ui-utility--mt-lg">
|
|
66
|
+
<table class="l-ui-table">
|
|
67
|
+
<caption class="l-ui-sr-only">Scopeable models reference</caption>
|
|
68
|
+
<thead class="l-ui-table__header">
|
|
69
|
+
<tr>
|
|
70
|
+
<th class="l-ui-table__header-cell" scope="col">Model</th>
|
|
71
|
+
<th class="l-ui-table__header-cell" scope="col">Description</th>
|
|
72
|
+
</tr>
|
|
73
|
+
</thead>
|
|
74
|
+
<tbody class="l-ui-table__body">
|
|
75
|
+
<tr>
|
|
76
|
+
<th class="l-ui-table__cell--primary" scope="row"><code>Layered::Assistant::Conversation</code></th>
|
|
77
|
+
<td class="l-ui-table__cell">User conversations (has polymorphic <code>owner</code>)</td>
|
|
78
|
+
</tr>
|
|
79
|
+
<tr>
|
|
80
|
+
<th class="l-ui-table__cell--primary" scope="row"><code>Layered::Assistant::Assistant</code></th>
|
|
81
|
+
<td class="l-ui-table__cell">Assistant configurations (has polymorphic <code>owner</code>)</td>
|
|
82
|
+
</tr>
|
|
83
|
+
<tr>
|
|
84
|
+
<th class="l-ui-table__cell--primary" scope="row"><code>Layered::Assistant::Provider</code></th>
|
|
85
|
+
<td class="l-ui-table__cell">API provider credentials (has polymorphic <code>owner</code>)</td>
|
|
86
|
+
</tr>
|
|
87
|
+
</tbody>
|
|
88
|
+
</table>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<h3 class="l-ui-utility--mt-lg">Scope all owned resources to the current user</h3>
|
|
92
|
+
<pre class="l-ui-surface l-ui-utility--mt-md"><code>Layered::Assistant.scope do |model_class|
|
|
93
|
+
model_class.where(owner: current_user)
|
|
94
|
+
end</code></pre>
|
|
95
|
+
|
|
96
|
+
<h3 class="l-ui-utility--mt-lg">Scope conversations only</h3>
|
|
97
|
+
<pre class="l-ui-surface l-ui-utility--mt-md"><code>Layered::Assistant.scope do |model_class|
|
|
98
|
+
if model_class == Layered::Assistant::Conversation
|
|
99
|
+
model_class.where(owner: current_user)
|
|
100
|
+
else
|
|
101
|
+
model_class.all
|
|
102
|
+
end
|
|
103
|
+
end</code></pre>
|
|
104
|
+
|
|
105
|
+
<p class="l-ui-utility--mt-md">
|
|
106
|
+
When no scope block is configured, queries are unscoped. Record-level access control
|
|
107
|
+
is the host application's responsibility; the scope block is the integration point for it.
|
|
108
|
+
</p>
|
|
109
|
+
|
|
54
110
|
<h2 class="l-ui-utility--mt-xl">Panel helpers</h2>
|
|
55
111
|
<p class="l-ui-utility--mt-md">
|
|
56
112
|
Two helpers are available to wire the <code>layered-ui</code> panel to the assistant engine.
|
|
@@ -90,6 +146,39 @@ end</code></pre>
|
|
|
90
146
|
</table>
|
|
91
147
|
</div>
|
|
92
148
|
|
|
149
|
+
<h2 class="l-ui-utility--mt-xl">Configuration</h2>
|
|
150
|
+
<p class="l-ui-utility--mt-md">
|
|
151
|
+
Optional settings can be added to your initialiser (<code>config/initializers/layered_assistant.rb</code>):
|
|
152
|
+
</p>
|
|
153
|
+
|
|
154
|
+
<div class="l-ui-container--table l-ui-utility--mt-lg">
|
|
155
|
+
<table class="l-ui-table">
|
|
156
|
+
<caption class="l-ui-sr-only">Configuration options reference</caption>
|
|
157
|
+
<thead class="l-ui-table__header">
|
|
158
|
+
<tr>
|
|
159
|
+
<th class="l-ui-table__header-cell" scope="col">Option</th>
|
|
160
|
+
<th class="l-ui-table__header-cell" scope="col">Default</th>
|
|
161
|
+
<th class="l-ui-table__header-cell" scope="col">Description</th>
|
|
162
|
+
</tr>
|
|
163
|
+
</thead>
|
|
164
|
+
<tbody class="l-ui-table__body">
|
|
165
|
+
<tr>
|
|
166
|
+
<th class="l-ui-table__cell--primary" scope="row"><code>log_errors</code></th>
|
|
167
|
+
<td class="l-ui-table__cell"><code>false</code></td>
|
|
168
|
+
<td class="l-ui-table__cell">Log API errors to stdout from the AI API clients</td>
|
|
169
|
+
</tr>
|
|
170
|
+
<tr>
|
|
171
|
+
<th class="l-ui-table__cell--primary" scope="row"><code>skip_db_encryption</code></th>
|
|
172
|
+
<td class="l-ui-table__cell"><code>false</code></td>
|
|
173
|
+
<td class="l-ui-table__cell">Disable Active Record Encryption on <code>Provider#secret</code>. Only for development/test environments without encryption keys configured</td>
|
|
174
|
+
</tr>
|
|
175
|
+
</tbody>
|
|
176
|
+
</table>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<pre class="l-ui-surface l-ui-utility--mt-lg"><code>Layered::Assistant.log_errors = true
|
|
180
|
+
Layered::Assistant.skip_db_encryption = true</code></pre>
|
|
181
|
+
|
|
93
182
|
<h2 class="l-ui-utility--mt-xl">Getting started</h2>
|
|
94
183
|
<ol class="l-ui-list l-ui-utility--mt-md">
|
|
95
184
|
<li><%= link_to "Create a provider", layered_assistant.new_provider_path %> (e.g. Anthropic or OpenAI) with your API key</li>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class NormaliseProviderProtocolValues < ActiveRecord::Migration[8.1]
|
|
2
|
+
def up
|
|
3
|
+
execute "UPDATE layered_assistant_providers SET protocol = 'anthropic' WHERE protocol = 'Anthropic'"
|
|
4
|
+
execute "UPDATE layered_assistant_providers SET protocol = 'openai' WHERE protocol = 'OpenAI'"
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def down
|
|
8
|
+
execute "UPDATE layered_assistant_providers SET protocol = 'Anthropic' WHERE protocol = 'anthropic'"
|
|
9
|
+
execute "UPDATE layered_assistant_providers SET protocol = 'OpenAI' WHERE protocol = 'openai'"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -24,3 +24,37 @@
|
|
|
24
24
|
# Layered::Assistant.authorize do
|
|
25
25
|
# head :forbidden unless current_user&.admin?
|
|
26
26
|
# end
|
|
27
|
+
|
|
28
|
+
# Configure record scoping for layered-assistant-rails.
|
|
29
|
+
# By default, all records are visible to any authorised user. Use the scope
|
|
30
|
+
# block to restrict which records are returned from the engine's controllers.
|
|
31
|
+
#
|
|
32
|
+
# The block receives the model class and runs in controller context, so you
|
|
33
|
+
# have access to current_user and other helpers. Return an ActiveRecord
|
|
34
|
+
# relation (e.g. model_class.where(...) or model_class.all).
|
|
35
|
+
#
|
|
36
|
+
# Models passed through the scope block:
|
|
37
|
+
# - Layered::Assistant::Conversation (has polymorphic owner)
|
|
38
|
+
# - Layered::Assistant::Assistant (has polymorphic owner)
|
|
39
|
+
# - Layered::Assistant::Provider (has polymorphic owner)
|
|
40
|
+
#
|
|
41
|
+
# Scope all owned resources to the current user:
|
|
42
|
+
#
|
|
43
|
+
# Layered::Assistant.scope do |model_class|
|
|
44
|
+
# model_class.where(owner: current_user)
|
|
45
|
+
# end
|
|
46
|
+
#
|
|
47
|
+
# Scope conversations only, leave others unscoped:
|
|
48
|
+
#
|
|
49
|
+
# Layered::Assistant.scope do |model_class|
|
|
50
|
+
# if model_class == Layered::Assistant::Conversation
|
|
51
|
+
# model_class.where(owner: current_user)
|
|
52
|
+
# else
|
|
53
|
+
# model_class.all
|
|
54
|
+
# end
|
|
55
|
+
# end
|
|
56
|
+
|
|
57
|
+
# Optional settings (uncomment to enable):
|
|
58
|
+
# Layered::Assistant.log_errors = true # log API errors to stdout
|
|
59
|
+
# Layered::Assistant.api_request_timeout = 210 # total API timeout in seconds, including full streaming response (default: 210)
|
|
60
|
+
# Layered::Assistant.skip_db_encryption = true # skip encryption on Provider#secret (dev/test only)
|
data/lib/layered/assistant.rb
CHANGED
|
@@ -11,9 +11,17 @@ require "layered/assistant/engine"
|
|
|
11
11
|
module Layered
|
|
12
12
|
module Assistant
|
|
13
13
|
mattr_reader :authorize_block
|
|
14
|
+
mattr_reader :scope_block
|
|
15
|
+
mattr_accessor :log_errors, default: false
|
|
16
|
+
mattr_accessor :api_request_timeout, default: 210
|
|
17
|
+
mattr_accessor :skip_db_encryption, default: false
|
|
14
18
|
|
|
15
19
|
def self.authorize(&block)
|
|
16
20
|
@@authorize_block = block
|
|
17
21
|
end
|
|
22
|
+
|
|
23
|
+
def self.scope(&block)
|
|
24
|
+
@@scope_block = block
|
|
25
|
+
end
|
|
18
26
|
end
|
|
19
27
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: layered-assistant-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- layered.ai
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-03-
|
|
11
|
+
date: 2026-03-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -340,6 +340,7 @@ files:
|
|
|
340
340
|
- app/models/layered/assistant/message.rb
|
|
341
341
|
- app/models/layered/assistant/model.rb
|
|
342
342
|
- app/models/layered/assistant/provider.rb
|
|
343
|
+
- app/services/layered/assistant/chunk_parser.rb
|
|
343
344
|
- app/services/layered/assistant/chunk_service.rb
|
|
344
345
|
- app/services/layered/assistant/client_service.rb
|
|
345
346
|
- app/services/layered/assistant/clients/anthropic.rb
|
|
@@ -347,6 +348,7 @@ files:
|
|
|
347
348
|
- app/services/layered/assistant/clients/openai.rb
|
|
348
349
|
- app/services/layered/assistant/messages_service.rb
|
|
349
350
|
- app/services/layered/assistant/models/create_service.rb
|
|
351
|
+
- app/services/layered/assistant/response_timer.rb
|
|
350
352
|
- app/services/layered/assistant/token_estimator.rb
|
|
351
353
|
- app/views/layered/assistant/assistants/_form.html.erb
|
|
352
354
|
- app/views/layered/assistant/assistants/edit.html.erb
|
|
@@ -393,10 +395,13 @@ files:
|
|
|
393
395
|
- app/views/layouts/layered/assistant/_host_navigation.html.erb
|
|
394
396
|
- app/views/layouts/layered/assistant/application.html.erb
|
|
395
397
|
- config/importmap.rb
|
|
398
|
+
- config/locales/en.yml
|
|
396
399
|
- config/routes.rb
|
|
397
400
|
- data/models.json
|
|
398
401
|
- db/migrate/20260312000000_create_layered_assistant_tables.rb
|
|
399
402
|
- db/migrate/20260315000000_add_stopped_to_layered_assistant_messages.rb
|
|
403
|
+
- db/migrate/20260315100000_add_response_timing_to_layered_assistant_messages.rb
|
|
404
|
+
- db/migrate/20260317000000_normalise_provider_protocol_values.rb
|
|
400
405
|
- lib/generators/layered/assistant/install_generator.rb
|
|
401
406
|
- lib/generators/layered/assistant/migrations_generator.rb
|
|
402
407
|
- lib/generators/layered/assistant/templates/initializer.rb
|