Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def done?
end

def output
helpers.format_text(pane.text, object: pane.work_package)
helpers.format_text(pane.text, object: pane.work_package, project: pane.project, untrusted_source: true)
end
end
end
Expand Down
14 changes: 8 additions & 6 deletions app/components/ai/text_transforms/result_pane_state.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,20 @@
module AI
module TextTransforms
class ResultPaneState
attr_reader :run, :scope, :request_id, :work_package, :context_ids, :rejection
attr_reader :run, :scope, :request_id, :context, :rejection

def self.rejected(message:, label:, scope:, request_id:, context_ids: {})
new(run: nil, scope:, request_id:, label:, context_ids:, rejection: message)
delegate :work_package, :project, to: :context
delegate :ids, to: :context, prefix: true

def self.rejected(message:, label:, scope:, request_id:, context: Context.none)
new(run: nil, scope:, request_id:, label:, context:, rejection: message)
end

def initialize(run:, scope:, request_id:, work_package: nil, context_ids: {}, label: nil, rejection: nil)
def initialize(run:, scope:, request_id:, context: Context.none, label: nil, rejection: nil)
@run = run
@scope = scope
@request_id = request_id
@work_package = work_package
@context_ids = context_ids
@context = context
@label = label
@rejection = rejection
end
Expand Down
66 changes: 66 additions & 0 deletions app/contracts/ai/text_transforms/pane_params_contract.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module AI
module TextTransforms
class PaneParamsContract < DryApplicationContract
SCOPES = %w[document selection].freeze
STATES = %w[generating done failed stopped cancelled].freeze
CONTEXT_KEYS = %i[work_package_id project_id type_id].freeze
KEYS = %i[request_id scope work_package_id project_id type_id after state action_id input retry_of].freeze

config.messages.namespace = :ai_text_transform_pane

params do
required(:request_id).filled(:string, :uuid_v4?)
required(:scope).filled(:string, included_in?: SCOPES)
optional(:work_package_id).maybe(:integer, gt?: 0)
optional(:project_id).maybe(:integer, gt?: 0)
optional(:type_id).maybe(:integer, gt?: 0)
optional(:after).maybe(:integer, gteq?: 0)
optional(:state).maybe(:string, included_in?: STATES)
optional(:action_id).maybe(:integer, gt?: 0)
optional(:input).maybe(:string, max_size?: AI::TextTransformRun::MAX_INPUT_LENGTH)
optional(:retry_of).maybe(:string, :uuid_v4?)
end

rule(:project_id, :work_package_id) do
key(:project_id).failure(:with_work_package) if values[:project_id] && values[:work_package_id]
end

rule(:type_id, :project_id) do
key(:type_id).failure(:without_project) if values[:type_id].nil? != values[:project_id].nil?
end

rule(:retry_of, :action_id, :input) do
key(:retry_of).failure(:with_action) if values[:retry_of] && (values[:action_id] || values[:input])
end
end
end
end
179 changes: 179 additions & 0 deletions app/controllers/ai/text_transform_panes_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module AI
class TextTransformPanesController < ApplicationController
include OpTurbo::ComponentStream
include AI::TextTransformActionsFeature

ACCEPTED_EVENT = "op-dispatched:ai-text-transform:accepted"
CLOSED_EVENT = "op-dispatched:ai-text-transform:closed"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🍏 apply is imperative and closed a past participle. Worth settling before AI-176 ships, since renaming later breaks the client.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to op-dispatched:ai-text-transform:accepted. The other dispatched events all report what happened (changed, updated, moved), and at that point the text is not applied yet. The route stays apply.


before_action :require_login
before_action :require_ai_text_transform_actions_feature
authorization_checked! :create, :show, :destroy, :apply, :cancel
before_action :validate_pane_params, except: %i[destroy cancel]
before_action :find_run, only: %i[show apply cancel]
before_action :find_retried_run, only: :create
before_action :resolve_context, only: %i[create apply]

