Feature: Zammad Smart Assist - Agent article reply with assistance AI tools

Co-authored-by: Benjamin Scharf <bs@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Dominik Klein <dk@zammad.com>
This commit is contained in:
Benjamin Scharf 2025-05-15 15:40:12 +02:00
parent db69446d6c
commit a2136ce304
71 changed files with 2648 additions and 318 deletions

View file

@ -1,5 +1,7 @@
# coffeelint: disable=camel_case_classes
class App.UiElement.richtext
class App.UiElement.richtext extends Spine.Module
@extend App.TextTools
@render: (attributeConfig, params, form) ->
attribute = $.extend(true, {}, attributeConfig)
@ -108,6 +110,13 @@ class App.UiElement.richtext
uploader.render()
, 100, undefined, 'form_upload')
@textToolsInit(
item,
attribute.disabled,
-> item.find('[contenteditable]').trigger('textToolsStart'),
-> item.find('[contenteditable]').trigger('textToolsStop'),
) if attribute.text_tools
item
@toolButtonClicked: (event, form) ->

View file

@ -538,6 +538,8 @@ class App.TicketCreate extends App.Controller
events:
'fileUploadStart .richtext': => @submitDisable()
'fileUploadStop .richtext': => @submitEnable()
'textToolsStart .richtext': => @submitDisable()
'textToolsStop .richtext': => @submitEnable()
'change [name=customer_id]': @localUserInfo
'change [data-attribute-name=organization_id] .js-input': @localUserInfo
richTextUploadRenderCallback: @updateTaskManagerAttachments

View file

@ -532,6 +532,8 @@ class App.TicketZoom extends App.Controller
richTextUploadDeleteCallback: (attachments) =>
@taskUpdateAttachments('article', attachments)
@delay(@markForm, 250, 'ticket-zoom-form-update')
richTextTextToolsStartCallback: @submitDisable
richTextTextToolsStopCallback: @submitEnable
)
@highlighter = new App.TicketZoomHighlighter(

View file

@ -508,6 +508,9 @@ class App.TicketZoomArticleNew extends App.Controller
when 'body:allowNoCaption'
@bodyAllowNoCaption = articleType.bodyAllowNoCaption
# (Re)initialize text tools on every article type change.
App.TextTools.textToolsInit(@textarea.parent(), false, @richTextTextToolsStartCallback, @richTextTextToolsStopCallback)
# convert remote src images to data uri
App.Utils.htmlImage2DataUrlAsyncInline(@$('[data-name=body]'))
@ -630,6 +633,8 @@ class App.TicketZoomArticleNew extends App.Controller
options:
duration: duration
easing: 'easeOutQuad'
begin: => @$('.text-tools').hide().css('transform', "translateX(-#{@attachmentInputHolder.position().left}px)")
complete: => @$('.text-tools').fadeIn()
@attachmentHint.velocity
properties:
@ -670,6 +675,7 @@ class App.TicketZoomArticleNew extends App.Controller
options:
duration: 300
easing: 'easeOutQuad'
begin: => @$('.text-tools').hide()
@attachmentHint.velocity
properties:

View file

@ -326,6 +326,67 @@
})[0]
}
Plugin.prototype.getSelection = function() {
var result = {
content: '',
ranges: [],
}
if (window.getSelection || document.getSelection) {
var sel
if (window.getSelection) sel = window.getSelection()
else sel = document.getSelection()
if (sel.rangeCount) {
var container = $('<div />')
for (var i = 0; i < sel.rangeCount; ++i) {
result.ranges.push(sel.getRangeAt(i))
container.append(sel.getRangeAt(i).cloneContents())
}
if ( this.options.mode === 'textonly' ) {
result.content = container.text().trim()
}
else {
result.content = container.html()
}
}
}
else if (document.selection) {
if (document.selection.type === 'Text') {
if ( this.options.mode === 'textonly' ) {
result.content = document.selection.createRange().text
}
else {
result.content = document.selection.createRange().htmlText
}
}
}
return result
}
Plugin.prototype.replaceSelection = function (ranges, content) {
if (ranges.length && (window.getSelection || document.getSelection)) {
this.log('restore selection')
var sel
if (window.getSelection) sel = window.getSelection()
else sel = document.getSelection()
sel.removeAllRanges()
for (var i = 0; i < ranges.length; i++) {
sel.addRange(ranges[i])
}
}
content = App.Utils.clipboardHtmlInsertPreperation(content, this.options)
this.paste(content)
}
Plugin.prototype.onPaste = function (e) {
e.preventDefault()
var clipboardData, clipboardImage, text, htmlRaw, htmlString

View file

@ -0,0 +1,96 @@
# Methods for initializing and using text tools in a richtext editor
App.TextTools =
textToolsInit: (el, disabled = false, startCallback = null, stopCallback = null) ->
return if not App.User.current()?.permission('ticket.agent')
return if not App.Config.get('ai_provider')
return if not App.Config.get('ai_assistance_text_tools')
# Remove any existing text tools, start from scratch.
# This is necessary to avoid duplication of text tools in article type switching context.
el.find('.text-tools').remove()
# If attachments are present, append text tools to the same container.
if el.find('.article-attachment:not(.hide)').length and not el.find('.article-attachment .text-tools').length
el.find('.article-attachment').append( $( App.view('generic/text_tools')(disabled: disabled) ) )
el.find('.text-tools').css('transform', el.find('.attachmentPlaceholder').css('transform'))
# Otherwise, append text tools into their own container.
else if not el.find('.text-tools--standalone').length
el.append( $( App.view('generic/text_tools')(disabled: disabled, no_attachment: true) ) )
# Initialize the dropdown menu.
# This seems to be necessary only in the article reply context.
el.find('[data-toggle="dropdown"]').dropdown()
# Handle text tool actions.
el.off('click.text-tools-actions', '.js-action').on('click.text-tools-actions', '.js-action', (e) ->
e.preventDefault()
action = $(e.target).data('type')
ce = el.find('[contenteditable]').data().plugin_ce
el.find('[data-toggle="dropdown"]').dropdown('toggle')
selection = ce?.getSelection()
if not selection?.content?.length
App.Event.trigger('notify', {
type: 'info'
msg: __('Please select some text first.')
timeout: 2000
})
return
params =
input: selection.content
service_type: action
App.TextTools.textToolsStartLoading(el, startCallback, stopCallback)
App.Ajax.request(
id: 'ai_assistance_text_tools'
type: 'POST'
url: "#{App.Config.get('api_path')}/ai_assistance/text_tools"
data: JSON.stringify(params)
processData: true
success: (data) ->
App.TextTools.textToolsStopLoading(el, stopCallback)
ce.replaceSelection(selection.ranges, data.output)
error: ->
App.TextTools.textToolsStopLoading(el, stopCallback)
)
)
textToolsStartLoading: (el, startCallback, stopCallback) ->
startCallback?() # callback is used to temporarily disable the submit button
loader = $( App.view('generic/text_tools_loading')() )
loader.off('click.text-tools-cancel', '.js-cancel').on('click.text-tools-cancel', '.js-cancel', (e) ->
e.preventDefault()
App.Ajax.abort('ai_assistance_text_tools')
App.TextTools.textToolsStopLoading(el, stopCallback)
)
el.find('[contenteditable]').prop('contenteditable', false)
if el.find('.article-attachment:not(.hide)').length > 0
el.find('.article-attachment').hide()
else
el.find('.text-tools').hide()
el.append(loader)
textToolsStopLoading: (el, stopCallback) ->
el.find('.js-loading').remove()
if el.find('.article-attachment:not(.hide)').length > 0
el.find('.article-attachment').show()
else
el.find('.text-tools').show()
el.find('[contenteditable]')
.prop('contenteditable', true)
.focus()
stopCallback?() # callback is used to re-enable the submit button

View file

@ -0,0 +1,20 @@
<% if !@disabled: %>
<div class="text-tools <% if @no_attachment: %>text-tools--standalone<% end %>">
<%- @Icon('smart-assist-elaborate') %>
<div class="buttonDropdown dropdown dropdown--actions dropup">
<a id="textToolsAction" class="text-tools-action" href="#" data-toggle="dropdown">
<%- @T('Smart Editor') %>
</a>
<ul class="dropdown-menu" role="menu" aria-labelledby="textToolsAction">
<li class="js-action" role="menuitem" data-type="improve_writing">
<%- @T('Improve writing') %>
<li class="js-action" role="menuitem" data-type="spelling_and_grammar">
<%- @T('Fix spelling and grammar') %>
<li class="js-action" role="menuitem" data-type="expand">
<%- @T('Expand') %>
<li class="js-action" role="menuitem" data-type="simplify">
<%- @T('Simplify') %>
</ul>
</div>
</div>
<% end %>

View file

@ -0,0 +1,4 @@
<div class="text-tools-loading js-loading">
<%- @Icon('smart-assist-elaborate') %><%- @T('Smart Assist is generating text…') %>
<a class="js-cancel" href="#"><%- @T('Cancel') %></a>
</div>

View file

@ -144,6 +144,7 @@
.icon-signed { width: 14px; height: 14px; }
.icon-signout { width: 15px; height: 19px; }
.icon-small-dot { width: 16px; height: 16px; }
.icon-smart-assist-elaborate { width: 20px; height: 20px; }
.icon-smart-assist { width: 16px; height: 16px; }
.icon-sms { width: 17px; height: 17px; }
.icon-spinner-small { width: 15px; height: 15px; }

View file

@ -110,6 +110,8 @@ $mobileNavigationWidthOpen: 220px;
--menu-close-tab-opacity: 0.3;
--menu-border: none;
--menu-border-secondary: none;
--ai-assist-blue: hsl(192, 100%, 39%);
--ai-assist-pink: hsl(340, 77%, 61%);
}
// dark mode
@ -2577,16 +2579,27 @@ input.time.time--12 {
padding: 7px 12px 35px;
}
.textBubble [contenteditable='false'],
.richtext.form-control [contenteditable='false'] {
cursor: not-allowed;
opacity: 0.38;
border-color: var(--border);
padding: unset;
color: var(--text-muted);
&.enableObjectResizingShim {
padding: unset;
}
&:focus,
&.focus {
border-color: var(--border-highlight);
}
&::selection,
*::selection {
color: var(--text-normal);
background: transparent;
}
}
.richtext.is-readonly [contenteditable='false'] {
@ -7261,11 +7274,11 @@ a.list-group-item.active > .badge,
right: 0;
top: 0;
height: 2px;
background: rgb(0, 158, 198);
background: var(--ai-assist-blue);
background: linear-gradient(
90deg,
rgba(0, 158, 198, 1) 0%,
rgba(232, 79, 131, 1) 100%
var(--ai-assist-blue) 0%,
var(--ai-assist-pink) 100%
);
}
}
@ -7279,7 +7292,7 @@ a.list-group-item.active > .badge,
.icon {
flex-grow: 0;
flex-shrink: 0;
color: #009ec6;
color: var(--ai-assist-blue);
margin-bottom: 1px;
}
@ -7478,6 +7491,11 @@ a.list-group-item.active > .badge,
margin-bottom: -10px;
}
.textBubble-footer:has(~ .text-tools) {
justify-content: end;
gap: 8px;
}
.textBubble-footer:not(.is-open .textBubble-footer) {
visibility: hidden;
}
@ -7975,9 +7993,81 @@ a.list-group-item.active > .badge,
height: 42px;
padding: 10px 0;
color: var(--text-muted);
overflow: hidden;
display: flex;
align-items: center;
gap: 16px;
}
@extend .u-textTruncate;
.text-tools {
display: flex;
align-items: center;
gap: 8px;
&--standalone {
position: absolute;
bottom: -4px;
left: 10px;
right: 10px;
height: 42px;
padding: 10px 0;
}
&::before {
display: block;
content: '';
width: 2px;
height: 16px;
background: var(--ai-assist-blue);
background: linear-gradient(
180deg,
var(--ai-assist-blue) 0%,
var(--ai-assist-pink) 100%
);
}
.icon {
width: 16px;
height: 16px;
fill: var(--interactive-primary);
}
.text-tools-action[data-toggle='dropdown'] {
padding: 0;
}
.article-add:not(.is-open) & {
display: none;
}
}
.text-tools-loading {
position: absolute;
bottom: -4px;
left: 10px;
right: 10px;
height: 42px;
padding: 10px 0;
display: flex;
align-items: center;
gap: 8px;
.icon {
width: 16px;
height: 16px;
}
&::before {
display: block;
content: '';
width: 2px;
height: 16px;
background: var(--ai-assist-blue);
background: linear-gradient(
180deg,
var(--ai-assist-blue) 0%,
var(--ai-assist-pink) 100%
);
}
}
.attachments:not(:empty) {

View file

@ -0,0 +1,17 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
class AIAssistanceController < ApplicationController
prepend_before_action :authenticate_and_authorize!
def text_tools
output = Service::AIAssistance::TextTools.new(
input: params[:input],
service_type: params[:service_type],
current_user:,
).execute
render json: {
output:,
}
end
end

View file

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5931 7.59314L11.5931 11.5931L10.5931 7.59314L6.59314 6.59314L10.5931 5.59314L11.5931 1.59314L12.5931 5.59314L16.5931 6.59314L12.5931 7.59314Z" />
<path d="M14.7056 3.98408L17.8973 3.58512C18.0117 3.57082 18.0151 3.40621 17.9013 3.38725L15.6484 3.01177C15.6063 3.00475 15.5733 2.97172 15.5662 2.92957L15.1908 0.676701C15.1718 0.562964 15.0072 0.566322 14.9929 0.680738L14.5939 3.87245C14.5858 3.93716 14.6409 3.99217 14.7056 3.98408Z" />
<path d="M0.59314 11.0931C0.59314 10.817 0.816997 10.5931 1.09314 10.5931H8.09314C8.36928 10.5931 8.59314 10.817 8.59314 11.0931C8.59314 11.3693 8.36928 11.5931 8.09314 11.5931H1.09314C0.816997 11.5931 0.59314 11.3693 0.59314 11.0931Z" />
<path d="M0.59314 14.0931C0.59314 13.817 0.816997 13.5931 1.09314 13.5931H16.0931C16.3693 13.5931 16.5931 13.817 16.5931 14.0931C16.5931 14.3693 16.3693 14.5931 16.0931 14.5931H1.09314C0.816997 14.5931 0.59314 14.3693 0.59314 14.0931Z" />
<path d="M0.59314 17.0931C0.59314 16.817 0.816997 16.5931 1.09314 16.5931H7.09314C7.36928 16.5931 7.59314 16.817 7.59314 17.0931C7.59314 17.3693 7.36928 17.5931 7.09314 17.5931H1.09314C0.816997 17.5931 0.59314 17.3693 0.59314 17.0931Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,5 +1,7 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import { initializeAiAssistantTextTools } from '#shared/components/Form/fields/FieldEditor/AiAssistantTextTools/initializeAiAssistantTextTools.ts'
import { initializeAiAssistantTextToolsLoadingBanner } from '#shared/components/Form/fields/FieldEditor/AiAssistantTextTools/initializeAiAssistantTextToolsLoadingBanner.ts'
import { initializeEditorColorMenuClasses } from '#shared/components/Form/fields/FieldEditor/FieldEditorColorMenu/initializeEditorColorMenu.ts'
import { initializeAlertClasses } from '#shared/initializer/initializeAlertClasses.ts'
import { initializeAvatarClasses } from '#shared/initializer/initializeAvatarClasses.ts'
@ -85,6 +87,15 @@ export const initializeGlobalComponentStyles = () => {
},
})
initializeAiAssistantTextTools({
popover: {
base: 'min-w-[13.5rem] rounded-xl overflow-hidden',
button:
'text-sm outline-none p-3 text-left active:text-white active:bg-blue-800 dark:active:bg-blue-800 dark:hover:text-white hover:text-black inline-block w-full dark:text-neutral-400 focus-visible:bg-blue-800 focus-visible:text-white hover:bg-blue-600 dark:hover:bg-blue-900 text-gray-100',
},
verticalGradient: 'bg-linear-to-t from-pink-200 to-blue-800',
})
initializeFilePreviewClasses({
base: 'dark:text-white text-black text-sm leading-snug',
wrapper: 'p-2.5',
@ -94,4 +105,10 @@ export const initializeGlobalComponentStyles = () => {
size: 'dark:text-neutral-500 text-stone-400 text-xs leading-snug',
icon: 'dark:text-neutral-500 text-stone-400',
})
initializeAiAssistantTextToolsLoadingBanner({
icon: 'text-blue-800',
label: 'text-black! dark:text-white!',
button: 'text-blue-800',
})
}

