From bc34e3f7c79e25f61c30752e9ac47c2329a34dfb Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 9 Sep 2026 13:47:17 +0200 Subject: [PATCH 01/46] [AI-100] Prototype: admin sandbox for text transform actions Adds a sandbox below the text transform action form that posts text against the run API and polls the run, so the whole stack can be tried in the browser. The representer exposes the stored system prompt for this sandbox only. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017HL6QmQW5pV6v8FAig2gJB --- .../sandbox_component.html.erb | 141 ++++++++++ .../sandbox_component.rb | 60 +++++ .../text_transform_actions/edit.html.erb | 2 + .../ai-text-transform-sandbox.controller.ts | 254 ++++++++++++++++++ 4 files changed, 457 insertions(+) create mode 100644 app/components/admin/text_transform_actions/sandbox_component.html.erb create mode 100644 app/components/admin/text_transform_actions/sandbox_component.rb create mode 100644 frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts diff --git a/app/components/admin/text_transform_actions/sandbox_component.html.erb b/app/components/admin/text_transform_actions/sandbox_component.html.erb new file mode 100644 index 000000000000..839655cb6ba7 --- /dev/null +++ b/app/components/admin/text_transform_actions/sandbox_component.html.erb @@ -0,0 +1,141 @@ +<%#-- 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, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= + render(Primer::Beta::BorderBox.new(mt: 5, data: controller_data, test_selector: "ai-text-transform-sandbox")) do |box| + box.with_header do |header| + header.with_title(tag: :h2) { label(:title) } + end + + box.with_body do + flex_layout(direction: :column, gap: 3) do |layout| + layout.with_row do + render(Primer::Beta::Text.new(tag: :p, color: :muted)) { label(:description) } + end + + layout.with_row do + render( + Primer::Alpha::TextArea.new( + name: "sandbox[content]", + label: label(:content_label), + rows: 8, + full_width: true, + data: target(:content) + ) + ) + end + + layout.with_row do + flex_layout(gap: 3, wrap: :wrap) do |fields| + fields.with_column do + render( + Primer::Alpha::TextField.new( + name: "sandbox[work_package_id]", + label: label(:work_package_id_label), + data: target(:workPackageId) + ) + ) + end + fields.with_column do + render( + Primer::Alpha::TextField.new( + name: "sandbox[project_id]", + label: label(:project_id_label), + data: target(:projectId) + ) + ) + end + fields.with_column do + render( + Primer::Alpha::TextField.new( + name: "sandbox[type_id]", + label: label(:type_id_label), + data: target(:typeId) + ) + ) + end + end + end + + layout.with_row do + flex_layout(gap: 2, align_items: :center) do |buttons| + buttons.with_column do + render( + Primer::Beta::Button.new( + scheme: :primary, + data: target(:execute).merge(action: "ai-text-transform-sandbox#execute") + ) + ) { label(:execute) } + end + buttons.with_column do + render( + Primer::Beta::Button.new( + scheme: :danger, + disabled: true, + data: target(:cancel).merge(action: "ai-text-transform-sandbox#cancel") + ) + ) { label(:cancel) } + end + buttons.with_column do + render(Primer::Beta::Text.new(tag: :span, font_family: :mono, data: target(:status))) { "idle" } + end + end + end + + layout.with_row do + content_tag( + :pre, + "", + class: "op-uc-p", + style: "white-space: pre-wrap; min-height: 6rem;", + data: target(:output) + ) + end + + layout.with_row do + render(Primer::Beta::Details.new) do |details| + details.with_summary { label(:system_prompt) } + details.with_body do + content_tag(:pre, "", style: "white-space: pre-wrap; font-size: 12px;", data: target(:systemPrompt)) + end + end + end + + layout.with_row do + render(Primer::Beta::Details.new) do |details| + details.with_summary { label(:raw_events) } + details.with_body do + content_tag(:pre, "", style: "white-space: pre-wrap; font-size: 12px;", data: target(:log)) + end + end + end + end + end + end +%> diff --git a/app/components/admin/text_transform_actions/sandbox_component.rb b/app/components/admin/text_transform_actions/sandbox_component.rb new file mode 100644 index 000000000000..c249491198fd --- /dev/null +++ b/app/components/admin/text_transform_actions/sandbox_component.rb @@ -0,0 +1,60 @@ +# 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Admin + module TextTransformActions + # Prototype only: lets an administrator run the action against arbitrary text + # through the execute API and watch the run being polled. + class SandboxComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include API::V3::Utilities::PathHelper + + alias action model + + private + + def controller_data + { + controller: "ai-text-transform-sandbox", + ai_text_transform_sandbox_url_value: api_v3_paths.ai_text_transform_runs, + ai_text_transform_sandbox_action_id_value: action.id + } + end + + def target(name) + { ai_text_transform_sandbox_target: name } + end + + def label(key) + I18n.t("admin.text_transform_actions.sandbox.#{key}") + end + end + end +end diff --git a/app/views/admin/text_transform_actions/edit.html.erb b/app/views/admin/text_transform_actions/edit.html.erb index e3020986c29b..c76bf403ddbf 100644 --- a/app/views/admin/text_transform_actions/edit.html.erb +++ b/app/views/admin/text_transform_actions/edit.html.erb @@ -42,3 +42,5 @@ See COPYRIGHT and LICENSE files for more details. %> <%= render(Admin::TextTransformActions::FormComponent.new(@text_transform_action)) %> + +<%= render(Admin::TextTransformActions::SandboxComponent.new(@text_transform_action)) %> diff --git a/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts b/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts new file mode 100644 index 000000000000..13ff98b050d2 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts @@ -0,0 +1,254 @@ +//-- 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, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { Controller } from '@hotwired/stimulus'; + +interface RunEvent { + seq:number; + kind:'status'|'text_delta'|'completed'|'error'; + payload:{ status?:string; delta?:string; text?:string; message?:string; reason?:string }; +} + +interface RunResponse { + id:string; + status:string; + systemPrompt?:string; + events:RunEvent[]; + _links:{ self:{ href:string }; cancel?:{ href:string } }; +} + +const TERMINAL_STATUSES = ['succeeded', 'failed', 'cancelled']; +const MIN_DELAY = 400; +const MAX_DELAY = 1000; +const DELAY_STEP = 150; + +/** + * Prototype sandbox for the description assistant: posts the textarea content + * against the execute API and polls the run with an adaptive cursor-based loop. + */ +export default class AiTextTransformSandboxController extends Controller { + static targets = [ + 'content', 'workPackageId', 'projectId', 'typeId', 'execute', 'cancel', 'status', 'output', 'log', 'systemPrompt', + ]; + + static values = { url: String, actionId: Number }; + + declare readonly contentTarget:HTMLTextAreaElement; + declare readonly workPackageIdTarget:HTMLInputElement; + declare readonly projectIdTarget:HTMLInputElement; + declare readonly typeIdTarget:HTMLInputElement; + declare readonly executeTarget:HTMLButtonElement; + declare readonly cancelTarget:HTMLButtonElement; + declare readonly statusTarget:HTMLElement; + declare readonly outputTarget:HTMLElement; + declare readonly logTarget:HTMLElement; + declare readonly systemPromptTarget:HTMLElement; + declare readonly urlValue:string; + declare readonly actionIdValue:number; + + private runUrl:string|null = null; + private cancelUrl:string|null = null; + private cursor = 0; + private delay = MIN_DELAY; + private timer:ReturnType|null = null; + private inFlight = false; + private text = ''; + private readonly onVisibilityChange = () => { this.handleVisibilityChange(); }; + + connect():void { + document.addEventListener('visibilitychange', this.onVisibilityChange); + } + + disconnect():void { + document.removeEventListener('visibilitychange', this.onVisibilityChange); + this.stopPolling(); + } + + async execute():Promise { + this.reset(); + this.setStatus('creating run'); + + const response = await this.request(this.urlValue, 'POST', JSON.stringify(this.body())); + if (!response.ok) { + this.setStatus(`create failed: HTTP ${response.status}`); + this.appendLog(await response.text()); + this.executeTarget.disabled = false; + return; + } + + const run = await response.json() as RunResponse; + this.runUrl = run._links.self.href; + this.cancelUrl = run._links.cancel?.href ?? null; + this.cancelTarget.disabled = this.cancelUrl === null; + this.appendLog(`created ${run.id}`); + this.systemPromptTarget.textContent = run.systemPrompt ?? ''; + this.apply(run); + this.schedule(); + } + + async cancel():Promise { + if (!this.cancelUrl) { + return; + } + this.appendLog('cancel requested'); + const response = await this.request(this.cancelUrl, 'POST'); + this.appendLog(`cancel -> HTTP ${response.status}`); + } + + private body() { + const body:Record = { + actionId: this.actionIdValue, + content: this.contentTarget.value, + }; + const workPackageId = this.workPackageIdTarget.value.trim(); + const projectId = this.projectIdTarget.value.trim(); + const typeId = this.typeIdTarget.value.trim(); + + if (workPackageId) { + body.workPackageId = Number(workPackageId); + } else if (projectId || typeId) { + body.projectId = Number(projectId); + body.typeId = Number(typeId); + } + return body; + } + + private schedule():void { + this.stopPolling(); + this.timer = setTimeout(() => { void this.poll(); }, this.delay); + this.delay = Math.min(MAX_DELAY, this.delay + DELAY_STEP); + } + + private async poll():Promise { + if (!this.runUrl || this.inFlight) { + return; + } + if (document.hidden) { + this.schedule(); + return; + } + + this.inFlight = true; + const requestedCursor = this.cursor; + try { + const response = await this.request(`${this.runUrl}?after=${requestedCursor}`, 'GET'); + if (!response.ok) { + this.setStatus(`poll failed: HTTP ${response.status}`); + return; + } + if (requestedCursor !== this.cursor) { + return; + } + const run = await response.json() as RunResponse; + this.apply(run); + if (!TERMINAL_STATUSES.includes(run.status)) { + this.schedule(); + } + } finally { + this.inFlight = false; + } + } + + private apply(run:RunResponse):void { + this.setStatus(run.status); + run.events.forEach((event) => { + this.cursor = Math.max(this.cursor, event.seq); + this.appendLog(`#${event.seq} ${event.kind} ${JSON.stringify(event.payload)}`); + switch (event.kind) { + case 'text_delta': + this.text += event.payload.delta ?? ''; + this.outputTarget.textContent = this.text; + break; + case 'completed': + this.text = event.payload.text ?? ''; + this.outputTarget.textContent = this.text; + break; + case 'error': + this.outputTarget.textContent = `${this.text}\n\n[${event.payload.reason ?? 'error'}] ${event.payload.message ?? ''}`; + break; + default: + break; + } + }); + + if (TERMINAL_STATUSES.includes(run.status)) { + this.stopPolling(); + this.cancelTarget.disabled = true; + this.executeTarget.disabled = false; + this.appendLog(`finished with ${run.status}`); + } + } + + private request(url:string, method:'GET'|'POST', body?:string):Promise { + return fetch(url, { + method, + body, + credentials: 'same-origin', + headers: { + Accept: 'application/hal+json', + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + } + + private reset():void { + this.stopPolling(); + this.runUrl = null; + this.cancelUrl = null; + this.cursor = 0; + this.delay = MIN_DELAY; + this.text = ''; + this.outputTarget.textContent = ''; + this.logTarget.textContent = ''; + this.systemPromptTarget.textContent = ''; + this.executeTarget.disabled = true; + this.cancelTarget.disabled = true; + } + + private stopPolling():void { + if (this.timer !== null) { + clearTimeout(this.timer); + this.timer = null; + } + } + + private handleVisibilityChange():void { + if (!document.hidden && this.runUrl && this.timer === null && !this.inFlight) { + this.schedule(); + } + } + + private setStatus(status:string):void { + this.statusTarget.textContent = status; + } + + private appendLog(line:string):void { + this.logTarget.textContent += `${new Date().toISOString().slice(11, 23)} ${line}\n`; + } +} From 6039a878a8f5e11da39e26339a9d3757a3838e62 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 9 Sep 2026 13:49:13 +0200 Subject: [PATCH 02/46] [AI-100] Demo: run the prototype through the AI-3 LLM connection Replaces the environment-configured prototype gateway with a gateway on Llm::Runtime, so the description assistant uses the connection and the model binding an administrator configures in the admin UI. Enables the llm_connection feature flag for PullPreview. Demo only, never to be merged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017HL6QmQW5pV6v8FAig2gJB --- app/services/ai/text_transforms/gateway.rb | 2 +- .../ai/text_transforms/llm_gateway.rb | 89 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 app/services/ai/text_transforms/llm_gateway.rb diff --git a/app/services/ai/text_transforms/gateway.rb b/app/services/ai/text_transforms/gateway.rb index 450efa4670de..710954aeb177 100644 --- a/app/services/ai/text_transforms/gateway.rb +++ b/app/services/ai/text_transforms/gateway.rb @@ -35,7 +35,7 @@ class Gateway def ready? = ready end - def self.build = NullGateway.new + def self.build = LlmGateway.new end end end diff --git a/app/services/ai/text_transforms/llm_gateway.rb b/app/services/ai/text_transforms/llm_gateway.rb new file mode 100644 index 000000000000..737087ced333 --- /dev/null +++ b/app/services/ai/text_transforms/llm_gateway.rb @@ -0,0 +1,89 @@ +# 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module TextTransforms + class LlmGateway < Gateway + FEATURE = :description_assistant + + def readiness + resolution = Llm::Runtime.for(FEATURE) + return Readiness.new(true, nil) if resolution.ready? + + Readiness.new(false, resolution.status) + rescue OpenProject::Llm::UnknownFeature + Readiness.new(false, :feature_unregistered) + end + + def stream(system:, user:, timeout:, &) + @cancelled = nil + response = chat(system, timeout).ask(user) { |chunk| forward(chunk, &) } + response.content.to_s + rescue Cancelled + raise + rescue StandardError => e + raise @cancelled if @cancelled + + raise translate(e) + end + + private + + def chat(system, timeout) + Llm::Runtime.for(FEATURE).chat(timeout:, max_retries: 0).with_instructions(system) + end + + def forward(chunk) + content = chunk.content + return if content.to_s.empty? + + yield content + rescue Cancelled => e + @cancelled = e + raise + end + + def translate(error) + Rails.logger.info { "AI text transform gateway failed: #{error.class}" } + + case error + when Llm::Errors::TimeoutError + Errors::TimedOut.new(error.class.name) + when Llm::Errors::ConnectionError + Errors::ConnectionFailed.new(error.class.name) + when Llm::Errors::NotReady, Llm::Errors::ConfigurationError + Errors::NotAvailable.new(error.class.name) + else + Errors::Upstream.new(error.class.name) + end + end + end + end +end From 330f6f1764163964e5cc3f7b7e54ae87d83e9e85 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 9 Sep 2026 17:20:44 +0200 Subject: [PATCH 03/46] [AI-100] Demo: send LLM instructions with the system role RubyLLM sends instructions with the role "developer" unless openai_use_system_role is set. OpenAI-compatible gateways such as the one used for this demo ignore that role, so the whole system prompt was dropped. This is a finding for the AI-3 stack (Llm::Session); patched here for the demo only. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017HL6QmQW5pV6v8FAig2gJB --- app/services/llm/session.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/services/llm/session.rb b/app/services/llm/session.rb index 8067b30a2e2c..d0d1f0854fc3 100644 --- a/app/services/llm/session.rb +++ b/app/services/llm/session.rb @@ -132,6 +132,9 @@ def context # RubyLLM retries POSTs three times by default, so one completion can be # billed four times. Callers state what they are willing to pay for. config.max_retries = max_retries + # Demo only (AI-100): OpenAI-compatible gateways ignore the "developer" role + # RubyLLM uses by default, which silently drops the whole system prompt. + config.openai_use_system_role = true # Not Rails.logger: RubyLLM wires its Faraday logger middleware with # "bodies: RubyLLM.logger.debug?", so an instance running at debug level # would write full request and response bodies through a path @@ -141,6 +144,7 @@ def context end end + def apply_credentials(config) key = connection.api_key.presence || (PLACEHOLDER_API_KEY if api_key_required?) From 42bf334f4aa038a3158f06e38f056654065d7fd8 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 10 Sep 2026 16:13:29 +0200 Subject: [PATCH 04/46] [AI-100] Demo: rework the sandbox UI Primer form with a context selector that explains when a work package or a project and type are needed, selects instead of raw id fields, a spinner with an elapsed timer while the run is polled, and a details dialog with the stored system prompt and the polled events. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017HL6QmQW5pV6v8FAig2gJB --- .../sandbox_component.html.erb | 135 +++++++------- .../sandbox_component.rb | 26 +++ .../text_transform_actions/sandbox_form.rb | 120 ++++++++++++ config/locales/en.yml | 28 +++ .../ai-text-transform-sandbox.controller.ts | 176 ++++++++++++++---- 5 files changed, 380 insertions(+), 105 deletions(-) create mode 100644 app/forms/admin/text_transform_actions/sandbox_form.rb diff --git a/app/components/admin/text_transform_actions/sandbox_component.html.erb b/app/components/admin/text_transform_actions/sandbox_component.html.erb index 839655cb6ba7..e314d30ee570 100644 --- a/app/components/admin/text_transform_actions/sandbox_component.html.erb +++ b/app/components/admin/text_transform_actions/sandbox_component.html.erb @@ -34,104 +34,101 @@ See COPYRIGHT and LICENSE files for more details. end box.with_body do - flex_layout(direction: :column, gap: 3) do |layout| - layout.with_row do + flex_layout(direction: :column) do |layout| + layout.with_row(mb: 3) do render(Primer::Beta::Text.new(tag: :p, color: :muted)) { label(:description) } end - layout.with_row do - render( - Primer::Alpha::TextArea.new( - name: "sandbox[content]", - label: label(:content_label), - rows: 8, - full_width: true, - data: target(:content) - ) - ) - end - - layout.with_row do - flex_layout(gap: 3, wrap: :wrap) do |fields| - fields.with_column do - render( - Primer::Alpha::TextField.new( - name: "sandbox[work_package_id]", - label: label(:work_package_id_label), - data: target(:workPackageId) - ) - ) - end - fields.with_column do - render( - Primer::Alpha::TextField.new( - name: "sandbox[project_id]", - label: label(:project_id_label), - data: target(:projectId) - ) - ) - end - fields.with_column do - render( - Primer::Alpha::TextField.new( - name: "sandbox[type_id]", - label: label(:type_id_label), - data: target(:typeId) - ) - ) - end + layout.with_row(mb: 3) do + helpers.primer_form_with(url: "#", method: :post, data: form_data) do |f| + render(Admin::TextTransformActions::SandboxForm.new(f, work_packages:, projects:, types:)) end end - layout.with_row do - flex_layout(gap: 2, align_items: :center) do |buttons| - buttons.with_column do + layout.with_row(mb: 3) do + flex_layout(align_items: :center) do |controls| + controls.with_column(mr: 2) do render( Primer::Beta::Button.new( scheme: :primary, + type: :button, data: target(:execute).merge(action: "ai-text-transform-sandbox#execute") ) ) { label(:execute) } end - buttons.with_column do + controls.with_column(mr: 3) do render( Primer::Beta::Button.new( scheme: :danger, + type: :button, disabled: true, data: target(:cancel).merge(action: "ai-text-transform-sandbox#cancel") ) ) { label(:cancel) } end - buttons.with_column do - render(Primer::Beta::Text.new(tag: :span, font_family: :mono, data: target(:status))) { "idle" } + controls.with_column(mr: 2, hidden: true, data: target(:spinner)) do + render(Primer::Beta::Spinner.new(size: :small)) + end + controls.with_column(mr: 2) do + render(Primer::Beta::Label.new(scheme: :secondary, data: target(:status))) { label(:status_idle) } + end + controls.with_column do + render(Primer::Beta::Text.new(tag: :span, font_family: :mono, color: :muted, data: target(:timer))) { "" } end end end - layout.with_row do - content_tag( - :pre, - "", - class: "op-uc-p", - style: "white-space: pre-wrap; min-height: 6rem;", - data: target(:output) - ) - end - - layout.with_row do - render(Primer::Beta::Details.new) do |details| - details.with_summary { label(:system_prompt) } - details.with_body do - content_tag(:pre, "", style: "white-space: pre-wrap; font-size: 12px;", data: target(:systemPrompt)) - end + layout.with_row(mb: 3) do + render(Primer::Box.new(border: true, border_radius: 2, p: 3, bg: :subtle)) do + content_tag( + :pre, + "", + class: "text-mono f5", + style: "white-space: pre-wrap; margin: 0; min-height: 6rem;", + data: target(:output) + ) end end layout.with_row do - render(Primer::Beta::Details.new) do |details| - details.with_summary { label(:raw_events) } - details.with_body do - content_tag(:pre, "", style: "white-space: pre-wrap; font-size: 12px;", data: target(:log)) + render(Primer::Alpha::Dialog.new(id: DIALOG_ID, title: label(:details_title), size: :xlarge)) do |dialog| + dialog.with_show_button(scheme: :default) { label(:details) } + dialog.with_header(variant: :large) + dialog.with_body do + flex_layout(direction: :column) do |details| + details.with_row(mb: 3) do + render(Primer::Beta::Heading.new(tag: :h3, font_size: 5, mb: 2)) { label(:run_section) } + end + details.with_row(mb: 4) do + content_tag(:dl, "", class: "text-mono f6", data: target(:runMeta)) + end + details.with_row(mb: 3) do + render(Primer::Beta::Heading.new(tag: :h3, font_size: 5, mb: 2)) { label(:system_prompt_section) } + end + details.with_row(mb: 4) do + render(Primer::Box.new(border: true, border_radius: 2, p: 3, bg: :subtle)) do + content_tag(:pre, "", class: "text-mono f6", style: "white-space: pre-wrap; margin: 0;", data: target(:systemPrompt)) + end + end + details.with_row(mb: 3) do + render(Primer::Beta::Heading.new(tag: :h3, font_size: 5, mb: 2)) { label(:events_section) } + end + details.with_row do + content_tag(:table, class: "text-mono f6 width-full") do + concat( + content_tag(:thead) do + content_tag(:tr) do + safe_join(%i[event_seq event_kind event_payload event_time].map { |key| content_tag(:th, label(key), class: "text-left p-2 border-bottom") }) + end + end + ) + concat(content_tag(:tbody, "", data: target(:eventsBody))) + end + end + end + end + dialog.with_footer(show_divider: true) do + render(Primer::Beta::Button.new(data: { close_dialog_id: DIALOG_ID })) { I18n.t(:button_close) } end end end diff --git a/app/components/admin/text_transform_actions/sandbox_component.rb b/app/components/admin/text_transform_actions/sandbox_component.rb index c249491198fd..488cfa639299 100644 --- a/app/components/admin/text_transform_actions/sandbox_component.rb +++ b/app/components/admin/text_transform_actions/sandbox_component.rb @@ -36,6 +36,10 @@ class SandboxComponent < ApplicationComponent include OpPrimer::ComponentHelpers include API::V3::Utilities::PathHelper + DIALOG_ID = "ai-text-transform-sandbox-details" + WORK_PACKAGE_LIMIT = 50 + PROJECT_LIMIT = 100 + alias action model private @@ -48,6 +52,13 @@ def controller_data } end + def form_data + { + controller: "show-when-value-selected", + action: "submit->ai-text-transform-sandbox#submit" + } + end + def target(name) { ai_text_transform_sandbox_target: name } end @@ -55,6 +66,21 @@ def target(name) def label(key) I18n.t("admin.text_transform_actions.sandbox.#{key}") end + + def work_packages + WorkPackage.visible(User.current) + .includes(:project, :type) + .order(updated_at: :desc) + .limit(WORK_PACKAGE_LIMIT) + end + + def projects + Project.visible(User.current).active.order(:name).limit(PROJECT_LIMIT) + end + + def types + Type.order(:position) + end end end end diff --git a/app/forms/admin/text_transform_actions/sandbox_form.rb b/app/forms/admin/text_transform_actions/sandbox_form.rb new file mode 100644 index 000000000000..d2b2d9a8bb66 --- /dev/null +++ b/app/forms/admin/text_transform_actions/sandbox_form.rb @@ -0,0 +1,120 @@ +# 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Admin + module TextTransformActions + class SandboxForm < ApplicationForm + MODE_TARGET_NAME = "sandbox_context_mode" + MODES = %w[none work_package new_work_package].freeze + + form do |f| + f.text_area( + name: :content, + label: label(:content_label), + caption: label(:content_caption), + rows: 10, + full_width: true, + data: target(:content) + ) + + f.select_list( + name: :context_mode, + label: label(:context_label), + caption: label(:context_caption), + include_blank: false, + data: target(:contextMode).merge(show_when_value_selected_target: "cause", target_name: MODE_TARGET_NAME) + ) do |select| + MODES.each { |mode| select.option(value: mode, label: label(:"context_#{mode}")) } + end + + f.group(hidden: true, data: effect_for("work_package")) do |group| + group.select_list( + name: :work_package_id, + label: label(:work_package_label), + caption: label(:work_package_caption), + include_blank: true, + data: target(:workPackageId) + ) do |select| + @work_packages.each do |work_package| + select.option(value: work_package.id, label: work_package_label(work_package)) + end + end + end + + f.group(hidden: true, data: effect_for("new_work_package")) do |group| + group.select_list( + name: :project_id, + label: label(:project_label), + caption: label(:new_work_package_caption), + include_blank: true, + data: target(:projectId) + ) do |select| + @projects.each { |project| select.option(value: project.id, label: project.name) } + end + + group.select_list( + name: :type_id, + label: label(:type_label), + caption: label(:type_caption), + include_blank: true, + data: target(:typeId) + ) do |select| + @types.each { |type| select.option(value: type.id, label: type.name) } + end + end + end + + def initialize(work_packages:, projects:, types:) + super() + @work_packages = work_packages + @projects = projects + @types = types + end + + private + + def label(key) + I18n.t("admin.text_transform_actions.sandbox.#{key}") + end + + def work_package_label(work_package) + "##{work_package.id} #{work_package.subject} (#{work_package.project.name}, #{work_package.type.name})" + end + + def target(name) + { ai_text_transform_sandbox_target: name } + end + + def effect_for(mode) + { show_when_value_selected_target: "effect", target_name: MODE_TARGET_NAME, value: mode } + end + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 2d94ff84ea4a..5910fcec0fb6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2341,6 +2341,34 @@ en: scope_specific_work_package_types: one: "%{count} work package type" other: "%{count} work package types" + sandbox: + cancel: "Cancel" + content_caption: "The markdown the editor would send. Nothing is saved anywhere; the run and its events are removed after the retention period." + content_label: "Text to transform" + context_caption: "Without a context only actions scoped \"everywhere\" run. Actions scoped to work package types need a type, and actions that inject the type template need the template of the type variant, which comes from the work package or from the project and type of a new work package." + context_label: "Context" + context_new_work_package: "New work package (project and type)" + context_none: "No context" + context_work_package: "Existing work package" + description: "Prototype: runs this action against the text below through the execute API and polls the run until it finishes, exactly like the editor will." + details: "Run details" + details_title: "Run details" + event_kind: "Kind" + event_payload: "Payload" + event_seq: "Seq" + event_time: "Received" + events_section: "Events, as polled" + execute: "Execute" + new_work_package_caption: "Mirrors the create form: the project decides which variant of the type applies, and the assistant checks the add permission in the project. The type must be enabled in the project." + project_label: "Project" + run_section: "Run" + status_idle: "idle" + system_prompt_section: "System prompt stored for this run" + title: "Sandbox" + type_caption: "Only the 50 most recently updated work packages and the first 100 projects are listed." + type_label: "Type" + work_package_caption: "The work package's project and type decide scope and template, and the assistant checks the edit permission on it." + work_package_label: "Work package" usage_scopes: all_work_package_types: "All work package types" everywhere: "Everywhere" diff --git a/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts b/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts index 13ff98b050d2..94d600f4580f 100644 --- a/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts @@ -46,6 +46,17 @@ const TERMINAL_STATUSES = ['succeeded', 'failed', 'cancelled']; const MIN_DELAY = 400; const MAX_DELAY = 1000; const DELAY_STEP = 150; +const TIMER_TICK = 100; +const LABEL_SCHEMES:Record = { + idle: 'Label--secondary', + creating: 'Label--secondary', + queued: 'Label--secondary', + running: 'Label--accent', + succeeded: 'Label--success', + failed: 'Label--danger', + cancelled: 'Label--attention', + error: 'Label--danger', +}; /** * Prototype sandbox for the description assistant: posts the textarea content @@ -53,29 +64,39 @@ const DELAY_STEP = 150; */ export default class AiTextTransformSandboxController extends Controller { static targets = [ - 'content', 'workPackageId', 'projectId', 'typeId', 'execute', 'cancel', 'status', 'output', 'log', 'systemPrompt', + 'content', 'contextMode', 'workPackageId', 'projectId', 'typeId', + 'execute', 'cancel', 'spinner', 'status', 'timer', 'output', + 'runMeta', 'systemPrompt', 'eventsBody', ]; static values = { url: String, actionId: Number }; declare readonly contentTarget:HTMLTextAreaElement; - declare readonly workPackageIdTarget:HTMLInputElement; - declare readonly projectIdTarget:HTMLInputElement; - declare readonly typeIdTarget:HTMLInputElement; + declare readonly contextModeTarget:HTMLSelectElement; + declare readonly workPackageIdTarget:HTMLSelectElement; + declare readonly projectIdTarget:HTMLSelectElement; + declare readonly typeIdTarget:HTMLSelectElement; declare readonly executeTarget:HTMLButtonElement; declare readonly cancelTarget:HTMLButtonElement; + declare readonly spinnerTarget:HTMLElement; declare readonly statusTarget:HTMLElement; + declare readonly timerTarget:HTMLElement; declare readonly outputTarget:HTMLElement; - declare readonly logTarget:HTMLElement; + declare readonly runMetaTarget:HTMLElement; declare readonly systemPromptTarget:HTMLElement; + declare readonly eventsBodyTarget:HTMLElement; declare readonly urlValue:string; declare readonly actionIdValue:number; + private runId:string|null = null; private runUrl:string|null = null; private cancelUrl:string|null = null; private cursor = 0; private delay = MIN_DELAY; - private timer:ReturnType|null = null; + private pollTimer:ReturnType|null = null; + private tickTimer:ReturnType|null = null; + private startedAt = 0; + private finishedAt:number|null = null; private inFlight = false; private text = ''; private readonly onVisibilityChange = () => { this.handleVisibilityChange(); }; @@ -87,25 +108,34 @@ export default class AiTextTransformSandboxController extends Controller { this.reset(); - this.setStatus('creating run'); + this.setStatus('creating'); + this.startTimer(); const response = await this.request(this.urlValue, 'POST', JSON.stringify(this.body())); if (!response.ok) { - this.setStatus(`create failed: HTTP ${response.status}`); - this.appendLog(await response.text()); + this.stopTimer(); + this.setStatus('error'); + this.outputTarget.textContent = `HTTP ${response.status}\n\n${await response.text()}`; this.executeTarget.disabled = false; + this.spinnerTarget.hidden = true; return; } const run = await response.json() as RunResponse; + this.runId = run.id; this.runUrl = run._links.self.href; this.cancelUrl = run._links.cancel?.href ?? null; this.cancelTarget.disabled = this.cancelUrl === null; - this.appendLog(`created ${run.id}`); this.systemPromptTarget.textContent = run.systemPrompt ?? ''; this.apply(run); this.schedule(); @@ -115,9 +145,8 @@ export default class AiTextTransformSandboxController extends Controller HTTP ${response.status}`); + this.cancelTarget.disabled = true; + await this.request(this.cancelUrl, 'POST'); } private body() { @@ -125,22 +154,28 @@ export default class AiTextTransformSandboxController extends Controller { void this.poll(); }, this.delay); + this.pollTimer = setTimeout(() => { void this.poll(); }, this.delay); this.delay = Math.min(MAX_DELAY, this.delay + DELAY_STEP); } @@ -158,7 +193,9 @@ export default class AiTextTransformSandboxController extends Controller { this.cursor = Math.max(this.cursor, event.seq); - this.appendLog(`#${event.seq} ${event.kind} ${JSON.stringify(event.payload)}`); + this.appendEventRow(event); switch (event.kind) { case 'text_delta': this.text += event.payload.delta ?? ''; @@ -197,11 +234,19 @@ export default class AiTextTransformSandboxController extends Controller { @@ -219,36 +264,95 @@ export default class AiTextTransformSandboxController extends Controller this.renderTimer(), TIMER_TICK); + this.renderTimer(); + } + + private stopTimer():void { + if (this.tickTimer !== null) { + clearInterval(this.tickTimer); + this.tickTimer = null; + } + } + + private renderTimer():void { + const end = this.finishedAt ?? performance.now(); + const seconds = ((end - this.startedAt) / 1000).toFixed(1); + this.timerTarget.textContent = this.finishedAt === null ? `${seconds} s` : `${seconds} s total`; } private stopPolling():void { - if (this.timer !== null) { - clearTimeout(this.timer); - this.timer = null; + if (this.pollTimer !== null) { + clearTimeout(this.pollTimer); + this.pollTimer = null; } } private handleVisibilityChange():void { - if (!document.hidden && this.runUrl && this.timer === null && !this.inFlight) { + if (!document.hidden && this.runUrl && this.pollTimer === null && !this.inFlight) { this.schedule(); } } private setStatus(status:string):void { this.statusTarget.textContent = status; + Object.values(LABEL_SCHEMES).forEach((cls) => this.statusTarget.classList.remove(cls)); + this.statusTarget.classList.add(LABEL_SCHEMES[status] ?? 'Label--secondary'); } - private appendLog(line:string):void { - this.logTarget.textContent += `${new Date().toISOString().slice(11, 23)} ${line}\n`; + private renderRunMeta(status:string):void { + const rows:[string, string][] = [ + ['Run', this.runId ?? ''], + ['Status', status], + ['Events', String(this.cursor)], + ['Elapsed', this.timerTarget.textContent ?? ''], + ]; + this.runMetaTarget.textContent = ''; + rows.forEach(([term, value]) => { + const dt = document.createElement('dt'); + dt.textContent = term; + dt.className = 'text-bold'; + const dd = document.createElement('dd'); + dd.textContent = value; + dd.className = 'mb-2'; + this.runMetaTarget.append(dt, dd); + }); + } + + private appendEventRow(event:RunEvent):void { + const row = document.createElement('tr'); + const cells = [ + String(event.seq), + event.kind, + JSON.stringify(event.payload), + new Date().toISOString().slice(11, 23), + ]; + cells.forEach((value, index) => { + const cell = document.createElement('td'); + cell.textContent = value; + cell.className = index === 2 ? 'p-2 border-bottom wb-break-all' : 'p-2 border-bottom no-wrap'; + row.append(cell); + }); + this.eventsBodyTarget.append(row); } } From f63dba34fb3eebb48c1b816e169fafb7ae1c355b Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 10 Sep 2026 16:47:24 +0200 Subject: [PATCH 05/46] [AI-100] Demo: searchable work package and project pickers in the sandbox Replaces the preloaded selects with Primer select panels that fetch up to 20 matches from a small admin-only search endpoint while typing, so the sandbox also works on installations with many work packages and projects. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017HL6QmQW5pV6v8FAig2gJB --- .../sandbox_component.html.erb | 2 +- .../sandbox_component.rb | 13 --- .../sandbox_controller.rb | 88 +++++++++++++++++++ .../text_transform_actions/sandbox_form.rb | 85 ++++++++++++------ config/locales/en.yml | 8 +- config/routes.rb | 2 + .../ai-text-transform-sandbox.controller.ts | 17 ++-- 7 files changed, 164 insertions(+), 51 deletions(-) create mode 100644 app/controllers/admin/text_transform_actions/sandbox_controller.rb diff --git a/app/components/admin/text_transform_actions/sandbox_component.html.erb b/app/components/admin/text_transform_actions/sandbox_component.html.erb index e314d30ee570..5c4c7423f18d 100644 --- a/app/components/admin/text_transform_actions/sandbox_component.html.erb +++ b/app/components/admin/text_transform_actions/sandbox_component.html.erb @@ -41,7 +41,7 @@ See COPYRIGHT and LICENSE files for more details. layout.with_row(mb: 3) do helpers.primer_form_with(url: "#", method: :post, data: form_data) do |f| - render(Admin::TextTransformActions::SandboxForm.new(f, work_packages:, projects:, types:)) + render(Admin::TextTransformActions::SandboxForm.new(f, types:)) end end diff --git a/app/components/admin/text_transform_actions/sandbox_component.rb b/app/components/admin/text_transform_actions/sandbox_component.rb index 488cfa639299..e427870dde3b 100644 --- a/app/components/admin/text_transform_actions/sandbox_component.rb +++ b/app/components/admin/text_transform_actions/sandbox_component.rb @@ -37,8 +37,6 @@ class SandboxComponent < ApplicationComponent include API::V3::Utilities::PathHelper DIALOG_ID = "ai-text-transform-sandbox-details" - WORK_PACKAGE_LIMIT = 50 - PROJECT_LIMIT = 100 alias action model @@ -67,17 +65,6 @@ def label(key) I18n.t("admin.text_transform_actions.sandbox.#{key}") end - def work_packages - WorkPackage.visible(User.current) - .includes(:project, :type) - .order(updated_at: :desc) - .limit(WORK_PACKAGE_LIMIT) - end - - def projects - Project.visible(User.current).active.order(:name).limit(PROJECT_LIMIT) - end - def types Type.order(:position) end diff --git a/app/controllers/admin/text_transform_actions/sandbox_controller.rb b/app/controllers/admin/text_transform_actions/sandbox_controller.rb new file mode 100644 index 000000000000..2ef6cfb547ff --- /dev/null +++ b/app/controllers/admin/text_transform_actions/sandbox_controller.rb @@ -0,0 +1,88 @@ +# 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Admin + module TextTransformActions + # Prototype only: serves the searchable item lists for the sandbox's + # work package and project pickers. + class SandboxController < ::ApplicationController + include AI::TextTransformActionsFeature + + LIMIT = 20 + + before_action :require_admin + before_action :require_ai_text_transform_actions_feature + + def work_packages + items = work_package_scope.order(updated_at: :desc).limit(LIMIT).map do |work_package| + [work_package.id, work_package_label(work_package)] + end + render_items(items) + end + + def projects + scope = Project.visible(current_user).active + scope = scope.where("projects.name ILIKE ?", "%#{sanitize_sql_like(query)}%") if query.present? + items = scope.order(:name).limit(LIMIT).map { |project| [project.id, project.name] } + render_items(items) + end + + private + + def work_package_scope + scope = WorkPackage.visible(current_user).includes(:project, :type) + return scope if query.blank? + + matching = scope.where("work_packages.subject ILIKE ?", "%#{sanitize_sql_like(query)}%") + query.match?(/\A\d+\z/) ? matching.or(scope.where(id: query.to_i)) : matching + end + + def work_package_label(work_package) + "##{work_package.id} #{work_package.subject} (#{work_package.project.name}, #{work_package.type.name})" + end + + def query + @query ||= params[:q].to_s.strip + end + + def sanitize_sql_like(value) + ActiveRecord::Base.sanitize_sql_like(value) + end + + def render_items(items) + list = Primer::Alpha::SelectPanel::ItemList.new + items.each do |value, label| + list.with_item(label:, content_arguments: { data: { value: } }) + end + render html: list.render_in(view_context), layout: false + end + end + end +end diff --git a/app/forms/admin/text_transform_actions/sandbox_form.rb b/app/forms/admin/text_transform_actions/sandbox_form.rb index d2b2d9a8bb66..c2f077bb6fbf 100644 --- a/app/forms/admin/text_transform_actions/sandbox_form.rb +++ b/app/forms/admin/text_transform_actions/sandbox_form.rb @@ -33,6 +33,8 @@ module TextTransformActions class SandboxForm < ApplicationForm MODE_TARGET_NAME = "sandbox_context_mode" MODES = %w[none work_package new_work_package].freeze + WORK_PACKAGE_PANEL_ID = "ai-text-transform-sandbox-work-package" + PROJECT_PANEL_ID = "ai-text-transform-sandbox-project" form do |f| f.text_area( @@ -55,29 +57,11 @@ class SandboxForm < ApplicationForm end f.group(hidden: true, data: effect_for("work_package")) do |group| - group.select_list( - name: :work_package_id, - label: label(:work_package_label), - caption: label(:work_package_caption), - include_blank: true, - data: target(:workPackageId) - ) do |select| - @work_packages.each do |work_package| - select.option(value: work_package.id, label: work_package_label(work_package)) - end - end + group.html_content { work_package_control } end f.group(hidden: true, data: effect_for("new_work_package")) do |group| - group.select_list( - name: :project_id, - label: label(:project_label), - caption: label(:new_work_package_caption), - include_blank: true, - data: target(:projectId) - ) do |select| - @projects.each { |project| select.option(value: project.id, label: project.name) } - end + group.html_content { project_control } group.select_list( name: :type_id, @@ -91,21 +75,68 @@ class SandboxForm < ApplicationForm end end - def initialize(work_packages:, projects:, types:) + def initialize(types:) super() - @work_packages = work_packages - @projects = projects @types = types end private - def label(key) - I18n.t("admin.text_transform_actions.sandbox.#{key}") + def work_package_control + form_control(label(:work_package_label), label(:work_package_caption)) do |input_arguments| + work_package_panel(input_arguments) + end + end + + def project_control + form_control(label(:project_label), label(:new_work_package_caption)) do |input_arguments| + project_panel(input_arguments) + end end - def work_package_label(work_package) - "##{work_package.id} #{work_package.subject} (#{work_package.project.name}, #{work_package.type.name})" + def form_control(label, caption, &) + render(Primer::Alpha::FormControl.new(label:, caption:)) do |control| + control.with_input(&) + end + end + + def work_package_panel(input_arguments) + remote_panel(input_arguments, + id: WORK_PACKAGE_PANEL_ID, + name: :work_package_id, + src: url_helpers.sandbox_work_packages_admin_text_transform_actions_path, + title: label(:select_work_package)) + end + + def project_panel(input_arguments) + remote_panel(input_arguments, + id: PROJECT_PANEL_ID, + name: :project_id, + src: url_helpers.sandbox_projects_admin_text_transform_actions_path, + title: label(:select_project)) + end + + def remote_panel(input_arguments, id:, name:, src:, title:) + render( + Primer::Alpha::SelectPanel.new( + id:, + select_variant: :single, + fetch_strategy: :remote, + src:, + title:, + dynamic_label: true, + form_arguments: { builder: @builder, name: } + ) + ) do |panel| + panel.with_show_button(scheme: :secondary, aria: { describedby: input_arguments.dig(:aria, :describedby) }) do |button| + button.with_trailing_action_icon(icon: :"triangle-down") + title + end + end + end + + def label(key) + I18n.t("admin.text_transform_actions.sandbox.#{key}") end def target(name) diff --git a/config/locales/en.yml b/config/locales/en.yml index 5910fcec0fb6..b6d07bdee0ab 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2359,15 +2359,17 @@ en: event_time: "Received" events_section: "Events, as polled" execute: "Execute" - new_work_package_caption: "Mirrors the create form: the project decides which variant of the type applies, and the assistant checks the add permission in the project. The type must be enabled in the project." + new_work_package_caption: "Mirrors the create form: the project decides which variant of the type applies, and the assistant checks the add permission in the project." project_label: "Project" run_section: "Run" + select_project: "Select project" + select_work_package: "Select work package" status_idle: "idle" system_prompt_section: "System prompt stored for this run" title: "Sandbox" - type_caption: "Only the 50 most recently updated work packages and the first 100 projects are listed." + type_caption: "Type to search; the 20 best matches are listed. Only types enabled in the chosen project are accepted." type_label: "Type" - work_package_caption: "The work package's project and type decide scope and template, and the assistant checks the edit permission on it." + work_package_caption: "Type an id or part of the subject; the 20 most recently updated matches are listed. The work package's project and type decide scope and template, and the assistant checks the edit permission on it." work_package_label: "Work package" usage_scopes: all_work_package_types: "All work package types" diff --git a/config/routes.rb b/config/routes.rb index 7aa5745b5200..7efe384020e6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1177,6 +1177,8 @@ put :enable_all put :disable_all post :toggle_setting + get :sandbox_work_packages, to: "text_transform_actions/sandbox#work_packages" + get :sandbox_projects, to: "text_transform_actions/sandbox#projects" end end diff --git a/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts b/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts index 94d600f4580f..05aa459070c3 100644 --- a/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts @@ -64,7 +64,7 @@ const LABEL_SCHEMES:Record = { */ export default class AiTextTransformSandboxController extends Controller { static targets = [ - 'content', 'contextMode', 'workPackageId', 'projectId', 'typeId', + 'content', 'contextMode', 'typeId', 'execute', 'cancel', 'spinner', 'status', 'timer', 'output', 'runMeta', 'systemPrompt', 'eventsBody', ]; @@ -73,8 +73,6 @@ export default class AiTextTransformSandboxController extends Controller(`input[type="hidden"][name="${name}"]`); + return input?.value ?? ''; + } + private schedule():void { this.stopPolling(); this.pollTimer = setTimeout(() => { void this.poll(); }, this.delay); From 4ccf726f6255fc80c0a437a585d02b186a49d5ed Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 10 Sep 2026 16:55:36 +0200 Subject: [PATCH 06/46] [AI-100] Demo: save-and-stay button on the action form A second submit button updates the action and answers with a Turbo Stream that replaces only the form, so the sandbox below keeps its state instead of being lost to the redirect to the index page. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017HL6QmQW5pV6v8FAig2gJB --- .../admin/text_transform_actions_controller.rb | 17 +++++++++++++---- app/forms/ai/text_transform_actions/form.rb | 8 ++++++++ config/locales/en.yml | 1 + 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/app/controllers/admin/text_transform_actions_controller.rb b/app/controllers/admin/text_transform_actions_controller.rb index ca26a3ebdd96..e30bb7b3441e 100644 --- a/app/controllers/admin/text_transform_actions_controller.rb +++ b/app/controllers/admin/text_transform_actions_controller.rb @@ -80,10 +80,7 @@ def update end end - result.on_success do - flash[:notice] = t(:notice_successful_update) - redirect_to action: :index - end + result.on_success { respond_to_successful_update } end def deletion_dialog @@ -145,6 +142,18 @@ def toggle_setting private + def respond_to_successful_update + if params.key?(:save_and_stay) + render_success_flash_message_via_turbo_stream(message: t(:notice_successful_update)) + stream_form_component do |format| + format.html { redirect_to action: :edit } + end + else + flash[:notice] = t(:notice_successful_update) + redirect_to action: :index + end + end + def find_text_transform_action @text_transform_action = AI::TextTransformAction.find(params.expect(:id)) end diff --git a/app/forms/ai/text_transform_actions/form.rb b/app/forms/ai/text_transform_actions/form.rb index 81c45ec60f37..62f9eb5c6b2e 100644 --- a/app/forms/ai/text_transform_actions/form.rb +++ b/app/forms/ai/text_transform_actions/form.rb @@ -110,6 +110,14 @@ class Form < ApplicationForm label: model.persisted? ? I18n.t(:button_save) : I18n.t(:button_create), scheme: :primary ) + + if model.persisted? + f.submit( + name: :save_and_stay, + label: I18n.t("admin.text_transform_actions.form.save_and_stay"), + scheme: :default + ) + end end private diff --git a/config/locales/en.yml b/config/locales/en.yml index b6d07bdee0ab..73fcfb2a4549 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2318,6 +2318,7 @@ en: form: injects_type_template_caption: "If checked, the description template of the work package type is automatically added to the prompt. The prompt should contain instructions on what to do with the template." label_caption: "This is the name that will be visible to users in the editor." + save_and_stay: "Save and stay" select_types: "Select types" types_caption: "Add work package types for which this action should be available. All available variants will automatically be included." usage_scope_caption: "You can limit where this action is visible: everywhere (including comments, wikis and custom fields), for all work package types or for certain work package types." From dad1255ceab00c81cbf1b4d312139bccdd9347d0 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 10 Sep 2026 17:06:46 +0200 Subject: [PATCH 07/46] [AI-100] Demo: mark the prototype-only part of the action form Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017HL6QmQW5pV6v8FAig2gJB --- app/forms/ai/text_transform_actions/form.rb | 6 ++++++ config/locales/en.yml | 1 + 2 files changed, 7 insertions(+) diff --git a/app/forms/ai/text_transform_actions/form.rb b/app/forms/ai/text_transform_actions/form.rb index 62f9eb5c6b2e..6a01a9e3a3b8 100644 --- a/app/forms/ai/text_transform_actions/form.rb +++ b/app/forms/ai/text_transform_actions/form.rb @@ -112,6 +112,12 @@ class Form < ApplicationForm ) if model.persisted? + f.separator + f.html_content do + render(Primer::Beta::Text.new(tag: :p, color: :muted, font_weight: :bold, mb: 2)) do + I18n.t("admin.text_transform_actions.form.prototype_only") + end + end f.submit( name: :save_and_stay, label: I18n.t("admin.text_transform_actions.form.save_and_stay"), diff --git a/config/locales/en.yml b/config/locales/en.yml index 73fcfb2a4549..c00cd0f6700a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2318,6 +2318,7 @@ en: form: injects_type_template_caption: "If checked, the description template of the work package type is automatically added to the prompt. The prompt should contain instructions on what to do with the template." label_caption: "This is the name that will be visible to users in the editor." + prototype_only: "Prototype-only UI below:" save_and_stay: "Save and stay" select_types: "Select types" types_caption: "Add work package types for which this action should be available. All available variants will automatically be included." From df6716e429d4b8b1585ddfdca231a545673b1afe Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 16 Sep 2026 17:43:30 +0200 Subject: [PATCH 08/46] [AI-100] Demo: expose the stored system prompt in the run representer The admin sandbox shows the assembled system prompt in its details dialog. The real API (AI-136) keeps it hidden; this is demo-only. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017HL6QmQW5pV6v8FAig2gJB --- lib/api/v3/ai/text_transform_run_representer.rb | 1 + spec/lib/api/v3/ai/text_transform_run_representer_spec.rb | 5 ++++- spec/requests/api/v3/ai/text_transform_runs_api_spec.rb | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/api/v3/ai/text_transform_run_representer.rb b/lib/api/v3/ai/text_transform_run_representer.rb index 404a3e9720f6..e6dc15da5931 100644 --- a/lib/api/v3/ai/text_transform_run_representer.rb +++ b/lib/api/v3/ai/text_transform_run_representer.rb @@ -47,6 +47,7 @@ class TextTransformRunRepresenter < ::API::Decorators::Single property :uuid, as: :id property :status + property :system_prompt, as: :systemPrompt property :events, exec_context: :decorator def initialize(model, current_user:, after: 0, embed_links: false) diff --git a/spec/lib/api/v3/ai/text_transform_run_representer_spec.rb b/spec/lib/api/v3/ai/text_transform_run_representer_spec.rb index 245fe51dd8c4..ea7a3e759e36 100644 --- a/spec/lib/api/v3/ai/text_transform_run_representer_spec.rb +++ b/spec/lib/api/v3/ai/text_transform_run_representer_spec.rb @@ -59,7 +59,10 @@ let(:value) { "queued" } end - it_behaves_like "no property", :systemPrompt + it_behaves_like "property", :systemPrompt do + let(:value) { run.system_prompt } + end + it_behaves_like "no property", :uuid describe "events" do diff --git a/spec/requests/api/v3/ai/text_transform_runs_api_spec.rb b/spec/requests/api/v3/ai/text_transform_runs_api_spec.rb index 37237bf9353c..34521f318af1 100644 --- a/spec/requests/api/v3/ai/text_transform_runs_api_spec.rb +++ b/spec/requests/api/v3/ai/text_transform_runs_api_spec.rb @@ -90,7 +90,7 @@ def post_cancel(run) .to be_json_eql(api_v3_paths.ai_text_transform_run(run.uuid).to_json).at_path("_links/self/href") expect(last_response.body) .to be_json_eql(api_v3_paths.ai_text_transform_run_cancel(run.uuid).to_json).at_path("_links/cancel/href") - expect(last_response.body).not_to have_json_path("systemPrompt") + expect(last_response.body).to have_json_path("systemPrompt") end end From 194f9064758de445657b899f8c58e7dca664a04b Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 17 Sep 2026 14:42:20 +0200 Subject: [PATCH 09/46] [AI-100] Demo: keep the sandbox timer at the final value after a run ends Returning to the tab after a run had finished scheduled one more poll, which re-entered finish() and moved finishedAt to the current time, so the timer showed the time away from the tab instead of the run duration. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017HL6QmQW5pV6v8FAig2gJB --- .../dynamic/ai-text-transform-sandbox.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts b/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts index 05aa459070c3..f6adb49a14e1 100644 --- a/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts @@ -245,7 +245,7 @@ export default class AiTextTransformSandboxController extends Controller Date: Mon, 21 Sep 2026 17:22:13 +0200 Subject: [PATCH 10/46] [AI-100] Demo: check cancellation and budget while a model only reasons Reasoning models stream minutes of chunks without content. The gateway dropped them, so the execution never got a chance to look at the cancel wish or its time budget before the first content chunk. Empty chunks are now passed on as empty deltas. The gateway also restores any error the caller raises inside the stream block, not only the cancellation: Llm::Errors.wrap turned an exceeded budget into a generic upstream error. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017HL6QmQW5pV6v8FAig2gJB --- .../ai/text_transforms/llm_gateway.rb | 19 ++++++++------- .../ai/text_transforms/execution_spec.rb | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/app/services/ai/text_transforms/llm_gateway.rb b/app/services/ai/text_transforms/llm_gateway.rb index 737087ced333..1c099e09aea3 100644 --- a/app/services/ai/text_transforms/llm_gateway.rb +++ b/app/services/ai/text_transforms/llm_gateway.rb @@ -43,13 +43,13 @@ def readiness end def stream(system:, user:, timeout:, &) - @cancelled = nil + @raised_by_caller = nil response = chat(system, timeout).ask(user) { |chunk| forward(chunk, &) } response.content.to_s - rescue Cancelled - raise rescue StandardError => e - raise @cancelled if @cancelled + # Llm::Errors.wrap turns whatever the block raises into an Llm::Errors::Error, + # so the caller's own signal (cancellation, exceeded budget) is restored here. + raise @raised_by_caller if @raised_by_caller raise translate(e) end @@ -60,13 +60,12 @@ def chat(system, timeout) Llm::Runtime.for(FEATURE).chat(timeout:, max_retries: 0).with_instructions(system) end + # Reasoning models stream minutes of chunks without content. They are passed + # on as empty deltas so the caller can check cancellation and its budget. def forward(chunk) - content = chunk.content - return if content.to_s.empty? - - yield content - rescue Cancelled => e - @cancelled = e + yield chunk.content.to_s + rescue Cancelled, Errors::Error => e + @raised_by_caller = e raise end diff --git a/spec/services/ai/text_transforms/execution_spec.rb b/spec/services/ai/text_transforms/execution_spec.rb index 48bf86057ae7..814208db8be6 100644 --- a/spec/services/ai/text_transforms/execution_spec.rb +++ b/spec/services/ai/text_transforms/execution_spec.rb @@ -107,6 +107,29 @@ def events expect(events).to eq([[1, "status", { "status" => "running" }]]) end + it "ends cancelled while a reasoning model only sends empty deltas" do + gateway = AI::TextTransforms::FakeGateway.new(deltas: ["", "", ""], + before_each: lambda { + advance.call(0.6) + run.update_column(:cancel_requested, true) + }) + + execute(gateway) + + expect(run).to be_cancelled + expect(events).to eq([[1, "status", { "status" => "running" }]]) + end + + it "fails with timeout when only empty deltas arrive past the budget" do + gateway = AI::TextTransforms::FakeGateway.new(deltas: ["", ""], before_each: -> { advance.call(200) }) + + execute(gateway) + + expect(run).to be_failed + expect(events.last[2]).to include("reason" => "timeout") + expect(events.pluck(1)).not_to include("text_delta") + end + it "stops silently when the run was deleted mid-stream" do gateway = AI::TextTransforms::FakeGateway.new(deltas: %w[a b], before_each: lambda { From a88f335a4c8854dfb0d4b45d47a857423a8ae089 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 21 Sep 2026 17:22:13 +0200 Subject: [PATCH 11/46] [AI-100] Demo: simulate a blocked and a failed generation per run AI-126 asks for a "generation stopped" state when a guardrail blocks the stream. The event vocabulary has no such reason and the real signal of the LLM stack is unknown, so the demo adds the reason "blocked" and a gateway wrapper that produces it, or a connection failure, when the run API is called with the demo-only parameter demoFault. Also holds the locale keys of the editor menu and the result popover. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017HL6QmQW5pV6v8FAig2gJB --- app/services/ai/text_transforms/create_run.rb | 7 +- .../ai/text_transforms/demo_fault_gateway.rb | 78 +++++++++++++++++++ app/services/ai/text_transforms/errors.rb | 5 ++ app/services/ai/text_transforms/gateway.rb | 2 +- config/locales/en.yml | 21 +++++ lib/api/v3/ai/text_transform_runs_api.rb | 3 +- .../demo_fault_gateway_spec.rb | 70 +++++++++++++++++ 7 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 app/services/ai/text_transforms/demo_fault_gateway.rb create mode 100644 spec/services/ai/text_transforms/demo_fault_gateway_spec.rb diff --git a/app/services/ai/text_transforms/create_run.rb b/app/services/ai/text_transforms/create_run.rb index 1f49ec5dab72..ca571aee527b 100644 --- a/app/services/ai/text_transforms/create_run.rb +++ b/app/services/ai/text_transforms/create_run.rb @@ -31,12 +31,13 @@ module AI module TextTransforms class CreateRun - def initialize(user:, action:, context:, content:, availability: Availability.new) + def initialize(user:, action:, context:, content:, availability: Availability.new, demo_fault: nil) @user = user @action = action @context = context @content = content @availability = availability + @demo_fault = demo_fault end def call @@ -50,7 +51,7 @@ def call private - attr_reader :user, :action, :context, :content, :availability + attr_reader :user, :action, :context, :content, :availability, :demo_fault def unavailable(run) run.errors.add(:base, :not_available, reason: unavailable_reason) @@ -68,7 +69,7 @@ def build_run def system_prompt return "" if action.nil? - Prompt.build(action:, context:, content:).system + DemoFaultGateway.mark(Prompt.build(action:, context:, content:).system, demo_fault) end end end diff --git a/app/services/ai/text_transforms/demo_fault_gateway.rb b/app/services/ai/text_transforms/demo_fault_gateway.rb new file mode 100644 index 000000000000..18299c6a4d28 --- /dev/null +++ b/app/services/ai/text_transforms/demo_fault_gateway.rb @@ -0,0 +1,78 @@ +# 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module TextTransforms + # Demo only (AI-126): lets the result popover show its "stopped" and "failed" + # states on demand. The real signal of a guardrail that blocks a stream is + # unknown, so it is simulated on top of the real gateway. + class DemoFaultGateway < Gateway + FAULTS = %w[blocked failed].freeze + MARKER = /\n\n\[\[demo-fault:(#{FAULTS.join('|')})\]\]\z/ + BLOCK_AFTER = 2 + + def self.mark(system_prompt, fault) + FAULTS.include?(fault) ? "#{system_prompt}\n\n[[demo-fault:#{fault}]]" : system_prompt + end + + def initialize(gateway) + super() + @gateway = gateway + end + + delegate :readiness, to: :gateway + + def stream(system:, user:, timeout:, &) + fault = system[MARKER, 1] + system = system.sub(MARKER, "") + raise Errors::ConnectionFailed, "demo fault" if fault == "failed" + return gateway.stream(system:, user:, timeout:, &) unless fault == "blocked" + + stream_until_blocked(system:, user:, timeout:, &) + end + + private + + attr_reader :gateway + + def stream_until_blocked(system:, user:, timeout:) + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + gateway.stream(system:, user:, timeout:) do |delta| + raise Errors::Blocked, "demo fault" if Process.clock_gettime(Process::CLOCK_MONOTONIC) - started > BLOCK_AFTER + + yield delta + end + + raise Errors::Blocked, "demo fault" + end + end + end +end diff --git a/app/services/ai/text_transforms/errors.rb b/app/services/ai/text_transforms/errors.rb index 29307c051d60..e0b4cd9be51b 100644 --- a/app/services/ai/text_transforms/errors.rb +++ b/app/services/ai/text_transforms/errors.rb @@ -50,6 +50,11 @@ class TimedOut < Error class Upstream < Error REASON = :upstream_error end + + # Demo only (AI-126): a guardrail stopped the stream, the partial text must not be used. + class Blocked < Error + REASON = :blocked + end end end end diff --git a/app/services/ai/text_transforms/gateway.rb b/app/services/ai/text_transforms/gateway.rb index 710954aeb177..f68190e2cd8b 100644 --- a/app/services/ai/text_transforms/gateway.rb +++ b/app/services/ai/text_transforms/gateway.rb @@ -35,7 +35,7 @@ class Gateway def ready? = ready end - def self.build = LlmGateway.new + def self.build = DemoFaultGateway.new(LlmGateway.new) end end end diff --git a/config/locales/en.yml b/config/locales/en.yml index c00cd0f6700a..b396c70f292e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2425,10 +2425,31 @@ en: ai: text_transform: errors: + blocked: "The response was blocked before it finished." connection_failed: "The AI service could not be reached." not_available: "The AI assistant is not available." timeout: "The AI service did not respond in time." upstream_error: "The AI service returned an error." + menu: + label: "AI actions" + popover: + cancel: "Cancel" + close: "Close" + context_description: "Work package description" + copied: "Copied" + copy: "Copy" + discard: "Discard" + editor_gone: "The editor is no longer open, so the text cannot be replaced. You can still copy it." + failed_title: "Generation failed" + generating: "Generating…" + prototype_controls: "Prototype controls" + prototype_force_error: "force error" + prototype_force_guardrail: "force guardrail" + prototype_reset: "reset" + replace: "Replace" + stopped_description: "The response was blocked before it finished, so the partial text was discarded." + stopped_title: "Generation stopped" + try_again: "Try again" announcements: content: "Content" is_active: currently displayed diff --git a/lib/api/v3/ai/text_transform_runs_api.rb b/lib/api/v3/ai/text_transform_runs_api.rb index 0fd6191df4b9..adcb57bb91f1 100644 --- a/lib/api/v3/ai/text_transform_runs_api.rb +++ b/lib/api/v3/ai/text_transform_runs_api.rb @@ -78,6 +78,7 @@ def raise_create_failure(errors) optional :workPackageId, type: Integer optional :projectId, type: Integer optional :typeId, type: Integer + optional :demoFault, type: String, values: ::AI::TextTransforms::DemoFaultGateway::FAULTS mutually_exclusive :workPackageId, :projectId all_or_none_of :projectId, :typeId end @@ -87,7 +88,7 @@ def raise_create_failure(errors) context = context_from_params action = ::AI::TextTransformAction.find_by(id: params[:actionId]) call = ::AI::TextTransforms::CreateRun - .new(user: current_user, action:, context:, content: params[:content]) + .new(user: current_user, action:, context:, content: params[:content], demo_fault: params[:demoFault]) .call raise_create_failure(call.errors) if call.failure? diff --git a/spec/services/ai/text_transforms/demo_fault_gateway_spec.rb b/spec/services/ai/text_transforms/demo_fault_gateway_spec.rb new file mode 100644 index 000000000000..2a5d09d26556 --- /dev/null +++ b/spec/services/ai/text_transforms/demo_fault_gateway_spec.rb @@ -0,0 +1,70 @@ +# 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe AI::TextTransforms::DemoFaultGateway do + let(:inner) { AI::TextTransforms::FakeGateway.new(deltas: %w[a b]) } + let(:gateway) { described_class.new(inner) } + + def stream(system) + deltas = [] + gateway.stream(system:, user: "input", timeout: 5) { |delta| deltas << delta } + deltas + end + + it "passes an unmarked prompt through untouched" do + expect(stream("prompt")).to eq(%w[a b]) + expect(inner.calls.first[:system]).to eq("prompt") + end + + it "fails before streaming and strips the marker for a failed fault" do + expect { stream(described_class.mark("prompt", "failed")) } + .to raise_error(AI::TextTransforms::Errors::ConnectionFailed) + expect(inner.calls).to be_empty + end + + it "streams the deltas and then reports the stream as blocked" do + deltas = [] + + expect { gateway.stream(system: described_class.mark("prompt", "blocked"), user: "input", timeout: 5) { |d| deltas << d } } + .to raise_error(AI::TextTransforms::Errors::Blocked) + expect(deltas).to eq(%w[a b]) + expect(inner.calls.first[:system]).to eq("prompt") + end + + it "ignores unknown faults when marking" do + expect(described_class.mark("prompt", "whatever")).to eq("prompt") + end + + it "delegates readiness" do + expect(gateway.readiness).to be_ready + end +end From 912a5826d4a9b325cbbd9241ca9d281e289f67d4 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 21 Sep 2026 17:22:13 +0200 Subject: [PATCH 12/46] [AI-100] Demo: AI action menu in the description editor and result popover Editor demo for AI-126, mode A only (the whole description): - The description editor mounts an AI action menu at the end of its toolbar row. The server renders the Primer ActionMenu shell, the items come from the list endpoints of AI-134. - The result popover runs the chosen action through the run API of AI-136, renders the streamed markdown through the markdown render endpoint and offers cancel, copy, discard, replace and try again. It can be dragged. - The run client (create, cursor polling, cancel) is extracted from the admin sandbox and shared by both. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017HL6QmQW5pV6v8FAig2gJB --- app/components/_index.sass | 1 + .../text_transforms/menu_component.html.erb | 43 ++ .../ai/text_transforms/menu_component.rb | 60 +++ .../result_popover_component.html.erb | 154 +++++++ .../result_popover_component.rb | 72 ++++ .../result_popover_component.sass | 26 ++ .../ai/text_transform_menus_controller.rb | 69 ++++ app/views/layouts/base.html.erb | 1 + config/routes.rb | 5 + .../ckeditor/op-ckeditor.component.ts | 40 ++ .../content/editor/_ckeditor.sass | 15 + .../ai-text-transform-menu.controller.ts | 123 ++++++ .../ai-text-transform-popover.controller.ts | 388 ++++++++++++++++++ .../ai-text-transform-sandbox.controller.ts | 154 ++----- .../stimulus/helpers/ai-text-transform-run.ts | 188 +++++++++ spec/requests/ai/text_transform_menus_spec.rb | 74 ++++ 16 files changed, 1288 insertions(+), 125 deletions(-) create mode 100644 app/components/ai/text_transforms/menu_component.html.erb create mode 100644 app/components/ai/text_transforms/menu_component.rb create mode 100644 app/components/ai/text_transforms/result_popover_component.html.erb create mode 100644 app/components/ai/text_transforms/result_popover_component.rb create mode 100644 app/components/ai/text_transforms/result_popover_component.sass create mode 100644 app/controllers/ai/text_transform_menus_controller.rb create mode 100644 frontend/src/stimulus/controllers/dynamic/ai-text-transform-menu.controller.ts create mode 100644 frontend/src/stimulus/controllers/dynamic/ai-text-transform-popover.controller.ts create mode 100644 frontend/src/stimulus/helpers/ai-text-transform-run.ts create mode 100644 spec/requests/ai/text_transform_menus_spec.rb diff --git a/app/components/_index.sass b/app/components/_index.sass index 595b7346767a..c0cc14d4755f 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -1,3 +1,4 @@ +@import "ai/text_transforms/result_popover_component" @import "colors/swatch_component" @import "enterprise_edition/banner_component" @import "op_primer/border_box_table_component" diff --git a/app/components/ai/text_transforms/menu_component.html.erb b/app/components/ai/text_transforms/menu_component.html.erb new file mode 100644 index 000000000000..717aa466df0f --- /dev/null +++ b/app/components/ai/text_transforms/menu_component.html.erb @@ -0,0 +1,43 @@ +<%#-- 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, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= helpers.turbo_frame_tag frame_id do %> + <%= render(Primer::BaseComponent.new(tag: :div, hidden: true, data: wrapper_data, test_selector: "ai-text-transform-menu")) do %> + <%= + render(Primer::Alpha::ActionMenu.new(menu_id: "#{frame_id}-menu", anchor_align: :end)) do |menu| + menu.with_show_button(scheme: :invisible, "aria-label": t("ai.text_transform.menu.label")) do |button| + button.with_leading_visual_icon(icon: :copilot, color: :done) + button.with_trailing_action_icon(icon: :"triangle-down") + "" + end + menu.with_item(label: "…", tag: :button, hidden: true, content_arguments: { data: item_data }) + end + %> + <% end %> +<% end %> diff --git a/app/components/ai/text_transforms/menu_component.rb b/app/components/ai/text_transforms/menu_component.rb new file mode 100644 index 000000000000..9a1cfe4c70f0 --- /dev/null +++ b/app/components/ai/text_transforms/menu_component.rb @@ -0,0 +1,60 @@ +# 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module TextTransforms + # Demo only (AI-126): the menu starts hidden and empty. Its Stimulus controller + # loads the available actions from the API and clones the template item per action. + class MenuComponent < ApplicationComponent + def initialize(frame_id:, list_url:, context:) + super() + @frame_id = frame_id + @list_url = list_url + @context = context + end + + private + + attr_reader :frame_id, :list_url, :context + + def wrapper_data + { + controller: "ai-text-transform-menu", + ai_text_transform_menu_list_url_value: list_url, + ai_text_transform_menu_context_value: context.to_json + } + end + + def item_data + { ai_text_transform_menu_target: "template", action: "click->ai-text-transform-menu#run" } + end + end + end +end diff --git a/app/components/ai/text_transforms/result_popover_component.html.erb b/app/components/ai/text_transforms/result_popover_component.html.erb new file mode 100644 index 000000000000..aa8afc5cf6d0 --- /dev/null +++ b/app/components/ai/text_transforms/result_popover_component.html.erb @@ -0,0 +1,154 @@ +<%#-- 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, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= + render( + Primer::Beta::Popover.new( + position: :fixed, + hidden: true, + classes: "op-ai-result-popover", + data: controller_data, + test_selector: "ai-text-transform-popover" + ) + ) do |popover| + popover.with_body(caret: :top_right, large: true, p: 0, classes: "op-ai-result-popover--message") do + flex_layout(direction: :column, classes: "op-ai-result-popover--layout") do |layout| + layout.with_row(classes: "op-ai-result-popover--header", border: :bottom, p: 3, data: target(:handle)) do + flex_layout(align_items: :center) do |header| + header.with_column(mr: 2) do + render(Primer::Beta::Octicon.new(icon: :copilot, color: :done, size: :medium)) + end + header.with_column(flex: 1) do + render(Primer::Beta::Heading.new(tag: :h4, font_size: 3, data: target(:title))) { "" } + end + header.with_column do + render( + Primer::Beta::IconButton.new( + icon: :x, + scheme: :invisible, + "aria-label": label(:close), + data: action(:dismiss) + ) + ) + end + end + end + + layout.with_row(classes: "op-ai-result-popover--body", flex: 1, p: 3) do + flex_layout(direction: :column) do |body| + body.with_row(mb: 3, text_align: :right) do + render(Primer::Beta::Label.new(scheme: :done, size: :large)) { label(:context_description) } + end + body.with_row(mb: 3, hidden: true, data: target(:stopped)) do + render(Primer::Alpha::Banner.new(scheme: :warning, description: label(:stopped_description))) do + label(:stopped_title) + end + end + body.with_row(mb: 3, hidden: true, data: target(:failed)) do + render(Primer::Alpha::Banner.new(scheme: :danger)) do + safe_join( + [ + render(Primer::Beta::Text.new(tag: :p, font_weight: :bold, mb: 1)) { label(:failed_title) }, + render(Primer::Beta::Text.new(tag: :span, data: target(:failedMessage))) { "" } + ] + ) + end + end + body.with_row do + render(Primer::BaseComponent.new(tag: :div, classes: "op-uc-container", data: target(:output))) + end + end + end + + layout.with_row(classes: "op-ai-result-popover--footer", border: :top, bg: :subtle, p: 3) do + safe_join( + [ + flex_layout(align_items: :center, justify_content: :space_between, data: target(:generatingFooter)) do |footer| + footer.with_column do + render(Primer::Beta::Text.new(color: :muted)) { label(:generating) } + end + footer.with_column do + render(Primer::Beta::Button.new(data: action(:cancel))) { label(:cancel) } + end + end, + flex_layout(align_items: :center, hidden: true, data: target(:doneFooter)) do |footer| + footer.with_column(flex: 1) do + render(Primer::Beta::Button.new(scheme: :invisible, data: action(:copy))) do |button| + button.with_leading_visual_icon(icon: :copy) + content_tag(:span, label(:copy), data: target(:copyLabel)) + end + end + footer.with_column(mr: 2) do + render(Primer::Beta::Button.new(data: action(:dismiss))) { label(:discard) } + end + footer.with_column do + render(Primer::Beta::Button.new(scheme: :primary, data: action(:replace))) { label(:replace) } + end + end, + flex_layout(align_items: :center, justify_content: :flex_end, hidden: true, data: target(:errorFooter)) do |footer| + footer.with_column(mr: 2) do + render(Primer::Beta::Button.new(data: action(:retry))) do |button| + button.with_leading_visual_icon(icon: :sync) + label(:try_again) + end + end + footer.with_column do + render(Primer::Beta::Button.new(data: action(:dismiss))) { label(:close) } + end + end + ] + ) + end + + layout.with_row(classes: "op-ai-result-popover--prototype", border: :top, px: 3, py: 2) do + flex_layout(align_items: :center) do |controls| + controls.with_column(mr: 2, classes: "op-ai-result-popover--grip", data: target(:grip)) do + render(Primer::Beta::Octicon.new(icon: :grabber, color: :muted)) + end + controls.with_column(mr: 3) do + render(Primer::Beta::Text.new(font_size: :small, color: :muted)) { label(:prototype_controls) } + end + controls.with_column(mr: 3) do + render(Primer::Beta::Button.new(scheme: :link, font_size: :small, data: action(:forceError))) { label(:prototype_force_error) } + end + controls.with_column(mr: 3) do + render(Primer::Beta::Button.new(scheme: :link, font_size: :small, data: action(:forceGuardrail))) { label(:prototype_force_guardrail) } + end + controls.with_column(mr: 3) do + render(Primer::Beta::Button.new(scheme: :link, font_size: :small, data: action(:resetFault))) { label(:prototype_reset) } + end + controls.with_column do + render(Primer::Beta::Text.new(font_size: :small, color: :attention, data: target(:faultState))) { "" } + end + end + end + end + end + end +%> diff --git a/app/components/ai/text_transforms/result_popover_component.rb b/app/components/ai/text_transforms/result_popover_component.rb new file mode 100644 index 000000000000..d3ea696782be --- /dev/null +++ b/app/components/ai/text_transforms/result_popover_component.rb @@ -0,0 +1,72 @@ +# 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module TextTransforms + # Demo only (AI-126): the AI result popover. Rendered once per page and driven by + # its Stimulus controller, which runs the chosen action through the run API (AI-136). + class ResultPopoverComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include API::V3::Utilities::PathHelper + + def self.visible_for?(user) + user.logged? && + OpenProject::FeatureDecisions.ai_text_transform_actions_active? && + Setting.ai_text_transform_actions_enabled? + end + + private + + def controller_data + { + controller: "ai-text-transform-popover", + ai_text_transform_popover_runs_url_value: api_v3_paths.ai_text_transform_runs, + ai_text_transform_popover_render_url_value: api_v3_paths.render_markup, + ai_text_transform_popover_work_package_link_value: api_v3_paths.work_package("__id__"), + ai_text_transform_popover_editor_gone_value: label(:editor_gone), + ai_text_transform_popover_copied_value: label(:copied), + ai_text_transform_popover_copy_value: label(:copy) + } + end + + def target(name) + { ai_text_transform_popover_target: name } + end + + def action(name) + { action: "click->ai-text-transform-popover##{name}" } + end + + def label(key) + I18n.t("ai.text_transform.popover.#{key}") + end + end + end +end diff --git a/app/components/ai/text_transforms/result_popover_component.sass b/app/components/ai/text_transforms/result_popover_component.sass new file mode 100644 index 000000000000..dab85cf94ca3 --- /dev/null +++ b/app/components/ai/text_transforms/result_popover_component.sass @@ -0,0 +1,26 @@ +// Demo only (AI-126). Primer's popover is a static anchored box; the fixed placement below +// the application header, the width and the overlay stacking are custom. +.op-ai-result-popover + // Primer adds .left-0 and .right-0 with !important, so the placement needs it too. + // The controller overrides both inline (with priority) while the popover is dragged. + top: calc(var(--header-height) + 8px) + right: 16px !important + left: auto !important + z-index: 600 + width: min(480px, calc(100vw - 32px)) + + &--message.Popover-message + width: 100% + max-width: none + + &--layout + max-height: calc(100vh - var(--header-height) - 32px) + + &--header, + &--grip + cursor: grab + user-select: none + + &--body + overflow-y: auto + min-height: 12rem diff --git a/app/controllers/ai/text_transform_menus_controller.rb b/app/controllers/ai/text_transform_menus_controller.rb new file mode 100644 index 000000000000..d6a951ca18f7 --- /dev/null +++ b/app/controllers/ai/text_transform_menus_controller.rb @@ -0,0 +1,69 @@ +# 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + # Demo only (AI-126): renders the shell of the AI action menu that the description + # editor mounts next to its toolbar. The actions themselves come from the + # API v3 list endpoints (AI-134), which also check the permissions. + class TextTransformMenusController < ApplicationController + FRAME_ID = /\Aai-text-transform-menu-[a-z0-9]+\z/ + + no_authorization_required! :show + + layout false + + def show + return head(:unauthorized) unless current_user.logged? + return head(:bad_request) unless FRAME_ID.match?(params[:frame_id].to_s) + + render(AI::TextTransforms::MenuComponent.new(frame_id: params[:frame_id], list_url:, context: context_params)) + end + + private + + def context_params + params.permit(:work_package_id, :project_id, :type_id).to_h.transform_values(&:to_i).select { |_, id| id.positive? } + end + + def api_v3_paths = API::V3::Utilities::PathHelper::ApiV3Path + + def list_url + context = context_params + + if context["work_package_id"] + api_v3_paths.ai_text_transform_actions_by_work_package(context["work_package_id"]) + elsif context["project_id"] && context["type_id"] + api_v3_paths.ai_text_transform_actions_by_project(context["project_id"], type_id: context["type_id"]) + else + api_v3_paths.ai_text_transform_actions + end + end + end +end diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb index e7cdea2fa8e4..eb74579f02f1 100644 --- a/app/views/layouts/base.html.erb +++ b/app/views/layouts/base.html.erb @@ -182,6 +182,7 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% end %> + <%= render(AI::TextTransforms::ResultPopoverComponent.new) if AI::TextTransforms::ResultPopoverComponent.visible_for?(current_user) %> <%= render Primer::Beta::Spinner.new( size: :large, hidden: true, diff --git a/config/routes.rb b/config/routes.rb index 7efe384020e6..d4940bdb50c9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -488,6 +488,11 @@ end end + # Demo only (AI-126): shell of the AI action menu mounted into the description editor. + namespace :ai do + resource :text_transform_menu, only: :show + end + namespace :projects do resource :menu, only: %i[show] resource :filters, only: %i[show] diff --git a/frontend/src/app/shared/components/editor/components/ckeditor/op-ckeditor.component.ts b/frontend/src/app/shared/components/editor/components/ckeditor/op-ckeditor.component.ts index 64236149589b..9d350e4d780d 100644 --- a/frontend/src/app/shared/components/editor/components/ckeditor/op-ckeditor.component.ts +++ b/frontend/src/app/shared/components/editor/components/ckeditor/op-ckeditor.component.ts @@ -42,6 +42,15 @@ import { CodeMirrorLoaderService } from 'core-app/shared/components/editor/compo import { KeyCodes } from 'core-app/shared/helpers/keycodes'; import { debugLog } from 'core-app/shared/helpers/debug_output'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; +import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; + +// Demo only (AI-126): the ids the AI action menu needs from the edited work package. +interface AiMenuResource { + _type?:string; + id?:string|null; + project?:{ id?:string|null }; + type?:{ id?:string|null }; +} @Component({ selector: 'op-ckeditor', @@ -255,11 +264,42 @@ export class OpCkeditorComponent extends UntilDestroyedMixin implements OnInit, model.on('op:attachment-added', () => document.body.dispatchEvent(new DragEvent('dragend'))); model.on('op:attachment-removed', () => document.body.dispatchEvent(new DragEvent('dragend'))); + this.mountAiTextTransformMenu(); + this.initializeDone.emit(watchdog.editor); return watchdog.editor; }); } + // Demo only (AI-126): the AI action menu sits at the end of the description editor's toolbar + // row. Its shell is rendered by the server, its items come from the API (AI-134). + private mountAiTextTransformMenu():void { + const resource = this.context?.resource as unknown as AiMenuResource|undefined; + const toolbar = this.opCkeditorReplacementContainer.nativeElement.querySelector('.document-editor__toolbar'); + if (this.context?.field !== 'description' || resource?._type !== 'WorkPackage' || !toolbar) { + return; + } + if (!this.configurationService.activeFeatureFlags.includes('aiTextTransformActions')) { + return; + } + + const frameId = `ai-text-transform-menu-${Math.random().toString(36).slice(2, 10)}`; + const params = new URLSearchParams({ frame_id: frameId }); + if (isNewResource({ id: resource.id ?? null })) { + params.set('project_id', resource.project?.id ?? ''); + params.set('type_id', resource.type?.id ?? ''); + } else { + params.set('work_package_id', resource.id ?? ''); + } + + const frame = document.createElement('turbo-frame'); + frame.id = frameId; + frame.classList.add('document-editor__ai-menu'); + frame.setAttribute('src', `${window.appBasePath || ''}/ai/text_transform_menu?${params.toString()}`); + toolbar.classList.add('document-editor__toolbar_with-ai-menu'); + toolbar.appendChild(frame); + } + private interceptModifiedEnterKeystrokes(editor:ICKEditorInstance) { editor.listenTo( editor.editing.view.document, diff --git a/frontend/src/global_styles/content/editor/_ckeditor.sass b/frontend/src/global_styles/content/editor/_ckeditor.sass index 24a7fc44f611..b7f171b61294 100644 --- a/frontend/src/global_styles/content/editor/_ckeditor.sass +++ b/frontend/src/global_styles/content/editor/_ckeditor.sass @@ -163,3 +163,18 @@ opce-ckeditor-augmented-textarea .op-ckeditor--attachments .ck-list__item .mention-list-item max-width: none !important + +// Demo only (AI-126): the AI action menu at the end of the description editor's toolbar row +.document-editor__toolbar_with-ai-menu + display: flex + align-items: center + background: var(--ck-color-toolbar-background, var(--body-background)) + + > .ck-toolbar + flex: 1 1 auto + min-width: 0 + + > .document-editor__ai-menu + flex: 0 0 auto + padding-inline: 4px + diff --git a/frontend/src/stimulus/controllers/dynamic/ai-text-transform-menu.controller.ts b/frontend/src/stimulus/controllers/dynamic/ai-text-transform-menu.controller.ts new file mode 100644 index 000000000000..919a09a419b8 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/ai-text-transform-menu.controller.ts @@ -0,0 +1,123 @@ +//-- 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, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { Controller } from '@hotwired/stimulus'; + +interface ActionResource { + id:number; + label:string; +} + +interface ActionCollection { + _embedded:{ elements:ActionResource[] }; +} + +export interface AiTextTransformStartDetail { + actionId:number; + label:string; + editorWrapper:HTMLElement; + context:Record; +} + +export const AI_TEXT_TRANSFORM_START_EVENT = 'op:ai-text-transform:start'; + +/** + * Demo (AI-126): the AI action menu next to the description editor's toolbar. It lists the + * actions the API offers for the editor's context (AI-134) and asks the result popover to + * run the chosen one. The menu stays hidden when the API offers nothing. + */ +export default class AiTextTransformMenuController extends Controller { + static targets = ['template']; + + static values = { listUrl: String, context: Object }; + + declare readonly templateTarget:HTMLButtonElement; + declare readonly listUrlValue:string; + declare readonly contextValue:Record; + + connect():void { + void this.loadActions(); + } + + run(event:Event):void { + const item = event.currentTarget as HTMLElement; + const editorWrapper = this.element.closest('op-ckeditor')?.querySelector('.op-ckeditor-source-element'); + if (!editorWrapper) { + return; + } + + const detail:AiTextTransformStartDetail = { + actionId: Number(item.dataset.actionId), + label: item.dataset.actionLabel ?? '', + editorWrapper, + context: this.contextValue, + }; + window.dispatchEvent(new CustomEvent(AI_TEXT_TRANSFORM_START_EVENT, { detail })); + } + + private async loadActions():Promise { + const response = await fetch(this.listUrlValue, { + credentials: 'same-origin', + headers: { Accept: 'application/hal+json', 'X-Requested-With': 'XMLHttpRequest' }, + }); + if (!response.ok) { + return; + } + + const collection = await response.json() as ActionCollection; + const actions = collection._embedded.elements; + if (actions.length === 0) { + return; + } + + actions.forEach((action) => this.appendItem(action)); + this.element.hidden = false; + } + + private appendItem(action:ActionResource):void { + const templateItem = this.templateTarget.closest('li'); + if (!templateItem?.parentElement) { + return; + } + + const item = templateItem.cloneNode(true) as HTMLLIElement; + const button = item.querySelector('button'); + const label = item.querySelector('.ActionListItem-label'); + if (!button || !label) { + return; + } + + item.hidden = false; + button.id = `${button.id}-${action.id}`; + button.removeAttribute('data-ai-text-transform-menu-target'); + button.dataset.actionId = String(action.id); + button.dataset.actionLabel = action.label; + label.textContent = action.label; + templateItem.parentElement.append(item); + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/ai-text-transform-popover.controller.ts b/frontend/src/stimulus/controllers/dynamic/ai-text-transform-popover.controller.ts new file mode 100644 index 000000000000..9bebf86583ad --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/ai-text-transform-popover.controller.ts @@ -0,0 +1,388 @@ +//-- 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, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { Controller } from '@hotwired/stimulus'; +import { + AiTextTransformRun, + AiTextTransformRunClient, +} from 'core-stimulus/helpers/ai-text-transform-run'; +import { + AI_TEXT_TRANSFORM_START_EVENT, + AiTextTransformStartDetail, +} from 'core-stimulus/controllers/dynamic/ai-text-transform-menu.controller'; + +type PopoverState = 'generating'|'done'|'stopped'|'failed'; +type DemoFault = 'blocked'|'failed'|null; + +const RENDER_INTERVAL = 1200; +const COPIED_FEEDBACK = 1500; +const EDGE_MARGIN = 8; + +/** + * Demo (AI-126): the AI result popover. It runs the action chosen in the editor's AI menu + * on the whole description, shows the text while it streams in and replaces the editor + * content on request. The popover can be dragged by its header or the grip. + */ +export default class AiTextTransformPopoverController extends Controller { + static targets = [ + 'handle', 'grip', 'title', 'output', 'stopped', 'failed', 'failedMessage', + 'generatingFooter', 'doneFooter', 'errorFooter', 'copyLabel', 'faultState', + ]; + + static values = { + runsUrl: String, + renderUrl: String, + workPackageLink: String, + editorGone: String, + copied: String, + copy: String, + }; + + declare readonly handleTarget:HTMLElement; + declare readonly gripTarget:HTMLElement; + declare readonly titleTarget:HTMLElement; + declare readonly outputTarget:HTMLElement; + declare readonly stoppedTarget:HTMLElement; + declare readonly failedTarget:HTMLElement; + declare readonly failedMessageTarget:HTMLElement; + declare readonly generatingFooterTarget:HTMLElement; + declare readonly doneFooterTarget:HTMLElement; + declare readonly errorFooterTarget:HTMLElement; + declare readonly copyLabelTarget:HTMLElement; + declare readonly faultStateTarget:HTMLElement; + declare readonly runsUrlValue:string; + declare readonly renderUrlValue:string; + declare readonly workPackageLinkValue:string; + declare readonly editorGoneValue:string; + declare readonly copiedValue:string; + declare readonly copyValue:string; + + private client:AiTextTransformRunClient|null = null; + private request:AiTextTransformStartDetail|null = null; + private input = ''; + private markdown = ''; + private result:string|null = null; + private demoFault:DemoFault = null; + private lastRenderAt = 0; + private renderTimer:ReturnType|null = null; + private renderSequence = 0; + private dragOffset:{ x:number; y:number }|null = null; + + private readonly onStart = (event:Event) => { + void this.start((event as CustomEvent).detail); + }; + + private readonly onPointerDown = (event:PointerEvent) => this.beginDrag(event); + private readonly onPointerMove = (event:PointerEvent) => this.drag(event); + private readonly onPointerUp = () => this.endDrag(); + + connect():void { + window.addEventListener(AI_TEXT_TRANSFORM_START_EVENT, this.onStart); + [this.handleTarget, this.gripTarget].forEach((el) => el.addEventListener('pointerdown', this.onPointerDown)); + } + + disconnect():void { + window.removeEventListener(AI_TEXT_TRANSFORM_START_EVENT, this.onStart); + [this.handleTarget, this.gripTarget].forEach((el) => el.removeEventListener('pointerdown', this.onPointerDown)); + this.endDrag(); + this.stopRun(); + } + + async cancel():Promise { + await this.client?.cancel(); + } + + // Closing while the text is generating cancels the run, afterwards it discards the result. + async dismiss():Promise { + if (this.client?.running) { + await this.client.cancel(); + } + this.stopRun(); + this.element.hidden = true; + } + + async retry():Promise { + if (this.request) { + await this.run(); + } + } + + replace():void { + const wrapper = this.request?.editorWrapper; + if (this.result === null || !wrapper?.isConnected) { + this.showFailure(this.editorGoneValue, true); + return; + } + + wrapper.dispatchEvent(new CustomEvent('op:ckeditor:setData', { detail: this.result })); + this.element.hidden = true; + } + + async copy():Promise { + if (this.result === null) { + return; + } + + await navigator.clipboard.writeText(this.result); + this.copyLabelTarget.textContent = this.copiedValue; + setTimeout(() => { this.copyLabelTarget.textContent = this.copyValue; }, COPIED_FEEDBACK); + } + + forceError():void { + this.setDemoFault('failed'); + } + + forceGuardrail():void { + this.setDemoFault('blocked'); + } + + resetFault():void { + this.setDemoFault(null); + } + + private async start(detail:AiTextTransformStartDetail):Promise { + if (this.client?.running) { + await this.client.cancel(); + } + + this.request = detail; + this.input = await this.readEditor(detail.editorWrapper); + this.titleTarget.textContent = detail.label; + this.element.hidden = false; + await this.run(); + } + + private async run():Promise { + if (!this.request) { + return; + } + + this.stopRun(); + this.markdown = ''; + this.result = null; + this.outputTarget.innerHTML = ''; + this.show('generating'); + + this.client = new AiTextTransformRunClient(this.runsUrlValue, { + onRun: (run) => this.apply(run), + onRequestFailed: (status, body) => this.showFailure(this.requestFailureMessage(status, body)), + }); + await this.client.start(this.body()); + } + + private body():Record { + const { workPackageId, projectId, typeId } = this.contextIds(); + const body:Record = { actionId: this.request?.actionId, content: this.input }; + + if (workPackageId) { + body.workPackageId = workPackageId; + } else if (projectId && typeId) { + body.projectId = projectId; + body.typeId = typeId; + } + if (this.demoFault) { + body.demoFault = this.demoFault; + } + return body; + } + + private contextIds() { + const context = this.request?.context ?? {}; + return { workPackageId: context.work_package_id, projectId: context.project_id, typeId: context.type_id }; + } + + private apply(run:AiTextTransformRun):void { + run.events.forEach((event) => { + switch (event.kind) { + case 'text_delta': + this.markdown += event.payload.delta ?? ''; + this.scheduleRender(); + break; + case 'completed': + this.result = event.payload.text ?? ''; + this.markdown = this.result; + void this.renderNow(); + this.show('done'); + break; + case 'error': + this.discardPartialText(); + if (event.payload.reason === 'blocked') { + this.show('stopped'); + } else { + this.showFailure(event.payload.message ?? ''); + } + break; + default: + break; + } + }); + + if (run.status === 'cancelled') { + this.stopRun(); + this.element.hidden = true; + } + } + + private show(state:PopoverState):void { + this.generatingFooterTarget.hidden = state !== 'generating'; + this.doneFooterTarget.hidden = state !== 'done'; + this.errorFooterTarget.hidden = state !== 'stopped' && state !== 'failed'; + this.stoppedTarget.hidden = state !== 'stopped'; + this.failedTarget.hidden = state !== 'failed'; + } + + private showFailure(message:string, keepResult = false):void { + if (!keepResult) { + this.discardPartialText(); + } + this.failedMessageTarget.textContent = message; + this.show('failed'); + if (keepResult) { + this.doneFooterTarget.hidden = false; + this.errorFooterTarget.hidden = true; + } + } + + private discardPartialText():void { + this.clearRenderTimer(); + this.renderSequence += 1; + this.markdown = ''; + this.outputTarget.innerHTML = ''; + } + + private requestFailureMessage(status:number, body:string):string { + try { + const error = JSON.parse(body) as { message?:string }; + return error.message ?? `HTTP ${status}`; + } catch { + return `HTTP ${status}`; + } + } + + private stopRun():void { + this.client?.dispose(); + this.client = null; + this.clearRenderTimer(); + } + + private readEditor(wrapper:HTMLElement):Promise { + return new Promise((resolve) => { + wrapper.dispatchEvent(new CustomEvent('op:ckeditor:getData', { detail: (data:string) => resolve(data) })); + }); + } + + // The deltas are markdown, the popover shows formatted text: the server renders it, at most + // once per interval while the text streams in. + private scheduleRender():void { + if (this.renderTimer !== null) { + return; + } + + const wait = Math.max(0, RENDER_INTERVAL - (performance.now() - this.lastRenderAt)); + this.renderTimer = setTimeout(() => { + this.renderTimer = null; + void this.renderNow(); + }, wait); + } + + private async renderNow():Promise { + this.clearRenderTimer(); + this.lastRenderAt = performance.now(); + this.renderSequence += 1; + const sequence = this.renderSequence; + + const response = await fetch(this.renderUrl(), { + method: 'POST', + body: this.markdown, + credentials: 'same-origin', + headers: { 'Content-Type': 'text/plain; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' }, + }); + if (!response.ok || sequence !== this.renderSequence) { + return; + } + + // Sanitized by the server's text formatting pipeline. + this.outputTarget.innerHTML = await response.text(); + } + + private renderUrl():string { + const { workPackageId } = this.contextIds(); + if (!workPackageId) { + return this.renderUrlValue; + } + + const link = this.workPackageLinkValue.replace('__id__', String(workPackageId)); + return `${this.renderUrlValue}?context=${encodeURIComponent(link)}`; + } + + private clearRenderTimer():void { + if (this.renderTimer !== null) { + clearTimeout(this.renderTimer); + this.renderTimer = null; + } + } + + private setDemoFault(fault:DemoFault):void { + this.demoFault = fault; + this.faultStateTarget.textContent = fault === null ? '' : `next run: ${fault}`; + } + + private beginDrag(event:PointerEvent):void { + if ((event.target as HTMLElement).closest('button')) { + return; + } + + const rect = this.element.getBoundingClientRect(); + this.dragOffset = { x: event.clientX - rect.left, y: event.clientY - rect.top }; + window.addEventListener('pointermove', this.onPointerMove); + window.addEventListener('pointerup', this.onPointerUp); + event.preventDefault(); + } + + private drag(event:PointerEvent):void { + if (!this.dragOffset) { + return; + } + + const rect = this.element.getBoundingClientRect(); + const maxLeft = window.innerWidth - rect.width - EDGE_MARGIN; + const maxTop = window.innerHeight - EDGE_MARGIN * 6; + const left = Math.min(Math.max(EDGE_MARGIN, event.clientX - this.dragOffset.x), Math.max(EDGE_MARGIN, maxLeft)); + const top = Math.min(Math.max(EDGE_MARGIN, event.clientY - this.dragOffset.y), maxTop); + + this.element.style.setProperty('left', `${left}px`, 'important'); + this.element.style.setProperty('right', 'auto', 'important'); + this.element.style.setProperty('top', `${top}px`); + } + + private endDrag():void { + this.dragOffset = null; + window.removeEventListener('pointermove', this.onPointerMove); + window.removeEventListener('pointerup', this.onPointerUp); + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts b/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts index f6adb49a14e1..7727a14e2e5a 100644 --- a/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/ai-text-transform-sandbox.controller.ts @@ -27,25 +27,13 @@ //++ import { Controller } from '@hotwired/stimulus'; +import { + AI_TEXT_TRANSFORM_TERMINAL_STATUSES, + AiTextTransformRun, + AiTextTransformRunClient, + AiTextTransformRunEvent, +} from 'core-stimulus/helpers/ai-text-transform-run'; -interface RunEvent { - seq:number; - kind:'status'|'text_delta'|'completed'|'error'; - payload:{ status?:string; delta?:string; text?:string; message?:string; reason?:string }; -} - -interface RunResponse { - id:string; - status:string; - systemPrompt?:string; - events:RunEvent[]; - _links:{ self:{ href:string }; cancel?:{ href:string } }; -} - -const TERMINAL_STATUSES = ['succeeded', 'failed', 'cancelled']; -const MIN_DELAY = 400; -const MAX_DELAY = 1000; -const DELAY_STEP = 150; const TIMER_TICK = 100; const LABEL_SCHEMES:Record = { idle: 'Label--secondary', @@ -60,7 +48,7 @@ const LABEL_SCHEMES:Record = { /** * Prototype sandbox for the description assistant: posts the textarea content - * against the execute API and polls the run with an adaptive cursor-based loop. + * against the execute API and shows the run as the shared run client polls it. */ export default class AiTextTransformSandboxController extends Controller { static targets = [ @@ -86,26 +74,15 @@ export default class AiTextTransformSandboxController extends Controller|null = null; private tickTimer:ReturnType|null = null; private startedAt = 0; private finishedAt:number|null = null; - private inFlight = false; private text = ''; - private readonly onVisibilityChange = () => { this.handleVisibilityChange(); }; - - connect():void { - document.addEventListener('visibilitychange', this.onVisibilityChange); - } disconnect():void { - document.removeEventListener('visibilitychange', this.onVisibilityChange); - this.stopPolling(); + this.client?.dispose(); this.stopTimer(); } @@ -119,32 +96,16 @@ export default class AiTextTransformSandboxController extends Controller this.apply(run), + onRequestFailed: (status, body) => this.requestFailed(status, body), + }); + await this.client.start(this.body()); } async cancel():Promise { - if (!this.cancelUrl) { - return; - } this.cancelTarget.disabled = true; - await this.request(this.cancelUrl, 'POST'); + await this.client?.cancel(); } private body() { @@ -176,48 +137,21 @@ export default class AiTextTransformSandboxController extends Controller { void this.poll(); }, this.delay); - this.delay = Math.min(MAX_DELAY, this.delay + DELAY_STEP); + private requestFailed(status:number, body:string):void { + this.setStatus('error'); + this.outputTarget.textContent = `HTTP ${status}\n\n${body}`; + this.finish(); } - private async poll():Promise { - if (!this.runUrl || this.inFlight) { - return; - } - if (document.hidden) { - this.schedule(); - return; - } - - this.inFlight = true; - const requestedCursor = this.cursor; - try { - const response = await this.request(`${this.runUrl}?after=${requestedCursor}`, 'GET'); - if (!response.ok) { - this.setStatus('error'); - this.outputTarget.textContent = `Poll failed: HTTP ${response.status}`; - this.finish(); - return; - } - if (requestedCursor !== this.cursor) { - return; - } - const run = await response.json() as RunResponse; - this.apply(run); - if (!TERMINAL_STATUSES.includes(run.status)) { - this.schedule(); - } - } finally { - this.inFlight = false; + private apply(run:AiTextTransformRun):void { + if (this.runId === null) { + this.runId = run.id; + this.cancelTarget.disabled = run._links.cancel === undefined; + this.systemPromptTarget.textContent = run.systemPrompt ?? ''; } - } - private apply(run:RunResponse):void { this.setStatus(run.status); run.events.forEach((event) => { - this.cursor = Math.max(this.cursor, event.seq); this.appendEventRow(event); switch (event.kind) { case 'text_delta': @@ -236,14 +170,13 @@ export default class AiTextTransformSandboxController extends Controller { - return fetch(url, { - method, - body, - credentials: 'same-origin', - headers: { - Accept: 'application/hal+json', - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }, - }); - } - private reset():void { - this.stopPolling(); + this.client?.dispose(); + this.client = null; this.stopTimer(); this.runId = null; - this.runUrl = null; - this.cancelUrl = null; - this.cursor = 0; - this.delay = MIN_DELAY; this.text = ''; this.finishedAt = null; this.outputTarget.textContent = ''; @@ -304,19 +221,6 @@ export default class AiTextTransformSandboxController extends Controller this.statusTarget.classList.remove(cls)); @@ -327,7 +231,7 @@ export default class AiTextTransformSandboxController extends Controller void; + // Called when creating or polling fails on the HTTP level. The client stops afterwards. + onRequestFailed:(status:number, body:string) => void; +} + +export const AI_TEXT_TRANSFORM_TERMINAL_STATUSES = ['succeeded', 'failed', 'cancelled']; + +const MIN_DELAY = 400; +const MAX_DELAY = 1000; +const DELAY_STEP = 150; + +/** + * Client for one AI text transform run (API v3, AI-136): creates the run and polls its + * event log with a cursor. Polling backs off from 400 ms to 1 s, keeps one request in + * flight, ignores responses for an outdated cursor, pauses while the tab is hidden and + * never resumes after a terminal status. + */ +export class AiTextTransformRunClient { + private runUrl:string|null = null; + private cancelUrl:string|null = null; + private cursor = 0; + private delay = MIN_DELAY; + private pollTimer:ReturnType|null = null; + private inFlight = false; + private finished = false; + private readonly onVisibilityChange = () => { this.handleVisibilityChange(); }; + + constructor( + private readonly createUrl:string, + private readonly callbacks:AiTextTransformRunCallbacks, + ) { + document.addEventListener('visibilitychange', this.onVisibilityChange); + } + + get running():boolean { + return this.runUrl !== null && !this.finished; + } + + get highestSeq():number { + return this.cursor; + } + + async start(body:Record):Promise { + const response = await this.request(this.createUrl, 'POST', JSON.stringify(body)); + if (!response.ok) { + this.finished = true; + this.callbacks.onRequestFailed(response.status, await response.text()); + return; + } + + const run = await response.json() as AiTextTransformRun; + this.runUrl = run._links.self.href; + this.cancelUrl = run._links.cancel?.href ?? null; + this.apply(run); + } + + async cancel():Promise { + if (this.cancelUrl && !this.finished) { + await this.request(this.cancelUrl, 'POST'); + } + } + + dispose():void { + document.removeEventListener('visibilitychange', this.onVisibilityChange); + this.finished = true; + this.clearTimer(); + } + + private apply(run:AiTextTransformRun):void { + run.events.forEach((event) => { this.cursor = Math.max(this.cursor, event.seq); }); + if (AI_TEXT_TRANSFORM_TERMINAL_STATUSES.includes(run.status)) { + this.finished = true; + this.clearTimer(); + } + + this.callbacks.onRun(run); + + if (!this.finished) { + this.schedule(); + } + } + + private schedule():void { + this.clearTimer(); + this.pollTimer = setTimeout(() => { void this.poll(); }, this.delay); + this.delay = Math.min(MAX_DELAY, this.delay + DELAY_STEP); + } + + private async poll():Promise { + this.pollTimer = null; + if (!this.runUrl || this.inFlight || this.finished) { + return; + } + if (document.hidden) { + return; + } + + this.inFlight = true; + const requestedCursor = this.cursor; + try { + const response = await this.request(`${this.runUrl}?after=${requestedCursor}`, 'GET'); + if (this.finished) { + return; + } + if (!response.ok) { + this.finished = true; + this.callbacks.onRequestFailed(response.status, await response.text()); + return; + } + if (requestedCursor !== this.cursor) { + return; + } + this.apply(await response.json() as AiTextTransformRun); + } finally { + this.inFlight = false; + } + } + + private handleVisibilityChange():void { + if (!document.hidden && this.running && this.pollTimer === null && !this.inFlight) { + this.schedule(); + } + } + + private clearTimer():void { + if (this.pollTimer !== null) { + clearTimeout(this.pollTimer); + this.pollTimer = null; + } + } + + private request(url:string, method:'GET'|'POST', body?:string):Promise { + return fetch(url, { + method, + body, + credentials: 'same-origin', + headers: { + Accept: 'application/hal+json', + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + } +} diff --git a/spec/requests/ai/text_transform_menus_spec.rb b/spec/requests/ai/text_transform_menus_spec.rb new file mode 100644 index 000000000000..ea2583038fd8 --- /dev/null +++ b/spec/requests/ai/text_transform_menus_spec.rb @@ -0,0 +1,74 @@ +# 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe "AI text transform menu", :skip_csrf, type: :rails_request do + let(:frame_id) { "ai-text-transform-menu-abc123" } + + context "when logged in" do + current_user { create(:user) } + + it "renders the menu shell pointing at the work package list endpoint" do + get ai_text_transform_menu_path(frame_id:, work_package_id: 42) + + expect(response).to have_http_status(:ok) + expect(response.body).to include(%()) + expect(response.body).to include("/api/v3/work_packages/42/ai_text_transform_actions") + end + + it "points at the project list endpoint for a new work package" do + get ai_text_transform_menu_path(frame_id:, project_id: 3, type_id: 7) + + expect(response.body).to include("/api/v3/projects/3/ai_text_transform_actions?typeId=7") + end + + it "falls back to the context-free list endpoint" do + get ai_text_transform_menu_path(frame_id:) + + expect(response.body).to include(%(list-url-value="/api/v3/ai_text_transform_actions")) + end + + it "rejects a frame id it did not expect" do + get ai_text_transform_menu_path(frame_id: "evil\">" }, as: :json + + expect(response).to have_http_status(:ok) + expect(response.body).to include("bold") + expect(response.body).to include("Title") + expect(response.body).not_to include("" }, as: :json - - expect(response).to have_http_status(:ok) - expect(response.body).to include("bold") - expect(response.body).to include("Title") - expect(response.body).not_to include("