rails_ai_admin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +458 -0
  3. data/LICENSE +21 -0
  4. data/README.md +292 -0
  5. data/SECURITY.md +14 -0
  6. data/THIRD_PARTY_NOTICES.md +19 -0
  7. data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
  8. data/app/assets/builds/rails_ai_admin.css +345 -0
  9. data/app/assets/builds/rails_ai_admin.js +33 -0
  10. data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
  11. data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
  12. data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
  13. data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
  14. data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
  15. data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
  16. data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
  17. data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
  18. data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
  19. data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
  20. data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
  21. data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
  22. data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
  23. data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
  24. data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
  25. data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
  26. data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
  27. data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
  28. data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
  29. data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
  30. data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
  31. data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
  32. data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
  33. data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
  34. data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
  35. data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
  36. data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
  37. data/app/models/rails_ai_admin/application_record.rb +8 -0
  38. data/app/models/rails_ai_admin/audit_event.rb +12 -0
  39. data/app/models/rails_ai_admin/batch_item.rb +8 -0
  40. data/app/models/rails_ai_admin/operation_run.rb +20 -0
  41. data/app/models/rails_ai_admin/selection.rb +9 -0
  42. data/app/models/rails_ai_admin/selection_item.rb +9 -0
  43. data/app/models/rails_ai_admin/work_item.rb +18 -0
  44. data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
  45. data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
  46. data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
  47. data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
  48. data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
  49. data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
  50. data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
  51. data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
  52. data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
  53. data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
  54. data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
  55. data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
  56. data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
  57. data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
  58. data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
  59. data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
  60. data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
  61. data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
  62. data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
  63. data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
  64. data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
  65. data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
  66. data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
  67. data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
  68. data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
  69. data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
  70. data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
  71. data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
  72. data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
  73. data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
  74. data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
  75. data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
  76. data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
  77. data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
  78. data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
  79. data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
  80. data/app/views/rails_ai_admin/search/index.html.erb +20 -0
  81. data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
  82. data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
  83. data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
  84. data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
  85. data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
  86. data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
  87. data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
  88. data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
  89. data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
  90. data/config/locales/en.yml +549 -0
  91. data/config/routes.rb +64 -0
  92. data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
  93. data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
  94. data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
  95. data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
  96. data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
  97. data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
  98. data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
  99. data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
  100. data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
  101. data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
  102. data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
  103. data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
  104. data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
  105. data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
  106. data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
  107. data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
  108. data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
  109. data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
  110. data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
  111. data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
  112. data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
  113. data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
  114. data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
  115. data/lib/rails_ai_admin/assistant/records.rb +71 -0
  116. data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
  117. data/lib/rails_ai_admin/assistant/tools.rb +477 -0
  118. data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
  119. data/lib/rails_ai_admin/assistant.rb +50 -0
  120. data/lib/rails_ai_admin/authorization/access.rb +296 -0
  121. data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
  122. data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
  123. data/lib/rails_ai_admin/collections/claim.rb +91 -0
  124. data/lib/rails_ai_admin/collections/contract.rb +113 -0
  125. data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
  126. data/lib/rails_ai_admin/collections/queries.rb +79 -0
  127. data/lib/rails_ai_admin/collections/result.rb +15 -0
  128. data/lib/rails_ai_admin/collections/runner.rb +208 -0
  129. data/lib/rails_ai_admin/configuration.rb +178 -0
  130. data/lib/rails_ai_admin/context.rb +29 -0
  131. data/lib/rails_ai_admin/doctor.rb +301 -0
  132. data/lib/rails_ai_admin/engine.rb +24 -0
  133. data/lib/rails_ai_admin/error.rb +16 -0
  134. data/lib/rails_ai_admin/field.rb +625 -0
  135. data/lib/rails_ai_admin/fields/association.rb +188 -0
  136. data/lib/rails_ai_admin/fields/computation.rb +43 -0
  137. data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
  138. data/lib/rails_ai_admin/fields/file_value.rb +23 -0
  139. data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
  140. data/lib/rails_ai_admin/fields/projection.rb +78 -0
  141. data/lib/rails_ai_admin/fields/registry.rb +48 -0
  142. data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
  143. data/lib/rails_ai_admin/fields/type.rb +50 -0
  144. data/lib/rails_ai_admin/forms/plan.rb +90 -0
  145. data/lib/rails_ai_admin/input_schema.rb +76 -0
  146. data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
  147. data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
  148. data/lib/rails_ai_admin/mcp.rb +130 -0
  149. data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
  150. data/lib/rails_ai_admin/navigation/menu.rb +31 -0
  151. data/lib/rails_ai_admin/navigation/settings.rb +28 -0
  152. data/lib/rails_ai_admin/operation.rb +120 -0
  153. data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
  154. data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
  155. data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
  156. data/lib/rails_ai_admin/operations/registry.rb +80 -0
  157. data/lib/rails_ai_admin/operations/result.rb +21 -0
  158. data/lib/rails_ai_admin/operations/runner.rb +633 -0
  159. data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
  160. data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
  161. data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
  162. data/lib/rails_ai_admin/outbox/registry.rb +36 -0
  163. data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
  164. data/lib/rails_ai_admin/outbox/writer.rb +43 -0
  165. data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
  166. data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
  167. data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
  168. data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
  169. data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
  170. data/lib/rails_ai_admin/presentation/history.rb +46 -0
  171. data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
  172. data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
  173. data/lib/rails_ai_admin/presentation/status.rb +40 -0
  174. data/lib/rails_ai_admin/prune.rb +61 -0
  175. data/lib/rails_ai_admin/queries/list.rb +162 -0
  176. data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
  177. data/lib/rails_ai_admin/record_version.rb +40 -0
  178. data/lib/rails_ai_admin/registry.rb +45 -0
  179. data/lib/rails_ai_admin/request_log_filter.rb +24 -0
  180. data/lib/rails_ai_admin/resource.rb +249 -0
  181. data/lib/rails_ai_admin/resource_identity.rb +15 -0
  182. data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
  183. data/lib/rails_ai_admin/selections/builder.rb +162 -0
  184. data/lib/rails_ai_admin/templates/catalog.rb +84 -0
  185. data/lib/rails_ai_admin/templates/inspector.rb +94 -0
  186. data/lib/rails_ai_admin/templates/metadata.rb +48 -0
  187. data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
  188. data/lib/rails_ai_admin/test_kit.rb +103 -0
  189. data/lib/rails_ai_admin/version.rb +5 -0
  190. data/lib/rails_ai_admin/work.rb +48 -0
  191. data/lib/rails_ai_admin.rb +112 -0
  192. data/lib/tasks/rails_ai_admin.rake +58 -0
  193. metadata +328 -0
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Snapshot capture/view is deliberately separate from business confirmation.
5
+ class SelectionsController < ApplicationController
6
+ def create
7
+ @resource = RailsAiAdmin.registry.fetch(params[:resource])
8
+ resolve_list_navigation
9
+ # Nothing ticked is an ordinary mistake: return to the same list (RAA-013).
10
+ if Array(params[:target_ids]).compact_blank.empty?
11
+ Authorization::Access.new(@context, @resource).authorize_selection!
12
+ redirect_to list_return_path, status: :see_other, notice: t("rails_ai_admin.selections.empty")
13
+ return
14
+ end
15
+ selection = Selections::Builder.new.capture(context: @context, resource: @resource, target_ids: params[:target_ids])
16
+ redirect_to selection_path(selection_ref: selection.selection_ref, **navigation_params), status: :see_other
17
+ end
18
+
19
+ def show
20
+ records = []
21
+ @selection = Selections::Builder.new.revalidate(context: @context, selection_ref: params[:selection_ref]) { |record| records << record }
22
+ @resource = RailsAiAdmin.registry.fetch(@selection.resource_key)
23
+ resolve_list_navigation
24
+ access = Authorization::Access.new(@context, @resource)
25
+ # Offered when the actor may delete at least one selected record now.
26
+ @deletable = @resource.action_enabled?(:destroy) && records.any? do |record|
27
+ access.authorize!(:destroy, record)
28
+ rescue AccessDenied
29
+ false
30
+ end
31
+ @operations = RailsAiAdmin.operation_registry.custom(@resource).select do |definition|
32
+ next false unless definition.scope == :collection
33
+ access.authorize!(definition.key)
34
+ definition.fields(access, nil)
35
+ true
36
+ rescue AccessDenied
37
+ false
38
+ end
39
+ end
40
+
41
+ # Bulk delete reviews the fixed selection: records the actor may delete
42
+ # now, and how many will be skipped.
43
+ def confirm_destroy
44
+ load_deletable
45
+ raise AccessDenied if @deletable.empty?
46
+ end
47
+
48
+ # Each record is deleted through the native destroy operation, with its own
49
+ # policy check, captured version, transaction and audit event. One record
50
+ # that cannot be deleted does not stop the others.
51
+ def destroy
52
+ digest = params[:manifest_digest]
53
+ raise AccessDenied, "INVALID_QUERY" unless digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/)
54
+ load_deletable(manifest_digest: digest)
55
+ raise AccessDenied if @deletable.empty?
56
+ runner = Operations::Runner.new
57
+ @outcomes = @deletable.map do |target|
58
+ code = begin
59
+ intent = runner.prepare(context: @context, resource: @resource, operation: :destroy, target_id: target.id, expected_version: target.expected_version)
60
+ runner.execute(context: @context, intent_ref: intent.id, input: {}, human_confirmed: true, origin: :ui).code
61
+ rescue AccessDenied => error
62
+ error.code
63
+ end
64
+ [ target, code ]
65
+ end
66
+ @deleted = @outcomes.count { |_, code| code == "OK" }
67
+ failed = @outcomes.reject { |_, code| code == "OK" }
68
+ if failed.empty? && @skipped.zero?
69
+ redirect_to list_return_path, status: :see_other, notice: t("rails_ai_admin.selections.destroy.done", count: @deleted)
70
+ else
71
+ @failed = failed
72
+ render :destroyed
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def load_deletable(manifest_digest: nil)
79
+ records = {}
80
+ @selection = Selections::Builder.new.revalidate(context: @context, selection_ref: params[:selection_ref], manifest_digest: manifest_digest) do |record|
81
+ records[record.id.to_s] = record
82
+ end
83
+ @resource = RailsAiAdmin.registry.fetch(@selection.resource_key)
84
+ resolve_list_navigation
85
+ raise AccessDenied unless @resource.action_enabled?(:destroy)
86
+ access = Authorization::Access.new(@context, @resource)
87
+ @deletable = @selection.targets.select do |target|
88
+ access.authorize!(:destroy, records.fetch(target.id))
89
+ rescue AccessDenied
90
+ false
91
+ end
92
+ @skipped = @selection.targets.size - @deletable.size
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Admin-owned rails_tool_views endpoints (plan §6.3.1 shared mode). They use
5
+ # the public present/restore/dispatch/render API with an explicit admin Scope
6
+ # and never the host's global resolver, parent controller or agent adapter.
7
+ # rails_tool_views is referenced only inside actions, so conventional hosts
8
+ # can eager-load this file without loading the integration.
9
+ class SurfacesController < ApplicationController
10
+ ACT_FIELDS = %w[action_name base_revision idempotency_key source_component arguments].freeze
11
+ FORM_FURNITURE = %w[authenticity_token commit button].freeze
12
+
13
+ before_action :require_surfaces
14
+ around_action :bind_admin_context
15
+
16
+ # Native confirmation page -> persistent review Surface for the same intent.
17
+ def create
18
+ resource = RailsAiAdmin.registry.fetch(params[:resource])
19
+ definition = RailsAiAdmin.operation_registry.fetch(resource, params[:operation])
20
+ access = Authorization::Access.new(@context, resource)
21
+ record = access.find!(params[:id], action: definition.key)
22
+ surface = tool_views.present(context: @context, resource: resource, definition: definition, record: record,
23
+ intent_ref: params[:intent_ref], input: confirmed_input)
24
+ redirect_to admin_surface_path(surface_id: surface.id), status: :see_other
25
+ rescue AccessDenied => error
26
+ # An authorized actor with an expired, invalidated or input-mismatched
27
+ # intent gets the native conflict status, not anti-enumeration 404.
28
+ raise unless %w[APPROVAL_EXPIRED APPROVAL_INVALIDATED].include?(error.code)
29
+ admin_status(:conflict)
30
+ rescue ::RailsToolViews::Error => error
31
+ # The same intent was already presented with different input.
32
+ error.code == "IDEMPOTENCY_CONFLICT" ? admin_status(:conflict) : reject(error)
33
+ end
34
+
35
+ def show
36
+ @surface = ::RailsToolViews.restore(params[:surface_id], scope: admin_scope)
37
+ @surface_scope = admin_scope
38
+ rescue ::RailsToolViews::Error => error
39
+ reject(error)
40
+ end
41
+
42
+ def act
43
+ input = request.request_parameters.except(*FORM_FURNITURE)
44
+ raise AccessDenied, "INVALID_QUERY" unless (input.keys - ACT_FIELDS).empty?
45
+ revision = input["base_revision"]
46
+ raise AccessDenied, "INVALID_QUERY" unless revision.is_a?(String) && revision.match?(/\A[1-9][0-9]{0,9}\z/)
47
+ arguments = input.fetch("arguments", {})
48
+ raise AccessDenied, "INVALID_QUERY" unless arguments.is_a?(Hash)
49
+ result = ::RailsToolViews.dispatch(params[:surface_id], scope: admin_scope, action: input["action_name"],
50
+ arguments: arguments, base_revision: revision.to_i, idempotency_key: input["idempotency_key"],
51
+ source_component: input["source_component"])
52
+ respond_to do |format|
53
+ format.turbo_stream do
54
+ @surface = ::RailsToolViews.restore(result[:surface_id], scope: admin_scope)
55
+ @surface_scope = admin_scope
56
+ render turbo_stream: turbo_stream.replace(@surface.dom_id, helpers.admin_surface(@surface, scope: admin_scope))
57
+ end
58
+ format.html { redirect_to surface_return_path(result[:surface_id]), status: :see_other }
59
+ end
60
+ rescue ::RailsToolViews::Error => error
61
+ reject(error)
62
+ end
63
+
64
+ private
65
+
66
+ def tool_views
67
+ require "rails_ai_admin/integrations/tool_views"
68
+ Integrations::ToolViews
69
+ end
70
+
71
+ def require_surfaces
72
+ raise AccessDenied unless RailsAiAdmin.configuration.surfaces
73
+ tool_views.ensure_registered!
74
+ end
75
+
76
+ def bind_admin_context(&block)
77
+ tool_views.with_context(@context, &block)
78
+ end
79
+
80
+ def admin_scope = @admin_scope ||= tool_views.scope_for(@context)
81
+
82
+ # A review shown in a conversation returns there, so confirming or
83
+ # dismissing keeps the person in the chat (RAA-019).
84
+ def surface_return_path(surface_id)
85
+ reference = ::RailsToolViews::Surface.where(id: surface_id).pick(:context_ref)
86
+ chat_ref = reference.is_a?(String) && reference[/\Araa:chat:([0-9a-f-]{36})\z/, 1]
87
+ if chat_ref && RailsAiAdmin.configuration.assistant
88
+ require "rails_ai_admin/assistant"
89
+ chat = Assistant::Chat.owned(@context, chat_ref)
90
+ return assistant_conversation_path(chat_ref: chat.ref) if chat
91
+ end
92
+ admin_surface_path(surface_id: surface_id)
93
+ end
94
+
95
+ def confirmed_input
96
+ value = params[:confirmed_input]
97
+ raise AccessDenied, "INVALID_QUERY" unless value.is_a?(String) && value.bytesize <= Integrations::ToolViews::MAX_INPUT_BYTES
98
+ parsed = JSON.parse(value)
99
+ raise AccessDenied, "INVALID_QUERY" unless parsed.is_a?(Hash)
100
+ parsed
101
+ rescue JSON::ParserError
102
+ raise AccessDenied, "INVALID_QUERY"
103
+ end
104
+
105
+ # Unknown, foreign or unauthorized Surfaces all look missing. A stale
106
+ # revision or business-level rejection re-renders the saved Surface.
107
+ def reject(error)
108
+ case error.code
109
+ when "NOT_FOUND", "NOT_ALLOWED", "INVALID_SCOPE", "SURFACE_PURGED" then admin_status(:not_found)
110
+ when "CONFLICT", "IDEMPOTENCY_CONFLICT", "SURFACE_CLOSED", "OPERATION_FAILED", "RATE_LIMITED"
111
+ @surface = ::RailsToolViews.restore(params[:surface_id], scope: admin_scope)
112
+ @surface_scope = admin_scope
113
+ @surface_error = error
114
+ render :show, status: error.status
115
+ when "INCOMPATIBLE_COMPONENT" then admin_status(:unavailable)
116
+ else admin_status(:invalid_request)
117
+ end
118
+ rescue ::RailsToolViews::Error
119
+ admin_status(:not_found)
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Inline SVG bars: no script and no inline style, so the admin CSP holds.
5
+ # The chart is decorative for assistive technology; the caption and the data
6
+ # table beside it carry the numbers.
7
+ module DashboardHelper
8
+ CHART_WIDTH = 600
9
+ CHART_HEIGHT = 120
10
+
11
+ def admin_trend_chart(trend)
12
+ count = trend.days.size
13
+ gap = 2.0
14
+ bar = (CHART_WIDTH - gap * (count - 1)) / count
15
+ bars = trend.days.each_with_index.map do |(_day, value), index|
16
+ height = value.zero? ? 0 : [ (value.fdiv(trend.max) * (CHART_HEIGHT - 4)).round(1), 2 ].max
17
+ tag.rect(x: (index * (bar + gap)).round(2), y: (CHART_HEIGHT - height).round(2), width: bar.round(2), height: height, rx: 1, class: "raa-bar")
18
+ end
19
+ baseline = tag.line(x1: 0, y1: CHART_HEIGHT - 0.5, x2: CHART_WIDTH, y2: CHART_HEIGHT - 0.5, class: "raa-axis")
20
+ content_tag(:svg, safe_join(bars + [ baseline ]), viewBox: "0 0 #{CHART_WIDTH} #{CHART_HEIGHT}", class: "raa-chart",
21
+ preserveAspectRatio: "none", focusable: "false", aria: { hidden: true })
22
+ end
23
+
24
+ def admin_number(value, capped)
25
+ capped ? t("rails_ai_admin.dashboard.more_than", number: number_with_delimiter(value)) : number_with_delimiter(value)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module FieldsHelper
5
+ # Only receives the already-authorized scalar projection. Host partials are
6
+ # trusted Rails code, not a sandbox or an authorization mechanism.
7
+ # record_id names the record a file field links to; pages that show one
8
+ # record use @record.
9
+ def admin_field_value(field, value, view:, record_id: @record&.id)
10
+ value = String.new(value) if value.is_a?(String)
11
+ return admin_file_value(field, value, view: view, record_id: record_id) if field.scalar_type == :file && !field.plugin?
12
+ return admin_files_value(field, value, view: view, record_id: record_id) if field.scalar_type == :files
13
+ return admin_references_value(field, value) if field.scalar_type == :references
14
+ if field.scalar_type == :belongs_to
15
+ reference = value.nil? ? nil : admin_associations.reference(field, value)
16
+ return render(partial: "rails_ai_admin/fields/reference", locals: { field: field, value: value, reference: reference })
17
+ end
18
+ text = field.format(value, time_zone: @context.time_zone)
19
+ unless field.plugin? || value.nil?
20
+ # The readable text stays visible; the exact value is machine-readable,
21
+ # and long text keeps its line breaks (RAA-015).
22
+ if field.scalar_type == :datetime
23
+ text = tag.time(text, datetime: value.in_time_zone(@context.time_zone).iso8601)
24
+ elsif field.scalar_type == :date
25
+ text = tag.time(text, datetime: value.iso8601)
26
+ elsif field.scalar_type == :textarea && view != :index
27
+ text = tag.span(text, class: "raa-multiline")
28
+ elsif field.scalar_type == :email
29
+ text = mail_to(value.to_s, text)
30
+ elsif field.scalar_type == :url && field.web_address?(value.to_s)
31
+ # Only an absolute http(s) address becomes a link (never javascript:).
32
+ text = link_to(text, value.to_s, rel: "noopener noreferrer nofollow", target: "_blank")
33
+ elsif field.scalar_type == :json
34
+ text = view == :index ? text.squish.truncate(80) : tag.pre(text, class: "raa-json")
35
+ elsif field.scalar_type == :rich_text
36
+ # The stored HTML through Rails' safe list; lists show plain text.
37
+ text = view == :index ? text.squish.truncate(80) : tag.div(sanitize(value.to_s), class: "raa-rich-text")
38
+ end
39
+ end
40
+ partial = field.display_partial(view) || "rails_ai_admin/fields/#{view == :index ? 'index' : 'show'}"
41
+ render(partial: partial, locals: { field: field, value: value, text: text })
42
+ end
43
+
44
+ # Labels through each target's own grants; unavailable targets stay keys.
45
+ REFERENCES_SHOWN = 50
46
+ def admin_references_value(field, value)
47
+ ids = Array(value)
48
+ return "—" if ids.empty?
49
+ admin_associations.prime(field, ids.first(REFERENCES_SHOWN))
50
+ items = ids.first(REFERENCES_SHOWN).map do |id|
51
+ reference = admin_associations.reference(field, id)
52
+ reference.visible ? link_to(reference.label, resource_path(resource: reference.resource_key, id: reference.id)) : reference.label
53
+ end
54
+ items << t("rails_ai_admin.references.more", count: ids.size - REFERENCES_SHOWN) if ids.size > REFERENCES_SHOWN
55
+ safe_join(items, ", ")
56
+ end
57
+
58
+ # Checkboxes for the visible targets (up to 100). With more targets, the
59
+ # current ones stay as checkboxes and one key input, with the search
60
+ # picker, adds another. The empty hidden value lets "none" be submitted.
61
+ def admin_references_input(form, field, value, input_options)
62
+ selected = Array(value)
63
+ id = form.field_id(field.name)
64
+ name = "#{form.field_name(field.name)}[]"
65
+ choices = admin_associations.choices(field)
66
+ admin_associations.prime(field, selected)
67
+ options = if choices.complete
68
+ choices.options
69
+ else
70
+ selected.filter_map { |key| (reference = admin_associations.reference(field, key)).visible ? [ reference.label, key ] : nil }
71
+ end
72
+ boxes = options.map do |label, key|
73
+ box_id = "#{id}--#{key}"
74
+ tag.div(safe_join([ check_box_tag(name, key, selected.include?(key), id: box_id), " ", label_tag(box_id, label) ]), class: "raa-check")
75
+ end
76
+ parts = [ hidden_field_tag(name, "", id: nil), *boxes ]
77
+ parts << tag.p(t("rails_ai_admin.fields.none"), class: "raa-field-help") if options.empty? && choices.complete
78
+ unless choices.complete
79
+ target = Fields::Association.target(field)
80
+ key_input = text_field_tag(name, "", id: "#{id}--add", maxlength: 100, autocomplete: "off", spellcheck: false,
81
+ placeholder: t("rails_ai_admin.references.key_placeholder", target: target.label.singularize))
82
+ parts << admin_reference_picker(target, key_input, "#{id}--add", field, nil)
83
+ end
84
+ tag.div(safe_join(parts), id: id, class: "raa-references", role: "group", aria: { labelledby: "#{id}--label", describedby: input_options.dig(:aria, :describedby), invalid: input_options.dig(:aria, :invalid) })
85
+ end
86
+
87
+ # A file links to the admin's own download route, which checks the record
88
+ # and field grants again. Raster images also show a small inline preview.
89
+ def admin_file_value(field, value, view:, record_id:)
90
+ return "—" unless value.is_a?(Fields::FileValue)
91
+ text = field.format(value, time_zone: @context.time_zone)
92
+ return text unless record_id && @resource
93
+ path = resource_file_path(resource: @resource.key, id: record_id, field: field.name)
94
+ link = link_to(text, path)
95
+ return link unless view != :index && value.image?
96
+ safe_join([ tag.img(src: resource_file_path(resource: @resource.key, id: record_id, field: field.name, disposition: "inline"),
97
+ alt: value.filename, class: "raa-file-preview", loading: "lazy"), link ])
98
+ end
99
+
100
+ # Trix, from the host's Action Text assets, edits a hidden input. The
101
+ # label and descriptions point at the editor; file attachments are refused.
102
+ def admin_rich_text_input(form, field, value, input_options)
103
+ id = form.field_id(field.name)
104
+ editor = content_tag("trix-editor", "", id: id, input: "#{id}--value", class: "raa-rich-text-editor",
105
+ aria: input_options[:aria].merge(required: input_options[:required], labelledby: "#{id}--label"), data: { controller: "raa-rich-text",
106
+ action: "trix-file-accept->raa-rich-text#refuse trix-change->raa-rich-text#change" })
107
+ safe_join([ stylesheet_link_tag("trix"), javascript_include_tag("trix", defer: true),
108
+ hidden_field_tag(form.field_name(field.name), value, id: "#{id}--value"), editor ])
109
+ end
110
+
111
+ # Several files: a count in lists, a gallery of previews and links on
112
+ # show pages. Each link names its attachment.
113
+ def admin_files_value(field, value, view:, record_id:)
114
+ files = Array(value).grep(Fields::FileValue)
115
+ return field.format(files, time_zone: @context.time_zone) if view == :index || files.empty? || !(record_id && @resource)
116
+ items = files.map do |file|
117
+ path = resource_file_path(resource: @resource.key, id: record_id, field: field.name, attachment: file.id)
118
+ text = "#{file.filename} (#{number_to_human_size(file.byte_size)})"
119
+ preview = file.image? ? tag.img(src: resource_file_path(resource: @resource.key, id: record_id, field: field.name, attachment: file.id, disposition: "inline"), alt: file.filename, class: "raa-file-preview", loading: "lazy") : nil
120
+ tag.li(safe_join([ preview, link_to(text, path) ].compact))
121
+ end
122
+ tag.ul(safe_join(items), class: "raa-file-list")
123
+ end
124
+
125
+ # Current files with remove boxes, then a multiple file chooser.
126
+ def admin_files_input(form, field, value, input_options)
127
+ id = form.field_id(field.name)
128
+ current = Array(value).grep(Fields::FileValue)
129
+ boxes = current.map do |file|
130
+ box_id = "#{id}--remove-#{file.id}"
131
+ tag.li(safe_join([ check_box_tag("remove_attachments[#{field.name}][]", file.id, false, id: box_id), " ",
132
+ label_tag(box_id, t("rails_ai_admin.files.remove_named", file: file.filename)) ]), class: "raa-check")
133
+ end
134
+ options = input_options.merge(multiple: true, required: nil)
135
+ options[:accept] = field.options[:accept].join(",") if field.options[:accept]
136
+ safe_join([ (tag.ul(safe_join(boxes), class: "raa-file-list") if boxes.any?), form.file_field(field.name, **options),
137
+ tag.p(t("rails_ai_admin.files.list_limit", size: number_to_human_size(field.options[:max_size]), count: field.options[:max_files]), class: "raa-field-help") ].compact)
138
+ end
139
+
140
+ # Current file, a file chooser and (when the field may be empty) a remove box.
141
+ def admin_file_input(form, field, value, input_options)
142
+ current = value.is_a?(Fields::FileValue) ? value : nil
143
+ parts = []
144
+ if current
145
+ parts << tag.p(t("rails_ai_admin.files.current", file: field.format(current, time_zone: @context.time_zone)), class: "raa-file-current")
146
+ end
147
+ options = input_options.merge(required: current ? nil : input_options[:required])
148
+ options[:accept] = field.options[:accept].join(",") if field.options[:accept]
149
+ parts << form.file_field(field.name, **options)
150
+ parts << tag.p(t("rails_ai_admin.files.limit", size: number_to_human_size(field.options[:max_size])), class: "raa-field-help")
151
+ if current && !admin_field_required?(field)
152
+ id = "#{form.field_id(field.name)}--remove"
153
+ parts << tag.p(safe_join([ check_box_tag("remove_files[]", field.name.to_s, false, id: id), " ", label_tag(id, t("rails_ai_admin.files.remove")) ]), class: "raa-file-remove")
154
+ end
155
+ safe_join(parts)
156
+ end
157
+
158
+ # A bounded picker of currently visible targets. A stored key the actor
159
+ # cannot see stays selected so saving other fields never clears it; past
160
+ # MAX_CHOICES (or without a target list grant) the form takes a typed key.
161
+ def admin_reference_input(form, field, value, input_options)
162
+ value = value.nil? ? nil : String.new(value.to_s)
163
+ choices = admin_associations.choices(field)
164
+ unless choices.complete
165
+ target = Fields::Association.target(field)
166
+ key_input = form.text_field(field.name, value: value, maxlength: 100, autocomplete: "off", spellcheck: false,
167
+ placeholder: t("rails_ai_admin.references.key_placeholder", target: target.label.singularize), **input_options)
168
+ return admin_reference_picker(target, key_input, form.field_id(field.name), field, value)
169
+ end
170
+ options = choices.options.map(&:dup)
171
+ if value.present? && options.none? { |_, key| key == value }
172
+ options.unshift([ t("rails_ai_admin.references.not_available", label: admin_associations.reference(field, value).label), value ])
173
+ end
174
+ form.select(field.name, options, { selected: value, include_blank: field.options[:nullable] ? t("rails_ai_admin.fields.none") : t("rails_ai_admin.fields.choose_record") }, input_options)
175
+ end
176
+
177
+ # The typed key input, plus a search box when the actor can list the
178
+ # target (the search endpoint applies the same grants again).
179
+ def admin_reference_picker(target, key_input, key_id, field, value)
180
+ return key_input unless allowed_action?(target, :index)
181
+ current = value.present? ? admin_associations.reference(field, value) : nil
182
+ render(partial: "rails_ai_admin/fields/reference_picker",
183
+ locals: { target: target, key_input: key_input, key_id: key_id, current: current&.visible ? current : nil })
184
+ end
185
+
186
+ # Untranslated templates for the picker script; it fills %{count} and %{label}.
187
+ def admin_reference_messages
188
+ results = t("rails_ai_admin.references.results")
189
+ { searching: t("rails_ai_admin.references.searching"), none: t("rails_ai_admin.references.none"),
190
+ failed: t("rails_ai_admin.references.failed"), one: results[:one], other: results[:other],
191
+ chosen: t("rails_ai_admin.references.chosen") }
192
+ end
193
+
194
+ # Says how the box matches, so "Love" not finding "Ada Lovelace" under
195
+ # prefix search is not a surprise (RAA-021).
196
+ def admin_search_placeholder(fields)
197
+ modes = fields.map { |field| field.options[:searchable] == true ? :prefix : field.options[:searchable] }.uniq
198
+ case modes
199
+ when [ :prefix ] then t("rails_ai_admin.search.prefix")
200
+ when [ :contains ] then t("rails_ai_admin.search.contains")
201
+ when [ :exact ] then t("rails_ai_admin.search.exact")
202
+ else t("rails_ai_admin.search.any")
203
+ end
204
+ end
205
+
206
+ # The same rule as blank-input coercion: required or not nullable (RAA-023).
207
+ # An edit form's empty password box keeps the current password.
208
+ def admin_field_required?(field)
209
+ return field.options[:required] == true && @operation != :update if field.scalar_type == :password
210
+ field.options[:required] == true || field.options[:nullable] == false
211
+ end
212
+
213
+ def admin_time_input_value(field, value)
214
+ return value if value.nil? || value.is_a?(String) && !value.match?(Field::TIME_OF_DAY)
215
+ field.format_time_of_day(value)
216
+ end
217
+
218
+ # Submitted text comes back as typed; a stored value is indented.
219
+ def admin_json_input_value(value)
220
+ return value if value.nil? || value.is_a?(String)
221
+ JSON.pretty_generate(value)
222
+ rescue JSON::GeneratorError
223
+ nil
224
+ end
225
+
226
+ # datetime-local value in the context zone. Seconds are kept when set so
227
+ # an unchanged form submits the stored second back.
228
+ def admin_datetime_input_value(value)
229
+ return value unless value.respond_to?(:in_time_zone)
230
+ local = value.in_time_zone(@context.time_zone)
231
+ local.strftime(local.sec.zero? ? "%Y-%m-%dT%H:%M" : "%Y-%m-%dT%H:%M:%S")
232
+ end
233
+
234
+ def admin_field_choices(field, value)
235
+ choices = field.choice_options.map(&:dup)
236
+ if value.present? && !field.choice_values.include?(value)
237
+ choices.unshift([ t("rails_ai_admin.fields.unavailable_value"), String.new(value.to_s), { disabled: true } ])
238
+ end
239
+ choices
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Native inputs for already-granted typed filters. Values are the normalized
5
+ # list hint strings; the server coerces them again before querying.
6
+ module FiltersHelper
7
+ def admin_filter_label(field, operator)
8
+ case operator.to_sym
9
+ when :gte then t("rails_ai_admin.filters.from", label: field.label)
10
+ when :lte then t("rails_ai_admin.filters.to", label: field.label)
11
+ else Field::RANGE_TYPES.include?(field.scalar_type) ? t("rails_ai_admin.filters.equals", label: field.label) : field.label
12
+ end
13
+ end
14
+
15
+ def admin_filter_input(field, operator, value, id:)
16
+ name = "filter[#{field.name}][#{operator}]"
17
+ value = value && String.new(value)
18
+ case field.scalar_type
19
+ when :belongs_to
20
+ choices = admin_associations.choices(field)
21
+ unless choices.complete
22
+ key_input = text_field_tag(name, value, id: id, maxlength: 100, autocomplete: "off", spellcheck: false)
23
+ return admin_reference_picker(Fields::Association.target(field), key_input, id, field, value)
24
+ end
25
+ options = [ [ t("rails_ai_admin.filters.any"), "" ] ] + choices.options.map(&:dup)
26
+ options << [ admin_associations.reference(field, value).label, value ] if value.present? && options.none? { |_, key| key == value }
27
+ select_tag(name, options_for_select(options, value), id: id)
28
+ when :enum, :select
29
+ select_tag(name, options_for_select([ [ t("rails_ai_admin.filters.any"), "" ] ] + field.choice_options.map(&:dup), value), id: id)
30
+ when :boolean
31
+ select_tag(name, options_for_select([ [ t("rails_ai_admin.filters.any"), "" ], [ t("rails_ai_admin.fields.yes"), "true" ], [ t("rails_ai_admin.fields.no"), "false" ] ], value), id: id)
32
+ when :integer then number_field_tag(name, value, id: id, step: 1)
33
+ when :decimal then text_field_tag(name, value, id: id, inputmode: "decimal", maxlength: 200)
34
+ when :date then date_field_tag(name, value, id: id)
35
+ when :datetime then datetime_local_field_tag(name, value, id: id)
36
+ else text_field_tag(name, value, id: id, maxlength: 200)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module HistoryHelper
5
+ def admin_history_allowed?(resource, record)
6
+ Authorization::Access.new(@context, resource).authorize_history!(record)
7
+ rescue AccessDenied
8
+ false
9
+ end
10
+
11
+ def admin_history_time(time)
12
+ local = time.in_time_zone(@context.time_zone)
13
+ tag.time(local.strftime("%-d %b %Y %H:%M:%S %Z"), datetime: local.iso8601)
14
+ end
15
+
16
+ # "Updated", "Created", "Deleted" or a custom operation's description,
17
+ # plus the outcome when the change was not saved.
18
+ def admin_history_change(resource, entry)
19
+ name = if %w[create update destroy export].include?(entry.operation)
20
+ t(entry.operation, scope: %i[rails_ai_admin history operations])
21
+ else
22
+ RailsAiAdmin.operation_registry.custom(resource).find { |definition| definition.key.to_s == entry.operation }&.description ||
23
+ entry.operation.to_s.humanize
24
+ end
25
+ return name if entry.status == "succeeded"
26
+ safe_join([ name, tag.br, tag.span(t("rails_ai_admin.history.not_saved"), class: "raa-danger"), " ", admin_outcome(entry.code) ])
27
+ end
28
+
29
+ def admin_history_fields(entry)
30
+ return "—" if entry.fields.empty? && entry.hidden_fields.zero?
31
+ parts = [ entry.fields.join(", ") ]
32
+ parts << t("rails_ai_admin.history.other_fields", count: entry.hidden_fields) if entry.hidden_fields.positive?
33
+ parts.reject(&:blank?).join("; ")
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Original project-owned outline glyphs, with fixed SVG attributes and paths.
5
+ module NavigationHelper
6
+ # Page title parts, most specific first (RAA-024). The first call wins, so
7
+ # a page rendered inside another keeps the outer page's title.
8
+ def admin_title(*parts)
9
+ @raa_title_parts ||= parts.compact.map { |part| part.to_s.squish.truncate(80) }.reject(&:empty?)
10
+ nil
11
+ end
12
+
13
+ # A result code as a sentence, with the code kept as secondary text for
14
+ # support (RAA-027).
15
+ def admin_outcome(code)
16
+ return "" if code.blank?
17
+ safe_join([ Presentation::Outcomes.sentence(code), " ", tag.span("(#{code})", class: "raa-code") ])
18
+ end
19
+
20
+ # A stored run, item or work status ("succeeded") as display text.
21
+ def admin_state_name(status)
22
+ status = status.to_s
23
+ return status.humanize unless status.match?(/\A[a-z][a-z_]{0,63}\z/)
24
+ t(status, scope: %i[rails_ai_admin states], default: status.humanize)
25
+ end
26
+
27
+ # { "succeeded" => 2, "failed" => 1 } as "Succeeded: 2 · Failed: 1".
28
+ def admin_state_counts(counts)
29
+ counts.map { |status, count| t("rails_ai_admin.states.count", state: admin_state_name(status), count: count) }.join(" · ")
30
+ end
31
+
32
+ # "Edit ORD-0001 · Orders · Administration".
33
+ def admin_page_title
34
+ [ *Array(@raa_title_parts), RailsAiAdmin.configuration.title ].join(" · ")
35
+ end
36
+
37
+ ICON_PATHS = {
38
+ "users" => [ "M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z", "M2 21v-3a6 6 0 0 1 12 0v3M17 4a4 4 0 0 1 0 8M18 15a5 5 0 0 1 4 5v1" ],
39
+ "shopping-bag" => [ "M4 7h16l1 14H3L4 7ZM8 8V6a4 4 0 0 1 8 0v2" ],
40
+ "life-buoy" => [ "M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0ZM16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z", "m5 5 4 4m6 6 4 4M5 19l4-4m6-6 4-4" ],
41
+ "document" => [ "M5 2h9l5 5v15H5V2ZM14 2v6h5M8 12h8M8 16h8" ],
42
+ "grid" => [ "M3 3h7v7H3V3Zm11 0h7v7h-7V3ZM3 14h7v7H3v-7Zm11 0h7v7h-7v-7Z" ]
43
+ }.transform_values { |paths| paths.each(&:freeze).freeze }.freeze
44
+
45
+ def navigation_icon(name)
46
+ return unless name
47
+ paths = ICON_PATHS.fetch(name) { raise ConfigurationError, "Unknown navigation icon" }
48
+ tag.svg(class: "raa-nav-icon", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor",
49
+ "stroke-width": "1.7", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", focusable: "false") do
50
+ safe_join(paths.map { |path| tag.path(d: path) })
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # View helpers for admin-owned review Surfaces. rails_tool_views is touched
5
+ # only when a Surface is actually rendered, never when this file loads.
6
+ module ToolViewsHelper
7
+ # Renders through RailsToolViews.render with fresh authorization. The
8
+ # rails_tool_views view helpers are added to this view instance only.
9
+ def admin_surface(surface, scope:, error: nil)
10
+ extend ::RailsToolViews::SurfacesHelper unless is_a?(::RailsToolViews::SurfacesHelper)
11
+ ::RailsToolViews.render(surface, view_context: self, scope: scope, error: error)
12
+ end
13
+
14
+ # A Surface referenced from a conversation, restored for the current admin.
15
+ def admin_assistant_surface(surface_id, scope:)
16
+ surface = ::RailsToolViews.restore(surface_id, scope: scope)
17
+ admin_surface(surface, scope: scope)
18
+ rescue ::RailsToolViews::Error
19
+ tag.p(t("rails_ai_admin.surfaces.no_longer_available"), class: "raa-chat-activity")
20
+ end
21
+
22
+ # Like rails_tool_views_action_form, but posts to the admin endpoint so the
23
+ # host's global resolver never authenticates admin actions.
24
+ def admin_surface_action_form(surface, action, **options, &block)
25
+ fields = rails_tool_views_action_fields(surface, action)
26
+ form_with(url: admin_surface_actions_path(surface_id: surface.id), method: :post, scope: :arguments, namespace: surface.dom_id, **options) do |form|
27
+ safe_join([ fields, (capture(form, &block) if block) ])
28
+ end
29
+ end
30
+
31
+ # The current authorized review behind a Surface, or nil.
32
+ def admin_surface_review(props)
33
+ Integrations::ToolViews.review(Integrations::ToolViews.current_context, props)
34
+ end
35
+
36
+ def admin_surface_changes(review, wire)
37
+ Integrations::ToolViews.proposed_changes(review, wire)
38
+ end
39
+
40
+ def admin_surface_display_values(review, wire)
41
+ Integrations::ToolViews.display_values(review, wire)
42
+ end
43
+
44
+ def admin_surface_review_available?(resource, definition)
45
+ RailsAiAdmin.configuration.surfaces && resource.surface_operation?(definition.key)
46
+ end
47
+ end
48
+ end