tiptapify 0.2.5 → 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.
package/index.d.ts CHANGED
@@ -135,6 +135,23 @@ export type TiptapifyAiProvider = (
135
135
 
136
136
  export type TiptapifyAiTokenProvider = () => Promise<string | null> | string | null
137
137
 
138
+ export interface TiptapifyAiBackendRequest {
139
+ prompt: string
140
+ instruction: string
141
+ stream?: boolean
142
+ thinking?: boolean
143
+ reasoning_effort?: TiptapifyAiReasoningEffort
144
+ model?: string
145
+ }
146
+
147
+ export interface TiptapifyAiBackendProviderOptions {
148
+ endpoint: string
149
+ headers?: Record<string, string>
150
+ tokenProvider?: TiptapifyAiTokenProvider
151
+ }
152
+
153
+ export declare function createAiBackendProvider(options: TiptapifyAiBackendProviderOptions): TiptapifyAiProvider
154
+
138
155
  export interface TiptapifyAiStorage {
139
156
  getItem: (key: string) => string | null | Promise<string | null>
140
157
  setItem: (key: string, value: string) => void | Promise<void>
@@ -143,6 +160,8 @@ export interface TiptapifyAiStorage {
143
160
 
144
161
  export interface TiptapifyAiOptions {
145
162
  aiProvider?: TiptapifyAiProvider
163
+ aiEndpoint?: string
164
+ aiHeaders?: Record<string, string>
146
165
  model?: string
147
166
  promptExamples?: TiptapifyAiPromptExample[]
148
167
  mode?: TiptapifyAiMode
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tiptapify",
3
3
  "types": "./index.d.ts",
4
- "version": "0.2.5",
4
+ "version": "0.2.6",
5
5
  "description": "Tiptap3 editor with Vuetify3 menu implementation",
6
6
  "exports": {
7
7
  ".": {
@@ -6,20 +6,22 @@ import { SlashCommandsConfig } from '@tiptapify/types/slashCommandsTypes'
6
6
  import { computed, onBeforeUnmount, PropType, provide, ref, ShallowRef } from 'vue'
7
7
  import { default as Toolbar } from '@tiptapify/components/Toolbar/Index.vue'
8
8
  import { Editor, EditorContent } from '@tiptap/vue-3'
9
- import { TiptapifyAiOptions, TiptapifyEditor, TiptapifyFooterAlignment, variantBtnTypes, variantFieldTypes } from '@tiptapify/types/editor'
9
+ import { TiptapifyAiOptions, TiptapifyAiResolvedOptions, TiptapifyEditor, TiptapifyFooterAlignment, variantBtnTypes, variantFieldTypes } from '@tiptapify/types/editor'
10
10
  import MenuBubble from '@tiptapify/components/MenuBubble.vue'
11
11
  import MenuFloating from '@tiptapify/components/MenuFloating.vue'
12
12
 
13
13
  import { useI18n } from 'vue-i18n'
14
14
 
15
15
  import { getTiptapEditor } from '@tiptapify/components/index'
16
+ import { createAiBackendProvider } from '@tiptapify/extensions/components/ai/backend'
16
17
 
17
18
  import Footer from '@tiptapify/components/Footer.vue'
18
19
  import { useTheme } from 'vuetify/framework'
19
20
 
20
21
  const props = defineProps({
21
22
  locale: { type: String, default () { return 'en' } },
22
- content: { type: [String, Object] as PropType<string | Record<string, never>>, required: true },
23
+ content: { type: [String, Object] as PropType<string | Record<string, never>>, default () { return '' } },
24
+ modelValue: { type: [String, Object] as PropType<string | Record<string, never>>, default () { return '' } },
23
25
  height: { type: [Number, String], default () { return null } },
24
26
  variantBtn: { type: String as PropType<variantBtnTypes>, default() { return defaults.variantBtn } },
25
27
  variantField: { type: String as PropType<variantFieldTypes>, default() { return defaults.variantField } },
@@ -48,12 +50,14 @@ const props = defineProps({
48
50
  loadingHeight: { type: String, default() { return '1px' } },
49
51
  })
50
52
 
53
+ const emit = defineEmits(['update:modelValue', 'editor-ready', 'content-changed'])
54
+
51
55
  const loadingProgress = ref(0)
52
56
 
53
57
  const { t } = useI18n()
54
58
 
55
59
  const appTheme = useTheme()
56
- const currentTheme = ref(appTheme.global.name)
60
+ const currentTheme = computed(() => appTheme.name.value)
57
61
 
58
62
  const defaultAiPromptExamples = computed(() => [
59
63
  {
@@ -81,15 +85,30 @@ const tiptapifyAi = computed(() => {
81
85
 
82
86
  const options = props.ai === true ? {} : props.ai
83
87
 
84
- return {
88
+ const resolved: TiptapifyAiResolvedOptions = {
85
89
  ...options,
86
90
  enabled: true,
87
91
  promptExamples: options.promptExamples?.length ? options.promptExamples : defaultAiPromptExamples.value,
88
92
  }
93
+
94
+ // Backend generation: when no `aiProvider` is given but an `aiEndpoint`
95
+ // is set, synthesize a provider that posts the request to the endpoint.
96
+ // An explicit `aiProvider` always wins.
97
+ if (!resolved.aiProvider && resolved.aiEndpoint) {
98
+ resolved.aiProvider = createAiBackendProvider({
99
+ endpoint: resolved.aiEndpoint,
100
+ headers: resolved.aiHeaders,
101
+ tokenProvider: resolved.tokenProvider,
102
+ })
103
+ }
104
+
105
+ return resolved
89
106
  })
90
107
 
91
108
  function contentChanged() {
92
- emit('content-changed', { html: editor.value?.getHTML(), json: editor.value?.getJSON() })
109
+ const html = editor.value?.getHTML() ?? ''
110
+ emit('update:modelValue', html)
111
+ emit('content-changed', { html, json: editor.value?.getJSON() })
93
112
  }
94
113
 
95
114
  function onContainerClick(event: MouseEvent) {
@@ -101,7 +120,7 @@ function onContainerClick(event: MouseEvent) {
101
120
  }
102
121
 
103
122
  const editor: ShallowRef<Editor | undefined> = getTiptapEditor(
104
- props.content,
123
+ props.content || props.modelValue,
105
124
  computed(() => props.placeholder || t('content.placeholder')).value,
106
125
  props.slashCommands,
107
126
  props.customExtensions,
@@ -117,8 +136,6 @@ const editor: ShallowRef<Editor | undefined> = getTiptapEditor(
117
136
  },
118
137
  )
119
138
 
120
- const emit = defineEmits(['update:modelValue', 'editor-ready', 'content-changed'])
121
-
122
139
  provide('tiptapifyEditor', editor)
123
140
  provide('tiptapifyI18n', { t })
124
141
  provide('tiptapifyAi', tiptapifyAi)
@@ -19,6 +19,7 @@ import { computed, ComputedRef, inject, nextTick, PropType, Ref, ref, useTemplat
19
19
  import * as mdi from '@mdi/js'
20
20
  import { ComposerTranslation } from 'vue-i18n'
21
21
  import { exceedsLimit } from './charLimit'
22
+ import { DEFAULT_AI_INSTRUCTION } from './backend'
22
23
 
23
24
  defineProps({
24
25
  variantBtn: { type: String as PropType<variantBtnTypes>, default() { return defaults.variantBtn } },
@@ -180,7 +181,7 @@ function getSelectedText(from: number, to: number) {
180
181
  function buildDefaultMessages(context: TiptapifyAiEditorContext) {
181
182
  const systemPrompt = ai.value && ai.value.systemPrompt
182
183
  ? ai.value.systemPrompt
183
- : 'You are an AI writing assistant inside a rich text editor. Return only the final text to insert.'
184
+ : DEFAULT_AI_INSTRUCTION
184
185
  const contextText = context.selectedText || context.text
185
186
  const contextLabel = context.selectedText ? 'Selected text' : 'Editor text'
186
187
 
@@ -532,19 +533,19 @@ function apply() {
532
533
 
533
534
  .ai-reasoning {
534
535
  :deep(.v-expansion-panel-title) {
535
- color: var(--gray-5);
536
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
536
537
  font-size: .8125rem;
537
538
  font-weight: 500;
538
539
  padding: .625rem .75rem;
539
540
  }
540
541
 
541
542
  :deep(.v-expansion-panel-title__icon) {
542
- color: var(--gray-4);
543
+ color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
543
544
  }
544
545
  }
545
546
 
546
547
  .ai-reasoning__text {
547
- color: var(--gray-5);
548
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
548
549
  font-family: 'JetBrainsMono', monospace;
549
550
  font-size: .75rem;
550
551
  line-height: 1.4;
@@ -0,0 +1,314 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { TiptapifyAiEditorContext, TiptapifyAiRequest, TiptapifyAiStream } from '@tiptapify/types/editor'
3
+
4
+ import { buildAiBackendRequest, createAiBackendProvider, DEFAULT_AI_INSTRUCTION } from './backend'
5
+
6
+ function createContext(overrides: Partial<TiptapifyAiEditorContext> = {}): TiptapifyAiEditorContext {
7
+ return {
8
+ prompt: 'Improve this text',
9
+ selectedText: '',
10
+ text: '',
11
+ html: '',
12
+ json: {},
13
+ mode: 'insert',
14
+ ...overrides,
15
+ }
16
+ }
17
+
18
+ function createRequest(overrides: Partial<TiptapifyAiRequest> = {}): TiptapifyAiRequest {
19
+ return {
20
+ messages: [
21
+ { role: 'system', content: 'Be concise' },
22
+ { role: 'user', content: 'Improve this text' },
23
+ ],
24
+ ...overrides,
25
+ }
26
+ }
27
+
28
+ function createStream() {
29
+ const controller = new AbortController()
30
+ const chunks: string[] = []
31
+ const reasoning: string[] = []
32
+
33
+ const stream: TiptapifyAiStream = {
34
+ signal: controller.signal,
35
+ onChunk: (chunk: string) => chunks.push(chunk),
36
+ onReasoning: (chunk: string) => reasoning.push(chunk),
37
+ }
38
+
39
+ return { stream, chunks, reasoning, controller }
40
+ }
41
+
42
+ function createSseBody(lines: string[], { failAfter = -1 } = {}) {
43
+ let index = 0
44
+
45
+ return {
46
+ getReader() {
47
+ return {
48
+ async read() {
49
+ if (failAfter >= 0 && index > failAfter) {
50
+ throw new DOMException('The operation was aborted', 'AbortError')
51
+ }
52
+
53
+ index += 1
54
+
55
+ if (index <= lines.length) {
56
+ return { done: false, value: new TextEncoder().encode(lines[index - 1]) }
57
+ }
58
+
59
+ return { done: true, value: undefined }
60
+ },
61
+ }
62
+ },
63
+ } as unknown as ReadableStream<Uint8Array>
64
+ }
65
+
66
+ function createResponse(init: { status?: number, body?: string, sse?: string[], sseFailAfter?: number } = {}) {
67
+ const status = init.status ?? 200
68
+
69
+ return {
70
+ ok: status >= 200 && status < 300,
71
+ status,
72
+ body: init.sse ? createSseBody(init.sse, { failAfter: init.sseFailAfter }) : null,
73
+ text: async () => (init.body ?? ''),
74
+ } as unknown as Response
75
+ }
76
+
77
+ let fetchCalls: Array<{ url: string, init: RequestInit | undefined }>
78
+ let responseToReturn: Response | null
79
+
80
+ beforeEach(() => {
81
+ fetchCalls = []
82
+ responseToReturn = null
83
+
84
+ vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => {
85
+ fetchCalls.push({ url, init })
86
+
87
+ return responseToReturn as Response
88
+ }))
89
+ })
90
+
91
+ afterEach(() => {
92
+ vi.unstubAllGlobals()
93
+ })
94
+
95
+ describe('buildAiBackendRequest', () => {
96
+ it('always sends prompt and instruction', () => {
97
+ const payload = buildAiBackendRequest(createRequest(), createContext())
98
+
99
+ expect(payload).toEqual({
100
+ prompt: 'Improve this text',
101
+ instruction: 'Be concise',
102
+ })
103
+ })
104
+
105
+ it('falls back to the default instruction when there is no system message', () => {
106
+ const request = createRequest({ messages: [{ role: 'user', content: 'x' }] })
107
+
108
+ expect(buildAiBackendRequest(request, createContext()).instruction).toBe(DEFAULT_AI_INSTRUCTION)
109
+ })
110
+
111
+ it('sends optional fields only when they are set', () => {
112
+ const payload = buildAiBackendRequest(
113
+ createRequest({ model: 'gpt-4.1-mini', stream: true, enable_thinking: true, reasoning_effort: 'high' }),
114
+ createContext(),
115
+ )
116
+
117
+ expect(payload).toEqual({
118
+ prompt: 'Improve this text',
119
+ instruction: 'Be concise',
120
+ model: 'gpt-4.1-mini',
121
+ stream: true,
122
+ thinking: true,
123
+ reasoning_effort: 'high',
124
+ })
125
+ })
126
+
127
+ it('omits thinking when it is not enabled', () => {
128
+ const payload = buildAiBackendRequest(createRequest({ enable_thinking: false }), createContext())
129
+
130
+ expect(payload.thinking).toBeUndefined()
131
+ })
132
+
133
+ it('omits reasoning_effort for default or non-string values', () => {
134
+ expect(buildAiBackendRequest(createRequest({ reasoning_effort: 'default' }), createContext()).reasoning_effort).toBeUndefined()
135
+ expect(buildAiBackendRequest(createRequest({ reasoning_effort: 5 }), createContext()).reasoning_effort).toBeUndefined()
136
+ })
137
+ })
138
+
139
+ describe('createAiBackendProvider', () => {
140
+ it('posts the minimal payload as JSON to the endpoint', async () => {
141
+ responseToReturn = createResponse({ body: JSON.stringify({ content: 'Done' }) })
142
+ const provider = createAiBackendProvider({ endpoint: '/api/ai/generate' })
143
+
144
+ await provider(createRequest({ model: 'gpt-4.1-mini', stream: true }), createContext())
145
+
146
+ expect(fetchCalls).toHaveLength(1)
147
+ expect(fetchCalls[0].url).toBe('/api/ai/generate')
148
+ const init = fetchCalls[0].init as RequestInit
149
+
150
+ expect(init.method).toBe('POST')
151
+ expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json')
152
+ expect(JSON.parse(init.body as string)).toEqual({
153
+ prompt: 'Improve this text',
154
+ instruction: 'Be concise',
155
+ model: 'gpt-4.1-mini',
156
+ stream: true,
157
+ })
158
+ })
159
+
160
+ it('returns a JSON response body', async () => {
161
+ responseToReturn = createResponse({ body: JSON.stringify({ content: 'Done' }) })
162
+ const result = await createAiBackendProvider({ endpoint: '/api' })(createRequest(), createContext())
163
+
164
+ expect(result).toEqual({ content: 'Done' })
165
+ })
166
+
167
+ it('returns a plain text response body', async () => {
168
+ responseToReturn = createResponse({ body: 'Plain answer' })
169
+ const result = await createAiBackendProvider({ endpoint: '/api' })(createRequest(), createContext())
170
+
171
+ expect(result).toBe('Plain answer')
172
+ })
173
+
174
+ it('throws the backend error message on a non-OK response', async () => {
175
+ responseToReturn = createResponse({ status: 500, body: JSON.stringify({ error: 'Model is busy' }) })
176
+ const provider = createAiBackendProvider({ endpoint: '/api' })
177
+
178
+ await expect(provider(createRequest(), createContext())).rejects.toThrow('Model is busy')
179
+ })
180
+
181
+ it('throws a status error when the error body is not JSON', async () => {
182
+ responseToReturn = createResponse({ status: 502 })
183
+ const provider = createAiBackendProvider({ endpoint: '/api' })
184
+
185
+ await expect(provider(createRequest(), createContext())).rejects.toThrow('Request failed with status 502')
186
+ })
187
+
188
+ it('sends static headers and the tokenProvider token as Bearer', async () => {
189
+ responseToReturn = createResponse({ body: 'ok' })
190
+ const provider = createAiBackendProvider({
191
+ endpoint: '/api',
192
+ headers: { 'X-Api-Key': 'key' },
193
+ tokenProvider: () => 'secret',
194
+ })
195
+
196
+ await provider(createRequest(), createContext())
197
+ const headers = (fetchCalls[0].init as RequestInit).headers as Record<string, string>
198
+
199
+ expect(headers['Content-Type']).toBe('application/json')
200
+ expect(headers['X-Api-Key']).toBe('key')
201
+ expect(headers.Authorization).toBe('Bearer secret')
202
+ })
203
+
204
+ it('keeps a static Authorization header over the tokenProvider token', async () => {
205
+ responseToReturn = createResponse({ body: 'ok' })
206
+ const provider = createAiBackendProvider({
207
+ endpoint: '/api',
208
+ headers: { Authorization: 'Token abc' },
209
+ tokenProvider: () => 'secret',
210
+ })
211
+
212
+ await provider(createRequest(), createContext())
213
+ const headers = (fetchCalls[0].init as RequestInit).headers as Record<string, string>
214
+
215
+ expect(headers.Authorization).toBe('Token abc')
216
+ })
217
+
218
+ it('streams OpenAI-style SSE deltas into onChunk/onReasoning', async () => {
219
+ responseToReturn = createResponse({
220
+ sse: [
221
+ 'data: {"choices":[{"delta":{"reasoning_content":"Let me"}}]}\n',
222
+ 'data: {"choices":[{"delta":{"reasoning_content":" think"}}]}\n',
223
+ 'data: {"choices":[{"delta":{"content":"Hello"}}]}\n',
224
+ 'data: {"choices":[{"delta":{"content":" world"}}]}\n',
225
+ 'data: [DONE]\n',
226
+ ],
227
+ })
228
+ const { stream, chunks, reasoning } = createStream()
229
+ const provider = createAiBackendProvider({ endpoint: '/api' })
230
+ const result = await provider(createRequest({ stream: true }), createContext(), stream)
231
+
232
+ expect(chunks).toEqual(['Hello', ' world'])
233
+ expect(reasoning).toEqual(['Let me', ' think'])
234
+ expect(result).toEqual({ content: 'Hello world' })
235
+ expect((fetchCalls[0].init as RequestInit).signal).toBe(stream.signal)
236
+ })
237
+
238
+ it('accepts the simple { type, data } chunk format', async () => {
239
+ responseToReturn = createResponse({
240
+ sse: [
241
+ 'data: {"type":"reasoning","data":"plan"}\n',
242
+ 'data: {"type":"content","data":"One "}\n',
243
+ 'data: {"type":"content","data":"two"}\n',
244
+ ],
245
+ })
246
+ const { stream, chunks, reasoning } = createStream()
247
+ const result = await createAiBackendProvider({ endpoint: '/api' })(createRequest({ stream: true }), createContext(), stream)
248
+
249
+ expect(chunks).toEqual(['One ', 'two'])
250
+ expect(reasoning).toEqual(['plan'])
251
+ expect(result).toEqual({ content: 'One two' })
252
+ })
253
+
254
+ it('treats plain-text data lines as content chunks', async () => {
255
+ responseToReturn = createResponse({
256
+ sse: [
257
+ 'data: Hello \n',
258
+ 'data: world\n',
259
+ ],
260
+ })
261
+ const { stream, chunks } = createStream()
262
+ const result = await createAiBackendProvider({ endpoint: '/api' })(createRequest({ stream: true }), createContext(), stream)
263
+
264
+ expect(chunks).toEqual(['Hello ', 'world'])
265
+ expect(result).toEqual({ content: 'Hello world' })
266
+ })
267
+
268
+ it('reassembles chunks split across reader reads', async () => {
269
+ responseToReturn = createResponse({
270
+ sse: [
271
+ 'data: {"choices":[{"delta":{"conte',
272
+ 'nt":"Hel"}}]}\n',
273
+ 'data: {"choices":[{"delta":{"content":"lo"}}]}\n',
274
+ 'data: [DONE]\n',
275
+ ],
276
+ })
277
+ const { stream, chunks } = createStream()
278
+ const result = await createAiBackendProvider({ endpoint: '/api' })(createRequest({ stream: true }), createContext(), stream)
279
+
280
+ expect(chunks).toEqual(['Hel', 'lo'])
281
+ expect(result).toEqual({ content: 'Hello' })
282
+ })
283
+
284
+ it('keeps partially streamed content when the request is aborted', async () => {
285
+ responseToReturn = createResponse({ sse: ['data: {"choices":[{"delta":{"content":"Part"}}]}\n'], sseFailAfter: 0 })
286
+ const { stream, chunks } = createStream()
287
+ const provider = createAiBackendProvider({ endpoint: '/api' })
288
+ const result = await provider(createRequest({ stream: true }), createContext(), stream)
289
+
290
+ expect(chunks).toEqual(['Part'])
291
+ expect(result).toEqual({ content: 'Part' })
292
+ })
293
+
294
+ it('falls back to a plain body when the backend ignores stream', async () => {
295
+ responseToReturn = createResponse({ sse: ['{"content":"Full answer"}\n'] })
296
+ const { stream } = createStream()
297
+ const result = await createAiBackendProvider({ endpoint: '/api' })(createRequest({ stream: true }), createContext(), stream)
298
+
299
+ expect(result).toEqual({ content: 'Full answer' })
300
+ })
301
+
302
+ it('returns empty content when only reasoning was streamed', async () => {
303
+ responseToReturn = createResponse({
304
+ sse: [
305
+ 'data: {"choices":[{"delta":{"reasoning_content":"think"}}]}\n',
306
+ 'data: [DONE]\n',
307
+ ],
308
+ })
309
+ const { stream } = createStream()
310
+ const result = await createAiBackendProvider({ endpoint: '/api' })(createRequest({ stream: true }), createContext(), stream)
311
+
312
+ expect(result).toEqual({ content: '' })
313
+ })
314
+ })