tiptapify 0.2.5 → 0.3.0
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 +2 -2
- package/dist/tiptapify.css +1 -1
- package/dist/tiptapify.mjs +23328 -25129
- package/dist/tiptapify.umd.js +70 -72
- package/index.d.ts +19 -0
- package/package.json +1 -1
- package/src/components/Footer.vue +1 -1
- package/src/components/Tiptapify.vue +31 -10
- package/src/components/Toolbar/Index.vue +3 -4
- package/src/components/Toolbar/Items.vue +3 -3
- package/src/components/UI/HotkeyTooltip.test.ts +65 -0
- package/src/components/UI/HotkeyTooltip.vue +54 -0
- package/src/components/UI/Tooltip.vue +20 -0
- package/src/composables/useTiptapifyConfig.test.ts +66 -0
- package/src/composables/useTiptapifyConfig.ts +17 -0
- package/src/constants/defaults.ts +4 -2
- package/src/extensions/components/actions/redo/Button.vue +5 -9
- package/src/extensions/components/actions/undo/Button.vue +5 -9
- package/src/extensions/components/ai/Button.vue +7 -10
- package/src/extensions/components/ai/Dialog.vue +9 -13
- package/src/extensions/components/ai/backend.test.ts +314 -0
- package/src/extensions/components/ai/backend.ts +303 -0
- package/src/extensions/components/alignment/center/Button.vue +5 -9
- package/src/extensions/components/alignment/justify/Button.vue +5 -9
- package/src/extensions/components/alignment/left/Button.vue +5 -9
- package/src/extensions/components/alignment/right/Button.vue +5 -9
- package/src/extensions/components/format/bold/Button.vue +5 -9
- package/src/extensions/components/format/italic/Button.vue +5 -9
- package/src/extensions/components/format/strike/Button.vue +5 -9
- package/src/extensions/components/format/underline/Button.vue +5 -9
- package/src/extensions/components/formatExtra/code/Button.vue +5 -9
- package/src/extensions/components/formatExtra/codeBlock/Button.vue +5 -9
- package/src/extensions/components/formatExtra/quote/Button.vue +5 -9
- package/src/extensions/components/formatExtra/sub/Button.vue +5 -9
- package/src/extensions/components/formatExtra/sup/Button.vue +5 -9
- package/src/extensions/components/list/bullet/Button.vue +8 -13
- package/src/extensions/components/list/indent/Button.vue +5 -9
- package/src/extensions/components/list/numbered/Button.vue +5 -9
- package/src/extensions/components/list/outdent/Button.vue +5 -9
- package/src/extensions/components/list/task/Button.vue +5 -9
- package/src/extensions/components/media/charmap/Button.vue +5 -8
- package/src/extensions/components/media/charmap/Picker.vue +2 -3
- package/src/extensions/components/media/emoji/Button.vue +6 -9
- package/src/extensions/components/media/emoji/Picker.vue +2 -3
- package/src/extensions/components/media/iframe/Button.vue +6 -10
- package/src/extensions/components/media/iframe/IframeDialog.vue +3 -7
- package/src/extensions/components/media/image/Button.vue +6 -10
- package/src/extensions/components/media/image/ImageDialog.vue +5 -10
- package/src/extensions/components/media/link/Button.vue +7 -10
- package/src/extensions/components/media/link/LinkDialog.vue +3 -7
- package/src/extensions/components/media/table/Button.vue +10 -24
- package/src/extensions/components/media/table/ColumnActions.vue +4 -9
- package/src/extensions/components/media/table/RowActions.vue +4 -9
- package/src/extensions/components/media/video/Button.vue +6 -10
- package/src/extensions/components/media/video/VideoDialog.vue +3 -7
- package/src/extensions/components/misc/break/Button.vue +5 -9
- package/src/extensions/components/misc/formatClear/Button.vue +5 -9
- package/src/extensions/components/misc/fullscreen/Button.vue +5 -9
- package/src/extensions/components/misc/invisibleChar/Button.vue +5 -9
- package/src/extensions/components/misc/line/Button.vue +5 -9
- package/src/extensions/components/misc/preview/Button.vue +5 -9
- package/src/extensions/components/misc/source/Button.vue +6 -10
- package/src/extensions/components/misc/source/ShowSourceDialog.vue +4 -5
- package/src/extensions/components/style/color/Button.vue +5 -9
- package/src/extensions/components/style/fontFamily/Button.vue +5 -9
- package/src/extensions/components/style/fontSize/Button.vue +6 -7
- package/src/extensions/components/style/heading/Button.vue +6 -8
- package/src/extensions/components/style/highlight/Button.vue +5 -9
- package/src/extensions/components/style/lineHeight/Button.vue +5 -9
- package/src/index.ts +7 -0
- package/src/types/editor.ts +18 -1
- package/src/utils/hotkeys.test.ts +146 -0
- package/src/utils/hotkeys.ts +65 -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
|
+
}
|
|
@@ -3,15 +3,13 @@
|
|
|
3
3
|
import * as mdi from '@mdi/js'
|
|
4
4
|
import { Editor } from '@tiptap/vue-3'
|
|
5
5
|
import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
6
|
+
import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
|
|
7
|
+
import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
8
|
+
import { inject, Ref } from 'vue'
|
|
8
9
|
|
|
9
|
-
import defaults from '@tiptapify/constants/defaults'
|
|
10
10
|
import { ComposerTranslation } from 'vue-i18n'
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
variantBtn: { type: String as PropType<variantBtnTypes>, default() { return defaults.variantBtn } },
|
|
14
|
-
})
|
|
12
|
+
const { variantBtn } = useTiptapifyConfig()
|
|
15
13
|
|
|
16
14
|
const editor = inject('tiptapifyEditor') as Ref<Editor>
|
|
17
15
|
|
|
@@ -26,9 +24,7 @@ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
|
|
|
26
24
|
size="32"
|
|
27
25
|
@click="editor.chain().focus().toggleTextAlign('center').run()"
|
|
28
26
|
>
|
|
29
|
-
<
|
|
30
|
-
{{ t('alignments.center') }}
|
|
31
|
-
</VTooltip>
|
|
27
|
+
<Tooltip :label="t('alignments.center')" hotkey="Mod-Shift-e" />
|
|
32
28
|
<BtnIcon :icon="`mdiSvg:${mdi.mdiFormatAlignCenter}`" />
|
|
33
29
|
</VBtn>
|
|
34
30
|
</template>
|
|
@@ -3,15 +3,13 @@
|
|
|
3
3
|
import * as mdi from '@mdi/js'
|
|
4
4
|
import { Editor } from '@tiptap/vue-3'
|
|
5
5
|
import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
6
|
+
import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
|
|
7
|
+
import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
8
|
+
import { inject, Ref } from 'vue'
|
|
8
9
|
|
|
9
|
-
import defaults from '@tiptapify/constants/defaults'
|
|
10
10
|
import { ComposerTranslation } from 'vue-i18n'
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
variantBtn: { type: String as PropType<variantBtnTypes>, default() { return defaults.variantBtn } },
|
|
14
|
-
})
|
|
12
|
+
const { variantBtn } = useTiptapifyConfig()
|
|
15
13
|
|
|
16
14
|
const editor = inject('tiptapifyEditor') as Ref<Editor>
|
|
17
15
|
|
|
@@ -26,9 +24,7 @@ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
|
|
|
26
24
|
size="32"
|
|
27
25
|
@click="editor.chain().focus().toggleTextAlign('justify').run()"
|
|
28
26
|
>
|
|
29
|
-
<
|
|
30
|
-
{{ t('alignments.justify') }}
|
|
31
|
-
</VTooltip>
|
|
27
|
+
<Tooltip :label="t('alignments.justify')" hotkey="Mod-Shift-j" />
|
|
32
28
|
<BtnIcon :icon="`mdiSvg:${mdi.mdiFormatAlignJustify}`" />
|
|
33
29
|
</VBtn>
|
|
34
30
|
</template>
|