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.
@@ -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,
@@ -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,