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