tiptapify 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +1 -1
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +74210 -54575
  4. package/dist/tiptapify.umd.js +87 -84
  5. package/index.d.ts +42 -2
  6. package/package.json +45 -45
  7. package/src/components/Tiptapify.vue +108 -9
  8. package/src/components/Toolbar/Index.vue +2 -2
  9. package/src/components/Toolbar/Items.vue +9 -2
  10. package/src/components/UI/TiptapifyDialog.vue +7 -1
  11. package/src/extensions/components/ai/Button.vue +1 -1
  12. package/src/extensions/components/ai/Dialog.vue +338 -27
  13. package/src/extensions/components/ai/backend.test.ts +314 -0
  14. package/src/extensions/components/ai/backend.ts +303 -0
  15. package/src/extensions/components/ai/charLimit.test.ts +55 -0
  16. package/src/extensions/components/ai/charLimit.ts +30 -0
  17. package/src/extensions/components/ai/characterCount.test.ts +27 -0
  18. package/src/extensions/components/media/iframe/Button.vue +1 -2
  19. package/src/extensions/components/media/iframe/IframeDialog.vue +36 -31
  20. package/src/extensions/components/media/image/Button.vue +1 -1
  21. package/src/extensions/components/media/link/Button.vue +1 -1
  22. package/src/extensions/components/media/link/LinkDialog.vue +19 -36
  23. package/src/extensions/components/media/video/Button.vue +1 -1
  24. package/src/extensions/components/media/video/VideoDialog.vue +17 -17
  25. package/src/extensions/components/misc/source/Button.vue +1 -1
  26. package/src/i18n/locales/ar.json +165 -159
  27. package/src/i18n/locales/bg.json +212 -0
  28. package/src/i18n/locales/ch.json +165 -159
  29. package/src/i18n/locales/cz.json +165 -159
  30. package/src/i18n/locales/de.json +165 -159
  31. package/src/i18n/locales/el.json +212 -0
  32. package/src/i18n/locales/en.json +166 -159
  33. package/src/i18n/locales/es.json +165 -159
  34. package/src/i18n/locales/et.json +212 -0
  35. package/src/i18n/locales/fi.json +165 -159
  36. package/src/i18n/locales/fr.json +165 -159
  37. package/src/i18n/locales/he.json +212 -0
  38. package/src/i18n/locales/hi.json +212 -0
  39. package/src/i18n/locales/hr.json +212 -0
  40. package/src/i18n/locales/hu.json +165 -159
  41. package/src/i18n/locales/id.json +212 -0
  42. package/src/i18n/locales/it.json +165 -159
  43. package/src/i18n/locales/ja.json +165 -159
  44. package/src/i18n/locales/ko.json +165 -159
  45. package/src/i18n/locales/la.json +165 -159
  46. package/src/i18n/locales/lt.json +165 -159
  47. package/src/i18n/locales/nl.json +165 -159
  48. package/src/i18n/locales/no.json +212 -0
  49. package/src/i18n/locales/pl.json +165 -159
  50. package/src/i18n/locales/pt-BR.json +212 -0
  51. package/src/i18n/locales/pt.json +165 -159
  52. package/src/i18n/locales/ro.json +212 -0
  53. package/src/i18n/locales/ru.json +165 -159
  54. package/src/i18n/locales/se.json +165 -159
  55. package/src/i18n/locales/sk.json +212 -0
  56. package/src/i18n/locales/sl.json +212 -0
  57. package/src/i18n/locales/sr.json +212 -0
  58. package/src/i18n/locales/th.json +165 -159
  59. package/src/i18n/locales/tr.json +165 -159
  60. package/src/i18n/locales/uk.json +165 -159
  61. package/src/i18n/locales/vi.json +165 -159
  62. package/src/index.ts +7 -0
  63. package/src/types/editor.ts +40 -2
  64. package/src/utils/validateHref.test.ts +68 -0
  65. package/src/utils/validateHref.ts +21 -0
