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
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
|
@@ -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>>,
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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
|
+
})
|