def show
return head(:no_content) if unchanged?

run.cancelled? ? close_pane : update_sections
respond_with_turbo_streams unless performed?
end

def create
append_to_body_via_turbo_stream(component: AI::TextTransforms::ResultPaneComponent.new(pane: start_run))

respond_with_turbo_streams
end

def destroy
own_runs.find_by(uuid: params[:uuid])&.request_cancel!
close_pane

respond_with_turbo_streams
end

def apply
return head(:unprocessable_entity) unless run.succeeded?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🍊 show re-resolves the context and gives 403 once edit permission is gone, but apply does not, so the text can still be applied into a work package the user can no longer edit. Either before_action :resolve_context, only: %i[create apply] with show reusing it, or a note that the client owns this check.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: apply resolves the context as well (403 once the edit permission is gone).


dispatch_event_via_turbo_stream(ACCEPTED_EVENT, detail: { requestId: request_id,
scope: pane_params[:scope],
text: pane_for(run).text })

respond_with_turbo_streams
end

def cancel

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🍏 destroy and cancel both find the run and request cancellation; the differences are find_by tolerance and stream vs 204. If cancel exists for navigator.sendBeacon, a line in the PR description would explain it. Otherwise one of them could go.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, cancel is for sendBeacon / keepalive on page leave. Added to the PR description.

run.request_cancel!

head :no_content
end

private

attr_reader :run, :retried_run

def own_runs
AI::TextTransformRun.where(user: current_user)
end

def find_run
@run = own_runs.find_by(uuid: params[:uuid])
head :not_found unless run
end

def find_retried_run
return unless pane_params[:retry_of]

@retried_run = own_runs.failed.find_by(uuid: pane_params[:retry_of])
head :not_found unless retried_run
end

def pane_params
@pane_params ||= pane_contract.call(params.permit(*AI::TextTransforms::PaneParamsContract::KEYS).to_h)
end

def pane_contract
AI::TextTransforms::PaneParamsContract.new
end

def validate_pane_params
head :bad_request if pane_params.failure?
end

def request_id
pane_params[:request_id] unless pane_params.error?(:request_id)
end

def unchanged?
return false if run.events_after(pane_params[:after]).exists?

seen_state = pane_params[:state]
seen_state ? seen_state == pane_for(run).state : !run.terminal?
end

def context
@context || AI::TextTransforms::Context.none
end

def resolve_context
call = AI::TextTransforms::ResolveContext.new(user: current_user).call(**context_ids)
return @context = call.result if call.success?

head(call.errors.any? { |error| error.type == :error_not_found } ? :not_found : :forbidden)
end

def context_ids
pane_params.to_h.slice(*AI::TextTransforms::PaneParamsContract::CONTEXT_KEYS).compact
end

def start_run
action, content = run_source
call = AI::TextTransforms::CreateRun.new(user: current_user, action:, context:, content:).call
call.success? ? pane_for(call.result) : rejected_pane(call.errors, action)
end

def run_source
return [retried_run.action, retried_run.input] if retried_run

[AI::TextTransformAction.find_by(id: pane_params[:action_id]), pane_params[:input].to_s]
end

def pane_for(pane_run)
AI::TextTransforms::ResultPaneState.new(run: pane_run, scope: pane_params[:scope], request_id:, context:)
end

def rejected_pane(errors, action)
AI::TextTransforms::ResultPaneState.rejected(message: errors.full_messages.first,
label: action&.label.to_s,
scope: pane_params[:scope],
request_id:,
context:)
end

def update_sections
resolve_context
return if performed?

pane = pane_for(run)
[AI::TextTransforms::ResultPaneComponent::Body, AI::TextTransforms::ResultPaneComponent::Footer].each do |section|
update_via_turbo_stream(component: section.new(pane:))
end
end

