formstrap 0.4.8 → 0.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1053 @@
1
+ /* global Redactor */
2
+ /* eslint-disable no-useless-escape */
3
+ /* jshint esversion: 6 */
4
+ Redactor.add('plugin', 'ai', {
5
+ translations: {
6
+ en: {
7
+ ai: {
8
+ 'placeholder-image': 'Describe the image you want to generate.',
9
+ 'placeholder-text': 'Tell me what you want to write.',
10
+ send: 'Send',
11
+ stop: 'Stop',
12
+ discard: 'Discard',
13
+ insert: 'Insert',
14
+ prompt: 'Prompt',
15
+ 'image-style': 'Image style',
16
+ 'change-tone': 'Change tone'
17
+ }
18
+ }
19
+ },
20
+ dropdowns: {
21
+ items: {
22
+ improve: { title: 'Improve it', command: 'ai.set', params: { prompt: 'Improve it' } },
23
+ simplify: { title: 'Simplify it', command: 'ai.set', params: { prompt: 'Simplify it' } },
24
+ fix: { title: 'Fix any mistakes', command: 'ai.set', params: { prompt: 'Fix any mistakes' } },
25
+ shorten: { title: 'Make it shorter', command: 'ai.set', params: { prompt: 'Make it shorter' } },
26
+ detailed: { title: 'Make it more detailed', command: 'ai.set', params: { prompt: 'Make it more detailed' } },
27
+ complete: { title: 'Complete sentence', command: 'ai.set', params: { prompt: 'Complete sentence' } },
28
+ tone: { title: 'Change tone', command: 'ai.popupTone' },
29
+ translate: { title: 'Translate', command: 'ai.popupTranslate' }
30
+ }
31
+ },
32
+ defaults: {
33
+ tone: [
34
+ 'Academic',
35
+ 'Assertive',
36
+ 'Casual',
37
+ 'Confident',
38
+ 'Constructive',
39
+ 'Empathetic',
40
+ 'Exciting',
41
+ 'Fluent',
42
+ 'Formal',
43
+ 'Friendly',
44
+ 'Inspirational',
45
+ 'Professional'
46
+ ],
47
+ style: [
48
+ '3d model',
49
+ 'Digital art',
50
+ 'Isometric',
51
+ 'Line art',
52
+ 'Photorealistic',
53
+ 'Pixel art'
54
+ ],
55
+ translate: [
56
+ 'Arabic',
57
+ 'Chinese',
58
+ 'English',
59
+ 'French',
60
+ 'German',
61
+ 'Greek',
62
+ 'Italian',
63
+ 'Japanese',
64
+ 'Korean',
65
+ 'Portuguese',
66
+ 'Russian',
67
+ 'Spanish',
68
+ 'Swedish',
69
+ 'Ukrainian'
70
+ ],
71
+ size: {
72
+ '1792x1024': 'Landscape',
73
+ '1024x1792': 'Portrait',
74
+ '1024x1024': 'Square'
75
+ },
76
+ text: {
77
+ stream: true
78
+ },
79
+ image: {
80
+ save: false
81
+ },
82
+ makeit: 'Make it',
83
+ translateto: 'Translate to',
84
+ spinner: '<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_nOfF{animation:spinner_qtyZ 2s cubic-bezier(0.36,.6,.31,1) infinite}.spinner_fVhf{animation-delay:-.5s}.spinner_piVe{animation-delay:-1s}.spinner_MSNs{animation-delay:-1.5s}@keyframes spinner_qtyZ{0%{r:0}25%{r:3px;cx:4px}50%{r:3px;cx:12px}75%{r:3px;cx:20px}100%{r:0;cx:20px}}</style><circle class="spinner_nOfF" cx="4" cy="12" r="3"/><circle class="spinner_nOfF spinner_fVhf" cx="4" cy="12" r="3"/><circle class="spinner_nOfF spinner_piVe" cx="4" cy="12" r="3"/><circle class="spinner_nOfF spinner_MSNs" cx="4" cy="12" r="3"/></svg>'
85
+ },
86
+ observe (obj, name, toolbar) {
87
+ if (name === 'ai-tools' && !this.opts.is('ai.text.url')) {
88
+ return
89
+ } else if (name === 'ai-image' && !this.opts.is('ai.image.url')) {
90
+ return
91
+ }
92
+
93
+ return obj
94
+ },
95
+ popup (e, button) {
96
+ const uiState = this.app.ui.getState()
97
+ if (uiState.type !== 'addbar') {
98
+ this.app.dropdown.create('ai-tools', { items: this.opts.get('ai.items') || this.dropdowns.items })
99
+ this.app.dropdown.open(e, button)
100
+ } else {
101
+ this._buildPrompt()
102
+ }
103
+ },
104
+ promptImage (e, button) {
105
+ this._buildPrompt({ image: true })
106
+ },
107
+ popupTone (e, button) {
108
+ const buttons = {}
109
+ const items = this.opts.get('ai.tone') || this.defaults.tone
110
+ const makeit = this.opts.get('ai.makeit') || this.defaults.makeit
111
+
112
+ for (let i = 0; i < items.length; i++) {
113
+ buttons[i] = { title: items[i], command: 'ai.set', params: { prompt: makeit + ' ' + items[i] } }
114
+ }
115
+
116
+ this.app.dropdown.create('ai-tone', { items: buttons })
117
+ this.app.dropdown.open(e, button)
118
+ },
119
+ popupTranslate (e, button) {
120
+ const buttons = {}
121
+ const items = this.opts.get('ai.translate') || this.defaults.translate
122
+ const translateto = this.opts.get('ai.translateto') || this.defaults.translateto
123
+
124
+ for (let i = 0; i < items.length; i++) {
125
+ buttons[i] = { title: items[i], command: 'ai.set', params: { prompt: translateto + ' ' + items[i] } }
126
+ }
127
+
128
+ this.app.dropdown.create('ai-translate', { items: buttons })
129
+ this.app.dropdown.open(e, button)
130
+ },
131
+ set (params, button) {
132
+ this.promptButton = button
133
+ let text = this._getText()
134
+ let html = this._getHtml()
135
+
136
+ // spinner
137
+ if (text !== '' && params.empty !== true) {
138
+ this.promptButton.setIcon(this.defaults.spinner)
139
+ }
140
+
141
+ // empty
142
+ if (params.empty) {
143
+ text = ''
144
+ html = ''
145
+ this.promptButton.setIcon(this.defaults.spinner)
146
+ }
147
+
148
+ // broadcast
149
+ let message = params.prompt
150
+ const event = this.app.broadcast('ai.create', { prompt: message })
151
+ message = event.get('prompt')
152
+ this.modifiedValue = message
153
+
154
+ this._setPrompt(text, html, message, params.empty)
155
+ },
156
+ sendPrompt (e) {
157
+ e.preventDefault()
158
+ e.stopPropagation()
159
+
160
+ const apimodel = this.opts.get('ai.' + this.promptType + '.model')
161
+ let message = this._getMessage()
162
+
163
+ // broadcast
164
+ const event = this.app.broadcast('ai.create', { prompt: message })
165
+ message = event.get('prompt')
166
+ this.modifiedValue = message
167
+
168
+ if (message === '') return
169
+ const tone = this._getTone(message)
170
+
171
+ if (this.promptType === 'text') {
172
+ this.conversation.push({ role: 'user', content: message })
173
+ if (tone) {
174
+ this.conversation.push({ role: 'user', content: tone })
175
+ }
176
+ }
177
+
178
+ let request = {
179
+ model: apimodel,
180
+ stream: this.opts.get('ai.text.stream'),
181
+ messages: this.conversation
182
+ }
183
+
184
+ let size = '1024x1024'
185
+ if (this.promptType === 'image') {
186
+ size = this.$size.val()
187
+ }
188
+
189
+ request = (this.promptType === 'image') ? { model: apimodel, n: 1, size, prompt: message } : request
190
+
191
+ // loading
192
+ this.$progress.html(this.defaults.spinner)
193
+
194
+ // send
195
+ if (this.promptType !== 'image' && this.opts.is('ai.text.stream')) {
196
+ this._sendStream(this.$preview, message, true)
197
+ } else {
198
+ this._sendPrompt(request, '_complete')
199
+ }
200
+ },
201
+ insertPrompt (e) {
202
+ e.preventDefault()
203
+ e.stopPropagation()
204
+
205
+ const insertion = this.app.create('insertion')
206
+ let html = this.$preview.html()
207
+
208
+ if (this.promptType === 'image') {
209
+ const tag = this.opts.get('image.tag')
210
+ html = `<${tag}>${html}</${tag}>`
211
+ }
212
+
213
+ // broadcast
214
+ const event = this.app.broadcast('ai.before.insert', { html })
215
+ html = event.get('html')
216
+
217
+ const $target = this.savedInstance ? this.savedInstance.getBlock() : this.$prompt
218
+ const position = this.savedInstance ? 'after' : 'before'
219
+ const remove = !this.savedInstance
220
+
221
+ setTimeout(function () {
222
+ this.app.block.setTool(false)
223
+ const inserted = insertion.insert({ html, target: $target, position, remove })
224
+ this.$prompt.remove()
225
+ this.conversation = []
226
+
227
+ // broadcast
228
+ this.app.broadcast('ai.insert', { nodes: inserted })
229
+ }.bind(this), 3)
230
+ },
231
+ stopPrompt (e, reply) {
232
+ if (e) {
233
+ e.preventDefault()
234
+ e.stopPropagation()
235
+
236
+ reply = this.$preview.text()
237
+ if (this.isEvent) {
238
+ this.isEvent.close()
239
+ }
240
+ }
241
+
242
+ this.$preview.css({ 'white-space': '' })
243
+ this.$preview.html(this._parseReply(reply))
244
+ this.$insert.show()
245
+ this.$stop.hide()
246
+ this.$generate.show()
247
+
248
+ // broadcast
249
+ const eventName = (e) ? 'ai.stop' : 'ai.complete'
250
+ const prompt = this.$previewLabel.text()
251
+ const result = (e) ? { prompt } : { prompt, response: this._parseReply(reply) }
252
+ this.app.broadcast(eventName, result)
253
+ },
254
+ closePrompt (e) {
255
+ e.preventDefault()
256
+ e.stopPropagation()
257
+
258
+ this.app.block.setTool(false)
259
+ this.$prompt.remove()
260
+ this.conversation = []
261
+
262
+ if (this.isEvent) {
263
+ this.isEvent.close()
264
+ }
265
+
266
+ // broadcast
267
+ this.app.broadcast('ai.discard')
268
+ },
269
+
270
+ // =private
271
+
272
+ // get
273
+ _getTone (message) {
274
+ const tone = this.$select.val()
275
+ if (tone === '0' || tone === '1') {
276
+ return false
277
+ }
278
+
279
+ return `${this.opts.get('ai.makeit') || this.defaults.makeit} ${tone}`
280
+ },
281
+ _getHtml () {
282
+ let html = ''
283
+ let instances = this.app.blocks.get({ selected: true, instances: true })
284
+ const instance = this.app.block.get()
285
+
286
+ if (instances.length === 0 && instance) {
287
+ instances = [instance]
288
+ }
289
+
290
+ for (let i = 0; i < instances.length; i++) {
291
+ html = html + instances[i].getOuterHtml()
292
+ }
293
+
294
+ return html
295
+ },
296
+ _getText () {
297
+ let text = ''
298
+ let instances = this.app.blocks.get({ selected: true, instances: true })
299
+ const instance = this.app.block.get()
300
+
301
+ if (instances.length === 0 && instance) {
302
+ instances = [instance]
303
+ }
304
+
305
+ // normalize
306
+ for (let i = 0; i < instances.length; i++) {
307
+ if (instances[i].isEditable()) {
308
+ const type = instances[i].getType()
309
+ const prefix = (type === 'listitem') ? '- ' : ''
310
+
311
+ text = text + prefix + instances[i].getPlainText() + '\n'
312
+ }
313
+ }
314
+
315
+ text = text.trim()
316
+ text = text.replace(/\n$/, '')
317
+
318
+ return text
319
+ },
320
+ _getMessage () {
321
+ return this.$textarea.val().trim()
322
+ },
323
+ _getNode () {
324
+ const node = this.app.block.create()
325
+ let $node = node.getBlock()
326
+
327
+ $node = this._buildNode($node, { traverse: true })
328
+
329
+ return $node
330
+ },
331
+ _getInsertedNode () {
332
+ let $node = this.dom('<div class="rx-inserted-node" style="white-space: pre-wrap;">')
333
+
334
+ $node.html(this.opts.get('ai.spinner'))
335
+ $node = this._buildNode($node, { traverse: false })
336
+
337
+ return $node
338
+ },
339
+
340
+ // set
341
+ _setPrompt (text, html, prompt, empty) {
342
+ if (text === '' && empty !== true) return
343
+
344
+ this.promptType = 'text'
345
+ this.promptText = text
346
+ this.promptHtml = html
347
+
348
+ const messages = [{ role: 'user', content: text }, { role: 'user', content: prompt }]
349
+ const request = {
350
+ model: this.opts.get('ai.' + this.promptType + '.model'),
351
+ messages
352
+ }
353
+
354
+ // send
355
+ if (this.opts.is('ai.text.stream')) {
356
+ const $node = this._getInsertedNode()
357
+ $node.html(this.defaults.spinner)
358
+
359
+ this.app.dropdown.close()
360
+ this.app.context.close()
361
+
362
+ this._sendStream($node, messages)
363
+ } else {
364
+ this._sendPrompt(request, '_insert')
365
+ }
366
+ },
367
+
368
+ // send
369
+ _sendPrompt (request, complete) {
370
+ let data = {
371
+ url: this.opts.get('ai.' + this.promptType + '.endpoint'),
372
+ data: JSON.stringify(request)
373
+ }
374
+
375
+ const utils = this.app.create('utils')
376
+ data = utils.extendData(data, this.opts.get('ai.' + this.promptType + '.data'))
377
+
378
+ this.ajax.request('post', {
379
+ url: this.opts.get('ai.' + this.promptType + '.url'),
380
+ data,
381
+ before: function (xhr) {
382
+ const event = this.app.broadcast('ai.before.send', { xhr, data })
383
+ if (event.isStopped()) {
384
+ return false
385
+ }
386
+ }.bind(this),
387
+ success: this[complete].bind(this),
388
+ error: this._error.bind(this)
389
+ })
390
+ },
391
+ _sendStream ($node, message, preview) {
392
+ const apimodel = this.opts.get('ai.' + this.promptType + '.model')
393
+ const apiurl = this.opts.get('ai.' + this.promptType + '.endpoint')
394
+ const serverurl = this.opts.get('ai.' + this.promptType + '.url')
395
+
396
+ const request = {
397
+ model: apimodel,
398
+ stream: this.opts.get('ai.text.stream'),
399
+ messages: (preview) ? this.conversation : message
400
+ }
401
+
402
+ let data = {
403
+ url: apiurl,
404
+ data: JSON.stringify(request)
405
+ }
406
+
407
+ const utils = this.app.create('utils')
408
+ data = utils.extendData(data, this.opts.get('ai.' + this.promptType + '.data'))
409
+
410
+ let responseContent = ''
411
+ const source = this._createSource(serverurl, data)
412
+ this.isEvent = source
413
+ this.currentIndex = 0
414
+
415
+ // win target
416
+ const $target = this.app.scroll.getTarget()
417
+
418
+ // node class
419
+ $node.removeClass('rx-inserted-node-started')
420
+
421
+ // on message
422
+ source.addEventListener('message', function (event) {
423
+ // hide ui
424
+ this.app.dropdown.close()
425
+ this.app.context.close()
426
+
427
+ const message = event.data
428
+ const start = message.indexOf(': ', 'data') + 2
429
+ let data = message.slice(start, message.length)
430
+
431
+ if (data === '[DONE]') {
432
+ this._sendStreamDone(source, $node, responseContent, preview)
433
+ } else {
434
+ data = JSON.parse(data)
435
+ if (data.notification) {
436
+ this._sendStreamDone(source, $node, data.notification, preview)
437
+ return
438
+ }
439
+
440
+ const choices = data.choices
441
+ if (choices && choices.length > 0) {
442
+ const content = choices[0].delta.content
443
+
444
+ if (content) {
445
+ if (!$node.hasClass('rx-inserted-node-started')) {
446
+ $node.html('')
447
+ this._sendStreamPreviewSet(preview)
448
+ }
449
+
450
+ responseContent += content
451
+ this._typeCharacter($node, responseContent)
452
+
453
+ // Auto-scroll to the bottom as new content is added
454
+ if (this._isElementBottomBeyond($node.get())) {
455
+ $node.get().scrollIntoView(false)
456
+ $target.scrollTop($target.scrollTop() + 20)
457
+ }
458
+ $node.addClass('rx-inserted-node-started')
459
+ }
460
+ }
461
+ }
462
+ }.bind(this))
463
+
464
+ // on error
465
+ source.addEventListener('error', function (error) {
466
+ this._error(error)
467
+ source.close()
468
+ this.isEvent = false
469
+ }.bind(this))
470
+ },
471
+ _sendStreamDone (source, $node, responseContent, preview) {
472
+ if (!preview) {
473
+ this._insertAfterNode($node, responseContent)
474
+ } else {
475
+ const checkInterval = setInterval(function () {
476
+ if (this.currentIndex === responseContent.length) {
477
+ clearInterval(checkInterval)
478
+ this.stopPrompt(false, responseContent)
479
+ this.conversation.push({ role: 'assistant', content: responseContent })
480
+ }
481
+ }.bind(this), 100)
482
+ }
483
+
484
+ source.close()
485
+ this.isEvent = false
486
+ },
487
+ _sendStreamPreviewSet (preview) {
488
+ if (!preview) return
489
+
490
+ const value = this.modifiedValue || this.$textarea.val()
491
+ this.$progress.html('')
492
+ this.$previewLabel.html(this._sanitize(value))
493
+ this.$textarea.val('')
494
+ this.$preview.html('')
495
+ this.$preview.css({ 'white-space': 'pre-wrap' })
496
+ this.$generate.hide()
497
+ this.$stop.show()
498
+ },
499
+
500
+ // build
501
+ _buildNode ($node, traverse) {
502
+ let $last
503
+
504
+ this.instance = false
505
+ if (this.app.blocks.is()) {
506
+ if (!this.app.editor.isSelectAll()) {
507
+ $last = this.app.blocks.get({ first: true, selected: true })
508
+ $last = $last.closest('[data-rx-first-level]')
509
+ $last.before($node)
510
+ }
511
+
512
+ const isAll = this.app.editor.isSelectAll()
513
+ $last = this.app.blocks.removeAll()
514
+ if (isAll) {
515
+ $node = $last
516
+ }
517
+ } else {
518
+ this.instance = this.app.block.get()
519
+ if (!this.instance) {
520
+ this.instance = this.app.block.create()
521
+ const $first = this.app.blocks.get({ first: true })
522
+ $first.before(this.instance.getBlock())
523
+ }
524
+
525
+ if (this.instance.isType('listitem')) {
526
+ this.instance.getBlock().html('').append($node)
527
+ } else if (this.instance.isType('todoitem')) {
528
+ this.instance.getContentItem().html('').append($node)
529
+ } else {
530
+ this.instance.getBlock().before($node)
531
+ this.instance.remove(traverse)
532
+ }
533
+ }
534
+
535
+ return $node
536
+ },
537
+ _buildPrompt (params) {
538
+ this.savedInstance = false
539
+ this.conversation = []
540
+ this.$prompt = this._createPrompt(params)
541
+ this.$textarea.on('input focus', function () {
542
+ this.app.block.setTool('ai')
543
+ }.bind(this))
544
+
545
+ let instance = this.app.block.get()
546
+ const isMultiple = this.app.blocks.is()
547
+
548
+ // hide ui
549
+ this.app.dropdown.close()
550
+ this.app.context.close()
551
+
552
+ if (instance || isMultiple) {
553
+ if (isMultiple) {
554
+ instance = this.app.blocks.get({ last: true, selected: true, instances: true })
555
+ }
556
+
557
+ // find parent
558
+ const types = ['layout', 'table', 'quote', 'list', 'todo', 'image', 'embed']
559
+ const $parent = instance.getBlock().closest('[data-rx-type=' + types.join('],[data-rx-type=') + ']')
560
+ const $column = instance.getBlock().closest('[data-rx-type=column]')
561
+ if ($parent.length !== 0) {
562
+ if ($column.length !== 0) {
563
+ this.savedInstance = instance
564
+ }
565
+ instance = $parent.dataget('instance')
566
+ }
567
+
568
+ // insert ai form
569
+ this._insertPrompt(this.$prompt, instance)
570
+
571
+ if (isMultiple) {
572
+ this.app.blocks.unset()
573
+ }
574
+ } else {
575
+ this._insertPrompt(this.$prompt)
576
+ }
577
+
578
+ // adjust height
579
+ this.app.editor.adjustHeight()
580
+ },
581
+
582
+ // error
583
+ _error (error, response) {
584
+ const $node = this.app.editor.getEditor().find('.rx-inserted-node')
585
+ if ($node.length !== 0) {
586
+ // hide ui
587
+ this.app.dropdown.close()
588
+ this.app.context.close()
589
+
590
+ const insertion = this.app.create('insertion')
591
+ insertion.insert({ target: $node, remove: true, caret: 'end', html: this.promptHtml })
592
+ }
593
+
594
+ // hide loading
595
+ if (this.$progress) {
596
+ this.$progress.fadeOut(500, function () {
597
+ this.$progress.html('').removeAttr('style')
598
+ }.bind(this))
599
+ }
600
+
601
+ // broadcast
602
+ this.app.broadcast('ai.error', error || response)
603
+ },
604
+
605
+ // complete
606
+ _insert (response) {
607
+ this.promptButton.setIcon('')
608
+
609
+ // error
610
+ if (response.error) return this._error(response.error.message, response)
611
+ if (!response.choices) return this._error(response)
612
+
613
+ const reply = response.choices[0].message.content
614
+ let html = this._parseReply(reply)
615
+ const insertion = this.app.create('insertion')
616
+
617
+ // broadcast
618
+ const event = this.app.broadcast('ai.before.insert', { html })
619
+ html = event.get('html')
620
+
621
+ // hide ui
622
+ this.app.dropdown.close()
623
+ this.app.context.close()
624
+
625
+ let inserted
626
+ const instanceType = (this.instance && this.instance.isType(['listitem', 'todoitem']))
627
+ if (instanceType) {
628
+ this.instance.setContent(reply)
629
+ inserted = this.instance.getBlock()
630
+ } else {
631
+ const $node = this._getNode()
632
+ this.app.block.set($node)
633
+ inserted = insertion.insert({ html, caret: 'end' })
634
+ }
635
+
636
+ this.app.broadcast('ai.insert', { nodes: inserted })
637
+ },
638
+ _complete (response) {
639
+ let reply
640
+ let html
641
+ let imageUrl
642
+ const value = this.modifiedValue || this.$textarea.val()
643
+ let result
644
+
645
+ this.$progress.html('')
646
+ this.$previewLabel.html('')
647
+
648
+ // error
649
+ if (response.error) return this._error(response.error.message, response)
650
+ if (response.notification) {
651
+ this.$preview.html(response.notification + '<br>')
652
+ return
653
+ }
654
+
655
+ // current prompt
656
+ this.$previewLabel.html(this._sanitize(value))
657
+ this.$textarea.val('')
658
+ this.$textarea.focus()
659
+
660
+ // complete
661
+ if (this.promptType === 'text') {
662
+ if (!response.choices) return this._error(response)
663
+
664
+ reply = response.choices[0].message.content
665
+ this.conversation.push({ role: 'assistant', content: reply })
666
+ html = this._parseReply(reply)
667
+
668
+ result = html
669
+ this.$preview.html(html)
670
+ this.$insert.show()
671
+
672
+ // broadcast
673
+ const prompt = this.$previewLabel.text()
674
+ this.app.broadcast('ai.complete', { prompt, response: result })
675
+
676
+ // adjust height
677
+ this.app.editor.adjustHeight()
678
+ } else if (this.promptType === 'image') {
679
+ if (!response.data) return this._error(response)
680
+
681
+ imageUrl = response.data[0].url
682
+ // html = response.data[0].revised_prompt;
683
+
684
+ const saveUrl = this.opts.get('ai.image.save')
685
+ if (saveUrl) {
686
+ const utils = this.app.create('utils')
687
+ let data = {
688
+ url: imageUrl
689
+ }
690
+ data = utils.extendData(data, this.opts.get('ai.' + this.promptType + '.data'))
691
+
692
+ // save request
693
+ this.ajax.request('post', {
694
+ url: saveUrl,
695
+ data,
696
+ before: function (xhr) {
697
+ const event = this.app.broadcast('ai.before.save', { xhr, data })
698
+ if (event.isStopped()) {
699
+ return false
700
+ }
701
+ }.bind(this),
702
+ success: function (response) {
703
+ this.app.broadcast('ai.save', response)
704
+ this._completeImage(response.filename)
705
+ }.bind(this),
706
+ error: this._error.bind(this)
707
+ })
708
+ } else {
709
+ this._completeImage(imageUrl)
710
+ }
711
+ }
712
+ },
713
+ _completeImage (imageUrl) {
714
+ const $image = this.dom('<img>').attr('src', imageUrl)
715
+ const result = $image.get().outerHTML
716
+ this.$preview.html($image)
717
+ this.$insert.show()
718
+
719
+ // broadcast
720
+ const prompt = this.$previewLabel.text()
721
+ this.app.broadcast('ai.complete', { prompt, response: result })
722
+
723
+ // adjust height
724
+ this.app.editor.adjustHeight()
725
+ },
726
+
727
+ // parse
728
+ _parseReply (reply) {
729
+ const utils = this.app.create('utils')
730
+ const cleaner = this.app.create('cleaner')
731
+
732
+ let text = utils.parseMarkdown(reply)
733
+ text = cleaner.store(text, 'lists')
734
+ text = cleaner.store(text, 'headings')
735
+ text = cleaner.store(text, 'images')
736
+ text = cleaner.store(text, 'links')
737
+
738
+ text = this._parseMarkdown(text)
739
+ text = cleaner.restore(text, 'lists')
740
+ text = cleaner.restore(text, 'headings')
741
+ text = cleaner.restore(text, 'images')
742
+ text = cleaner.restore(text, 'links')
743
+
744
+ // clean up
745
+ text = text.replace(/<p><(ul|ol)>/g, '<$1>')
746
+ text = text.replace(/<\/(ul|ol)><\/p>/g, '</$1>')
747
+ text = text.replace(/<p><\/p>/g, '')
748
+
749
+ return text
750
+ },
751
+
752
+ // create
753
+ _createPrompt (params) {
754
+ params = Redactor.extend(true, {}, { image: false }, params)
755
+
756
+ // type
757
+ this.promptType = (params.image) ? 'image' : 'text'
758
+
759
+ const placeholder = this.lang.get('ai.placeholder-' + this.promptType)
760
+ const $editor = this.app.editor.getEditor()
761
+ $editor.find('.rx-ai-main').remove()
762
+
763
+ const $main = this.dom('<div class="rx-in-tool rx-ai-main">').attr({ contenteditable: false })
764
+ const $body = this.dom('<div class="rx-ai-body">')
765
+ const $footer = this.dom('<div class="rx-ai-footer">')
766
+ const $buttons = this.dom('<div class="rx-ai-buttons">')
767
+
768
+ this.$progress = this.dom('<div class="rx-ai-progress">')
769
+ this.$previewLabel = this.dom('<div class="rx-ai-preview-label">')
770
+ this.$preview = this.dom('<div class="rx-ai-preview">')
771
+ this.$prompt = this.dom('<div class="rx-ai-prompt">')
772
+ this.$label = this.dom('<label class="rx-ai-label">').html(this.lang.get('ai.prompt'))
773
+ this.$textarea = this.dom('<textarea class="rx-ai-textarea rx-form-textarea">').attr({ placeholder })
774
+ this.$select = this.dom('<select class="rx-ai-select rx-form-select">')
775
+ this.$size = this.dom('<select class="rx-ai-size rx-form-select">')
776
+
777
+ // footer
778
+ this._createPromptFooter($footer, $buttons)
779
+
780
+ this.$prompt.append(this.$label)
781
+ this.$prompt.append(this.$textarea)
782
+
783
+ $body.append(this.$progress)
784
+ $body.append(this.$previewLabel)
785
+ $body.append(this.$preview)
786
+ $body.append(this.$prompt)
787
+
788
+ $main.append($body)
789
+ $main.append($footer)
790
+
791
+ return $main
792
+ },
793
+ _createPromptFooter ($footer, $buttons) {
794
+ this._createTone(this.$select)
795
+ this._createSize(this.$size)
796
+ this._createPromptButtons($buttons)
797
+
798
+ $footer.append(this.$select)
799
+ if (this.promptType === 'image') {
800
+ $footer.append(this.$size)
801
+ }
802
+ $footer.append($buttons)
803
+ },
804
+ _createPromptButton (label) {
805
+ return this.dom('<button class="rx-ai-button rx-form-button">').html(label)
806
+ },
807
+ _createSize ($size) {
808
+ const items = this.opts.get('ai.size')
809
+ for (const [key, name] of Object.entries(items)) {
810
+ const $option = this.dom('<option>').val(key).html(name)
811
+ $size.append($option)
812
+ }
813
+ },
814
+ _createTone ($select) {
815
+ const items = (this.promptType === 'image') ? this.opts.get('ai.style') || this.defaults.style : this.opts.get('ai.tone') || this.defaults.tone
816
+ let name = (this.promptType === 'image') ? this.lang.get('ai.image-style') : this.lang.get('ai.change-tone')
817
+ let $option = this.dom('<option>').val(0).html(name)
818
+ $select.append($option)
819
+
820
+ $option = this.dom('<option>').val(1).html('---')
821
+ $select.append($option)
822
+
823
+ for (let i = 0; i < items.length; i++) {
824
+ name = this.lang.parse(items[i])
825
+ $option = this.dom('<option>').val(name).html(name)
826
+ $select.append($option)
827
+ }
828
+ },
829
+ _createPromptButtons ($buttons) {
830
+ this.$generate = this._createPromptButton(this.lang.get('ai.send')).addClass('rx-form-button-primary').on('click.rx-ai', this.sendPrompt.bind(this))
831
+ this.$stop = this._createPromptButton(this.lang.get('ai.stop')).addClass('rx-form-button-primary').on('click.rx-ai', this.stopPrompt.bind(this)).hide()
832
+ this.$discard = this._createPromptButton(this.lang.get('ai.discard')).addClass('rx-form-button-danger').on('click.rx-ai', this.closePrompt.bind(this))
833
+ this.$insert = this._createPromptButton(this.lang.get('ai.insert')).on('click.rx-ai', this.insertPrompt.bind(this)).hide()
834
+
835
+ $buttons.append(this.$discard)
836
+ $buttons.append(this.$insert)
837
+ $buttons.append(this.$stop)
838
+ $buttons.append(this.$generate)
839
+ },
840
+ _createSource (url, data) {
841
+ const eventTarget = new EventTarget()
842
+
843
+ const ajax = this.ajax.post({
844
+ url,
845
+ data,
846
+ before: function (xhr) {
847
+ const event = this.app.broadcast('ai.before.send', { xhr, data })
848
+ if (event.isStopped()) {
849
+ return false
850
+ }
851
+ }.bind(this)
852
+ })
853
+ const xhr = ajax.xhr
854
+ const that = this
855
+
856
+ let ongoing = false; let start = 0
857
+ xhr.onprogress = function () {
858
+ if (!ongoing) {
859
+ ongoing = true
860
+ eventTarget.dispatchEvent(new Event('open', {
861
+ status: xhr.status,
862
+ headers: xhr.getAllResponseHeaders(),
863
+ url: xhr.responseUrl
864
+ }))
865
+ }
866
+
867
+ let i, chunk
868
+
869
+ // error
870
+ if (that._isJsonString(xhr.responseText)) {
871
+ const response = JSON.parse(xhr.responseText)
872
+ if (response.error) {
873
+ that._error(response.error.message, response)
874
+ eventTarget.close()
875
+ return
876
+ }
877
+ }
878
+
879
+ // chunk
880
+ while ((i = xhr.responseText.indexOf('\n\n', start)) >= 0) {
881
+ chunk = xhr.responseText.slice(start, i)
882
+
883
+ start = i + 2
884
+ if (chunk.length) {
885
+ eventTarget.dispatchEvent(new MessageEvent('message', { data: chunk }))
886
+ }
887
+ }
888
+ }
889
+
890
+ // close func
891
+ eventTarget.close = function () {
892
+ xhr.abort()
893
+ }
894
+
895
+ return eventTarget
896
+ },
897
+
898
+ // is
899
+ _isElementBottomBeyond (element) {
900
+ const $target = this.app.scroll.getTarget()
901
+ const rect = element.getBoundingClientRect()
902
+ const elementBottom = rect.top + rect.height
903
+
904
+ return elementBottom > $target.get().innerHeight
905
+ },
906
+ _isJsonString (str) {
907
+ try {
908
+ JSON.parse(str)
909
+ } catch (e) {
910
+ return false
911
+ }
912
+ return true
913
+ },
914
+
915
+ // insert
916
+ _insertAfterNode ($tmp, content) {
917
+ let inserted
918
+ const instanceType = (this.instance && this.instance.isType(['listitem', 'todoitem']))
919
+ // broadcast
920
+ const event = this.app.broadcast('ai.before.insert', { html: content })
921
+ content = event.get('html')
922
+
923
+ if (instanceType) {
924
+ this.instance.setContent(content)
925
+ inserted = this.instance.getBlock()
926
+ } else {
927
+ const insertion = this.app.create('insertion')
928
+ const node = this.app.block.create()
929
+ const $node = node.getBlock()
930
+
931
+ // parse
932
+ content = this._parseReply(content)
933
+
934
+ $tmp.after($node)
935
+ $tmp.remove()
936
+ this.app.block.set($node)
937
+ inserted = insertion.insert({ html: content, caret: 'end' })
938
+ }
939
+
940
+ // broadcast
941
+ this.app.broadcast('ai.insert', { nodes: inserted })
942
+ },
943
+ _insertPrompt ($prompt, current, params) {
944
+ const elm = this.app.create('element')
945
+ let position = 'after'
946
+
947
+ // position
948
+ if (!current) {
949
+ if (this.opts.get('addPosition') === 'top') {
950
+ current = this.app.blocks.get({ first: true, instances: true })
951
+ position = 'before'
952
+ } else {
953
+ current = this.app.blocks.get({ last: true, instances: true })
954
+ position = 'after'
955
+ }
956
+ }
957
+
958
+ // insert
959
+ const $current = current.getBlock()
960
+ $current[position]($prompt)
961
+
962
+ // scroll
963
+ elm.scrollTo($prompt)
964
+
965
+ // build
966
+ this.app.observer.observeUnset()
967
+ this.$textarea.focus()
968
+ this.$textarea.on('input.rx-ai-autoresize keyup.rx-ai-autoreize', this._resize.bind(this))
969
+ this.$textarea.on('keydown.rx-ai-event', this._promptKeydown.bind(this))
970
+ },
971
+
972
+ // other
973
+ _promptKeydown (e) {
974
+ if (e.key === 'Enter' && !e.shiftKey) {
975
+ e.preventDefault()
976
+ this.sendPrompt(e)
977
+ }
978
+ },
979
+ _typeCharacter ($node, responseContent) {
980
+ if (this.currentIndex < responseContent.length) {
981
+ $node.get().textContent += responseContent.charAt(this.currentIndex)
982
+ this.currentIndex++
983
+ // adjust height
984
+ this.app.editor.adjustHeight()
985
+ setTimeout(function () {
986
+ this._typeCharacter($node, responseContent)
987
+ }.bind(this), 100)
988
+ }
989
+ },
990
+ _resize () {
991
+ this.$textarea.css('height', 'auto')
992
+ this.$textarea.css('height', this.$textarea.get().scrollHeight + 'px')
993
+
994
+ // adjust height
995
+ this.app.editor.adjustHeight()
996
+ },
997
+ _sanitize (str) {
998
+ return str.replace(/[&<>"']/g, char => {
999
+ switch (char) {
1000
+ case '&': return '&amp;'
1001
+ case '<': return '&lt;'
1002
+ case '>': return '&gt;'
1003
+ case '"': return '&quot;'
1004
+ case "'": return '&#39;'
1005
+ }
1006
+ })
1007
+ },
1008
+ _parseMarkdown (markdown) {
1009
+ let inCodeBlock = false
1010
+ let result = ''
1011
+ const lines = markdown.split('\n')
1012
+ const tags = this.opts.get('replaceTags')
1013
+
1014
+ // code parsing
1015
+ for (const line of lines) {
1016
+ if (line.startsWith('```')) {
1017
+ inCodeBlock = !inCodeBlock
1018
+ result += inCodeBlock ? this._replaceCodeLine(line) : '</code></pre>'
1019
+ } else if (line.startsWith('####_')) {
1020
+ result += line
1021
+ } else {
1022
+ result += inCodeBlock ? this._escapeHtml(line) + '\n' : `<p>${this._escapeHtml(line)}</p>`
1023
+ }
1024
+ }
1025
+
1026
+ // bold/italic parsing
1027
+ const bTag = (tags.b) ? tags.b : 'b'
1028
+ const iTag = (tags.i) ? tags.i : 'i'
1029
+
1030
+ result = result.replace(/\*\*\_(.*?)\_\*\*/g, '<' + bTag + '><' + iTag + '>$1</' + iTag + '></' + bTag + '>')
1031
+ result = result.replace(/\*\*(.*?)\*\*/g, '<' + bTag + '>$1</' + bTag + '>')
1032
+ result = result.replace(/\*(.*?)\*/g, '<' + iTag + '>$1</' + iTag + '>')
1033
+
1034
+ return result
1035
+ },
1036
+ _replaceCodeLine (line) {
1037
+ return line.replace(/\`\`\`(([^\s]+))?/gm, function (match, p1, p2) {
1038
+ const classAttribute = p2 ? ' class="' + p2 + '"' : ''
1039
+ return '<pre' + classAttribute + '><code>'
1040
+ })
1041
+ },
1042
+ _escapeHtml (text) {
1043
+ const htmlEntities = {
1044
+ '&': '&amp;',
1045
+ '<': '&lt;',
1046
+ '>': '&gt;',
1047
+ '"': '&quot;',
1048
+ "'": '&#39;'
1049
+ }
1050
+
1051
+ return text.replace(/[&<>"']/g, (match) => htmlEntities[match])
1052
+ }
1053
+ })