layered-assistant-rails 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +86 -9
- data/app/controllers/concerns/layered/assistant/message_creation.rb +17 -1
- data/app/controllers/layered/assistant/conversations_controller.rb +34 -54
- data/app/controllers/layered/assistant/messages_controller.rb +3 -2
- data/app/controllers/layered/assistant/panel/messages_controller.rb +3 -2
- data/app/controllers/layered/assistant/public/messages_controller.rb +3 -3
- data/app/controllers/layered/assistant/public/panel/messages_controller.rb +3 -3
- data/app/controllers/layered/assistant/tool_calls_controller.rb +34 -0
- data/app/javascript/layered_assistant/composer_controller.js +19 -4
- data/app/javascript/layered_assistant/message_streaming.js +9 -0
- data/app/jobs/layered/assistant/messages/tool_call_job.rb +22 -0
- data/app/layered_resources/layered/assistant/conversation_resource.rb +87 -0
- data/app/models/layered/assistant/conversation.rb +63 -0
- data/app/models/layered/assistant/message.rb +68 -1
- data/app/models/layered/assistant/provider.rb +2 -1
- data/app/services/layered/assistant/tool_runner_service.rb +135 -6
- data/app/tools/layered/assistant/tool.rb +88 -1
- data/app/views/layered/assistant/messages/_composer.html.erb +1 -1
- data/app/views/layered/assistant/messages/_tool_message.html.erb +39 -4
- data/app/views/layered/assistant/messages/create.turbo_stream.erb +1 -1
- data/app/views/layered/assistant/panel/conversations/index.html.erb +11 -42
- data/app/views/layered/assistant/panel/messages/_composer.html.erb +1 -1
- data/app/views/layered/assistant/panel/messages/create.turbo_stream.erb +1 -1
- data/app/views/layered/assistant/providers/new.html.erb +2 -1
- data/app/views/layered/assistant/public/messages/_composer.html.erb +1 -1
- data/app/views/layered/assistant/public/messages/create.turbo_stream.erb +1 -1
- data/app/views/layered/assistant/public/panel/conversations/index.html.erb +8 -28
- data/app/views/layered/assistant/public/panel/messages/_composer.html.erb +1 -1
- data/app/views/layered/assistant/public/panel/messages/create.turbo_stream.erb +1 -1
- data/config/routes.rb +13 -3
- data/data/models.json +34 -23
- data/db/migrate/20260919000000_add_tool_status_to_layered_assistant_messages.rb +8 -0
- data/lib/generators/layered/assistant/templates/initializer.rb +28 -0
- data/lib/layered/assistant/version.rb +1 -1
- data/lib/layered/assistant.rb +12 -0
- metadata +10 -10
- data/app/views/layered/assistant/conversations/_form.html.erb +0 -29
- data/app/views/layered/assistant/conversations/edit.html.erb +0 -6
- data/app/views/layered/assistant/conversations/index.html.erb +0 -62
- data/app/views/layered/assistant/conversations/new.html.erb +0 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 68ea0fb66720732e6e05a139e873ec6eb90912967e1a6e9c956461e76d7e347c
|
|
4
|
+
data.tar.gz: f4e561987280115a3e160a42a747ee036aab7373c8f65fcc33d36eac01dafec7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 56758769136c2e033813dcf466444c778643483739bcafa5048962bca0786e167a04a6a9de9085eed038a8d492cd08c2328363cc1a98f2ea58024bd6096efd99
|
|
7
|
+
data.tar.gz: 054c1b233c696bbec3d6c7e4e643c28fc69ffc378ce5ffa11b3110de59ed9cf8bbb87c9ed582b442f437a724a7d502609171c3022b67eb43f8b2a759a8190059
|
data/README.md
CHANGED
|
@@ -251,13 +251,15 @@ assistant.update!(tool_names: [ "weather", "order-lookup" ])
|
|
|
251
251
|
| `argument` | An argument the model may supply: `argument :name, :type, required:, description:, enum:, items:` |
|
|
252
252
|
| `tool_name` | The name the model calls the tool by. Defaults to the class name without its `Tool` suffix, namespaces hyphenated: `Weather::ForecastTool` becomes `weather-forecast` |
|
|
253
253
|
| `self.public =` | Whether the tool may be offered to a public assistant. `false` by default - see below |
|
|
254
|
+
| `permit` | A block deciding which conversations may call the tool - see [Who may call a tool](#who-may-call-a-tool) |
|
|
255
|
+
| `consent` | `:always` to put each call to the person talking before it runs. `:never` by default - see [Asking before a tool runs](#asking-before-a-tool-runs) |
|
|
254
256
|
|
|
255
257
|
Argument types are `:string`, `:integer`, `:number`, `:boolean`, `:array` and
|
|
256
258
|
`:object`. An `:array` takes `items:` to name its element type.
|
|
257
259
|
|
|
258
260
|
Subclass a tool to share logic and the declarations come with it: the child
|
|
259
|
-
inherits its parent's `description`, `public` flag
|
|
260
|
-
arguments of its own, and may redeclare one by name to narrow it. The name is
|
|
261
|
+
inherits its parent's `description`, `public` flag, `permit` block, `consent`
|
|
262
|
+
setting and arguments, adds any arguments of its own, and may redeclare one by name to narrow it. The name is
|
|
261
263
|
the exception - the child derives its own from its class name, since two tools
|
|
262
264
|
answering to one name would collide in the registry.
|
|
263
265
|
|
|
@@ -285,6 +287,79 @@ def call(reference:)
|
|
|
285
287
|
end
|
|
286
288
|
```
|
|
287
289
|
|
|
290
|
+
### Who may call a tool
|
|
291
|
+
|
|
292
|
+
Which assistants have a tool is a configuration decision; who may call it is a
|
|
293
|
+
runtime one. A `permit` block narrows a tool to the conversations that satisfy
|
|
294
|
+
it:
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
class RefundTool < Layered::Assistant::Tool
|
|
298
|
+
description "Refund an order."
|
|
299
|
+
|
|
300
|
+
permit { |conversation| conversation.user&.admin? }
|
|
301
|
+
end
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
An unpermitted tool is left out of the definitions sent to the provider, so
|
|
305
|
+
the model is never told it exists, and is refused if it asks for it anyway
|
|
306
|
+
from an earlier turn's history. The block is inherited like the other
|
|
307
|
+
declarations, so a tool subclassed to share logic keeps its parent's policy
|
|
308
|
+
unless it declares its own.
|
|
309
|
+
|
|
310
|
+
To apply one rule across every tool, configure an `authorize_tool` block in
|
|
311
|
+
your initialiser. It is given the tool class and the conversation, and
|
|
312
|
+
returning falsey withholds the tool:
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
Layered::Assistant.authorize_tool do |tool, conversation|
|
|
316
|
+
conversation.user&.permitted_tools&.include?(tool.tool_name)
|
|
317
|
+
end
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Unlike `authorize`, which guards the engine's routes, this is left open when
|
|
321
|
+
unconfigured: tool calls already sit behind that block and behind the set of
|
|
322
|
+
tools each assistant has been given. A block that raises denies the tool and
|
|
323
|
+
logs, rather than handing it over or failing the response.
|
|
324
|
+
|
|
325
|
+
### Asking before a tool runs
|
|
326
|
+
|
|
327
|
+
Reads are usually fine unattended. Anything that writes, spends or sends is
|
|
328
|
+
worth putting to the person talking first:
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
class RefundTool < Layered::Assistant::Tool
|
|
332
|
+
description "Refund an order."
|
|
333
|
+
consent :always
|
|
334
|
+
|
|
335
|
+
argument :reference, :string, required: true
|
|
336
|
+
|
|
337
|
+
def call(reference:)
|
|
338
|
+
owner.orders.find_by!(reference: reference).refund!
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
The call is recorded in the conversation with its arguments shown and nothing
|
|
344
|
+
in it, and the response stops there - the composer stays disabled, and the
|
|
345
|
+
call waits as long as it needs to, surviving a reload. Approving runs the tool
|
|
346
|
+
and the response picks up where it left off. Declining reports the refusal to
|
|
347
|
+
the model as the tool's result, so it can say something useful about being
|
|
348
|
+
turned down rather than the conversation dead-ending. Stopping the response
|
|
349
|
+
declines whatever is outstanding, including a call that has been approved but
|
|
350
|
+
has yet to run - so a tool that writes, spends or sends does not slip through
|
|
351
|
+
after the Stop. A call whose tool is already running is left to finish, its
|
|
352
|
+
result recorded, and the response is still not picked back up.
|
|
353
|
+
|
|
354
|
+
Calls in the same batch that need no consent still run while one waits: each
|
|
355
|
+
call stands on its own.
|
|
356
|
+
|
|
357
|
+
A tool that asks for consent needs both a user and an owner, so it is never
|
|
358
|
+
offered to a public assistant - even one an anonymous visitor happens to be
|
|
359
|
+
signed in for. There is nobody to ask an anonymous visitor, and a decision is
|
|
360
|
+
recorded through the owner-scoped route a conversation with no owner cannot
|
|
361
|
+
reach. The tool is withheld rather than offered and then stuck waiting.
|
|
362
|
+
|
|
288
363
|
### Tools and public assistants
|
|
289
364
|
|
|
290
365
|
A conversation with a public assistant has no owner - it belongs to an
|
|
@@ -319,7 +394,8 @@ composer stays disabled until a response completes without asking for
|
|
|
319
394
|
anything, and `max_tool_cycles` (default 10) bounds the loop.
|
|
320
395
|
|
|
321
396
|
Results are shown in the conversation as a collapsible panel naming the tool,
|
|
322
|
-
with its input and output.
|
|
397
|
+
with its input and output. A call waiting to be approved holds the loop where
|
|
398
|
+
it is until it has been answered.
|
|
323
399
|
|
|
324
400
|
## Configuration
|
|
325
401
|
|
|
@@ -358,16 +434,17 @@ Then visit `http://localhost:3000`.
|
|
|
358
434
|
|
|
359
435
|
### Deploying the dummy app
|
|
360
436
|
|
|
361
|
-
The dummy app can be deployed with [Kamal](https://kamal-deploy.org).
|
|
437
|
+
The dummy app can be deployed with [Kamal](https://kamal-deploy.org). Copy `.env.deploy.example` to `.env.deploy` and fill in the target, then deploy with `bin/deploy`:
|
|
362
438
|
|
|
363
439
|
```bash
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
kamal deploy
|
|
440
|
+
cp .env.deploy.example .env.deploy
|
|
441
|
+
bin/deploy # kamal deploy
|
|
442
|
+
bin/deploy setup # first run on a new server
|
|
443
|
+
bin/deploy logs # any other kamal subcommand passes straight through
|
|
369
444
|
```
|
|
370
445
|
|
|
446
|
+
`.env.deploy` is gitignored, so the server address stays out of the repo. The script reads it, checks the three `KAMAL_*` variables are set, and runs Kamal from `test/dummy`, where `config/deploy.yml` lives.
|
|
447
|
+
|
|
371
448
|
## Testing
|
|
372
449
|
|
|
373
450
|
Run the gem tests from the root directory:
|
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
module Layered
|
|
2
2
|
module Assistant
|
|
3
3
|
module MessageCreation
|
|
4
|
+
WAITING_ON_TOOL_CALL = "A tool call is waiting on you. Answer it before sending another message.".freeze
|
|
5
|
+
|
|
4
6
|
private
|
|
5
7
|
|
|
6
8
|
def create_messages_for(conversation:, content:, model_id:)
|
|
9
|
+
# A tool call that has yet to reach an answer holds the conversation.
|
|
10
|
+
# The composer is disabled while it waits, so anything arriving here is
|
|
11
|
+
# a stale tab - and answering it would send the model a call with no
|
|
12
|
+
# result yet, which the provider rejects. Said rather than dropped, or
|
|
13
|
+
# the message would vanish with nothing to explain it.
|
|
14
|
+
if conversation.unresolved_tool_call?
|
|
15
|
+
return {
|
|
16
|
+
message: conversation.messages.new(role: :user, content: content),
|
|
17
|
+
error: WAITING_ON_TOOL_CALL,
|
|
18
|
+
responding: true
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
7
22
|
message = conversation.messages.create(
|
|
8
23
|
role: :user,
|
|
9
24
|
content: content,
|
|
@@ -37,7 +52,8 @@ module Layered
|
|
|
37
52
|
{
|
|
38
53
|
message: message,
|
|
39
54
|
assistant_message: assistant_message,
|
|
40
|
-
error: error
|
|
55
|
+
error: error,
|
|
56
|
+
responding: error.nil?
|
|
41
57
|
}
|
|
42
58
|
end
|
|
43
59
|
end
|
|
@@ -1,80 +1,60 @@
|
|
|
1
1
|
module Layered
|
|
2
2
|
module Assistant
|
|
3
|
-
|
|
3
|
+
# Conversations are a layered resource for listing, starting, renaming
|
|
4
|
+
# and deleting them; only `show` - the conversation itself - and `stop`
|
|
5
|
+
# are this engine's own.
|
|
6
|
+
class ConversationsController < ResourcesController
|
|
4
7
|
include StoppableResponse
|
|
5
8
|
|
|
6
|
-
before_action :set_conversation, only: [ :
|
|
7
|
-
before_action :
|
|
8
|
-
|
|
9
|
-
def index
|
|
10
|
-
if params[:assistant_id]
|
|
11
|
-
@assistant = scoped(Assistant).find(params[:assistant_id])
|
|
12
|
-
@page_title = "Conversations - #{@assistant.name}"
|
|
13
|
-
@pagy, @conversations = pagy(@assistant.conversations.merge(scoped(Conversation)).includes(:owner, :user).by_created_at)
|
|
14
|
-
else
|
|
15
|
-
@page_title = "Conversations"
|
|
16
|
-
@pagy, @conversations = pagy(scoped(Conversation).includes(:assistant, :owner, :user).by_created_at)
|
|
17
|
-
end
|
|
18
|
-
end
|
|
9
|
+
before_action :set_conversation, only: [ :stop ]
|
|
10
|
+
before_action :drop_assistant_column, only: [ :index ], if: -> { params[:assistant_id] }
|
|
11
|
+
before_action :scope_choice_fields, only: [ :new, :create, :edit, :update ]
|
|
19
12
|
|
|
20
13
|
def show
|
|
21
|
-
|
|
14
|
+
super
|
|
15
|
+
@conversation = @record
|
|
22
16
|
@messages = @conversation.messages.includes(:model).by_created_at
|
|
23
17
|
@models = scoped_models
|
|
24
18
|
@selected_model_id = @messages.last&.model_id || @conversation.assistant.default_model_id || @models.first&.id
|
|
25
19
|
end
|
|
26
20
|
|
|
27
|
-
|
|
28
|
-
@page_title = "New conversation"
|
|
29
|
-
@conversation = Conversation.new(params.permit(conversation: [ :assistant_id ])[:conversation])
|
|
30
|
-
end
|
|
21
|
+
private
|
|
31
22
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
@conversation
|
|
35
|
-
@conversation.user = current_conversation_user
|
|
36
|
-
@conversation.assistant = scoped(Assistant).find(conversation_params[:assistant_id]) if conversation_params[:assistant_id].present?
|
|
37
|
-
@conversation.name = Conversation.default_name if @conversation.name.blank?
|
|
38
|
-
if @conversation.save
|
|
39
|
-
redirect_to layered_assistant.conversation_path(@conversation)
|
|
40
|
-
else
|
|
41
|
-
render :new, status: :unprocessable_entity
|
|
42
|
-
end
|
|
23
|
+
# `stop` is a custom member action, so the gem has loaded @record.
|
|
24
|
+
def set_conversation
|
|
25
|
+
@conversation = @record
|
|
43
26
|
end
|
|
44
27
|
|
|
45
|
-
|
|
46
|
-
|
|
28
|
+
# Listed under an assistant, every row would name the same one.
|
|
29
|
+
def drop_assistant_column
|
|
30
|
+
@columns = @columns.reject { |column| column[:attribute] == :assistant_id }
|
|
47
31
|
end
|
|
48
32
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
33
|
+
# The assistant is chosen once, when the conversation is started, and
|
|
34
|
+
# only from the owner's own.
|
|
35
|
+
def scope_choice_fields
|
|
36
|
+
if action_name.in?(%w[new create])
|
|
37
|
+
assistants = scoped(Assistant).by_name.map { |assistant| [ assistant.name, assistant.id ] }
|
|
38
|
+
@fields = @fields.map { |field| field[:attribute] == :assistant_id ? field.merge(collection: assistants) : field }
|
|
52
39
|
else
|
|
53
|
-
|
|
40
|
+
@fields = @fields.reject { |field| field[:attribute] == :assistant_id }
|
|
54
41
|
end
|
|
55
42
|
end
|
|
56
43
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def set_conversation
|
|
65
|
-
@conversation = scoped(Conversation).find_by!(uid: params[:id])
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def set_assistants
|
|
69
|
-
@assistants = scoped(Assistant).by_name
|
|
70
|
-
end
|
|
44
|
+
# An out-of-scope assistant 404s rather than being silently dropped,
|
|
45
|
+
# matching how a record itself is looked up. A blank one leaves any
|
|
46
|
+
# assistant the route already chose in place.
|
|
47
|
+
def layered_resource_params
|
|
48
|
+
attributes = super
|
|
49
|
+
return attributes.except(:assistant_id) unless action_name == "create"
|
|
71
50
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
params.require(:conversation).permit(:name, :assistant_id)
|
|
51
|
+
if attributes[:assistant_id].present?
|
|
52
|
+
attributes[:assistant_id] = scoped(Assistant).find(attributes[:assistant_id]).id
|
|
75
53
|
else
|
|
76
|
-
|
|
54
|
+
attributes.delete(:assistant_id)
|
|
77
55
|
end
|
|
56
|
+
attributes[:name] = Conversation.default_name if attributes[:name].blank?
|
|
57
|
+
attributes
|
|
78
58
|
end
|
|
79
59
|
end
|
|
80
60
|
end
|
|
@@ -20,15 +20,16 @@ module Layered
|
|
|
20
20
|
model_id: model_id
|
|
21
21
|
)
|
|
22
22
|
@message = result[:message]
|
|
23
|
+
@error = result[:error]
|
|
24
|
+
@responding = result[:responding]
|
|
23
25
|
|
|
24
|
-
unless @message.persisted?
|
|
26
|
+
unless @message.persisted? || @error
|
|
25
27
|
return head :unprocessable_entity
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
@assistant_message = result[:assistant_message]
|
|
29
31
|
@models = scoped_models
|
|
30
32
|
@selected_model_id = model_id
|
|
31
|
-
@error = result[:error]
|
|
32
33
|
|
|
33
34
|
respond_to do |format|
|
|
34
35
|
format.turbo_stream
|
|
@@ -16,15 +16,16 @@ module Layered
|
|
|
16
16
|
model_id: model_id
|
|
17
17
|
)
|
|
18
18
|
@message = result[:message]
|
|
19
|
+
@error = result[:error]
|
|
20
|
+
@responding = result[:responding]
|
|
19
21
|
|
|
20
|
-
unless @message.persisted?
|
|
22
|
+
unless @message.persisted? || @error
|
|
21
23
|
return head :unprocessable_entity
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
@assistant_message = result[:assistant_message]
|
|
25
27
|
@models = scoped_models
|
|
26
28
|
@selected_model_id = model_id
|
|
27
|
-
@error = result[:error]
|
|
28
29
|
|
|
29
30
|
respond_to do |format|
|
|
30
31
|
format.turbo_stream
|
|
@@ -13,13 +13,13 @@ module Layered
|
|
|
13
13
|
model_id: @conversation.assistant.default_model_id
|
|
14
14
|
)
|
|
15
15
|
@message = result[:message]
|
|
16
|
+
@error = result[:error]
|
|
17
|
+
@responding = result[:responding]
|
|
16
18
|
|
|
17
|
-
unless @message.persisted?
|
|
19
|
+
unless @message.persisted? || @error
|
|
18
20
|
return head :unprocessable_entity
|
|
19
21
|
end
|
|
20
22
|
|
|
21
|
-
@error = result[:error]
|
|
22
|
-
|
|
23
23
|
respond_to do |format|
|
|
24
24
|
format.turbo_stream
|
|
25
25
|
end
|
|
@@ -15,13 +15,13 @@ module Layered
|
|
|
15
15
|
model_id: @conversation.assistant.default_model_id
|
|
16
16
|
)
|
|
17
17
|
@message = result[:message]
|
|
18
|
+
@error = result[:error]
|
|
19
|
+
@responding = result[:responding]
|
|
18
20
|
|
|
19
|
-
unless @message.persisted?
|
|
21
|
+
unless @message.persisted? || @error
|
|
20
22
|
return head :unprocessable_entity
|
|
21
23
|
end
|
|
22
24
|
|
|
23
|
-
@error = result[:error]
|
|
24
|
-
|
|
25
25
|
respond_to do |format|
|
|
26
26
|
format.turbo_stream
|
|
27
27
|
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Layered
|
|
2
|
+
module Assistant
|
|
3
|
+
# Approving or declining a tool call that asked for consent before it ran.
|
|
4
|
+
class ToolCallsController < ApplicationController
|
|
5
|
+
DECISIONS = { "approve" => "approved", "decline" => "declined" }.freeze
|
|
6
|
+
|
|
7
|
+
before_action :set_conversation
|
|
8
|
+
|
|
9
|
+
def update
|
|
10
|
+
status = DECISIONS[params[:decision]]
|
|
11
|
+
return head :unprocessable_entity unless status
|
|
12
|
+
|
|
13
|
+
message = @conversation.messages.find(params[:id])
|
|
14
|
+
|
|
15
|
+
# Conditional so that two decisions arriving at once cannot both win,
|
|
16
|
+
# and so a second click or a stale tab changes nothing.
|
|
17
|
+
decided = Message.where(id: message.id, tool_status: "pending")
|
|
18
|
+
.update_all(tool_status: status, updated_at: Time.current)
|
|
19
|
+
return head :no_content if decided.zero?
|
|
20
|
+
|
|
21
|
+
message.reload.broadcast_updated
|
|
22
|
+
Messages::ToolCallJob.perform_later(message.id)
|
|
23
|
+
|
|
24
|
+
head :no_content
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def set_conversation
|
|
30
|
+
@conversation = scoped(Conversation).find_by!(uid: params[:conversation_id])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -7,11 +7,15 @@ export default class extends Controller {
|
|
|
7
7
|
static targets = ["form", "input", "sendButton", "stopButton"]
|
|
8
8
|
static values = {
|
|
9
9
|
responding: { type: Boolean, default: false },
|
|
10
|
+
waiting: { type: Boolean, default: false },
|
|
10
11
|
stopUrl: { type: String, default: "" }
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
connect() {
|
|
14
|
-
this._onChunkReceived = () =>
|
|
15
|
+
this._onChunkReceived = () => {
|
|
16
|
+
this.waitingValue = false
|
|
17
|
+
this._resetRespondingTimeout()
|
|
18
|
+
}
|
|
15
19
|
document.addEventListener("assistant:chunk-received", this._onChunkReceived)
|
|
16
20
|
this._applyRespondingState()
|
|
17
21
|
this.updateButtonDisabled()
|
|
@@ -26,6 +30,16 @@ export default class extends Controller {
|
|
|
26
30
|
this._applyRespondingState()
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
// A tool call waiting to be approved sends nothing until it is answered,
|
|
34
|
+
// so the safety timeout is stood down rather than giving up on it.
|
|
35
|
+
waitingValueChanged() {
|
|
36
|
+
if (this.waitingValue) {
|
|
37
|
+
clearTimeout(this._respondingTimeout)
|
|
38
|
+
} else {
|
|
39
|
+
this._resetRespondingTimeout()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
29
43
|
// Enter submits, Shift+Enter is a no-op (default newline),
|
|
30
44
|
// Alt+Enter inserts a newline without submitting.
|
|
31
45
|
submitOnEnter(event) {
|
|
@@ -81,9 +95,10 @@ export default class extends Controller {
|
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
// Toggle visibility of the Send and Stop buttons. While responding a
|
|
84
|
-
//
|
|
98
|
+
// 30-second safety timeout resets the composer in case the server
|
|
85
99
|
// never signals completion. The timeout is reset each time a chunk
|
|
86
|
-
// is received so long-running responses are not interrupted
|
|
100
|
+
// is received so long-running responses are not interrupted, and is
|
|
101
|
+
// not armed at all while a tool call waits to be approved.
|
|
87
102
|
_applyRespondingState() {
|
|
88
103
|
clearTimeout(this._respondingTimeout)
|
|
89
104
|
|
|
@@ -104,7 +119,7 @@ export default class extends Controller {
|
|
|
104
119
|
}
|
|
105
120
|
|
|
106
121
|
_resetRespondingTimeout() {
|
|
107
|
-
if (!this.respondingValue) return
|
|
122
|
+
if (!this.respondingValue || this.waitingValue) return
|
|
108
123
|
clearTimeout(this._respondingTimeout)
|
|
109
124
|
this._respondingTimeout = setTimeout(() => {
|
|
110
125
|
this.respondingValue = false
|
|
@@ -18,10 +18,19 @@ const pendingRender = new WeakMap()
|
|
|
18
18
|
|
|
19
19
|
Turbo.StreamActions.enable_composer = function () {
|
|
20
20
|
this.targetElements.forEach((form) => {
|
|
21
|
+
form.setAttribute("data-composer-waiting-value", "false")
|
|
21
22
|
form.setAttribute("data-composer-responding-value", "false")
|
|
22
23
|
})
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
// A tool call is waiting to be approved: the composer stays disabled, and
|
|
27
|
+
// holds there for as long as it takes rather than timing out.
|
|
28
|
+
Turbo.StreamActions.wait_composer = function () {
|
|
29
|
+
this.targetElements.forEach((form) => {
|
|
30
|
+
form.setAttribute("data-composer-waiting-value", "true")
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
function syncStreamingContent(target, markdown) {
|
|
26
35
|
const tmp = document.createElement("div")
|
|
27
36
|
tmp.innerHTML = renderMarkdown(markdown)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Layered
|
|
2
|
+
module Assistant
|
|
3
|
+
module Messages
|
|
4
|
+
# Carries out a tool call once it has been approved, or writes down the
|
|
5
|
+
# refusal, then picks the response back up. Runs out of band because a
|
|
6
|
+
# tool can be slow and the answer streams back like any other.
|
|
7
|
+
class ToolCallJob < ApplicationJob
|
|
8
|
+
queue_as :default
|
|
9
|
+
|
|
10
|
+
def perform(message_id)
|
|
11
|
+
message = Message.find(message_id)
|
|
12
|
+
# Nothing to do for a call still waiting on a decision. A call that
|
|
13
|
+
# is already answered is not run again, but is still passed on: a
|
|
14
|
+
# retry may be here because resuming the response is what failed.
|
|
15
|
+
return if message.consent_pending?
|
|
16
|
+
|
|
17
|
+
ToolRunnerService.new.resolve(message: message)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
module Layered
|
|
2
|
+
module Assistant
|
|
3
|
+
class ConversationResource < Layered::Resource::Base
|
|
4
|
+
model Conversation
|
|
5
|
+
|
|
6
|
+
# Conversations are addressed by uid, not id.
|
|
7
|
+
lookup_attribute :uid
|
|
8
|
+
|
|
9
|
+
columns [
|
|
10
|
+
# The name links to the conversation itself, not its edit form.
|
|
11
|
+
{ attribute: :name, primary: true, link: false,
|
|
12
|
+
render: ->(record, view) {
|
|
13
|
+
view.link_to(record.name, view.layered_assistant.conversation_path(record),
|
|
14
|
+
data: { turbo_frame: "_top" })
|
|
15
|
+
} },
|
|
16
|
+
{ attribute: :assistant_id, label: "Assistant", sortable: false,
|
|
17
|
+
render: ->(record, view) {
|
|
18
|
+
view.link_to(record.assistant.name,
|
|
19
|
+
view.layered_assistant.assistant_conversations_path(record.assistant),
|
|
20
|
+
data: { turbo_frame: "_top" })
|
|
21
|
+
} },
|
|
22
|
+
{ attribute: :messages_count, label: "Messages",
|
|
23
|
+
render: ->(record, view) {
|
|
24
|
+
view.link_to(record.messages_count.to_i,
|
|
25
|
+
view.layered_assistant.conversation_messages_path(record),
|
|
26
|
+
data: { turbo_frame: "_top" })
|
|
27
|
+
} },
|
|
28
|
+
{ attribute: :token_estimate, label: "Tokens",
|
|
29
|
+
render: ->(record, view) { view.number_with_delimiter(record.token_estimate.to_i) } },
|
|
30
|
+
# The person who did the talking, which is the owner itself until an
|
|
31
|
+
# owner block scopes ownership elsewhere. Neither is set for an
|
|
32
|
+
# anonymous visitor on a public assistant.
|
|
33
|
+
{ attribute: :user_id, label: "User", sortable: false,
|
|
34
|
+
render: ->(record) { record.user.try(:name) || record.owner.try(:name) || "Guest" } },
|
|
35
|
+
{ attribute: :created_at, label: "Created",
|
|
36
|
+
render: ->(record) { record.created_at.to_fs(:short) } }
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
search_fields [ :name ]
|
|
40
|
+
|
|
41
|
+
default_sort attribute: :created_at, direction: :desc
|
|
42
|
+
|
|
43
|
+
# `assistant_id` carries no collection here: the options have to be
|
|
44
|
+
# scoped to the owner, which only the controller can resolve.
|
|
45
|
+
# ConversationsController fills them in per request, and drops the
|
|
46
|
+
# field once the conversation exists - its assistant is fixed.
|
|
47
|
+
fields [
|
|
48
|
+
{ attribute: :assistant_id, label: "Assistant", as: :select, include_blank: "Select an assistant:", required: true },
|
|
49
|
+
# Left blank, the name is set from the first message, so the form
|
|
50
|
+
# must not insist on one even though the model does.
|
|
51
|
+
{ attribute: :name, required: false, hint: "Auto-set from first message if left blank" }
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
# Nested under an assistant, only that assistant's conversations are
|
|
55
|
+
# listed, and an out-of-scope assistant 404s before any are reached.
|
|
56
|
+
def self.scope(controller)
|
|
57
|
+
scope = controller.send(:scoped, Conversation)
|
|
58
|
+
if (assistant_id = controller.params[:assistant_id])
|
|
59
|
+
scope = scope.where(assistant: controller.send(:scoped, Assistant).find(assistant_id))
|
|
60
|
+
end
|
|
61
|
+
scope.includes(:assistant, :owner, :user)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Started from an assistant's conversations, the assistant is chosen
|
|
65
|
+
# already - resolved through `scoped` so an out-of-scope one 404s.
|
|
66
|
+
def self.build_record(controller)
|
|
67
|
+
assistant_id = controller.params[:assistant_id]
|
|
68
|
+
|
|
69
|
+
Conversation.new(
|
|
70
|
+
owner: controller.send(:current_owner!),
|
|
71
|
+
user: controller.send(:current_conversation_user),
|
|
72
|
+
assistant: (controller.send(:scoped, Assistant).find(assistant_id) if assistant_id)
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A new conversation opens straight into the chat; edits and deletes
|
|
77
|
+
# return to the list they were made from.
|
|
78
|
+
def self.after_save_path(controller, record)
|
|
79
|
+
if controller.action_name == "create"
|
|
80
|
+
controller.layered_assistant.conversation_path(record)
|
|
81
|
+
else
|
|
82
|
+
controller.layered_assistant.conversations_path
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|