def close_pane
remove_via_turbo_stream(component: AI::TextTransforms::ResultPaneComponent.new(pane: nil))
dispatch_event_via_turbo_stream(CLOSED_EVENT, detail: { requestId: request_id }) if request_id
end
end
end
10 changes: 10 additions & 0 deletions app/controllers/concerns/op_turbo/component_stream.rb
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,16 @@ def dispatch_event_via_turbo_stream(name, detail: {})
.render_in(view_context)
end

# Appends a component to `<body>`, for elements that live outside the page
# layout. Turbo replaces a direct child of `<body>` with the same id.
def append_to_body_via_turbo_stream(component:)
turbo_streams << lambda {
OpTurbo::StreamComponent
.new(action: :append, target: nil, targets: "body", template: component.render_in(view_context))
.render_in(view_context)
}
end

def turbo_streams
@turbo_streams ||= []
end
Expand Down
4 changes: 4 additions & 0 deletions app/models/ai/text_transform_run.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ def start!
update!(status: "running")
end

def request_cancel!
update_column(:cancel_requested, true) unless terminal?
end

def finish!(status, error_message: nil)
raise ArgumentError, "#{status} is not a terminal status" unless TERMINAL_STATUSES.include?(status.to_s)

Expand Down
10 changes: 10 additions & 0 deletions app/services/ai/text_transforms/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ def initialize(work_package:, project:, type:)
@type = type
end

def ids
if work_package
{ work_package_id: work_package.id }
elsif project
{ project_id: project.id, type_id: type.id }
else
{}
end
end

def type_variant
return @type_variant if defined?(@type_variant)

Expand Down
12 changes: 12 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5762,13 +5762,22 @@ en:

op_dry_validation:
errors:
ai_text_transform_pane:
rules:
project_id:
with_work_package: "must not be combined with a work package."
retry_of:
with_action: "must not be combined with an action or input."
type_id:
without_project: "must be given together with a project."
array?: "must be an array."
decimal?: "must be a decimal."
defined: "must not be defined."
eql?: "must be equal to %{left}."
filled?: "must be filled."
format?: "is in invalid format."
greater_or_equal_zero: "must be greater or equal to 0."
gt?: "must be greater than %{num}."
gteq?: "must be greater than or equal to %{num}."
hash?: "must be a hash."
included_in?:
Expand All @@ -5777,6 +5786,7 @@ en:
range: "must be one of: %{list_left} - %{list_right}."
int?: "must be an integer."
key?: "is missing."
max_size?: "must not be longer than %{num} characters."
not_found: "not found."
respond_to?: "does not implement required method."
rules:
Expand All @@ -5796,6 +5806,7 @@ en:
type?: "must be %{type}."
unexpected_key: "is not allowed."
uri?: "is not a valid URI."
uuid_v4?: "must be a UUID (version 4)."
or: "or"
rules:
blueprint: "Pattern blueprint"
Expand Down Expand Up @@ -7254,6 +7265,7 @@ en:
You can freely group the fields to reflect the needs for your domain.
text_form_configuration_required_attribute: "Attribute is marked required and thus always shown"
text_formatting:
image_link: "Image: %{name}"
markdown: "Markdown"
plain: "Plain text"
text_git_repo_example: "a bare and local repository (e.g. /gitrepo, c:\\gitrepo)"
Expand Down
9 changes: 9 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,15 @@
end
end

namespace :ai do
resources :text_transform_panes, only: %i[create show destroy], param: :uuid do
member do
post :apply
post :cancel
end
end
end

namespace :projects do
resource :menu, only: %i[show]
resource :filters, only: %i[show]
Expand Down
2 changes: 1 addition & 1 deletion lib/api/v3/ai/text_transform_runs_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def raise_create_failure(errors)

namespace :cancel do
post do
@run.update!(cancel_requested: true) unless @run.terminal?
@run.request_cancel!

status 204
end
Expand Down
Loading
Loading