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
@@ -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
+ })
@@ -0,0 +1,303 @@
1
+ import {
2
+ TiptapifyAiBackendProviderOptions,
3
+ TiptapifyAiBackendRequest,
4
+ TiptapifyAiEditorContext,
5
+ TiptapifyAiOpenAiResponse,
6
+ TiptapifyAiProvider,
7
+ TiptapifyAiReasoningEffort,
8
+ TiptapifyAiRequest,
9
+ TiptapifyAiResponse,
10
+ TiptapifyAiStream,
11
+ TiptapifyAiTokenProvider,
12
+ } from '@tiptapify/types/editor'
13
+
14
+ /**
15
+ * The instruction used when the AI options do not set a `systemPrompt`.
16
+ * Shared between the dialog's default messages and the backend provider
17
+ * (the `instruction` field of the backend payload).
18
+ */
19
+ export const DEFAULT_AI_INSTRUCTION = 'You are an AI writing assistant inside a rich text editor. Return only the final text to insert.'
20
+
21
+ /**
22
+ * Builds the minimal backend payload from the OpenAI-compatible request the
23
+ * dialog assembles.
24
+ *
25
+ * `prompt` (the user's request) and `instruction` (the system prompt, or the
26
+ * built-in default) are always present. `stream`, `thinking`,
27
+ * `reasoning_effort`, and `model` are included only when they are set.
28
+ */
29
+ export function buildAiBackendRequest(request: TiptapifyAiRequest, context: TiptapifyAiEditorContext): TiptapifyAiBackendRequest {
30
+ const systemMessage = request.messages.find(message => message.role === 'system')
31
+ const payload: TiptapifyAiBackendRequest = {
32
+ prompt: context.prompt,
33
+ instruction: typeof systemMessage?.content === 'string' ? systemMessage.content : DEFAULT_AI_INSTRUCTION,
34
+ }
35
+
36
+ if (request.model) {
37
+ payload.model = request.model
38
+ }
39
+
40
+ if (request.stream === true) {
41
+ payload.stream = true
42
+ }
43
+
44
+ if (request.enable_thinking === true) {
45
+ payload.thinking = true
46
+ }
47
+
48
+ const reasoningEffort = request.reasoning_effort
49
+ if (typeof reasoningEffort === 'string' && reasoningEffort !== '' && reasoningEffort !== 'default') {
50
+ payload.reasoning_effort = reasoningEffort as TiptapifyAiReasoningEffort
51
+ }
52
+
53
+ return payload
54
+ }
55
+
56
+ /**
57
+ * Creates a `TiptapifyAiProvider` that posts the minimal payload
58
+ * (`prompt` + `instruction` + optional fields) to a backend LLM endpoint.
59
+ *
60
+ * The response is lenient: a plain string, a `{ content }` object, or an
61
+ * OpenAI-compatible `choices` payload are all accepted. When the request has
62
+ * `stream: true`, the body is read as an SSE stream (`data:` lines,
63
+ * terminated by `data: [DONE]`); OpenAI-style deltas
64
+ * (`choices[0].delta.content` / `.reasoning_content`), a simple
65
+ * `{ type: 'content' | 'reasoning', data }` chunk, a flat
66
+ * `{ content, reasoning_content }` chunk, and plain-text `data` lines are
67
+ * all accepted.
68
+ */
69
+ export function createAiBackendProvider(options: TiptapifyAiBackendProviderOptions): TiptapifyAiProvider {
70
+ const { endpoint, headers, tokenProvider } = options
71
+
72
+ return async function aiBackendProvider(request, context, stream) {
73
+ const payload = buildAiBackendRequest(request, context)
74
+ const response = await fetch(endpoint, {
75
+ method: 'POST',
76
+ headers: await buildBackendHeaders(headers, tokenProvider),
77
+ body: JSON.stringify(payload),
78
+ signal: stream?.signal,
79
+ })
80
+
81
+ if (!response.ok) {
82
+ throw await createBackendError(response)
83
+ }
84
+
85
+ if (payload.stream !== true || !response.body) {
86
+ return parseBackendResponse(await response.text())
87
+ }
88
+
89
+ const { content, raw, aborted } = await readSseBody(response.body, stream)
90
+
91
+ if (aborted) {
92
+ // The user stopped the request — keep what has been streamed so far
93
+ return { content }
94
+ }
95
+
96
+ // Lenient: the backend may ignore `stream` and answer with a plain body
97
+ if (content === '' && raw.trim() !== '') {
98
+ const trimmedRaw = raw.trim()
99
+ try {
100
+ return JSON.parse(trimmedRaw)
101
+ } catch {
102
+ if (!trimmedRaw.includes('data:')) {
103
+ return trimmedRaw
104
+ }
105
+ }
106
+ }
107
+
108
+ return { content }
109
+ }
110
+ }
111
+
112
+ async function buildBackendHeaders(headers: Record<string, string> | undefined, tokenProvider?: TiptapifyAiTokenProvider): Promise<Record<string, string>> {
113
+ const result: Record<string, string> = {
114
+ 'Content-Type': 'application/json',
115
+ ...headers,
116
+ }
117
+
118
+ if (tokenProvider && !result.Authorization) {
119
+ const token = await tokenProvider()
120
+ if (token) {
121
+ result.Authorization = `Bearer ${token}`
122
+ }
123
+ }
124
+
125
+ return result
126
+ }
127
+
128
+ async function createBackendError(response: Response): Promise<Error> {
129
+ const body = await response.text()
130
+ let message = body
131
+
132
+ try {
133
+ const parsed = JSON.parse(body) as Record<string, unknown>
134
+ const error = parsed.error
135
+
136
+ if (typeof error === 'string') {
137
+ message = error
138
+ } else if (error && typeof error === 'object' && typeof (error as Record<string, unknown>).message === 'string') {
139
+ message = (error as Record<string, unknown>).message as string
140
+ } else if (typeof parsed.message === 'string') {
141
+ message = parsed.message
142
+ }
143
+ } catch {
144
+ // Keep the raw body
145
+ }
146
+
147
+ return new Error(message || `Request failed with status ${response.status}`)
148
+ }
149
+
150
+ function parseBackendResponse(text: string): TiptapifyAiResponse | TiptapifyAiOpenAiResponse | string {
151
+ try {
152
+ return JSON.parse(text)
153
+ } catch {
154
+ return text
155
+ }
156
+ }
157
+
158
+ type SseReadResult = {
159
+ content: string,
160
+ raw: string,
161
+ aborted: boolean,
162
+ }
163
+
164
+ async function readSseBody(body: ReadableStream<Uint8Array>, stream?: TiptapifyAiStream): Promise<SseReadResult> {
165
+ const result: SseReadResult = { content: '', raw: '', aborted: false }
166
+ const reader = body.getReader()
167
+ const decoder = new TextDecoder()
168
+ let buffer = ''
169
+
170
+ try {
171
+ while (true) {
172
+ const { done, value } = await reader.read()
173
+ if (done) {
174
+ break
175
+ }
176
+
177
+ const chunk = decoder.decode(value, { stream: true })
178
+ result.raw += chunk
179
+ buffer += chunk
180
+ const lines = buffer.split('\n')
181
+ buffer = lines.pop() ?? ''
182
+
183
+ for (const line of lines) {
184
+ result.content += parseSseLine(line, stream)
185
+ }
186
+ }
187
+
188
+ const tail = decoder.decode()
189
+ if (tail) {
190
+ result.raw += tail
191
+ buffer += tail
192
+ }
193
+
194
+ if (buffer) {
195
+ result.content += parseSseLine(buffer, stream)
196
+ }
197
+ } catch (error) {
198
+ if (stream?.signal.aborted || isAbortError(error)) {
199
+ result.aborted = true
200
+ return result
201
+ }
202
+ throw error
203
+ }
204
+
205
+ return result
206
+ }
207
+
208
+ /**
209
+ * Parses a single SSE line and forwards its content/reasoning chunks to the
210
+ * stream callbacks. Returns the portion dispatched as content so the caller
211
+ * can accumulate the full response.
212
+ */
213
+ function parseSseLine(line: string, stream?: TiptapifyAiStream): string {
214
+ const trimmed = line.replace(/\r$/, '')
215
+
216
+ if (!trimmed.startsWith('data:')) {
217
+ return ''
218
+ }
219
+
220
+ const data = trimmed.slice(5).trimStart()
221
+
222
+ if (data === '' || data.trim() === '[DONE]') {
223
+ return ''
224
+ }
225
+
226
+ let payload: unknown
227
+
228
+ try {
229
+ payload = JSON.parse(data)
230
+ } catch {
231
+ // A plain-text data line is a content chunk
232
+ if (stream) {
233
+ stream.onChunk(data)
234
+ }
235
+ return data
236
+ }
237
+
238
+ if (typeof payload === 'string') {
239
+ if (stream) {
240
+ stream.onChunk(payload)
241
+ }
242
+ return payload
243
+ }
244
+
245
+ if (typeof payload !== 'object' || payload === null) {
246
+ return ''
247
+ }
248
+
249
+ const record = payload as Record<string, unknown>
250
+
251
+ // Simple chunk format: { type: 'content' | 'reasoning', data: '...' }
252
+ if (typeof record.data === 'string') {
253
+ if (record.type === 'reasoning') {
254
+ if (stream) {
255
+ stream.onReasoning(record.data)
256
+ }
257
+ return ''
258
+ }
259
+
260
+ if (stream) {
261
+ stream.onChunk(record.data)
262
+ }
263
+ return record.data
264
+ }
265
+
266
+ // OpenAI-compatible chunk: choices[0].delta.content / .reasoning_content
267
+ const choice = Array.isArray(record.choices) ? record.choices[0] : null
268
+ const delta = choice !== null && typeof choice === 'object' ? (choice as Record<string, unknown>).delta : undefined
269
+
270
+ if (delta && typeof delta === 'object' && delta !== null) {
271
+ const deltaRecord = delta as Record<string, unknown>
272
+ let content = ''
273
+
274
+ if (typeof deltaRecord.reasoning_content === 'string' && deltaRecord.reasoning_content !== '') {
275
+ stream?.onReasoning(deltaRecord.reasoning_content)
276
+ }
277
+
278
+ if (typeof deltaRecord.content === 'string' && deltaRecord.content !== '') {
279
+ stream?.onChunk(deltaRecord.content)
280
+ content = deltaRecord.content
281
+ }
282
+
283
+ return content
284
+ }
285
+
286
+ // Flat chunk format: { content, reasoning_content }
287
+ let content = ''
288
+
289
+ if (typeof record.reasoning_content === 'string' && record.reasoning_content !== '') {
290
+ stream?.onReasoning(record.reasoning_content)
291
+ }
292
+
293
+ if (typeof record.content === 'string' && record.content !== '') {
294
+ stream?.onChunk(record.content)
295
+ content = record.content
296
+ }
297
+
298
+ return content
299
+ }
300
+
301
+ function isAbortError(error: unknown): boolean {
302
+ return error instanceof DOMException && error.name === 'AbortError'
303
+ }
@@ -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
+ }