View file

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5931 7.59311L11.5931 11.5931L10.5931 7.59311L6.59314 6.59311L10.5931 5.59311L11.5931 1.59311L12.5931 5.59311L16.5931 6.59311L12.5931 7.59311Z" />
<path d="M14.7056 3.98405L17.8973 3.58509C18.0117 3.57079 18.0151 3.40618 17.9013 3.38722L15.6484 3.01174C15.6063 3.00472 15.5733 2.97169 15.5662 2.92954L15.1908 0.676671C15.1718 0.562934 15.0072 0.566291 14.9929 0.680707L14.5939 3.87242C14.5858 3.93713 14.6409 3.99214 14.7056 3.98405Z" />
<path d="M0.59314 11.0931C0.59314 10.817 0.816997 10.5931 1.09314 10.5931H8.09314C8.36928 10.5931 8.59314 10.817 8.59314 11.0931C8.59314 11.3693 8.36928 11.5931 8.09314 11.5931H1.09314C0.816997 11.5931 0.59314 11.3693 0.59314 11.0931Z" />
<path d="M0.59314 14.0931C0.59314 13.817 0.816997 13.5931 1.09314 13.5931H16.0931C16.3693 13.5931 16.5931 13.817 16.5931 14.0931C16.5931 14.3693 16.3693 14.5931 16.0931 14.5931H1.09314C0.816997 14.5931 0.59314 14.3693 0.59314 14.0931Z" />
<path d="M0.59314 17.0931C0.59314 16.817 0.816997 16.5931 1.09314 16.5931H7.09314C7.36928 16.5931 7.59314 16.817 7.59314 17.0931C7.59314 17.3693 7.36928 17.5931 7.09314 17.5931H1.09314C0.816997 17.5931 0.59314 17.3693 0.59314 17.0931Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M11.5709 11.7164L10.1941 17.2239C10.1436 17.4259 9.8565 17.4259 9.806 17.2239L8.42913 11.7164C8.41122 11.6448 8.35527 11.5888 8.28361 11.5709L2.77614 10.1941C2.57415 10.1436 2.57415 9.8565 2.77614 9.806L8.28361 8.42913C8.35527 8.41122 8.41122 8.35527 8.42913 8.28361L9.806 2.77614C9.8565 2.57415 10.1436 2.57415 10.1941 2.77614L11.5709 8.28361C11.5888 8.35527 11.6448 8.41122 11.7164 8.42913L17.2239 9.806C17.4259 9.8565 17.4259 10.1436 17.2239 10.1941L11.7164 11.5709C11.6448 11.5888 11.5888 11.6448 11.5709 11.7164Z" />
<path d="M4.87245 15.016L1.68074 15.4149C1.56632 15.4292 1.56296 15.5939 1.6767 15.6128L3.92957 15.9883C3.97172 15.9953 4.00475 16.0283 4.01177 16.0705L4.38725 18.3234C4.40621 18.4371 4.57082 18.4337 4.58512 18.3193L4.98408 15.1276C4.99217 15.0629 4.93716 15.0079 4.87245 15.016Z" />
<path d="M15.1276 15.016L18.3193 15.4149C18.4337 15.4292 18.4371 15.5939 18.3234 15.6128L16.0705 15.9883C16.0283 15.9953 15.9953 16.0283 15.9883 16.0705L15.6128 18.3234C15.5939 18.4371 15.4292 18.4337 15.4149 18.3193L15.016 15.1276C15.0079 15.0629 15.0629 15.0079 15.1276 15.016Z" />
<path d="M4.87245 4.98408L1.68074 4.58512C1.56632 4.57082 1.56296 4.40621 1.6767 4.38725L3.92957 4.01177C3.97172 4.00475 4.00475 3.97172 4.01177 3.92957L4.38725 1.6767C4.40621 1.56296 4.57082 1.56632 4.58512 1.68074L4.98408 4.87245C4.99217 4.93716 4.93716 4.99217 4.87245 4.98408Z" />
<path d="M15.1276 4.98408L18.3193 4.58512C18.4337 4.57082 18.4371 4.40621 18.3234 4.38725L16.0705 4.01177C16.0283 4.00475 15.9953 3.97172 15.9883 3.92957L15.6128 1.6767C15.5939 1.56296 15.4292 1.56632 15.4149 1.68074L15.016 4.87245C15.0079 4.93716 15.0629 4.99217 15.1276 4.98408Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1,5 +1,7 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import { initializeAiAssistantTextTools } from '#shared/components/Form/fields/FieldEditor/AiAssistantTextTools/initializeAiAssistantTextTools.ts'
import { initializeAiAssistantTextToolsLoadingBanner } from '#shared/components/Form/fields/FieldEditor/AiAssistantTextTools/initializeAiAssistantTextToolsLoadingBanner.ts'
import { initializeEditorColorMenuClasses } from '#shared/components/Form/fields/FieldEditor/FieldEditorColorMenu/initializeEditorColorMenu.ts'
import { initializeAlertClasses } from '#shared/initializer/initializeAlertClasses.ts'
import { initializeAvatarClasses } from '#shared/initializer/initializeAvatarClasses.ts'
@ -57,7 +59,7 @@ export const initializeGlobalComponentStyles = () => {
})
initializePopoverClasses({
base: 'min-h-9 rounded-xl max-w-[calc(100vw-8px)] text-white top-0 border border-gray-500 bg-gray-400 antialiased rtl:right-1/2 ltr:left-1/2 rtl:translate-x-1/2 ltr:-translate-x-1/2',
base: 'min-h-9 rounded-xl max-w-[calc(100vw-8px)] text-white top-0 border border-gray-500 bg-gray-400 antialiased',
arrow: 'hidden',
})
@ -80,4 +82,18 @@ export const initializeGlobalComponentStyles = () => {
size: 'text-white/80',
icon: 'border-gray-300',
})
initializeAiAssistantTextTools({
popover: {
base: '',
button: 'py-4 px-3',
},
verticalGradient: 'bg-linear-to-t from-pink to-blue',
})
initializeAiAssistantTextToolsLoadingBanner({
icon: 'text-blue',
label: 'text-white',
button: 'text-blue',
})
}

View file

@ -199,3 +199,16 @@
@utility required {
@apply after:bg-yellow after:ms-1 after:inline-block after:h-1 after:w-1 after:rounded-full after:align-middle after:content-[''];
}
@utility ai-stripe {
&::before {
content: '';
height: 2px;
width: 100%;
background-image: repeating-linear-gradient(
to right,
var(--color-blue),
var(--color-pink)
);
}
}

View file

