layered-assistant-rails 0.1.1 → 0.1.3
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/assistants_controller.rb +3 -0
- data/app/controllers/layered/assistant/public/conversations_controller.rb +8 -0
- 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/conversation_select_controller.js +8 -0
- data/app/javascript/layered_assistant/index.js +2 -0
- 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/public/conversations/show.html.erb +11 -3
- data/app/views/layered/assistant/setup/_setup.html.erb +89 -0
- data/app/views/layouts/layered/assistant/application.html.erb +24 -14
- data/config/importmap.rb +1 -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 +10 -5
- data/app/views/layered/assistant/public/assistants/show.html.erb +0 -23
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0c6156a26192a7edd3fcb67ac5a170aa88ed8b52d2e673787d5595f05c420fbd
|
|
4
|
+
data.tar.gz: 79ec02dbdf134098f5ddb28d6dc480d626a27d35abb1151e1b6712e5496f09c9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3e09ac245b3bbbbb14c7e73540c6bd91d215323de65957b4452d797a6d5e23ddeb03b8fef7da870b781a75b1e18ebd29592563c06eacd3ea1038e7e6aa415447
|
|
7
|
+
data.tar.gz: a7ce50cbdba541810656dfd56fd660a435bb4d6b87b3f77fa50818c65374db059f7a5ba4d1aa5e2c1e2011efc8e4db706e1579ff9e9ea12c11bc68b2a438d3b3
|
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
|
|
@@ -20,6 +20,14 @@ module Layered
|
|
|
20
20
|
|
|
21
21
|
def show
|
|
22
22
|
@messages = @conversation.messages.includes(:model).by_created_at
|
|
23
|
+
@conversations = if session_conversation_uids.any?
|
|
24
|
+
Conversation.joins(:assistant).merge(Assistant.publicly_available)
|
|
25
|
+
.where(uid: session_conversation_uids, assistant: @conversation.assistant)
|
|
26
|
+
.by_created_at
|
|
27
|
+
.limit(20)
|
|
28
|
+
else
|
|
29
|
+
Conversation.none
|
|
30
|
+
end
|
|
23
31
|
end
|
|
24
32
|
|
|
25
33
|
private
|
|
@@ -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
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { application } from "controllers/application"
|
|
2
2
|
import ComposerController from "layered_assistant/composer_controller"
|
|
3
|
+
import ConversationSelectController from "layered_assistant/conversation_select_controller"
|
|
3
4
|
import MessagesController from "layered_assistant/messages_controller"
|
|
4
5
|
import PanelController from "layered_assistant/panel_controller"
|
|
5
6
|
import PanelNavController from "layered_assistant/panel_nav_controller"
|
|
6
7
|
import ProviderTemplateController from "layered_assistant/provider_template_controller"
|
|
7
8
|
|
|
8
9
|
application.register("composer", ComposerController)
|
|
10
|
+
application.register("conversation-select", ConversationSelectController)
|
|
9
11
|
application.register("messages", MessagesController)
|
|
10
12
|
application.register("panel", PanelController)
|
|
11
13
|
application.register("panel-nav", PanelNavController)
|
|
@@ -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
|
|