@@ -6,15 +6,20 @@ import {
6
6
  TiptapifyAiEditorContext,
7
7
  TiptapifyAiMode,
8
8
  TiptapifyAiOpenAiResponse,
9
+ TiptapifyAiReasoningEffort,
9
10
  TiptapifyAiRequest,
10
11
  TiptapifyAiResponse,
11
12
  TiptapifyAiResolvedOptions,
13
+ TiptapifyAiStream,
12
14
  TiptapifyEditor,
13
15
  variantBtnTypes,
14
16
  variantFieldTypes
15
17
  } from '@tiptapify/types/editor'
16
- import { computed, ComputedRef, inject, PropType, Ref, ref, useTemplateRef } from 'vue'
18
+ import { computed, ComputedRef, inject, nextTick, PropType, Ref, ref, useTemplateRef, watch } from 'vue'
19
+ import * as mdi from '@mdi/js'
17
20
  import { ComposerTranslation } from 'vue-i18n'
21
+ import { exceedsLimit } from './charLimit'
22
+ import { DEFAULT_AI_INSTRUCTION } from './backend'
18
23
 
19
24
  defineProps({
20
25
  variantBtn: { type: String as PropType<variantBtnTypes>, default() { return defaults.variantBtn } },
@@ -28,31 +33,128 @@ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
28
33
  const dialog = useTemplateRef('dialog')
29
34
  const prompt = ref('')
30
35
  const result = ref('')
36
+ const reasoning = ref('')
37
+ const reasoningPanel = ref<number[]>([])
38
+ const reasoningTextEl = ref<HTMLElement | null>(null)
31
39
  const error = ref('')
32
40
  const loading = ref(false)
41
+ const requestController = ref<AbortController | null>(null)
33
42
  const requestRange = ref<{ from: number, to: number } | null>(null)
34
43
  const requestMode = ref<TiptapifyAiMode>('insert')
44
+ const thinkingEnabled = ref(false)
45
+ const reasoningEffort = ref<'default' | TiptapifyAiReasoningEffort | null>(null)
46
+
47
+ watch(loading, (value) => {
48
+ if (value) {
49
+ reasoningPanel.value = [0]
50
+ }
51
+ })
52
+
53
+ watch(reasoning, () => {
54
+ nextTick(() => {
55
+ const element = reasoningTextEl.value
56
+ if (element) {
57
+ element.scrollTop = element.scrollHeight
58
+ }
59
+ })
60
+ })
35
61
 
36
62
  const aiProvider = computed(() => ai?.value ? ai.value.aiProvider : undefined)
37
63
  const promptExamples = computed(() => ai?.value ? ai.value.promptExamples : [])
64
+ const reasoningEffortOptions = computed<TiptapifyAiReasoningEffort[]>(() => {
65
+ const configured = ai?.value?.reasoningEffort
66
+ if (!configured || !Array.isArray(configured.options)) {
67
+ return []
68
+ }
69
+
70
+ return configured.options.filter(option => option !== 'default')
71
+ })
72
+ const reasoningEffortItems = computed<Array<'default' | TiptapifyAiReasoningEffort>>(() => {
73
+ return ['default', ...reasoningEffortOptions.value]
74
+ })
75
+ const reasoningEffortSupported = computed(() => reasoningEffortOptions.value.length > 0)
76
+ const thinkingSupported = computed(() => ai?.value?.thinking === true)
77
+ const showReasoning = computed(() => ai?.value?.showReasoning === true)
78
+ const showReasoningEffort = computed(() => thinkingSupported.value && reasoningEffortSupported.value && thinkingEnabled.value)
79
+ const useReasoningEffort = computed(() => {
80
+ return thinkingSupported.value
81
+ && thinkingEnabled.value
82
+ && reasoningEffortSupported.value
83
+ && reasoningEffort.value !== null
84
+ && reasoningEffort.value !== 'default'
85
+ })
38
86
  const isGenerateDisabled = computed(() => !aiProvider.value || !prompt.value.trim() || loading.value)
39
- const isApplyDisabled = computed(() => !result.value.trim() || loading.value)
87
+ const characterCountLimit = computed(() => {
88
+ const limit = getCharacterCountExtension()?.options?.limit
89
+ return typeof limit === 'number' ? limit : null
90
+ })
91
+ const limitExceeded = computed(() => {
92
+ const limit = characterCountLimit.value
93
+ if (limit === null || !result.value) {
94
+ return false
95
+ }
96
+
97
+ return exceedsLimit(result.value, {
98
+ doc: editor.value.state.doc,
99
+ limit,
100
+ mode: requestMode.value,
101
+ range: requestRange.value,
102
+ })
103
+ })
104
+ // A result that exceeds the character limit cannot be inserted (the
105
+ // CharacterCount extension rejects it), so the apply action (and its
106
+ // alternative modes) is disabled until the result is shortened.
107
+ const isApplyDisabled = computed(() => !result.value.trim() || loading.value || limitExceeded.value)
108
+ const actionLabel = computed(() => t(`ai.${requestMode.value}`))
109
+ const modeOptions: TiptapifyAiMode[] = ['insert', 'replace', 'append']
110
+ const alternativeModes = computed(() => modeOptions.filter(mode => mode !== requestMode.value))
111
+
112
+ function applyInMode(mode: TiptapifyAiMode) {
113
+ if (isApplyDisabled.value) {
114
+ return
115
+ }
116
+
117
+ requestMode.value = mode
118
+ apply()
119
+ }
40
120
 
41
121
  defineExpose({ showDialog })
42
122
 
123
+ function resetReasoningEffort() {
124
+ const configured = ai?.value?.reasoningEffort
125
+ const options = reasoningEffortOptions.value
126
+
127
+ if (options.length === 0) {
128
+ reasoningEffort.value = null
129
+ return
130
+ }
131
+
132
+ reasoningEffort.value = configured?.default && options.includes(configured.default)
133
+ ? configured.default
134
+ : 'default'
135
+ }
136
+
43
137
  function showDialog() {
44
138
  prompt.value = ai?.value && ai.value.defaultPrompt ? ai.value.defaultPrompt : ''
45
139
  result.value = ''
140
+ reasoning.value = ''
46
141
  error.value = ''
47
142
  requestRange.value = null
48
- requestMode.value = 'insert'
143
+ const { from, to } = editor.value.state.selection
144
+ requestMode.value = ai.value?.mode ?? (getSelectedText(from, to) ? 'replace' : 'insert')
145
+ thinkingEnabled.value = true
146
+ resetReasoningEffort()
49
147
 
50
148
  dialog.value?.open()
51
149
  }
52
150
 
53
151
  function close() {
152
+ requestController.value?.abort()
153
+ requestController.value = null
154
+
54
155
  prompt.value = ''
55
156
  result.value = ''
157
+ reasoning.value = ''
56
158
  error.value = ''
57
159
  requestRange.value = null
58
160
  requestMode.value = 'insert'
@@ -60,6 +162,10 @@ function close() {
60
162
  dialog.value?.close()
61
163
  }
62
164
 
165
+ function stop() {
166
+ requestController.value?.abort()
167
+ }
168
+
63
169
  function applyExample(examplePrompt: string) {
64
170
  prompt.value = examplePrompt
65
171
  }
@@ -75,7 +181,7 @@ function getSelectedText(from: number, to: number) {
75
181
  function buildDefaultMessages(context: TiptapifyAiEditorContext) {
76
182
  const systemPrompt = ai.value && ai.value.systemPrompt
77
183
  ? ai.value.systemPrompt
78
- : 'You are an assistant inside a rich text editor. Return only the final text to insert.'
184
+ : DEFAULT_AI_INSTRUCTION
79
185
  const contextText = context.selectedText || context.text
80
186
  const contextLabel = context.selectedText ? 'Selected text' : 'Editor text'
81
187
 
@@ -95,10 +201,12 @@ function buildDefaultMessages(context: TiptapifyAiEditorContext) {
95
201
 
96
202
  function buildOpenAiRequest(context: TiptapifyAiEditorContext): TiptapifyAiRequest {
97
203
  const options = ai.value || {}
204
+ const chatCompletionOptions = options.chatCompletionOptions ?? {}
205
+ const defaultStream = typeof chatCompletionOptions.stream === 'boolean' ? chatCompletionOptions.stream : false
98
206
  const request: TiptapifyAiRequest = {
99
- ...options.chatCompletionOptions,
207
+ ...chatCompletionOptions,
100
208
  messages: options.createMessages ? options.createMessages(context) : buildDefaultMessages(context),
101
- stream: false,
209
+ stream: options.stream ?? defaultStream,
102
210
  }
103
211
 
104
212
  if (options.model) {
@@ -109,6 +217,15 @@ function buildOpenAiRequest(context: TiptapifyAiEditorContext): TiptapifyAiReque
109
217
  request.temperature = options.temperature
110
218
  }
111
219
 
220
+ if (useReasoningEffort.value) {
221
+ delete request.enable_thinking
222
+ request.reasoning_effort = reasoningEffort.value
223
+ } else {
224
+ delete request.reasoning_effort
225
+
226
+ request.enable_thinking = thinkingSupported.value && thinkingEnabled.value
227
+ }
228
+
112
229
  return request
113
230
  }
114
231
 
@@ -141,12 +258,15 @@ async function generate() {
141
258
 
142
259
  error.value = ''
143
260
  result.value = ''
261
+ reasoning.value = ''
144
262
  loading.value = true
145
263
 
264
+ const controller = new AbortController()
265
+ requestController.value = controller
266
+
146
267
  const { from, to } = editor.value.state.selection
147
268
  const selectedText = getSelectedText(from, to)
148
269
  requestRange.value = selectedText ? { from, to } : null
149
- requestMode.value = ai.value ? ai.value.mode ?? (selectedText ? 'replace' : 'insert') : 'insert'
150
270
 
151
271
  const context: TiptapifyAiEditorContext = {
152
272
  prompt: prompt.value.trim(),
@@ -158,27 +278,51 @@ async function generate() {
158
278
  }
159
279
  const request = buildOpenAiRequest(context)
160
280
 
281
+ const stream: TiptapifyAiStream = {
282
+ signal: controller.signal,
283
+ onChunk: (chunk: string) => {
284
+ if (chunk && !controller.signal.aborted) {
285
+ result.value += chunk
286
+ }
287
+ },
288
+ onReasoning: (chunk: string) => {
289
+ if (chunk && !controller.signal.aborted) {
290
+ reasoning.value += chunk
291
+ }
292
+ },
293
+ }
294
+
161
295
  try {
162
- const response = await aiProvider.value(request, context)
163
- result.value = getResponseContent(response)
296
+ const response = await aiProvider.value(request, context, stream)
297
+ const content = getResponseContent(response)
298
+
299
+ if (!controller.signal.aborted && content) {
300
+ result.value = content
301
+ }
164
302
  } catch (err) {
165
- error.value = err instanceof Error ? err.message : t('ai.error')
303
+ if (!controller.signal.aborted) {
304
+ error.value = err instanceof Error ? err.message : t('ai.error')
305
+ }
166
306
  } finally {
167
307
  loading.value = false
168
308
  }
169
309
  }
170
310
 
311
+ function getCharacterCountExtension() {
312
+ return editor.value.options.extensions?.find(item => item.name === 'characterCount')
313
+ }
314
+
171
315
  function apply() {
172
316
  if (isApplyDisabled.value) {
173
317
  return
174
318
  }
175
319
 
176
320
  const chain = editor.value.chain().focus()
177
- if (requestRange.value) {
321
+ if (requestMode.value === 'replace' && requestRange.value) {
178
322
  chain.deleteRange(requestRange.value)
179
323
  }
180
324
 
181
- if (!requestRange.value && requestMode.value === 'append') {
325
+ if (requestMode.value === 'append') {
182
326
  chain.insertContentAt(editor.value.state.doc.content.size, result.value).run()
183
327
  } else {
184
328
  chain.insertContent(result.value).run()
@@ -197,19 +341,9 @@ function apply() {
197
341
  {{ t('ai.unavailable') }}
198
342
  </VAlert>
199
343
 
200
- <VTextarea
201
- v-model="prompt"
202
- :label="t('ai.prompt')"
203
- :variant="variantField"
204
- :disabled="loading || !aiProvider"
205
- rows="4"
206
- auto-grow
207
- autofocus
208
- />
209
-
210
344
  <div v-if="promptExamples.length" class="mb-4">
211
345
  <VLabel class="mb-2 d-block">
212
- {{ t('ai.examples') }}
346
+ {{ t('ai.quick_prompts') }}
213
347
  </VLabel>
214
348
  <div class="d-flex flex-wrap ga-2">
215
349
  <VChip
@@ -224,16 +358,83 @@ function apply() {
224
358
  </div>
225
359
  </div>
226
360
 
361
+ <VTextarea
362
+ v-model="prompt"
363
+ :label="t('ai.prompt')"
364
+ :variant="variantField"
365
+ :disabled="loading || !aiProvider"
366
+ rows="4"
367
+ auto-grow
368
+ autofocus
369
+ />
370
+
371
+ <div v-if="thinkingSupported" class="ai-thinking-toggle d-flex align-center mb-4">
372
+ <VIcon size="x-small" class="mr-2" :icon="`mdiSvg:${mdi.mdiBrain}`" />
373
+ <VLabel>{{ t('ai.thinking') }}</VLabel>
374
+ <VSwitch
375
+ v-model="thinkingEnabled"
376
+ :disabled="loading || useReasoningEffort"
377
+ density="compact"
378
+ hide-details
379
+ color="primary"
380
+ class="ai-thinking-toggle__switch"
381
+ />
382
+ </div>
383
+
384
+ <div v-if="showReasoningEffort" class="ai-reasoning-effort d-flex align-center mb-4">
385
+ <VIcon size="x-small" class="mr-2" :icon="`mdiSvg:${mdi.mdiGauge}`" />
386
+ <VLabel>{{ t('ai.reasoning_effort') }}</VLabel>
387
+ <VSelect
388
+ v-model="reasoningEffort"
389
+ :items="reasoningEffortItems"
390
+ :disabled="loading"
391
+ :variant="variantField"
392
+ density="compact"
393
+ hide-details
394
+ class="ai-reasoning-effort__select"
395
+ />
396
+ </div>
397
+
398
+ <VExpansionPanels v-if="showReasoning && reasoning" v-model="reasoningPanel" variant="default" class="ai-reasoning mb-4">
399
+ <VExpansionPanel :value="0">
400
+ <template #title>
401
+ <VIcon size="x-small" :icon="`mdiSvg:${mdi.mdiBrain}`" />
402
+ <span class="ml-2">{{ t('ai.thinking') }}</span>
403
+ </template>
404
+ <template #text>
405
+ <pre ref="reasoningTextEl" class="ai-reasoning__text">{{ reasoning }}</pre>
406
+ </template>
407
+ </VExpansionPanel>
408
+ </VExpansionPanels>
409
+
227
410
  <VTextarea
228
411
  v-model="result"
229
412
  :label="t('ai.result')"
230
413
  :variant="variantField"
231
- :loading="loading"
232
- :disabled="loading"
414
+ :loading="loading && !result"
415
+ :readonly="loading"
233
416
  rows="5"
234
417
  auto-grow
235
418
  />
236
419
 
420
+ <VLabel
421
+ v-if="characterCountLimit !== null && result"
422
+ class="ai-char-count d-block"
423
+ :class="{ 'ai-char-count--exceeded': limitExceeded }"
424
+ >
425
+ {{ result.length }} / {{ characterCountLimit }}
426
+ </VLabel>
427
+
428
+ <VAlert
429
+ v-if="limitExceeded"
430
+ type="error"
431
+ variant="tonal"
432
+ density="compact"
433
+ class="ai-limit-alert mt-4"
434
+ >
435
+ {{ t('ai.limit_exceeded') }}
436
+ </VAlert>
437
+
237
438
  <VAlert v-if="loading" type="info" variant="tonal" density="compact" class="mt-4">
238
439
  {{ t('ai.loading') }}
239
440
  </VAlert>
@@ -253,10 +454,120 @@ function apply() {
253
454
  <VBtn color="primary" :variant="variantBtn" :disabled="isGenerateDisabled" :loading="loading" @click="generate">
254
455
  {{ t('ai.generate') }}
255
456
  </VBtn>
256
- <VBtn color="primary" :variant="variantBtn" :disabled="isApplyDisabled" @click="apply">
257
- {{ t('ai.apply') }}
457
+ <VBtn v-if="loading" color="warning" :variant="variantBtn" @click="stop">
458
+ {{ t('ai.stop') }}
258
459
  </VBtn>
460
+
461
+ <VBtnGroup
462
+ :class="{ 'tiptapify-btn-group--elevated': variantBtn === 'elevated' && !isApplyDisabled }"
463
+ density="compact"
464
+ :variant="variantBtn"
465
+ divided
466
+ >
467
+ <VBtn
468
+ color="primary"
469
+ :variant="variantBtn"
470
+ :disabled="isApplyDisabled"
471
+ @click="apply"
472
+ >
473
+ {{ t('ai.insert') }}
474
+ </VBtn>
475
+
476
+ <VMenu>
477
+ <template #activator="{ props }">
478
+ <VBtn
479
+ v-bind="{ ...props, minWidth: 30 }"
480
+ color="primary"
481
+ :disabled="isApplyDisabled"
482
+ :variant="variantBtn"
483
+ >
484
+ <VIcon :icon="`mdiSvg:${mdi.mdiDotsVertical}`" />
485
+ </VBtn>
486
+ </template>
487
+ <VList>
488
+ <VListItem
489
+ v-for="mode in alternativeModes"
490
+ :key="mode"
491
+ @click="applyInMode(mode)"
492
+ >
493
+ <VListItemTitle>{{ t(`ai.${mode}`) }}</VListItemTitle>
494
+ </VListItem>
495
+ </VList>
496
+ </VMenu>
497
+ </VBtnGroup>
259
498
  </VCardActions>
260
499
  </template>
261
500
  </TiptapifyDialog>
262
501
  </template>
502
+
503
+ <style lang="scss" scoped>
504
+ .tiptapify-btn-group--elevated {
505
+ box-shadow:
506
+ 0 3px 1px -2px var(--v-shadow-key-umbra-opacity, rgba(0, 0, 0, 0.2)),
507
+ 0 2px 2px 0 var(--v-shadow-key-penumbra-opacity, rgba(0, 0, 0, 0.14)),
508
+ 0 1px 5px 0 var(--v-shadow-key-ambient-opacity, rgba(0, 0, 0, 0.12));
509
+ }
510
+
511
+ .ai-thinking-toggle {
512
+ &__switch {
513
+ flex: 0 0 auto;
514
+ margin-left: auto;
515
+
516
+ --v-switch-scale: 1;
517
+ --v-switch-track-height: 14px;
518
+ --v-switch-thumb-height: 20px;
519
+ --v-switch-thumb-width: 20px;
520
+ }
521
+ }
522
+
523
+ .ai-reasoning-effort {
524
+ &__select {
525
+ flex: 0 0 auto;
526
+ margin-left: auto;
527
+
528
+ :deep(.v-input) {
529
+ width: 140px;
530
+ }
531
+ }
532
+ }
533
+
534
+ .ai-reasoning {
535
+ :deep(.v-expansion-panel-title) {
536
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
537
+ font-size: .8125rem;
538
+ font-weight: 500;
539
+ padding: .625rem .75rem;
540
+ }
541
+
542
+ :deep(.v-expansion-panel-title__icon) {
543
+ color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
544
+ }
545
+ }
546
+
547
+ .ai-reasoning__text {
548
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
549
+ font-family: 'JetBrainsMono', monospace;
550
+ font-size: .75rem;
551
+ line-height: 1.4;
552
+ margin: 0;
553
+ max-height: 200px;
554
+ overflow: auto;
555
+ padding: .25rem .75rem .625rem;
556
+ white-space: pre-wrap;
557
+ word-break: break-word;
558
+ }
559
+
560
+ .ai-char-count {
561
+ font-size: .75rem;
562
+ margin-top: .25rem;
563
+ text-align: right;
564
+
565
+ &--exceeded {
566
+ color: var(--red-soft);
567
+ }
568
+ }
569
+
570
+ .ai-limit-alert {
571
+ color: var(--red-soft, #B24A40) !important;
572
+ }
573
+ </style>