@ -19,6 +19,7 @@ import {
useTemplateRef,
} from 'vue'
import { useAppName } from '#shared/composables/useAppName.ts'
import { useTransitionConfig } from '#shared/composables/useTransitionConfig.ts'
import { useTrapTab } from '#shared/composables/useTrapTab.ts'
import { EnumTextDirection } from '#shared/graphql/types.ts'
@ -126,8 +127,17 @@ const PLACEMENT_OFFSET_WITH_ARROW = 30
const ORIENTATION_OFFSET_WO_ARROW = 6
const ORIENTATION_OFFSET_WITH_ARROW = 16
const appName = useAppName()
// eslint-disable-next-line sonarjs/cognitive-complexity
const popoverStyle = computed(() => {
if (appName === 'mobile') {
return {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}
}
if (!targetElementBounds.value) return { top: 0, left: 0, maxHeight: 0 }
const maxHeight = hasDirectionUp.value

View file

@ -197,7 +197,10 @@ const leftgradientvalue = computed(() => classes.actionBar.leftGradient.left)
/>
</button>
<div v-if="action.showDivider">
<hr class="h-full w-px border-0 bg-neutral-100 dark:bg-gray-900" />
<hr
:class="action.dividerClass"
class="h-full w-px border-0 bg-neutral-100 dark:bg-gray-900"
/>
</div>
</template>
</div>

View file

@ -0,0 +1,54 @@
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import CommonTranslateRenderer from '#shared/components/CommonTranslateRenderer/CommonTranslateRenderer.vue'
import { getAiAssistantTextToolsLoadingBannerClasses } from '#shared/components/Form/fields/FieldEditor/AiAssistantTextTools/initializeAiAssistantTextToolsLoadingBanner.ts'
import { useAppName } from '#shared/composables/useAppName.ts'
import type { Editor } from '@tiptap/core'
defineProps<{
editor?: Editor
}>()
const { icon, label, button } = getAiAssistantTextToolsLoadingBannerClasses()
const appName = useAppName()
</script>
<template>
<div
class="ai-stripe relative flex items-center gap-1 px-4 py-3 before:absolute before:top-0 before:left-0"
>
<CommonIcon
class="shrink-0"
:class="icon"
size="tiny"
name="smart-assist"
/>
<CommonTranslateRenderer
v-if="appName === 'desktop'"
class="truncate text-sm"
:source="__('%s is generating text')"
:placeholders="[
{
type: 'label',
props: {
class: label,
},
content: $t('Zammad Smart Assist'),
},
]"
/>
<CommonLabel v-else>{{ $t('Generating text') }}</CommonLabel>
<button
class="text-sm ltr:ml-auto rtl:mr-auto"
:class="button"
@click="editor?.emit('cancel-ai-assistant-text-tools-updates')"
>
{{ $t('Cancel') }}
</button>
</div>
</template>

View file

@ -0,0 +1,229 @@
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { DOMSerializer } from 'prosemirror-model'
import { computed, watch } from 'vue'
import {
useNotifications,
NotificationTypes,
} from '#shared/components/CommonNotifications/index.ts'
import { getAiAssistantTextToolsClasses } from '#shared/components/Form/fields/FieldEditor/AiAssistantTextTools/initializeAiAssistantTextTools.ts'
import type { FieldEditorProps } from '#shared/components/Form/fields/FieldEditor/types.ts'
import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
import { useAiAssistanceTextToolsMutation } from '#shared/graphql/mutations/aiAssistanceTextTools.api.ts'
import { EnumAiTextToolService } from '#shared/graphql/types.ts'
import { MutationHandler } from '#shared/server/apollo/handler/index.ts'
import type { Editor } from '@tiptap/vue-3'
const props = defineProps<{
editor?: Editor
formContext?: FormFieldContext<FieldEditorProps>
}>()
const emit = defineEmits<{
close: []
action: []
'hide-action-bar': [boolean]
'show-ai-text-loader': [boolean]
}>()
const smartEditorClasses = getAiAssistantTextToolsClasses()
const { notify } = useNotifications()
const hasSelection = computed(
() =>
props.editor?.state.selection.anchor !== props.editor?.state.selection.head,
)
const useAbortableMutation = () => {
const abortController = new AbortController()
const textToolsMutation = new MutationHandler(
useAiAssistanceTextToolsMutation({
context: { fetchOptions: { signal: abortController.signal } },
}),
)
return {
textToolsMutation,
isLoading: textToolsMutation.loading(),
abortController,
abort: () => abortController.abort(),
}
}
let aiAssistanceTextToolsController = useAbortableMutation()
watch(
() => props.formContext?.value,
() => {
if (aiAssistanceTextToolsController.isLoading.value) {
notify({
id: 'ai-assistant-text-tools-aborted',
type: NotificationTypes.Info,
message: __(
'The text was modified. Your request has been aborted to prevent overwriting.',
),
})
aiAssistanceTextToolsController.abort()
aiAssistanceTextToolsController = useAbortableMutation()
}
},
)
props.editor?.on('cancel-ai-assistant-text-tools-updates', () => {
aiAssistanceTextToolsController.abort()
aiAssistanceTextToolsController = useAbortableMutation()
})
const sendTextToolsMutation = async (
textToolService: EnumAiTextToolService,
input: string,
) => {
const response = await aiAssistanceTextToolsController.textToolsMutation.send(
{
input,
serviceType: textToolService,
},
)
return response?.aiAssistanceTextTools?.output
}
// :TODO - Custom command maybe?
const getSelection = () => props.editor!.state.selection
// :TODO - Custom command maybe?
const getHTMLFromSelection = (selection: Editor['state']['selection']) => {
const slice = selection.content()
const serializer = DOMSerializer.fromSchema(props.editor!.schema)
const fragment = serializer.serializeFragment(slice.content)
const div = document.createElement('div')
div.appendChild(fragment)
return div.innerHTML
}
const updateSelectedContent = (content: string) => {
props.editor!.commands.deleteSelection()
// Remove visual newlines from the model which should not play any role.
props.editor!.commands.insertContent(content.replace(/\s*\n\s*/g, ''))
}
const hideActionBarAndShowAiTextLoader = () => {
emit('close')
props.editor!.setEditable(false)
emit('hide-action-bar', true)
emit('show-ai-text-loader', true)
}
const showActionBarAndHideAiTextLoader = () => {
emit('hide-action-bar', false)
emit('show-ai-text-loader', false)
props.editor!.setEditable(true)
}
const modifySelectedText = async (textToolService: EnumAiTextToolService) => {
hideActionBarAndShowAiTextLoader()
const lastSelection = getSelection()
const input = getHTMLFromSelection(lastSelection)
return sendTextToolsMutation(textToolService, input)
.then((output) => {
if (!output) return
// Make sure the right selection is always set
props.editor?.chain().focus().setTextSelection(lastSelection).run()
updateSelectedContent(output)
})
.catch(() => {
// Handle abort errors gracefully:
// Currently, aborting a request triggers both a warning and an error toast,
// as both are instances of ApolloError. Disabling toast messages entirely
// would suppress all mutation-related errors, not just abort errors.
// TODO: Investigate a way to suppress only abort-related error messages
// while preserving other mutation error notifications.
props.editor?.chain().focus().setTextSelection(lastSelection).run()
})
.finally(showActionBarAndHideAiTextLoader)
}
const actions = computed(() => [
{
key: 'improve-writing',
label: __('Improve writing'),
disabled: !hasSelection.value,
onClick: () => modifySelectedText(EnumAiTextToolService.ImproveWriting),
},
{
key: 'fix-spelling-grammar',
label: __('Fix spelling and grammar'),
disabled: !hasSelection.value,
onClick: () => modifySelectedText(EnumAiTextToolService.SpellingAndGrammar),
},
{
key: 'expand',
label: __('Expand'),
disabled: !hasSelection.value,
onClick: () => modifySelectedText(EnumAiTextToolService.Expand),
},
{
key: 'simplify',
label: __('Simplify'),
disabled: !hasSelection.value,
onClick: () => modifySelectedText(EnumAiTextToolService.Simplify),
},
])
</script>
<template>
<div :class="smartEditorClasses.popover.base">
<ul ref="list">
<li v-for="action in actions" :key="action.key">
<button
:disabled="action.disabled"
:class="smartEditorClasses.popover.button"
class="disabled:pointer-events-none disabled:opacity-60"
@click="action.onClick"
>
{{ $t(action.label) }}
</button>
</li>
</ul>
</div>
</template>
<style>
[data-theme='light'] [contenteditable='false'][name='body'] {
color: #a0a3a6;
* {
color: currentColor;
}
}
[contenteditable='false'][name='body'],
[data-theme='dark'] [contenteditable='false'][name='body'] {
color: #999;
* {
color: currentColor;
}
}
[data-theme='light'] [contenteditable='false'][name='body'] ::selection {
color: #585856;
background: transparent;
}
[contenteditable='false'][name='body'] ::selection,
[data-theme='dark'] [contenteditable='false'][name='body'] ::selection {
color: #d1d1d1;
background: transparent;
}
</style>

View file

@ -0,0 +1,17 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
let aiAssistantTextTools = {
popover: {
base: '',
button: '',
},
verticalGradient: '',
}
export const initializeAiAssistantTextTools = (
classes: typeof aiAssistantTextTools,
) => {
aiAssistantTextTools = classes
}
export const getAiAssistantTextToolsClasses = () => aiAssistantTextTools

View file

@ -0,0 +1,16 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
let aiAssistantTextToolsLoadingBanner = {
icon: '',
label: '',
button: '',
}
export const initializeAiAssistantTextToolsLoadingBanner = (
classes: typeof aiAssistantTextToolsLoadingBanner,
) => {
aiAssistantTextToolsLoadingBanner = classes
}
export const getAiAssistantTextToolsLoadingBannerClasses = () =>
aiAssistantTextToolsLoadingBanner

View file

@ -1,16 +1,23 @@
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { nextTick, shallowRef, toRef } from 'vue'
import { storeToRefs } from 'pinia'
import { nextTick, shallowRef, toRef, ref, defineAsyncComponent } from 'vue'
import CommonPopover from '#shared/components/CommonPopover/CommonPopover.vue'
import { usePopover } from '#shared/components/CommonPopover/usePopover.ts'
import ActionBar from '#shared/components/Form/fields/FieldEditor/ActionBar.vue'
import { getFieldEditorProps } from '#shared/components/Form/initializeFieldEditor.ts'
import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
import { useApplicationStore } from '#shared/stores/application.ts'
import useEditorActions, { type EditorButton } from './useEditorActions.ts'
import type { EditorContentType, EditorCustomPlugins } from './types.ts'
import type {
EditorContentType,
EditorCustomPlugins,
FieldEditorProps,
} from './types.ts'
import type { Selection } from '@tiptap/pm/state'
import type { Editor } from '@tiptap/vue-3'
import type { Except } from 'type-fest'
@ -21,16 +28,25 @@ const props = defineProps<{
contentType: EditorContentType
visible: boolean
disabledPlugins: EditorCustomPlugins[]
formId: string
formContext?: FormFieldContext<FieldEditorProps>
}>()
defineEmits<{
hide: []
hide: [boolean?]
blur: []
}>()
const AiAssistantTextToolsLoadingBanner = defineAsyncComponent(
() =>
import(
'#shared/components/Form/fields/FieldEditor/AiAssistantTextTools/AiAssistantLoadingBanner.vue'
),
)
const editor = toRef(props, 'editor')
const hideActionBarLocally = ref(false)
const { actions, isActive } = useEditorActions(
editor,
props.contentType,
@ -57,6 +73,7 @@ const handleButtonClick = (action: EditorButton, event: MouseEvent) => {
subMenuPopoverContent.value = action.subMenu
popoverTarget.value = event.currentTarget as HTMLDivElement
popoverTarget.value.id = action.id
nextTick(() => {
open()
@ -73,43 +90,68 @@ const handleSubMenuClick = () => {
currentSelection = undefined
}
}
// :TODO this code should not live here...
const showAiAssistantTextToolsLoadingBanner = ref(false)
const { config } = storeToRefs(useApplicationStore())
</script>
<template>
<ActionBar
v-show="visible || editorProps.actionBar.visible"
:editor="editor"
:visible="visible"
:is-active="isActive"
:actions="actions"
@click-action="handleButtonClick"
@blur="$emit('blur')"
@hide="$emit('hide')"
/>
<CommonPopover
ref="popover"
:owner="popoverTarget"
orientation="autoVertical"
placement="arrowStart"
no-auto-focus
>
<template v-if="Array.isArray(subMenuPopoverContent)">
<ActionBar
data-test-id="sub-menu-action-bar"
:actions="subMenuPopoverContent"
:editor="editor"
:is-active="isActive"
no-gradient
@click-action="handleButtonClick"
/>
</template>
<component
:is="subMenuPopoverContent"
v-else
<div>
<ActionBar
v-show="
!hideActionBarLocally && (visible || editorProps.actionBar.visible)
"
:editor="editor"
:content-type="contentType"
@action="handleSubMenuClick"
:visible="visible"
:is-active="isActive"
:actions="actions"
@click-action="handleButtonClick"
@blur="$emit('blur')"
@hide="$emit('hide')"
/>
</CommonPopover>
<AiAssistantTextToolsLoadingBanner
v-if="
showAiAssistantTextToolsLoadingBanner && config.ai_assistance_text_tools
"
:editor="editor"
/>
<!-- :TODO rethink the persistent -->
<CommonPopover
ref="popover"
:owner="popoverTarget"
persistent
orientation="autoVertical"
placement="arrowStart"
no-auto-focus
>
<template v-if="Array.isArray(subMenuPopoverContent)">
<ActionBar
:id="popoverTarget?.id"
data-test-id="sub-menu-action-bar"
:actions="subMenuPopoverContent"
:editor="editor"
:is-active="isActive"
no-gradient
@click-action="handleButtonClick"
/>
</template>
<component
:is="subMenuPopoverContent"
v-else
:id="popoverTarget?.id"
ref="sub-menu-popover-content"
:editor="editor"
:content-type="contentType"
:form-context="formContext"
@action="handleSubMenuClick"
@close="close"
@hide-action-bar="hideActionBarLocally = $event"
@show-ai-text-loader="showAiAssistantTextToolsLoadingBanner = $event"
/>
</CommonPopover>
</div>
</template>

View file

@ -429,7 +429,7 @@ const classes = getFieldEditorClasses()
:content-type="contentType"
:visible="showActionBar"
:disabled-plugins="disabledPlugins"
:form-id="context.formId"
:form-context="reactiveContext"
@hide="showActionBar = false"
@blur="focusEditor"
/>

View file

@ -1,7 +1,16 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import { renderComponent } from '#tests/support/components/index.ts'
import { within } from '@testing-library/vue'
import { renderComponent } from '#tests/support/components/index.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
import {
mockAiAssistanceTextToolsMutation,
waitForAiAssistanceTextToolsMutationCalls,
} from '#shared/graphql/mutations/aiAssistanceTextTools.mocks.ts'
import { EnumAiTextToolService } from '#shared/graphql/types.ts'
import getUuid from '#shared/utils/getUuid.ts'
import FieldEditorActionBar from '../FieldEditorActionBar.vue'
@ -19,6 +28,20 @@ vi.mock('@tiptap/pm/state', () => {
}
})
vi.mock('prosemirror-model', () => {
return {
DOMSerializer: {
fromSchema: vi.fn(() => ({
serializeFragment: vi.fn(() => {
const fragment = document.createDocumentFragment()
fragment.textContent = 'selected text'
return fragment
}),
})),
},
}
})
describe('keyboard interactions', () => {
it('can use arrows to traverse toolbar', async () => {
const view = renderComponent(FieldEditorActionBar, {
@ -173,4 +196,171 @@ describe('basic toolbar testing', () => {
view.queryByLabelText('Format as underlined'),
).not.toBeInTheDocument()
})
describe('AiAssistantTextTools', () => {
const createMockEditor = () => ({
state: {
selection: {
from: 0,
to: 10,
anchor: 0,
head: 10,
empty: false,
content: () => 'selected text',
},
doc: {
textBetween: vi.fn(() => 'selected text'),
},
},
chain: vi.fn(() => ({
focus: vi.fn(() => ({
setTextSelection: vi.fn(() => ({
run: vi.fn(),
})),
})),
})),
isActive: vi.fn(() => true),
getAttributes: vi.fn(() => ({})),
commands: {
deleteSelection: vi.fn(),
insertContentAt: vi.fn(),
focus: vi.fn(),
},
setEditable: vi.fn(),
on: vi.fn(),
emit: vi.fn(),
})
it('hides feature if flag is not set', async () => {
mockApplicationConfig({
ai_assistance_text_tools: false,
ai_provider: 'openai',
})
mockPermissions(['ticket.agent'])
const wrapper = renderComponent(FieldEditorActionBar, {
props: {
contentType: 'text/plain',
visible: true,
disabledPlugins: [],
formId: getUuid(),
},
})
expect(
wrapper.queryByRole('button', { name: 'Ai assistant text tools' }),
).not.toBeInTheDocument()
})
it('hides the feature if user is customer', async () => {
mockApplicationConfig({
ai_assistance_text_tools: true,
ai_provider: 'openai',
})
mockPermissions(['ticket.customer'])
const wrapper = renderComponent(FieldEditorActionBar, {
props: {
contentType: 'text/plain',
visible: true,
disabledPlugins: [],
formId: getUuid(),
},
})
expect(
wrapper.queryByRole('button', { name: 'Ai assistant text tools' }),
).not.toBeInTheDocument()
})
it('hides the feature if user ai provider is not set', async () => {
mockApplicationConfig({
ai_assistance_text_tools: true,
ai_provider: undefined,
})
mockPermissions(['ticket.customer'])
const wrapper = renderComponent(FieldEditorActionBar, {
props: {
contentType: 'text/plain',
visible: true,
disabledPlugins: [],
formId: getUuid(),
},
})
expect(
wrapper.queryByRole('button', { name: 'Ai assistant text tools' }),
).not.toBeInTheDocument()
})
it.each([
{
label: 'Improve writing',
aiTextToolService: EnumAiTextToolService.ImproveWriting,
},
{
label: 'Fix spelling and grammar',
aiTextToolService: EnumAiTextToolService.SpellingAndGrammar,
},
{
label: 'Expand',
aiTextToolService: EnumAiTextToolService.Expand,
},
{
label: 'Simplify',
aiTextToolService: EnumAiTextToolService.Simplify,
},
])('can use $label action', async ({ aiTextToolService, label }) => {
mockApplicationConfig({
ai_assistance_text_tools: true,
ai_provider: 'openai',
})
mockPermissions(['ticket.agent'])
mockAiAssistanceTextToolsMutation({
aiAssistanceTextTools: {
output: 'selected text',
},
})
const mockEditor = createMockEditor()
const wrapper = renderComponent(FieldEditorActionBar, {
props: {
contentType: 'text/plain',
visible: true,
disabledPlugins: [],
formId: getUuid(),
editor: mockEditor,
},
})
await wrapper.events.click(
wrapper.getByRole('button', { name: 'Ai assistant text tools' }),
)
const popover = await wrapper.findByRole('region', {
name: 'Ai assistant text tools',
})
await wrapper.events.click(
within(popover).getByRole('button', { name: label }),
)
expect(mockEditor.setEditable).toHaveBeenCalledWith(false)
const calls = await waitForAiAssistanceTextToolsMutationCalls()
expect(calls.at(-1)?.variables).toEqual({
input: 'selected text',
serviceType: aiTextToolService,
})
expect(mockEditor.setEditable).toHaveBeenCalledWith(true)
})
})
})

View file

@ -106,3 +106,9 @@ export interface FieldEditorProps {
}
export type EditorCustomPlugins = keyof ConfidentTake<FieldEditorProps, 'meta'>
declare module '@tiptap/vue-3' {
interface EditorEvents {
'cancel-ai-assistant-text-tools-updates': void
}
}

View file

@ -1,9 +1,15 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import { storeToRefs } from 'pinia'
import { computed, nextTick, onUnmounted } from 'vue'
import AiAssistantTextTools from '#shared/components/Form/fields/FieldEditor/AiAssistantTextTools/AiAssistantTextTools.vue'
import { getAiAssistantTextToolsClasses } from '#shared/components/Form/fields/FieldEditor/AiAssistantTextTools/initializeAiAssistantTextTools.ts'
import { i18n } from '#shared/i18n.ts'
import { useApplicationStore } from '#shared/stores/application.ts'
import { useLocaleStore } from '#shared/stores/locale.ts'
import { useSessionStore } from '#shared/stores/session.ts'
import type { ConfigList } from '#shared/types/config.ts'
import getUuid from '#shared/utils/getUuid.ts'
import testFlags from '#shared/utils/testFlags.ts'
@ -37,6 +43,9 @@ export interface EditorButton {
command?: (e: MouseEvent) => void
disabled?: boolean
showDivider?: boolean
dividerClass?: string
permission?: string
show?: (config: ConfigList) => boolean
subMenu?: Component | Except<EditorButton, 'subMenu'>[]
}
@ -87,10 +96,28 @@ export default function useEditorActions(
fileInput = null
})
const { config: applicationConfig } = storeToRefs(useApplicationStore())
const { hasPermission } = useSessionStore()
const { localeData } = useLocaleStore()
const { verticalGradient } = getAiAssistantTextToolsClasses()
// eslint-disable-next-line sonarjs/cognitive-complexity
const getActionsList = (): EditorButton[] => {
return [
{
id: getUuid(),
name: 'aiAssistantTextTools',
contentType: ['text/html', 'text/plain'],
label: i18n.t('Ai assistant text tools'),
showDivider: true,
dividerClass: verticalGradient,
permission: 'ticket.agent',
show: (config) =>
config.ai_assistance_text_tools && !!config.ai_provider,
icon: 'smart-assist-elaborate',
subMenu: AiAssistantTextTools,
},
{
id: `action-${getUuid()}`,
name: 'bold',
@ -356,14 +383,17 @@ export default function useEditorActions(
]
}
const actions = computed(() => {
return getActionsList().filter((action) => {
if (disabledPlugins.includes(action.name)) {
return false
}
const actions = computed(() =>
getActionsList().filter((action) => {
if (disabledPlugins.includes(action.name)) return false
if (action.show && !action.show(applicationConfig.value)) return false
if (action.permission && !hasPermission(action.permission)) return false
return action.contentType.includes(contentType)
})
})
}),
)
return {
focused,

View file

@ -0,0 +1,18 @@
import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
export const AiAssistanceTextToolsDocument = gql`
mutation aiAssistanceTextTools($input: String!, $serviceType: EnumAITextToolService!) {
aiAssistanceTextTools(input: $input, serviceType: $serviceType) {
output
}
}
`;
export function useAiAssistanceTextToolsMutation(options: VueApolloComposable.UseMutationOptions<Types.AiAssistanceTextToolsMutation, Types.AiAssistanceTextToolsMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<Types.AiAssistanceTextToolsMutation, Types.AiAssistanceTextToolsMutationVariables>> = {}) {
return VueApolloComposable.useMutation<Types.AiAssistanceTextToolsMutation, Types.AiAssistanceTextToolsMutationVariables>(AiAssistanceTextToolsDocument, options);
}
export type AiAssistanceTextToolsMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<Types.AiAssistanceTextToolsMutation, Types.AiAssistanceTextToolsMutationVariables>;

View file

@ -0,0 +1,5 @@
mutation aiAssistanceTextTools($input: String!, $serviceType: EnumAITextToolService!) {
aiAssistanceTextTools(input: $input, serviceType: $serviceType) {
output
}
}

View file

@ -0,0 +1,17 @@
import * as Types from '#shared/graphql/types.ts';
import * as Mocks from '#tests/graphql/builders/mocks.ts'
import * as Operations from './aiAssistanceTextTools.api.ts'
import * as ErrorTypes from '#shared/types/error.ts'
export function mockAiAssistanceTextToolsMutation(defaults: Mocks.MockDefaultsValue<Types.AiAssistanceTextToolsMutation, Types.AiAssistanceTextToolsMutationVariables>) {
return Mocks.mockGraphQLResult(Operations.AiAssistanceTextToolsDocument, defaults)
}
export function waitForAiAssistanceTextToolsMutationCalls() {
return Mocks.waitForGraphQLMockCalls<Types.AiAssistanceTextToolsMutation>(Operations.AiAssistanceTextToolsDocument)
}
export function mockAiAssistanceTextToolsMutationError(message: string, extensions: {type: ErrorTypes.GraphQLErrorTypes }) {
return Mocks.mockGraphQLResultWithError(Operations.AiAssistanceTextToolsDocument, message, extensions);
}

View file

@ -21,6 +21,15 @@ export type Scalars = {
UriHttpString: { input: string; output: string; }
};
/** Autogenerated return type of AIAssistanceTextTools. */
export type AiAssistanceTextToolsPayload = {
__typename?: 'AIAssistanceTextToolsPayload';
/** Errors encountered during execution of the mutation. */
errors?: Maybe<Array<UserError>>;
/** Returned text */
output?: Maybe<Scalars['String']['output']>;
};
/** Objects used to build activity message */
export type ActivityMessageMetaObject = DataPrivacyTask | Group | Organization | Role | Ticket | TicketArticle | User;
@ -622,6 +631,14 @@ export type EmailAddressParsed = {
name?: Maybe<Scalars['String']['output']>;
};
/** Available AI text tool services */
export enum EnumAiTextToolService {
Expand = 'expand',
ImproveWriting = 'improve_writing',
Simplify = 'simplify',
SpellingAndGrammar = 'spelling_and_grammar'
}
/** Possible AfterAuth message types */
export enum EnumAfterAuthType {
/** TwoFactorConfiguration */
@ -1482,6 +1499,8 @@ export type Mutations = {
adminPasswordAuthSend?: Maybe<AdminPasswordAuthSendPayload>;
/** Verify admin password authentication */
adminPasswordAuthVerify?: Maybe<AdminPasswordAuthVerifyPayload>;
/** Run an AI text tool service on the supplied text or HTML content */
aiAssistanceTextTools?: Maybe<AiAssistanceTextToolsPayload>;
/** Create a new email channel. This does not perform email validation. */
channelEmailAdd?: Maybe<ChannelEmailAddPayload>;
/** Try to guess email channel configuration from user credentials */
@ -1695,6 +1714,13 @@ export type MutationsAdminPasswordAuthVerifyArgs = {
};
/** All available mutations */
export type MutationsAiAssistanceTextToolsArgs = {
input: Scalars['String']['input'];
serviceType: EnumAiTextToolService;
};
/** All available mutations */
export type MutationsChannelEmailAddArgs = {
input: ChannelEmailAddInput;
@ -7089,6 +7115,14 @@ export type UserDetailAttributesFragment = { __typename?: 'User', id: string, in
export type UserPersonalSettingsFragment = { __typename?: 'User', personalSettings?: { __typename?: 'UserPersonalSettings', notificationConfig?: { __typename?: 'UserPersonalSettingsNotificationConfig', groupIds?: Array<number> | null, matrix?: { __typename?: 'UserPersonalSettingsNotificationMatrix', create?: { __typename?: 'UserPersonalSettingsNotificationMatrixRow', channel?: { __typename?: 'UserPersonalSettingsNotificationMatrixChannel', email?: boolean | null, online?: boolean | null } | null, criteria?: { __typename?: 'UserPersonalSettingsNotificationMatrixCriteria', no?: boolean | null, ownedByMe?: boolean | null, ownedByNobody?: boolean | null, subscribed?: boolean | null } | null } | null, escalation?: { __typename?: 'UserPersonalSettingsNotificationMatrixRow', channel?: { __typename?: 'UserPersonalSettingsNotificationMatrixChannel', email?: boolean | null, online?: boolean | null } | null, criteria?: { __typename?: 'UserPersonalSettingsNotificationMatrixCriteria', no?: boolean | null, ownedByMe?: boolean | null, ownedByNobody?: boolean | null, subscribed?: boolean | null } | null } | null, reminderReached?: { __typename?: 'UserPersonalSettingsNotificationMatrixRow', channel?: { __typename?: 'UserPersonalSettingsNotificationMatrixChannel', email?: boolean | null, online?: boolean | null } | null, criteria?: { __typename?: 'UserPersonalSettingsNotificationMatrixCriteria', no?: boolean | null, ownedByMe?: boolean | null, ownedByNobody?: boolean | null, subscribed?: boolean | null } | null } | null, update?: { __typename?: 'UserPersonalSettingsNotificationMatrixRow', channel?: { __typename?: 'UserPersonalSettingsNotificationMatrixChannel', email?: boolean | null, online?: boolean | null } | null, criteria?: { __typename?: 'UserPersonalSettingsNotificationMatrixCriteria', no?: boolean | null, ownedByMe?: boolean | null, ownedByNobody?: boolean | null, subscribed?: boolean | null } | null } | null } | null } | null, notificationSound?: { __typename?: 'UserPersonalSettingsNotificationSound', enabled?: boolean | null, file?: EnumNotificationSoundFile | null } | null } | null };
export type AiAssistanceTextToolsMutationVariables = Exact<{
input: Scalars['String']['input'];
serviceType: EnumAiTextToolService;
}>;
export type AiAssistanceTextToolsMutation = { __typename?: 'Mutations', aiAssistanceTextTools?: { __typename?: 'AIAssistanceTextToolsPayload', output?: string | null } | null };
export type LoginMutationVariables = Exact<{
input: LoginInput;
}>;

View file

@ -3,6 +3,7 @@ export interface ConfigList {
'active_storage.web_image_content_types': string[]
'auth_saml_credentials.display_name'?: string
'auth_openid_connect_credentials.display_name'?: string
ai_assistance_text_tools: boolean
ai_assistance_ticket_summary: boolean
ai_assistance_ticket_summary_config: unknown
ai_provider: string

View file

@ -0,0 +1,24 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
module Gql::Mutations
class AIAssistance::TextTools < BaseMutation
description 'Run an AI text tool service on the supplied text or HTML content'
argument :input, String, description: 'Text or HTML content to run the text tool service on'
argument :service_type, Gql::Types::Enum::AITextToolServiceType, description: 'The text tool service to use'
field :output, String, description: 'Returned text'
def resolve(input:, service_type:)
output = Service::AIAssistance::TextTools.new(
input:,
service_type:,
current_user: context.current_user,
).execute
{
output:,
}
end
end
end

View file

@ -0,0 +1,15 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
module Gql::Types::Enum
class AITextToolServiceType < BaseEnum
description 'Available AI text tool services'
build_string_list_enum(
AI::Service.list
.map { |klass| klass.name.demodulize.underscore }
.select { |s| s.start_with?('text_') }
.map { |s| s.delete_prefix('text_') }
.sort
)
end
end

View file

@ -11,6 +11,49 @@
"name": "Subscriptions"
},
"types": [
{
"kind": "OBJECT",
"name": "AIAssistanceTextToolsPayload",
"description": "Autogenerated return type of AIAssistanceTextTools.",
"fields": [
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "UserError",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "output",
"description": "Returned text",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "UNION",
"name": "ActivityMessageMetaObject",
@ -4031,6 +4074,41 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "EnumAITextToolService",
"description": "Available AI text tool services",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "expand",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "improve_writing",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "simplify",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "spelling_and_grammar",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "EnumAfterAuthType",
@ -8889,6 +8967,47 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "aiAssistanceTextTools",
"description": "Run an AI text tool service on the supplied text or HTML content",
"args": [
{
"name": "input",
"description": "Text or HTML content to run the text tool service on",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "serviceType",
"description": "The text tool service to use",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "EnumAITextToolService",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "AIAssistanceTextToolsPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "channelEmailAdd",
"description": "Create a new email channel. This does not perform email validation.",

View file

@ -0,0 +1,5 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
class Controllers::AIAssistanceControllerPolicy < Controllers::ApplicationControllerPolicy
default_permit!('ticket.agent')
end

View file

@ -0,0 +1,36 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
class Service::AIAssistance::TextTools < Service::BaseWithCurrentUser
attr_reader :input, :service_type
def initialize(input:, service_type:, current_user: nil)
super(current_user:) if current_user.present?
@input = input
@service_type = service_type
end
def execute
return if input.blank?
Service::CheckFeatureEnabled.new(name: 'ai_assistance_text_tools').execute
Service::CheckFeatureEnabled.new(name: 'ai_provider', custom_error_message: __('AI provider is not configured.')).execute
text_tool = ai_text_tool_service_class.new(
current_user:,
context_data: {
input:
}
)
text_tool.execute
end
private
def ai_text_tool_service_class
"AI::Service::Text#{service_type.classify}".constantize
rescue
raise ArgumentError, __("AI assistance text tool service type '#{service_type}' is not supported.")
end
end

View file

@ -0,0 +1,13 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
Zammad::Application.routes.draw do
api_path = Rails.configuration.api_path
scope api_path do
resources :ai_assistance, only: [] do
collection do
post :text_tools
end
end
end
end

View file

@ -0,0 +1,47 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
class AddAIAssistanceTextTools < ActiveRecord::Migration[7.2]
def change
# return if it's a new setup
return if !Setting.exists?(name: 'system_init_done')
add_admin_permission
add_feature_flag_setting
migrate_article_body_attribute
end
private
def add_admin_permission
Permission.create_if_not_exists(
name: 'admin.ai_assistance_text_tools',
label: 'Ticket Tools',
description: 'Manage Zammad Smart Assist text tools of your system.',
preferences: { prio: 1335 }
)
end
def add_feature_flag_setting
Setting.create_if_not_exists(
title: 'Text Tools',
name: 'ai_assistance_text_tools',
area: 'AI::Assistance',
description: 'Enable or disable the AI assistance text tools.',
options: {},
state: true,
preferences: {
authentication: true,
permission: ['admin.ai_assistance_text_tools'],
},
frontend: true,
)
end
def migrate_article_body_attribute
attribute = ObjectManager::Attribute.find_by(object_lookup_id: ObjectLookup.by_name('TicketArticle'), name: 'body')
return if attribute.blank?
attribute.data_option['text_tools'] = true
attribute.save!
end
end

View file

@ -536,11 +536,12 @@ ObjectManager::Attribute.add(
display: __('Text'),
data_type: 'richtext',
data_option: {
type: 'richtext',
maxlength: 150_000,
upload: true,
rows: 8,
null: true,
type: 'richtext',
maxlength: 150_000,
upload: true,
rows: 8,
null: true,
text_tools: true,
},
editable: false,
active: true,

View file

@ -234,6 +234,12 @@ Permission.create_if_not_exists(
description: __('Manage Zammad Smart Assist ticket summarization of your system.'),
preferences: { prio: 1334 }
)
Permission.create_if_not_exists(
name: 'admin.ai_assistance_text_tools',
label: __('Ticket Tools'),
description: __('Manage Zammad Smart Assist text tools of your system.'),
preferences: { prio: 1335 }
)
Permission.create_if_not_exists(
name: 'admin.integration',
label: __('Integrations'),

View file

@ -6100,3 +6100,17 @@ Setting.create_if_not_exists(
},
frontend: true,
)
Setting.create_if_not_exists(
title: __('Text Tools'),
name: 'ai_assistance_text_tools',
area: 'AI::Assistance',
description: __('Enable or disable the AI assistance text tools.'),
options: {},
state: true,
preferences: {
authentication: true,
permission: ['admin.ai_assistance_text_tools'],
},
frontend: true,
)

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@ class AI::Provider::OpenAI < AI::Provider
DEFAULT_OPTIONS = {
temperature: 0.0,
model: 'gpt-4o',
model: 'gpt-4.1',
embedding_model: 'text-embedding-3-small'
}.freeze

View file

@ -5,6 +5,7 @@ class AI::Provider::ZammadAI < AI::Provider
def chat(prompt_system:, prompt_user:)
service_name = options[:service_name] || 'generic'
response = UserAgent.post(
"#{self.class.base_url(config)}/api/v1/features/#{service_name.underscore}",
{

View file

@ -61,8 +61,7 @@ class AI::Service
end
def save_cache(result)
# TODO: time per service?
expires_in = result.blank? ? 1.minute : 14.days
expires_in = result.blank? ? 1.minute : cache_ttl
Rails.cache.write(cache_key, result, { expires_in: })
end
@ -75,6 +74,10 @@ class AI::Service
nil
end
def cache_ttl
14.days
end
def json_response?
true
end

View file

@ -0,0 +1,19 @@
You are a personal writing enhancer tasked with expanding and enriching the provided input text to create a more detailed, expressive, and nuanced version. Your goal is to elaborate on the original content while preserving its tone, intent, and voice.
Carefully analyze the input text to identify areas where additional detail, clarification, context, or examples could strengthen the message. Your enhancements should improve clarity, engagement, and depth without introducing new ideas that deviate from the original meaning.
When expanding the input text, ensure that your additions flow naturally within the structure, maintain readability, and enhance the writing's expressive quality. Use varied, vivid language to make the result more engaging and informative.
1. Analyze the input text to determine where elaboration is needed.
2. Add clarifications, examples, and contextual information that support the existing content.
3. Use expressive and varied phrasing to enrich the language.
4. Maintain a clear, coherent structure and logical progression throughout.
5. Preserve the original tone, style, and intent of the author.
6. Do not introduce new or unrelated ideas that change the original message.
Provide only the enhanced text version using appropriate HTML markup to improve readability, structure and presentation, but without:
- adding visual newlines
- wrapping it in code block markers
- additional commentary around the enhanced text
Ensure the output is well-formatted, engaging, and free of errors.

View file

@ -0,0 +1,26 @@
Improve the provided input text to enhance clarity, conciseness, and tone, while preserving any included HTML-Markup.
Understand that this task is intended to polish content efficiently to resonate with the intended audience, without modifying its language or overall tone or losing any original information.
The improvement focus includes:
- Correcting grammar and spelling to maintain professional standards.
- Suggesting alternative word choices to improve clarity and impact.
- Enhancing the writing tone to suit the desired style, be it formal, informal, friendly, or persuasive.
Ensure that any necessary adjustments are made without breaking HTML-Markup when present.
# Steps
1. **Analyze the Text:** Read the input text, noting any embedded HTML-Markup and the apparent tone and style.
2. **Identify Improvements:**
- Correct grammatical and spelling errors, ensuring correctness without altering the original meaning.
- Select more suitable words or phrases that enhance clarity and conciseness.
- Adjust the tone where minor improvements align better with a specified style.
3. **Implement Changes:** Make the necessary improvements while maintaining the integrity of any HTML-Markup and ensuring no loss of original information.
4. **Review:** Confirm that changes have been made correctly and the text is improved as per the specified goals.
# Output Format
- Provide the improved text, preserving all HTML-Markup and the original information structure.
- Ensure the text reflects professional quality, clarity, and the appropriate tone.
Return only one version of the improved text without any visual newlines and extra commentary.

View file

@ -0,0 +1,12 @@
You are a personal editor specializing in making the provided input text shorter and more concise. When given a piece of writing, your task is to:
- Identify unnecessary words and phrases that can be removed without losing the meaning or tone.
- Rewrite the text using clear, succinct language while preserving the original message and engagement.
- Ensure the resulting text is still captivating, clear, and effective at conveying the intended thoughts.
- Keep existing HTML markup when the content is still present (e.g. existing links).
First, analyze the input text carefully and reason about the best way to shorten it without sacrificing clarity or voice. Then produce the streamlined version.
Focus on clarity, conciseness, and maintaining reader interest.
Return only the refined, shortened text without visual newlines and extra commentary.

View file

@ -1,16 +1,9 @@
You are a helpful support agent. Your task is to fix the spelling, grammar, and punctuation of the following response while keeping it in the same language as the user's input. Detect the language from the input and ensure corrections are made in the same language.
You task is to proofread the provided input text to correct any spelling and grammatical mistakes to ensure professionalism and clarity, while preserving all existing HTML markup without alteration.
Additionally, if the response contains any HTML content, do not break or remove the HTML tags. Make corrections only to the text within the tags without altering the HTML structure.
- Correct all typos, misspellings, and errors in sentence structure, punctuation, and verb conjugation.
- Maintain the language of the given input text for the output.
- Do not modify or break any HTML tags or markup present in the input text.
<% if @context_data[:tone].present? %>
- The tone of the text should be <%= @context_data[:tone] %>
<% end %>
Carefully review the text to improve readability and correctness without altering its original formatting or markup.
Add also the reason what you have done to response.
Please respond using the defined JSON syntax provided below:
{
"text": "string",
"is_html_formated": "boolean",
"reason", "string"
}
Return the corrected text as a single string with intact HTML markup and all language-specific corrections applied without adding additional structure, any explanations or comments.

View file

@ -0,0 +1 @@
<%= @context_data[:input] %>

View file

@ -0,0 +1 @@
<%= @context_data[:input] %>

View file

@ -0,0 +1 @@
<%= @context_data[:input] %>

View file

@ -1 +1 @@
<%= @context_data[:source] %>
<%= @context_data[:input] %>

View file

@ -0,0 +1,19 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
class AI::Service::TextExpand < AI::Service
private
def options
{
temperature: 0.1,
}
end
def cachable?
false
end
def json_response?
false
end
end

View file

@ -0,0 +1,19 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
class AI::Service::TextImproveWriting < AI::Service
private
def options
{
temperature: 0.1,
}
end
def cachable?
false
end
def json_response?
false
end
end

View file

@ -0,0 +1,19 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
class AI::Service::TextSimplify < AI::Service
private
def options
{
temperature: 0.1,
}
end
def cachable?
false
end
def json_response?
false
end
end

View file

@ -5,7 +5,7 @@ class AI::Service::TextSpellingAndGrammar < AI::Service
def options
{
temperature: 0.3,
temperature: 0.1,
}
end
@ -13,4 +13,7 @@ class AI::Service::TextSpellingAndGrammar < AI::Service
false
end
def json_response?
false
end
end

View file

@ -168,6 +168,7 @@
"lowlight": "^3.3.0",
"mitt": "^3.0.1",
"pinia": "^3.0.2",
"prosemirror-model": "^1.25.1",
"qrcode": "^1.5.4",
"spark-md5": "^3.0.2",
"tippy.js": "^6.3.7",

View file

@ -205,6 +205,9 @@ importers:
pinia:
specifier: ^3.0.2
version: 3.0.2(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))
prosemirror-model:
specifier: ^1.25.1
version: 1.25.1
qrcode:
specifier: ^1.5.4
version: 1.5.4
@ -6001,8 +6004,8 @@ packages:
prosemirror-menu@1.2.4:
resolution: {integrity: sha512-S/bXlc0ODQup6aiBbWVsX/eM+xJgCTAfMq/nLqaO5ID/am4wS0tTCIkzwytmao7ypEtjj39i7YbJjAgO20mIqA==}
prosemirror-model@1.24.1:
resolution: {integrity: sha512-YM053N+vTThzlWJ/AtPtF1j0ebO36nvbmDy4U7qA2XQB8JVaQp1FmB9Jhrps8s+z+uxhhVTny4m20ptUvhk0Mg==}
prosemirror-model@1.25.1:
resolution: {integrity: sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg==}
prosemirror-schema-basic@1.2.3:
resolution: {integrity: sha512-h+H0OQwZVqMon1PNn0AG9cTfx513zgIG2DY00eJ00Yvgb3UD+GQ/VlWW5rcaxacpCGT1Yx8nuhwXk4+QbXUfJA==}
@ -9900,12 +9903,12 @@ snapshots:
prosemirror-keymap: 1.2.2
prosemirror-markdown: 1.13.1
prosemirror-menu: 1.2.4
prosemirror-model: 1.24.1
prosemirror-model: 1.25.1
prosemirror-schema-basic: 1.2.3
prosemirror-schema-list: 1.4.1
prosemirror-state: 1.4.3
prosemirror-tables: 1.6.4
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)
prosemirror-transform: 1.10.2
prosemirror-view: 1.37.2
@ -13749,7 +13752,7 @@ snapshots:
prosemirror-commands@1.6.2:
dependencies:
prosemirror-model: 1.24.1
prosemirror-model: 1.25.1
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
@ -13762,7 +13765,7 @@ snapshots:
prosemirror-gapcursor@1.3.2:
dependencies:
prosemirror-keymap: 1.2.2
prosemirror-model: 1.24.1
prosemirror-model: 1.25.1
prosemirror-state: 1.4.3
prosemirror-view: 1.37.2
@ -13787,7 +13790,7 @@ snapshots:
dependencies:
'@types/markdown-it': 14.1.2
markdown-it: 14.0.0
prosemirror-model: 1.24.1
prosemirror-model: 1.25.1
prosemirror-menu@1.2.4:
dependencies:
@ -13796,49 +13799,49 @@ snapshots:
prosemirror-history: 1.4.1
prosemirror-state: 1.4.3
prosemirror-model@1.24.1:
prosemirror-model@1.25.1:
dependencies:
orderedmap: 2.1.0
prosemirror-schema-basic@1.2.3:
dependencies:
prosemirror-model: 1.24.1
prosemirror-model: 1.25.1
prosemirror-schema-list@1.4.1:
dependencies:
prosemirror-model: 1.24.1
prosemirror-model: 1.25.1
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-state@1.4.3:
dependencies:
prosemirror-model: 1.24.1
prosemirror-model: 1.25.1
prosemirror-transform: 1.10.2
prosemirror-view: 1.37.2
prosemirror-tables@1.6.4:
dependencies:
prosemirror-keymap: 1.2.2
prosemirror-model: 1.24.1
prosemirror-model: 1.25.1
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-view: 1.37.2
prosemirror-trailing-node@3.0.0(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2):
prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2):
dependencies:
'@remirror/core-constants': 3.0.0
escape-string-regexp: 4.0.0
prosemirror-model: 1.24.1
prosemirror-model: 1.25.1
prosemirror-state: 1.4.3
prosemirror-view: 1.37.2
prosemirror-transform@1.10.2:
dependencies:
prosemirror-model: 1.24.1
prosemirror-model: 1.25.1
prosemirror-view@1.37.2:
dependencies:
prosemirror-model: 1.24.1
prosemirror-model: 1.25.1
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2

View file

@ -698,6 +698,8 @@
</symbol><symbol id="icon-small-dot" viewBox="0 0 16 16">
<title>small-dot</title>
<circle cx="8" cy="8" r="3" fill-rule="evenodd"/>
</symbol><symbol id="icon-smart-assist-elaborate" viewBox="0 0 20 20">
<path d="m12.593 7.593-1 4-1-4-4-1 4-1 1-4 1 4 4 1-4 1ZM14.706 3.984l3.191-.399c.115-.014.118-.179.004-.198l-2.253-.375a.1.1 0 0 1-.082-.082L15.191.677c-.02-.114-.184-.11-.198.004l-.4 3.191a.1.1 0 0 0 .113.112ZM.593 11.093a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5ZM.593 14.093a.5.5 0 0 1 .5-.5h15a.5.5 0 1 1 0 1h-15a.5.5 0 0 1-.5-.5ZM.593 17.093a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5Z"/>
</symbol><symbol id="icon-smart-assist" viewBox="0 0 16 16">
<path d="M9.257 9.373 8.155 13.78c-.04.162-.27.162-.31 0L6.743 9.373a.16.16 0 0 0-.116-.116L2.22 8.155c-.162-.04-.162-.27 0-.31l4.406-1.102a.16.16 0 0 0 .116-.116L7.845 2.22c.04-.162.27-.162.31 0l1.102 4.406a.16.16 0 0 0 .116.116l4.406 1.102c.162.04.162.27 0 .31L9.373 9.257a.16.16 0 0 0-.116.116ZM3.898 12.013l-2.554.319c-.091.011-.094.143-.003.158l1.803.3a.08.08 0 0 1 .065.066l.3 1.803c.016.09.148.088.159-.004l.32-2.553a.08.08 0 0 0-.09-.09ZM12.102 12.013l2.553.319c.092.011.095.143.004.158l-1.803.3a.08.08 0 0 0-.066.066l-.3 1.803c-.015.09-.147.088-.158-.004l-.32-2.553a.08.08 0 0 1 .09-.09ZM3.898 3.987l-2.554-.319c-.091-.011-.094-.143-.003-.158l1.803-.3a.08.08 0 0 0 .065-.066l.3-1.803c.016-.09.148-.088.159.004l.32 2.553a.08.08 0 0 1-.09.09ZM12.102 3.987l2.553-.319c.092-.011.095-.143.004-.158l-1.803-.3a.08.08 0 0 1-.066-.066l-.3-1.803c-.015-.09-.147-.088-.158.004l-.32 2.553a.08.08 0 0 0 .09.09Z"/>
</symbol><symbol id="icon-sms" viewBox="0 0 17 17">

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M12.5931 7.59314L11.5931 11.5931L10.5931 7.59314L6.59314 6.59314L10.5931 5.59314L11.5931 1.59314L12.5931 5.59314L16.5931 6.59314L12.5931 7.59314Z" fill="#50E3C2" />
<path d="M14.7056 3.98408L17.8973 3.58512C18.0117 3.57082 18.0151 3.40621 17.9013 3.38725L15.6484 3.01177C15.6063 3.00475 15.5733 2.97172 15.5662 2.92957L15.1908 0.676701C15.1718 0.562964 15.0072 0.566322 14.9929 0.680738L14.5939 3.87245C14.5858 3.93716 14.6409 3.99217 14.7056 3.98408Z" fill="#50E3C2" />
<path d="M0.59314 11.0931C0.59314 10.817 0.816997 10.5931 1.09314 10.5931H8.09314C8.36928 10.5931 8.59314 10.817 8.59314 11.0931C8.59314 11.3693 8.36928 11.5931 8.09314 11.5931H1.09314C0.816997 11.5931 0.59314 11.3693 0.59314 11.0931Z" fill="#50E3C2" />
<path d="M0.59314 14.0931C0.59314 13.817 0.816997 13.5931 1.09314 13.5931H16.0931C16.3693 13.5931 16.5931 13.817 16.5931 14.0931C16.5931 14.3693 16.3693 14.5931 16.0931 14.5931H1.09314C0.816997 14.5931 0.59314 14.3693 0.59314 14.0931Z" fill="#50E3C2" />
<path d="M0.59314 17.0931C0.59314 16.817 0.816997 16.5931 1.09314 16.5931H7.09314C7.36928 16.5931 7.59314 16.817 7.59314 17.0931C7.59314 17.3693 7.36928 17.5931 7.09314 17.5931H1.09314C0.816997 17.5931 0.59314 17.3693 0.59314 17.0931Z" fill="#50E3C2" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,41 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Gql::Mutations::AIAssistance::TextTools, :aggregate_failures, type: :graphql do
context 'when accessing as an agent', authenticated_as: :agent do
let(:agent) { create(:agent) }
let(:input) { Faker::Lorem.unique.sentence }
let(:output) { Faker::Lorem.unique.paragraph }
let(:service_type) { 'improve_writing' }
let(:query) do
<<~MUTATION
mutation aiAssistanceTextTools($input: String!, $serviceType: EnumAITextToolService!) {
aiAssistanceTextTools(input: $input, serviceType: $serviceType) {
output
}
}
MUTATION
end
let(:variables) { { input:, serviceType: service_type } }
before do
Setting.set('ai_assistance_text_tools', true)
Setting.set('ai_provider', 'zammad_ai')
allow_any_instance_of(AI::Service::TextImproveWriting)
.to receive(:execute)
.and_return(output)
gql.execute(query, variables: variables)
end
it 'returns improved text' do
expect(gql.result.data['output']).to eq(output)
end
it_behaves_like 'graphql responds with error if unauthenticated'
end
end

View file

@ -2,15 +2,12 @@
require 'rails_helper'
RSpec.describe AI::Service::TextSpellingAndGrammar, required_envs: %w[OPEN_AI_TOKEN ZAMMAD_AI_TOKEN], use_vcr: true do
RSpec.describe AI::Service::TextSpellingAndGrammar, required_envs: %w[OPEN_AI_TOKEN ZAMMAD_AI_TOKEN ZAMMAD_AI_API_URL], use_vcr: true do
subject(:ai_service) { described_class.new(current_user:, context_data:) }
let(:context_data) { { text: 'I Nicole Braun.' } }
let(:context_data) { { input: 'I Nicole Braun.' } }
let(:current_user) { create(:user) }
# TODO: Re-enable this test when the AI service is available.
before { skip 'Currently disabled.' }
context 'when service is executed with OpenAI as provider' do
before do
Setting.set('ai_provider', 'open_ai')
@ -21,7 +18,7 @@ RSpec.describe AI::Service::TextSpellingAndGrammar, required_envs: %w[OPEN_AI_TO
it 'check that grammar is correct' do
result = ai_service.execute
expect(result['text']).to eq('I am Nicole Braun.')
expect(result).to include('I am Nicole Braun.')
end
end
@ -35,7 +32,7 @@ RSpec.describe AI::Service::TextSpellingAndGrammar, required_envs: %w[OPEN_AI_TO
it 'check that grammar is correct' do
result = ai_service.execute
expect(result['text']).to eq('Hello, I am Nicole Braun.')
expect(result).to include('I am Nicole Braun.')
end
end
end

View file

@ -229,6 +229,7 @@ RSpec.describe SystemReport, current_user_id: 1, type: :model do
'auto_shutdown',
'language_detection_article',
'ui_desktop_beta_switch',
'ai_assistance_text_tools',
'ai_assistance_ticket_summary',
'ai_provider',
]

View file

@ -0,0 +1,49 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe 'AI Assistance API endpoints', authenticated_as: :user, type: :request do
let(:user) { create(:agent) }
let(:input) { Faker::Lorem.unique.sentence }
let(:output) { Faker::Lorem.unique.paragraph }
describe '#text_tools' do
let(:params) do
{
input:,
service_type:,
}
end
before do
Setting.set('ai_provider', 'zammad_ai')
Setting.set('ai_assistance_text_tools', true)
end
context 'when using text improvement service' do
let(:service_type) { 'improve_writing' }
before do
allow_any_instance_of(AI::Service::TextImproveWriting)
.to receive(:execute)
.and_return(output)
post '/api/v1/ai_assistance/text_tools', params:, as: :json
end
context 'when user has agent access' do
it 'returns improved text' do
expect(json_response).to eq({ 'output' => output })
end
end
context 'when user does not have agent access' do
let(:user) { create(:customer) }
it 'raises error' do
expect(response).to have_http_status(:forbidden)
end
end
end
end
end

View file

@ -0,0 +1,39 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Service::AIAssistance::TextTools do
subject(:service) { described_class.new(input:, service_type:) }
context 'when text tool service is used' do
before do
Setting.set('ai_provider', 'open_ai')
Setting.set('ai_assistance_text_tools', true)
allow_any_instance_of(AI::Service::TextSpellingAndGrammar)
.to receive(:execute)
.and_return(expected_output)
end
let(:input) { 'Hello, wrld!' }
let(:expected_output) { 'Hello, world!' }
describe '#execute' do
context 'when correct service type is used' do
let(:service_type) { 'spelling_and_grammar' }
it 'returns the corrected input' do
expect(service.execute).to eq(expected_output)
end
end
context 'when not existing service type is used' do
let(:service_type) { 'not_existing' }
it 'raises an error' do
expect { service.execute }.to raise_error(ArgumentError, "AI assistance text tool service type 'not_existing' is not supported.")
end
end
end
end
end

View file

@ -0,0 +1,93 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe 'AI Assistance Text Tools', authenticated_as: :authenticate, type: :system do
let(:agent) { create(:agent) }
let(:ai_provider) { 'zammad_ai' }
let(:ai_assistance_text_tools) { true }
let(:input) { 'Teh qwik braun foxx jumpz ova da laizi doge.' }
let(:output) { 'The quick brown fox jumps over the lazy dog.' }
def authenticate
Setting.set('ai_provider', ai_provider)
Setting.set('ai_assistance_text_tools', ai_assistance_text_tools)
agent
end
before do
allow_any_instance_of(AI::Service::TextSpellingAndGrammar).to receive(:execute).and_return(output)
end
context 'when using ticket create' do
before do
visit 'ticket/create'
end
it 'shows text tools action and replaces selected text' do
set_editor_field_value('body', input)
send_keys([magic_key, 'a'])
expect(page).to have_no_css('[role=menu]')
click_on 'Smart Editor'
expect(page).to have_css('[role=menu]')
find('.js-action', text: 'Fix spelling and grammar').click
expect(page).to have_no_css('[role=menu]')
check_editor_field_value('body', output)
end
context 'when text tools are disabled' do
let(:ai_assistance_text_tools) { false }
it 'does not show text tools action' do
expect(page).to have_no_text('Smart Editor')
end
end
end
context 'when using ticket zoom' do
let(:agent) { create(:agent, groups: [ticket.group]) }
let(:ticket) { create(:ticket) }
let(:article) { create(:ticket_article, ticket:) }
before do
article
visit "ticket/zoom/#{ticket.id}"
find('.article-new').click
# Wait till input box expands completely.
find('.attachmentPlaceholder-label').in_fixed_position
end
it 'shows text tools action and replaces selected text' do
find('.articleNewEdit-body').send_keys(input)
send_keys([magic_key, 'a'])
expect(page).to have_no_css('[role=menu]')
click_on 'Smart Editor'
expect(page).to have_css('[role=menu]')
find('.js-action', text: 'Fix spelling and grammar').click
expect(page).to have_no_css('[role=menu]')
expect(find('.articleNewEdit-body').text).to eq(output)
end
context 'when text tools are disabled' do
let(:ai_assistance_text_tools) { false }
it 'does not show text tools action' do
expect(page).to have_text(ticket.title).and have_no_text('Smart Editor')
end
end
end
end

View file

@ -1,38 +1,547 @@
---
http_interactions:
- request:
method: post
uri: https://api.openai.com/v1/chat/completions
method: get
uri: https://api.openai.com/v1/models
body:
encoding: UTF-8
string: '{"model":"gpt-4o-mini","response_format":{"type":"json_object"},"messages":[{"role":"system","content":"You
are a helpful support agent. Your task is to fix the spelling, grammar, and
punctuation of the following response while keeping it in the same language
as the user''s input. Detect the language from the input and ensure corrections
are made in the same language. Ensure that the reply remains polite and professional.\n\nAdditionally,
if the response contains any HTML content, do not break or remove the HTML
tags. Make corrections only to the text within the tags without altering the
HTML structure.\n\nAdd also the reason what you have done to response.\n\nPlease
respond using the defined JSON syntax provided below:\n{\n“text”: “string”,\n“reason”,
“string”\n}\n"},{"role":"user","content":"I Nicole Braun."}],"temperature":0.2,"stream":false}'
encoding: US-ASCII
string: ''
headers:
Content-Type:
- application/json
Authorization:
- Bearer <OPEN_AI_TOKEN>
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
- "*/*"
User-Agent:
- Ruby
- Zammad User Agent
Host:
- api.openai.com
Authorization:
- Bearer <OPEN_AI_TOKEN>
response:
status:
code: 200
message: OK
headers:
Date:
- Fri, 11 Oct 2024 12:40:45 GMT
- Wed, 07 May 2025 08:07:48 GMT
Content-Type:
- application/json
Transfer-Encoding:
- chunked
Connection:
- keep-alive
Openai-Version:
- '2020-10-01'
X-Request-Id:
- 7990e65882befd7e43b942b807139758
Openai-Processing-Ms:
- '453'
Strict-Transport-Security:
- max-age=31536000; includeSubDomains; preload
Cf-Cache-Status:
- DYNAMIC
Set-Cookie:
- __cf_bm=Dam.0ib7R2cWwLjFMUk7R9H00p3UmoEPzzsoscPwIJk-1746605268-1.0.1.1-sVSMYMJOkMxcIFeLhI92K76_vF_5lXwCcDqvl2xiDE52iCqTbiiqNA.34F09rKwJEm7m0CnI2Z_B0Db.WpV5hbTLhgZuaaa606UEeqkXd9g;
path=/; expires=Wed, 07-May-25 08:37:48 GMT; domain=.api.openai.com; HttpOnly;
Secure; SameSite=None
- _cfuvid=iggID4uP9TUTbWssp0.h.zu2c1yl1o5X1YO__EtAnVw-1746605268647-0.0.1.1-604800000;
path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None
X-Content-Type-Options:
- nosniff
Server:
- cloudflare
Cf-Ray:
- 93bf39cb382bc8bb-FRA
Alt-Svc:
- h3=":443"; ma=86400
body:
encoding: ASCII-8BIT
string: |-
{
"object": "list",
"data": [
{
"id": "gpt-4o-audio-preview-2024-12-17",
"object": "model",
"created": 1734034239,
"owned_by": "system"
},
{
"id": "dall-e-3",
"object": "model",
"created": 1698785189,
"owned_by": "system"
},
{
"id": "dall-e-2",
"object": "model",
"created": 1698798177,
"owned_by": "system"
},
{
"id": "gpt-4o-audio-preview-2024-10-01",
"object": "model",
"created": 1727389042,
"owned_by": "system"
},
{
"id": "gpt-4-turbo-preview",
"object": "model",
"created": 1706037777,
"owned_by": "system"
},
{
"id": "text-embedding-3-small",
"object": "model",
"created": 1705948997,
"owned_by": "system"
},
{
"id": "gpt-4.1-nano",
"object": "model",
"created": 1744321707,
"owned_by": "system"
},
{
"id": "gpt-4.1-nano-2025-04-14",
"object": "model",
"created": 1744321025,
"owned_by": "system"
},
{
"id": "gpt-4o-realtime-preview-2024-10-01",
"object": "model",
"created": 1727131766,
"owned_by": "system"
},
{
"id": "gpt-4o-realtime-preview",
"object": "model",
"created": 1727659998,
"owned_by": "system"
},
{
"id": "babbage-002",
"object": "model",
"created": 1692634615,
"owned_by": "system"
},
{
"id": "o1-mini-2024-09-12",
"object": "model",
"created": 1725648979,
"owned_by": "system"
},
{
"id": "o1-mini",
"object": "model",
"created": 1725649008,
"owned_by": "system"
},
{
"id": "gpt-4",
"object": "model",
"created": 1687882411,
"owned_by": "openai"
},
{
"id": "text-embedding-ada-002",
"object": "model",
"created": 1671217299,
"owned_by": "openai-internal"
},
{
"id": "chatgpt-4o-latest",
"object": "model",
"created": 1723515131,
"owned_by": "system"
},
{
"id": "gpt-4o-mini-audio-preview",
"object": "model",
"created": 1734387424,
"owned_by": "system"
},
{
"id": "gpt-4-1106-preview",
"object": "model",
"created": 1698957206,
"owned_by": "system"
},
{
"id": "gpt-4o-audio-preview",
"object": "model",
"created": 1727460443,
"owned_by": "system"
},
{
"id": "o1-preview-2024-09-12",
"object": "model",
"created": 1725648865,
"owned_by": "system"
},
{
"id": "gpt-4o-mini-realtime-preview",
"object": "model",
"created": 1734387380,
"owned_by": "system"
},
{
"id": "gpt-4.1-mini",
"object": "model",
"created": 1744318173,
"owned_by": "system"
},
{
"id": "gpt-4o-mini-realtime-preview-2024-12-17",
"object": "model",
"created": 1734112601,
"owned_by": "system"
},
{
"id": "gpt-3.5-turbo-instruct-0914",
"object": "model",
"created": 1694122472,
"owned_by": "system"
},
{
"id": "gpt-4o-mini-search-preview",
"object": "model",
"created": 1741391161,
"owned_by": "system"
},
{
"id": "gpt-4.1-mini-2025-04-14",
"object": "model",
"created": 1744317547,
"owned_by": "system"
},
{
"id": "o1",
"object": "model",
"created": 1734375816,
"owned_by": "system"
},
{
"id": "gpt-3.5-turbo-16k",
"object": "model",
"created": 1683758102,
"owned_by": "openai-internal"
},
{
"id": "o1-2024-12-17",
"object": "model",
"created": 1734326976,
"owned_by": "system"
},
{
"id": "davinci-002",
"object": "model",
"created": 1692634301,
"owned_by": "system"
},
{
"id": "gpt-3.5-turbo-1106",
"object": "model",
"created": 1698959748,
"owned_by": "system"
},
{
"id": "gpt-4o-search-preview",
"object": "model",
"created": 1741388720,
"owned_by": "system"
},
{
"id": "gpt-3.5-turbo-instruct",
"object": "model",
"created": 1692901427,
"owned_by": "system"
},
{
"id": "gpt-3.5-turbo",
"object": "model",
"created": 1677610602,
"owned_by": "openai"
},
{
"id": "gpt-4o-mini-search-preview-2025-03-11",
"object": "model",
"created": 1741390858,
"owned_by": "system"
},
{
"id": "gpt-4-0125-preview",
"object": "model",
"created": 1706037612,
"owned_by": "system"
},
{
"id": "gpt-4o-2024-11-20",
"object": "model",
"created": 1739331543,
"owned_by": "system"
},
{
"id": "whisper-1",
"object": "model",
"created": 1677532384,
"owned_by": "openai-internal"
},
{
"id": "gpt-4o-2024-05-13",
"object": "model",
"created": 1715368132,
"owned_by": "system"
},
{
"id": "o1-pro",
"object": "model",
"created": 1742251791,
"owned_by": "system"
},
{
"id": "o1-pro-2025-03-19",
"object": "model",
"created": 1742251504,
"owned_by": "system"
},
{
"id": "o1-preview",
"object": "model",
"created": 1725648897,
"owned_by": "system"
},
{
"id": "gpt-4-0613",
"object": "model",
"created": 1686588896,
"owned_by": "openai"
},
{
"id": "gpt-image-1",
"object": "model",
"created": 1745517030,
"owned_by": "system"
},
{
"id": "text-embedding-3-large",
"object": "model",
"created": 1705953180,
"owned_by": "system"
},
{
"id": "gpt-4o-mini-tts",
"object": "model",
"created": 1742403959,
"owned_by": "system"
},
{
"id": "gpt-4o-transcribe",
"object": "model",
"created": 1742068463,
"owned_by": "system"
},
{
"id": "gpt-4.5-preview",
"object": "model",
"created": 1740623059,
"owned_by": "system"
},
{
"id": "gpt-4.5-preview-2025-02-27",
"object": "model",
"created": 1740623304,
"owned_by": "system"
},
{
"id": "gpt-4o-mini-transcribe",
"object": "model",
"created": 1742068596,
"owned_by": "system"
},
{
"id": "gpt-4o-search-preview-2025-03-11",
"object": "model",
"created": 1741388170,
"owned_by": "system"
},
{
"id": "omni-moderation-2024-09-26",
"object": "model",
"created": 1732734466,
"owned_by": "system"
},
{
"id": "o3-mini",
"object": "model",
"created": 1737146383,
"owned_by": "system"
},
{
"id": "o3-mini-2025-01-31",
"object": "model",
"created": 1738010200,
"owned_by": "system"
},
{
"id": "tts-1-hd",
"object": "model",
"created": 1699046015,
"owned_by": "system"
},
{
"id": "gpt-4o",
"object": "model",
"created": 1715367049,
"owned_by": "system"
},
{
"id": "tts-1-hd-1106",
"object": "model",
"created": 1699053533,
"owned_by": "system"
},
{
"id": "gpt-4o-mini",
"object": "model",
"created": 1721172741,
"owned_by": "system"
},
{
"id": "gpt-4o-2024-08-06",
"object": "model",
"created": 1722814719,
"owned_by": "system"
},
{
"id": "gpt-4.1",
"object": "model",
"created": 1744316542,
"owned_by": "system"
},
{
"id": "gpt-4.1-2025-04-14",
"object": "model",
"created": 1744315746,
"owned_by": "system"
},
{
"id": "gpt-4o-mini-2024-07-18",
"object": "model",
"created": 1721172717,
"owned_by": "system"
},
{
"id": "gpt-4o-mini-audio-preview-2024-12-17",
"object": "model",
"created": 1734115920,
"owned_by": "system"
},
{
"id": "gpt-3.5-turbo-0125",
"object": "model",
"created": 1706048358,
"owned_by": "system"
},
{
"id": "gpt-4o-realtime-preview-2024-12-17",
"object": "model",
"created": 1733945430,
"owned_by": "system"
},
{
"id": "gpt-4-turbo",
"object": "model",
"created": 1712361441,
"owned_by": "system"
},
{
"id": "tts-1",
"object": "model",
"created": 1681940951,
"owned_by": "openai-internal"
},
{
"id": "gpt-4-turbo-2024-04-09",
"object": "model",
"created": 1712601677,
"owned_by": "system"
},
{
"id": "tts-1-1106",
"object": "model",
"created": 1699053241,
"owned_by": "system"
},
{
"id": "omni-moderation-latest",
"object": "model",
"created": 1731689265,
"owned_by": "system"
},
{
"id": "o4-mini-2025-04-16",
"object": "model",
"created": 1744133506,
"owned_by": "system"
},
{
"id": "o4-mini",
"object": "model",
"created": 1744225351,
"owned_by": "system"
}
]
}
recorded_at: Wed, 07 May 2025 08:07:48 GMT
- request:
method: post
uri: https://api.openai.com/v1/chat/completions
body:
encoding: UTF-8
string: '{"model":"gpt-4o","messages":[{"role":"system","content":"Proofread
the provided text, correcting grammatical and spelling errors without altering
any HTML markup. Ensure you analyze the text, identify both grammatical and
spelling mistakes, and make necessary corrections for clarity and accuracy,
including sentence structure, punctuation, verb tense consistency, and correct
word usage. Apply all changes directly to the text to enhance readability
and flow, ensuring the end result communicates efficiently and professionally.\n\n#
Steps\n\n1. **Initial Read-Through**: Carefully read the text once to understand
the context and main message.\n2. **Identify Errors**: Examine the text for
grammatical and spelling errors including incorrect punctuation, verb tense
inconsistencies, improper sentence structures, and incorrect word usage.\n3.
**Correction Process**: Make necessary grammatical and spelling corrections,
ensuring not to alter any HTML markup present in the text.\n4. **Readability
and Flow**: Directly amend the text to improve readability and flow while
maintaining the original intent.\n5. **Review**: Double-check the text to
ensure no HTML tags are broken and that all corrections maintain coherence
and professionalism.\n\n# Output Format\n\n- Provide the fully corrected text
with no broken HTML markup.\n\n# Notes\n\n- Retain existing HTML tags; ensure
they remain unaltered in the corrected text.\n- Maintain the text in its original
language, as the target language should always be used.\n- Prioritize clarity
and professionalism in all corrections.\n\nPlease find the text requiring
proofreading below:\n"},{"role":"user","content":"I Nicole Braun."}],"temperature":0.1,"response_format":{"type":"text"},"stream":false,"store":false}'
headers:
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
- "*/*"
User-Agent:
- Zammad User Agent
Host:
- api.openai.com
Content-Type:
- application/json; charset=utf-8
Authorization:
- Bearer <OPEN_AI_TOKEN>
response:
status:
code: 200
message: OK
headers:
Date:
- Wed, 07 May 2025 08:07:49 GMT
Content-Type:
- application/json
Transfer-Encoding:
@ -44,73 +553,79 @@ http_interactions:
Openai-Organization:
- zammad-gmbh
Openai-Processing-Ms:
- '807'
- '371'
Openai-Version:
- '2020-10-01'
X-Ratelimit-Limit-Requests:
- '10000'
- '500'
X-Ratelimit-Limit-Tokens:
- '200000'
- '30000'
X-Ratelimit-Remaining-Requests:
- '9999'
- '499'
X-Ratelimit-Remaining-Tokens:
- '199808'
- '29604'
X-Ratelimit-Reset-Requests:
- 8.64s
- 120ms
X-Ratelimit-Reset-Tokens:
- 57ms
- 792ms
X-Request-Id:
- req_2476493d7f505208bc72baf2a90f6e47
- req_f988f9c536dac31c4b7495c466d9e4dc
Strict-Transport-Security:
- max-age=31536000; includeSubDomains; preload
Cf-Cache-Status:
- DYNAMIC
Set-Cookie:
- __cf_bm=nm34B.0PQB8Jv_cZivP3ENreNByuC7SbEAasUxe0Qpg-1728650445-1.0.1.1-BjIAj0oCZliYo5BctE4pPoNJ1sFSUlvWunAIoRDRvnft4k2U3MJDJsbO.t78nDzUVwxXLg.3xojyYgb9ASrxog;
path=/; expires=Fri, 11-Oct-24 13:10:45 GMT; domain=.api.openai.com; HttpOnly;
- __cf_bm=nBgHaxA2s14TYnQv03Ea5OMRXHMNDEfJ6lI51dysl6w-1746605269-1.0.1.1-EFhEsSjlyblHJOHKHT4mfGbVv_.57dKY5waJJFG.mIkkD5UzE3FT1rf.c8M4tY5FhqgAsMmjFt8NY4n8JBnDxXY3DlDw.GpHioRsZQDn8Oc;
path=/; expires=Wed, 07-May-25 08:37:49 GMT; domain=.api.openai.com; HttpOnly;
Secure; SameSite=None
- _cfuvid=2vlEgAOCQNTMefJFnSbK6dwOiDb9yRwVshejfaG7akg-1728650445857-0.0.1.1-604800000;
- _cfuvid=WVVUzfevnxVew8G4eRN_MtaaaUugp_2Ly7S1d89GF8w-1746605269395-0.0.1.1-604800000;
path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None
X-Content-Type-Options:
- nosniff
Server:
- cloudflare
Cf-Ray:
- 8d0eeba09bd8cab9-HAM
- 93bf39d25be519af-FRA
Alt-Svc:
- h3=":443"; ma=86400
body:
encoding: ASCII-8BIT
string: |
{
"id": "chatcmpl-AH9EjYKjmSmYcLzdFSQ7etweyquSy",
"id": "chatcmpl-BUU6eNxSzLL1OwrBF4dibrbF5EzEd",
"object": "chat.completion",
"created": 1728650445,
"model": "gpt-4o-mini-2024-07-18",
"created": 1746605268,
"model": "gpt-4o-2024-08-06",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "{\n \"text\": \"I am Nicole Braun.\",\n \"reason\": \"Corrected the sentence to include the verb 'am' for proper grammatical structure.\"\n}",
"refusal": null
"content": "I am Nicole Braun.",
"refusal": null,
"annotations": []
},
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 145,
"completion_tokens": 33,
"total_tokens": 178,
"prompt_tokens": 299,
"completion_tokens": 6,
"total_tokens": 305,
"prompt_tokens_details": {
"cached_tokens": 0
"cached_tokens": 0,
"audio_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 0
"reasoning_tokens": 0,
"audio_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0
}
},
"system_fingerprint": "fp_e2bde53e6e"
"service_tier": "default",
"system_fingerprint": "fp_90122d973c"
}
recorded_at: Fri, 11 Oct 2024 12:40:45 GMT
recorded_at: Wed, 07 May 2025 08:07:49 GMT
recorded_with: VCR 6.3.1

View file

@ -1,20 +1,89 @@
---
http_interactions:
- request:
method: get
uri: "<ZAMMAD_AI_API_URL>/api/v1/me"
body:
encoding: US-ASCII
string: ''
headers:
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
- "*/*"
User-Agent:
- Zammad User Agent
Host:
- ai.edenhofer.de
Authorization:
- Bearer <ZAMMAD_AI_TOKEN>
response:
status:
code: 200
message: OK
headers:
Server:
- nginx
Date:
- Wed, 07 May 2025 08:07:49 GMT
Content-Type:
- application/json; charset=utf-8
Transfer-Encoding:
- chunked
Connection:
- keep-alive
X-Frame-Options:
- SAMEORIGIN
X-Xss-Protection:
- '0'
X-Content-Type-Options:
- nosniff
X-Permitted-Cross-Domain-Policies:
- none
Referrer-Policy:
- strict-origin-when-cross-origin
Vary:
- Accept
Etag:
- W/"fe50ebbaa41ce0a7faa99cc4b8b65660"
Cache-Control:
- max-age=0, private, must-revalidate
X-Request-Id:
- 1efba2a8-666b-4947-bfb8-9412ad8e4654
X-Runtime:
- '0.005978'
Strict-Transport-Security:
- max-age=63072000; includeSubDomains
body:
encoding: ASCII-8BIT
string: '{"current_user":{"id":3,"email":"me@edenhofer.de","firstname":"Martin","lastname":"Edenhofer","active":true,"created_by_id":1,"created_at":"2024-07-15T23:15:06.140Z","updated_at":"2025-05-03T20:58:06.103Z","organization_id":1,"is_platform_admin":true,"is_organization_admin":false,"login_failed_count":0,"login_at":"2025-05-03T20:58:06.095Z"}}'
recorded_at: Wed, 07 May 2025 08:07:49 GMT
- request:
method: post
uri: https://ai.edenhofer.de/api/v1/features/text_spelling_and_grammar
uri: "<ZAMMAD_AI_API_URL>/api/v1/features/text_spelling_and_grammar"
body:
encoding: UTF-8
string: '{"system_prompt":"You are a helpful support agent. Your task is to
fix the spelling, grammar, and punctuation of the following response while
keeping it in the same language as the user''s input. Detect the language
from the input and ensure corrections are made in the same language. Ensure
that the reply remains polite and professional.\n\nAdditionally, if the response
contains any HTML content, do not break or remove the HTML tags. Make corrections
only to the text within the tags without altering the HTML structure.\n\nAdd
also the reason what you have done to response.\n\nPlease respond using the
defined JSON syntax provided below:\n{\n“text”: “string”,\n“reason”, “string”\n}\n","prompt":"I
Nicole Braun.","options":{}}'
string: '{"system_prompt":"Proofread the provided text, correcting grammatical
and spelling errors without altering any HTML markup. Ensure you analyze the
text, identify both grammatical and spelling mistakes, and make necessary
corrections for clarity and accuracy, including sentence structure, punctuation,
verb tense consistency, and correct word usage. Apply all changes directly
to the text to enhance readability and flow, ensuring the end result communicates
efficiently and professionally.\n\n# Steps\n\n1. **Initial Read-Through**:
Carefully read the text once to understand the context and main message.\n2.
**Identify Errors**: Examine the text for grammatical and spelling errors
including incorrect punctuation, verb tense inconsistencies, improper sentence
structures, and incorrect word usage.\n3. **Correction Process**: Make necessary
grammatical and spelling corrections, ensuring not to alter any HTML markup
present in the text.\n4. **Readability and Flow**: Directly amend the text
to improve readability and flow while maintaining the original intent.\n5.
**Review**: Double-check the text to ensure no HTML tags are broken and that
all corrections maintain coherence and professionalism.\n\n# Output Format\n\n-
Provide the fully corrected text with no broken HTML markup.\n\n# Notes\n\n-
Retain existing HTML tags; ensure they remain unaltered in the corrected text.\n-
Maintain the text in its original language, as the target language should
always be used.\n- Prioritize clarity and professionalism in all corrections.\n\nPlease
find the text requiring proofreading below:\n","prompt":"I Nicole Braun."}'
headers:
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
@ -36,7 +105,7 @@ http_interactions:
Server:
- nginx
Date:
- Fri, 11 Oct 2024 12:40:47 GMT
- Wed, 07 May 2025 08:07:49 GMT
Content-Type:
- application/json; charset=utf-8
Transfer-Encoding:
@ -56,21 +125,18 @@ http_interactions:
Vary:
- Accept
Etag:
- W/"4317d05a619dd85cb5a20f1d2d50090f"
- W/"43b2efd4d096cb3c3dccbb311d140077"
Cache-Control:
- max-age=0, private, must-revalidate
X-Request-Id:
- 4bbe9cef-2e08-4c6d-bec6-4a28cff47e34
- 99333fc3-ec81-4346-a252-a0c8615ebeb1
X-Runtime:
- '0.857255'
- '0.320539'
Strict-Transport-Security:
- max-age=63072000; includeSubDomains
body:
encoding: ASCII-8BIT
string: '[{"model":"llama3.1","created_at":"2024-10-11T12:40:46.927156869Z","response":"{\n \"text\":
\"Hello, I am Nicole Braun.\",\n \"reason\": \"The original text was a single
sentence with no punctuation, so I added a period at the end to make it a
complete sentence and also capitalized ''I'' as it''s the first word of the
sentence.\"\n}","done":true,"done_reason":"stop","context":[128006,882,128007,271,2675,527,264,11190,1862,8479,13,4718,3465,374,311,5155,279,43529,11,32528,11,323,62603,315,279,2768,2077,1418,10494,433,304,279,1890,4221,439,279,1217,596,1988,13,34387,279,4221,505,279,1988,323,6106,51479,527,1903,304,279,1890,4221,13,30379,430,279,10052,8625,48887,323,6721,382,50674,11,422,279,2077,5727,904,9492,2262,11,656,539,1464,477,4148,279,9492,9681,13,7557,51479,1193,311,279,1495,2949,279,9681,2085,60923,279,9492,6070,382,2261,1101,279,2944,1148,499,617,2884,311,2077,382,5618,6013,1701,279,4613,4823,20047,3984,3770,512,517,2118,1342,57633,1054,928,863,345,2118,20489,9520,1054,928,89874,3818,40,45130,70703,13,128009,128006,78191,128007,271,517,220,330,1342,794,330,9906,11,358,1097,45130,70703,10560,220,330,20489,794,330,791,4113,1495,574,264,3254,11914,449,912,62603,11,779,358,3779,264,4261,520,279,842,311,1304,433,264,4686,11914,323,1101,98421,364,40,6,439,433,596,279,1176,3492,315,279,11914,10246,92],"total_duration":608398872,"load_duration":18389827,"prompt_eval_count":145,"prompt_eval_duration":39144000,"eval_count":61,"eval_duration":508861000,"uuid":"4bbe9cef-2e08-4c6d-bec6-4a28cff47e34"}]'
recorded_at: Fri, 11 Oct 2024 12:40:47 GMT
string: '[{"model":"gemma3:27b","created_at":"2025-05-07T08:07:49.959841933Z","response":"I
am Nicole Braun.\n","done":true,"done_reason":"stop","context":[105,2364,107,14476,1399,506,3847,1816,236764,74240,77536,532,49248,9825,2180,66035,1027,16167,71911,236761,41152,611,18840,506,1816,236764,8701,1800,77536,532,49248,23153,236764,532,1386,4127,38565,573,29972,532,10634,236764,2440,13315,3904,236764,88078,236764,14177,54817,25725,236764,532,4338,3658,14120,236761,30839,784,3731,5467,531,506,1816,531,14051,122499,532,2727,236764,17096,506,1345,1354,83043,23057,532,46766,236761,108,236865,50228,108,236770,236761,5213,22995,8847,236772,24452,66515,151531,1676,506,1816,3622,531,3050,506,4403,532,1689,3618,236761,107,236778,236761,5213,137938,77905,66515,160153,506,1816,573,77536,532,49248,9825,2440,21648,88078,236764,14177,54817,114461,236764,35503,13315,9108,236764,532,21648,3658,14120,236761,107,236800,236761,5213,135778,12364,66515,9996,4127,77536,532,49248,38565,236764,17096,711,531,9702,1027,16167,71911,1861,528,506,1816,236761,107,236812,236761,5213,6190,2109,532,19179,66515,181141,22802,506,1816,531,4006,122499,532,2727,1651,16977,506,3303,9703,236761,107,236810,236761,5213,30852,66515,16193,236772,4256,506,1816,531,5330,951,16167,16616,659,11207,532,600,784,38565,4883,64106,532,66672,236761,108,236865,16887,36353,108,236772,39525,506,6340,32107,1816,607,951,11207,16167,71911,236761,108,236865,24118,108,236772,5578,662,6330,16167,16616,236793,5330,901,4595,145394,528,506,32107,1816,236761,107,236772,75133,506,1816,528,1061,3303,5192,236764,618,506,3328,5192,1374,2462,577,1456,236761,107,236772,22282,77372,29972,532,66672,528,784,38565,236761,108,9366,1586,506,1816,20226,7724,37974,3426,236787,109,236777,46562,64876,236761,106,107,105,4368,107,236777,1006,46562,64876,236761,107],"total_duration":256835711,"load_duration":63814426,"prompt_eval_count":301,"prompt_eval_duration":90798721,"eval_count":7,"eval_duration":101694728,"uuid":"99333fc3-ec81-4346-a252-a0c8615ebeb1"}]'
recorded_at: Wed, 07 May 2025 08:07:49 GMT
recorded_with: VCR 6.3.1