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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +86 -9
  3. data/app/controllers/concerns/layered/assistant/message_creation.rb +17 -1
  4. data/app/controllers/layered/assistant/conversations_controller.rb +34 -54
  5. data/app/controllers/layered/assistant/messages_controller.rb +3 -2
  6. data/app/controllers/layered/assistant/panel/messages_controller.rb +3 -2
  7. data/app/controllers/layered/assistant/public/messages_controller.rb +3 -3
  8. data/app/controllers/layered/assistant/public/panel/messages_controller.rb +3 -3
  9. data/app/controllers/layered/assistant/tool_calls_controller.rb +34 -0
  10. data/app/javascript/layered_assistant/composer_controller.js +19 -4
  11. data/app/javascript/layered_assistant/message_streaming.js +9 -0
  12. data/app/jobs/layered/assistant/messages/tool_call_job.rb +22 -0
  13. data/app/layered_resources/layered/assistant/conversation_resource.rb +87 -0
  14. data/app/models/layered/assistant/conversation.rb +63 -0
  15. data/app/models/layered/assistant/message.rb +68 -1
  16. data/app/models/layered/assistant/provider.rb +2 -1
  17. data/app/services/layered/assistant/tool_runner_service.rb +135 -6
  18. data/app/tools/layered/assistant/tool.rb +88 -1
  19. data/app/views/layered/assistant/messages/_composer.html.erb +1 -1
  20. data/app/views/layered/assistant/messages/_tool_message.html.erb +39 -4
  21. data/app/views/layered/assistant/messages/create.turbo_stream.erb +1 -1
  22. data/app/views/layered/assistant/panel/conversations/index.html.erb +11 -42
  23. data/app/views/layered/assistant/panel/messages/_composer.html.erb +1 -1
  24. data/app/views/layered/assistant/panel/messages/create.turbo_stream.erb +1 -1
  25. data/app/views/layered/assistant/providers/new.html.erb +2 -1
  26. data/app/views/layered/assistant/public/messages/_composer.html.erb +1 -1
  27. data/app/views/layered/assistant/public/messages/create.turbo_stream.erb +1 -1
  28. data/app/views/layered/assistant/public/panel/conversations/index.html.erb +8 -28
  29. data/app/views/layered/assistant/public/panel/messages/_composer.html.erb +1 -1
  30. data/app/views/layered/assistant/public/panel/messages/create.turbo_stream.erb +1 -1
  31. data/config/routes.rb +13 -3
  32. data/data/models.json +34 -23
  33. data/db/migrate/20260919000000_add_tool_status_to_layered_assistant_messages.rb +8 -0
  34. data/lib/generators/layered/assistant/templates/initializer.rb +28 -0
  35. data/lib/layered/assistant/version.rb +1 -1
  36. data/lib/layered/assistant.rb +12 -0
  37. metadata +10 -10
  38. data/app/views/layered/assistant/conversations/_form.html.erb +0 -29
  39. data/app/views/layered/assistant/conversations/edit.html.erb +0 -6
  40. data/app/views/layered/assistant/conversations/index.html.erb +0 -62
  41. 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: '0849058555cc1dd6e7a2dffb73b3919086413374156a1c3300999eaa543c8a4b'
4
- data.tar.gz: fa46c8ac4e95587216f467be30eb25d8c01c427d642b589b63a0ab849984e4fb
3
+ metadata.gz: 68ea0fb66720732e6e05a139e873ec6eb90912967e1a6e9c956461e76d7e347c
4
+ data.tar.gz: f4e561987280115a3e160a42a747ee036aab7373c8f65fcc33d36eac01dafec7
5
5
  SHA512:
6
- metadata.gz: 6b4ae1f8d54d5a753b9560fd532cb04e80cbe1959a728deaa9dfaaa82f83d5a7a65edef6a341a61879f73d1c22d76785692ef430242c7e8f995abc19754bac83
7
- data.tar.gz: d43a15e7ed0b30c8a85dc578a4887b46b537d37e2d881f83fc2135539e60a8b4751dc3bc06fba37ed75e3ee0281b2fa52e089594abf91d31017a94156b065c0a
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 and arguments, adds any
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). Set the required environment variables and deploy from `test/dummy`:
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
- cd test/dummy
365
- export KAMAL_DEPLOY_IP=<server-ip>
366
- export KAMAL_DEPLOY_DOMAIN=<domain>
367
- export KAMAL_SSH_KEY=<path-to-ssh-key>
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
- class ConversationsController < ApplicationController
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: [ :show, :edit, :update, :destroy, :stop ]
7
- before_action :set_assistants, only: [ :new, :create ]
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
- @page_title = @conversation.name
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
- def new
28
- @page_title = "New conversation"
29
- @conversation = Conversation.new(params.permit(conversation: [ :assistant_id ])[:conversation])
30
- end
21
+ private
31
22
 
32
- def create
33
- @conversation = Conversation.new(conversation_params)
34
- @conversation.owner = current_owner!
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
- def edit
46
- @page_title = "Edit conversation"
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
- def update
50
- if @conversation.update(conversation_params)
51
- redirect_to layered_assistant.conversations_path, notice: "Conversation was successfully updated."
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
- render :edit, status: :unprocessable_entity
40
+ @fields = @fields.reject { |field| field[:attribute] == :assistant_id }
54
41
  end
55
42
  end
56
43
 
57
- def destroy
58
- @conversation.destroy
59
- redirect_to layered_assistant.conversations_path, notice: "Conversation was successfully deleted."
60
- end
61
-
62
- private
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
- def conversation_params
73
- if action_name == "create"
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
- params.require(:conversation).permit(:name)
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 = () => this._resetRespondingTimeout()
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
- // 60-second safety timeout resets the composer in case the server
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