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/README.md +1 -1
- package/dist/tiptapify.css +1 -1
- package/dist/tiptapify.mjs +11080 -10948
- package/dist/tiptapify.umd.js +56 -55
- package/index.d.ts +19 -0
- package/package.json +1 -1
- package/src/components/Tiptapify.vue +25 -8
- package/src/extensions/components/ai/Dialog.vue +5 -4
- package/src/extensions/components/ai/backend.test.ts +314 -0
- package/src/extensions/components/ai/backend.ts +303 -0
- package/src/index.ts +4 -0
- package/src/types/editor.ts +18 -1
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Plugin } from 'vue'
|
|
|
2
2
|
import Tiptapify from '@tiptapify/components/Tiptapify.vue'
|
|
3
3
|
import TiptapifyDialog from '@tiptapify/components/UI/TiptapifyDialog.vue'
|
|
4
4
|
import { TiptapifyFooterAlignment } from '@tiptapify/types/editor'
|
|
5
|
+
import { createAiBackendProvider } from '@tiptapify/extensions/components/ai/backend'
|
|
5
6
|
|
|
6
7
|
import { Editor as TipTapEditor, nodeViewProps, NodeViewWrapper, VueNodeViewRenderer } from '@tiptap/vue-3'
|
|
7
8
|
import { Node, Mark, markInputRule, markPasteRule, mergeAttributes } from '@tiptap/core'
|
|
@@ -37,6 +38,7 @@ export {
|
|
|
37
38
|
VueNodeViewRenderer,
|
|
38
39
|
Tiptapify,
|
|
39
40
|
TiptapifyDialog,
|
|
41
|
+
createAiBackendProvider,
|
|
40
42
|
Node,
|
|
41
43
|
Mark,
|
|
42
44
|
markInputRule,
|
|
@@ -47,6 +49,8 @@ export {
|
|
|
47
49
|
export type { TiptapifyFooterAlignment }
|
|
48
50
|
export type { CommandProps, InputRuleMatch, PasteRuleMatch }
|
|
49
51
|
export type {
|
|
52
|
+
TiptapifyAiBackendProviderOptions,
|
|
53
|
+
TiptapifyAiBackendRequest,
|
|
50
54
|
TiptapifyAiMode,
|
|
51
55
|
TiptapifyAiOptions,
|
|
52
56
|
TiptapifyAiChatMessage,
|
package/src/types/editor.ts
CHANGED
|
@@ -42,7 +42,7 @@ export type TiptapifyAiRequest = {
|
|
|
42
42
|
[key: string]: unknown,
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
export type TiptapifyAiReasoningEffort = 'low' | 'medium' | 'high'
|
|
45
|
+
export type TiptapifyAiReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
|
|
46
46
|
|
|
47
47
|
export type TiptapifyAiReasoningEffortOptions = {
|
|
48
48
|
options: TiptapifyAiReasoningEffort[],
|
|
@@ -76,6 +76,21 @@ export type TiptapifyAiProvider = (
|
|
|
76
76
|
|
|
77
77
|
export type TiptapifyAiTokenProvider = () => Promise<string | null> | string | null
|
|
78
78
|
|
|
79
|
+
export type TiptapifyAiBackendRequest = {
|
|
80
|
+
prompt: string,
|
|
81
|
+
instruction: string,
|
|
82
|
+
stream?: boolean,
|
|
83
|
+
thinking?: boolean,
|
|
84
|
+
reasoning_effort?: TiptapifyAiReasoningEffort,
|
|
85
|
+
model?: string,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type TiptapifyAiBackendProviderOptions = {
|
|
89
|
+
endpoint: string,
|
|
90
|
+
headers?: Record<string, string>,
|
|
91
|
+
tokenProvider?: TiptapifyAiTokenProvider,
|
|
92
|
+
}
|
|
93
|
+
|
|
79
94
|
export type TiptapifyAiStorage = {
|
|
80
95
|
getItem: (key: string) => string | null | Promise<string | null>,
|
|
81
96
|
setItem: (key: string, value: string) => void | Promise<void>,
|
|
@@ -84,6 +99,8 @@ export type TiptapifyAiStorage = {
|
|
|
84
99
|
|
|
85
100
|
export type TiptapifyAiOptions = {
|
|
86
101
|
aiProvider?: TiptapifyAiProvider,
|
|
102
|
+
aiEndpoint?: string,
|
|
103
|
+
aiHeaders?: Record<string, string>,
|
|
87
104
|
model?: string,
|
|
88
105
|
promptExamples?: TiptapifyAiPromptExample[],
|
|
89
106
|
mode?: TiptapifyAiMode,
|