@quidgest/chatbot 0.5.8 → 0.6.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/dist/style.css CHANGED
@@ -1 +1 @@
1
- .markdown-renderer pre,.markdown-renderer code{white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:auto}.markdown-renderer pre{position:relative;background-color:#f5f5f5;border:1px solid #e0e0e0;border-radius:6px;padding:.75rem 1rem;font-size:.875rem}.markdown-renderer code{padding:.2rem .4rem;border-radius:4px;font-size:.875rem}.q-field-preview{position:relative;display:flex;flex-direction:column;margin:1rem .25rem}.q-field-preview__toolbar{z-index:1;display:flex;flex-direction:row;align-items:center;justify-content:space-between;padding:.1rem .2rem}.q-field-preview__content{position:relative;background-color:#f5f5f5;border:1px solid #e0e0e0;border-radius:6px;padding:.75rem 1rem;font-size:.875rem}.q-field-preview__content.preserve-whitespace{white-space:pre-wrap}.q-field-preview__footer{display:flex;flex-direction:row;margin-top:.25rem}.q-field-preview:first-child{margin:0 1rem .25rem .25rem}.pulsing-dots{display:flex;align-items:center;justify-content:center;flex-direction:row;gap:.25rem}.generating-text{font-size:.9rem;color:var(--q-theme-primary)}.dots-container{display:flex;align-items:center;gap:.1rem}.dot{font-size:16px;line-height:1;animation:pulse 1s infinite;color:var(--q-theme-primary)}@keyframes pulse{0%,to{transform:scale(.8);opacity:.6}50%{transform:scale(1);opacity:1}}.q-chatbot__file-preview img,.q-chatbot__image-preview img{width:60px;height:60px;object-fit:cover;border-radius:4px;margin-right:.25rem;border:1px solid #eaebec;overflow:hidden}.q-chatbot__file-preview{display:inline-flex;align-items:center;position:relative;margin-top:.5rem;gap:.25rem;width:fit-content}.q-chatbot__file-preview img:focus{outline:solid rgb(var(--q-theme-info-rgb)/50%)}.q-chatbot__file-preview-container{display:flex;border-radius:.5rem;padding:.25rem .5rem;max-width:320px;align-items:center;justify-content:center;border:1px solid var(--q-theme-primary-light)}.q-chatbot__file-icon-container{display:flex;align-items:center;justify-content:center;border-radius:8px;width:36px;height:36px;flex-shrink:0;margin-right:10px;background:var(--q-theme-primary-light)}.q-chatbot__file-icon-container .q-icon{width:20px;height:20px}.q-chatbot__file-info{display:flex;flex-direction:column;overflow:hidden}.q-chatbot__file-name{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.q-chatbot__file-extension{font-size:10px}.q-chatbot{width:100%;height:100%;display:flex;flex-direction:column}.q-chatbot input{line-height:1.5rem}.q-chatbot .q-input-group .i-text__field{border-radius:0;flex:1}.q-chatbot__remove-file{position:absolute;top:-8px;right:-8px}.q-chatbot__text p{margin:0}.q-chatbot__content{background-color:#fff;border:1px solid #eaebec;height:100%;width:100%;display:flex;flex-direction:column;gap:.75rem;overflow:hidden}.q-chatbot__footer-container{padding:.8rem 0 0}.q-chatbot__input-wrapper{display:flex;flex-direction:column;position:relative}.q-chatbot .q-button.q-chatbot__remove-file{position:absolute;top:-5px;right:-5px;background-color:#00000080;color:#fff;border-radius:50%;padding:5px;font-size:10px;border:none}.q-chatbot .q-button.q-chatbot__remove-file:hover,.q-chatbot .q-button.q-chatbot__remove-file:focus{opacity:1;pointer-events:auto}.q-chatbot__send-container{padding-bottom:.25rem;display:flex;justify-content:space-between;width:100%}.q-chatbot__send-container .q-chatbot__send,.q-chatbot__send-container .q-chatbot__upload{border-radius:1rem}.q-chatbot__send-container .spacer{flex-grow:1}.q-chatbot__footer{position:sticky;padding:0 .5rem;border:1px solid #eaebec;border-radius:.25rem;bottom:0;width:100%;display:flex;flex-direction:column;gap:.25rem}.q-chatbot__footer-disabled{background-color:rgb(var(--q-theme-neutral-light-rgb)/25%);cursor:not-allowed}.q-chatbot__footer.drag-over{border:2px dashed rgb(var(--q-theme-primary-rgb)/25%);background-color:#018bd20d}.q-chatbot__footer .q-chatbot__input{min-height:50px;max-height:100px;border-bottom:1px solid #eaebec;overflow-y:auto}.q-chatbot__footer .q-text-area{max-height:100%;overflow-y:auto}.q-chatbot__footer .q-text-area .q-field__control{border:none}.q-chatbot__upload-container{display:flex;justify-content:flex-start;padding:.25rem 0}.q-chatbot__upload-container .q-chatbot__upload{border-radius:1rem}.q-chatbot__messages-container{display:flex;flex-direction:column;flex-grow:1;padding:0 1rem 2rem;gap:1.5rem;overflow-y:auto}.q-chatbot__messages-wrapper{display:flex;max-width:100%;gap:.2rem}.q-chatbot__tools{display:flex;flex-direction:row;justify-content:space-between;max-width:100%;padding:.25rem .5rem}.q-chatbot__message-wrapper{display:flex;flex-direction:column;gap:.2rem}.q-chatbot__message-container{display:flex;flex-direction:column;gap:.25rem}.q-chatbot__messages-wrapper_right{justify-content:flex-end}.q-chatbot__messages-wrapper_right .q-chatbot__message-container{align-items:flex-end}.q-chatbot__messages-wrapper_right .q-chatbot__message-wrapper{display:flex;align-items:flex-end}.q-chatbot__profile.q-icon__img{border-radius:50%;height:2rem;width:2rem}.q-chatbot__message{padding:.3rem .5rem;background-color:#eaebec;width:fit-content;min-height:2rem;white-space:normal;border-radius:0 .5rem .5rem}.q-chatbot__messages-wrapper_right .q-chatbot__message{background-color:#018bd233;border-radius:.5rem 0 .5rem .5rem;white-space:normal}.q-chatbot__sender{white-space:nowrap;color:#7c858d;font-size:.7rem}.q-chatbot__retry-button{align-items:center;display:flex}.q-chatbot__dialog-title{margin:.5rem 0}.hidden-input{display:none}
1
+ .markdown-renderer pre,.markdown-renderer code{white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:auto}.markdown-renderer pre{position:relative;background-color:#f5f5f5;border:1px solid #e0e0e0;border-radius:6px;padding:.75rem 1rem;font-size:.875rem}.markdown-renderer code{padding:.2rem .4rem;border-radius:4px;font-size:.875rem}.markdown-renderer__execution-plan-content{background-color:#2d2d2d;color:#fff;padding:.75rem 1rem;border-radius:6px;margin-top:.5rem}.markdown-renderer__execution-plan-content ul,.markdown-renderer__execution-plan-content ol{margin:0;padding-left:1.5rem}.markdown-renderer__execution-plan-content p{margin:0}.q-field-preview{position:relative;display:flex;flex-direction:column;margin:1rem .25rem}.q-field-preview__toolbar{z-index:1;display:flex;flex-direction:row;align-items:center;justify-content:space-between;padding:.1rem .2rem}.q-field-preview__content{position:relative;background-color:#f5f5f5;border:1px solid #e0e0e0;border-radius:6px;padding:.75rem 1rem;font-size:.875rem}.q-field-preview__content.preserve-whitespace{white-space:pre-wrap}.q-field-preview__footer{display:flex;flex-direction:row;margin-top:.25rem}.q-field-preview:first-child{margin:0 1rem .25rem .25rem}.pulsing-dots{display:flex;align-items:center;justify-content:center;flex-direction:row;gap:.25rem}.generating-text{font-size:.9rem;color:var(--q-theme-primary)}.dots-container{display:flex;align-items:center;gap:.1rem}.dot{font-size:16px;line-height:1;animation:pulse 1s infinite;color:var(--q-theme-primary)}@keyframes pulse{0%,to{transform:scale(.8);opacity:.6}50%{transform:scale(1);opacity:1}}.q-chatbot__file-preview img,.q-chatbot__image-preview img{width:60px;height:60px;object-fit:cover;border-radius:4px;margin-right:.25rem;border:1px solid #eaebec;overflow:hidden}.q-chatbot__file-preview{display:inline-flex;align-items:center;position:relative;margin-top:.5rem;gap:.25rem;width:fit-content}.q-chatbot__file-preview img:focus{outline:solid rgb(var(--q-theme-info-rgb)/50%)}.q-chatbot__file-preview-container{display:flex;border-radius:.5rem;padding:.25rem .5rem;max-width:320px;align-items:center;justify-content:center;border:1px solid var(--q-theme-primary-light)}.q-chatbot__file-icon-container{display:flex;align-items:center;justify-content:center;border-radius:8px;width:36px;height:36px;flex-shrink:0;margin-right:10px;background:var(--q-theme-primary-light)}.q-chatbot__file-icon-container .q-icon{width:20px;height:20px}.q-chatbot__file-info{display:flex;flex-direction:column;overflow:hidden}.q-chatbot__file-name{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.q-chatbot__file-extension{font-size:10px}.q-chatbot{width:100%;height:100%;display:flex;flex-direction:column}.q-chatbot input{line-height:1.5rem}.q-chatbot .q-input-group .i-text__field{border-radius:0;flex:1}.q-chatbot__remove-file{position:absolute;top:-8px;right:-8px}.q-chatbot__text p{margin:0}.q-chatbot__content{background-color:#fff;border:1px solid #eaebec;height:100%;width:100%;display:flex;flex-direction:column;gap:.75rem;overflow:hidden}.q-chatbot__footer-container{padding:.8rem 0 0}.q-chatbot__input-wrapper{display:flex;flex-direction:column;position:relative}.q-chatbot .q-button.q-chatbot__remove-file{position:absolute;top:-5px;right:-5px;background-color:#00000080;color:#fff;border-radius:50%;padding:5px;font-size:10px;border:none}.q-chatbot .q-button.q-chatbot__remove-file:hover,.q-chatbot .q-button.q-chatbot__remove-file:focus{opacity:1;pointer-events:auto}.q-chatbot__send-container{padding-bottom:.25rem;display:flex;justify-content:space-between;width:100%}.q-chatbot__send-container .q-chatbot__send,.q-chatbot__send-container .q-chatbot__upload{border-radius:1rem}.q-chatbot__send-container .spacer{flex-grow:1}.q-chatbot__footer{position:sticky;padding:0 .5rem;border:1px solid #eaebec;border-radius:.25rem;bottom:0;width:100%;display:flex;flex-direction:column;gap:.25rem}.q-chatbot__footer-disabled{background-color:rgb(var(--q-theme-neutral-light-rgb)/25%);cursor:not-allowed}.q-chatbot__footer.drag-over{border:2px dashed rgb(var(--q-theme-primary-rgb)/25%);background-color:#018bd20d}.q-chatbot__footer .q-chatbot__input{min-height:50px;max-height:100px;border-bottom:1px solid #eaebec;overflow-y:auto}.q-chatbot__footer .q-text-area{max-height:100%;overflow-y:auto}.q-chatbot__footer .q-text-area .q-field__control{border:none}.q-chatbot__upload-container{display:flex;justify-content:flex-start;padding:.25rem 0}.q-chatbot__upload-container .q-chatbot__upload{border-radius:1rem}.q-chatbot__messages-container{display:flex;flex-direction:column;flex-grow:1;padding:0 1rem 2rem;gap:1.5rem;overflow-y:auto}.q-chatbot__messages-wrapper{display:flex;max-width:100%;gap:.2rem}.q-chatbot__tools{display:flex;flex-direction:row;justify-content:space-between;max-width:100%;padding:.25rem .5rem}.q-chatbot__message-wrapper{display:flex;flex-direction:column;gap:.2rem}.q-chatbot__message-container{display:flex;flex-direction:column;gap:.25rem}.q-chatbot__messages-wrapper_right{justify-content:flex-end}.q-chatbot__messages-wrapper_right .q-chatbot__message-container{align-items:flex-end}.q-chatbot__messages-wrapper_right .q-chatbot__message-wrapper{display:flex;align-items:flex-end}.q-chatbot__profile.q-icon__img{border-radius:50%;height:2rem;width:2rem}.q-chatbot__message{padding:.3rem .5rem;background-color:#eaebec;width:fit-content;min-height:2rem;white-space:normal;border-radius:0 .5rem .5rem}.q-chatbot__messages-wrapper_right .q-chatbot__message{background-color:#018bd233;border-radius:.5rem 0 .5rem .5rem;white-space:normal}.q-chatbot__sender{white-space:nowrap;color:#7c858d;font-size:.7rem}.q-chatbot__retry-button{align-items:center;display:flex}.q-chatbot__dialog-title{margin:.5rem 0}.hidden-input{display:none}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@quidgest/chatbot",
3
3
  "private": false,
4
- "version": "0.5.8",
4
+ "version": "0.6.0",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
7
7
  "main": "dist/index.cjs",
@@ -13,7 +13,7 @@
13
13
  class="q-chatbot__messages-container"
14
14
  @scroll="handleScroll">
15
15
  <div
16
- v-for="message in messages"
16
+ v-for="(message, index) in messages"
17
17
  :key="message.id"
18
18
  :class="getMessageClasses(message.sender)">
19
19
  <chat-bot-message
@@ -26,8 +26,10 @@
26
26
  :api-endpoint="props.apiEndpoint"
27
27
  :session-i-d="message.sessionID"
28
28
  :fields="message.fields"
29
+ :is-last-message="index === messages.length - 1"
29
30
  @regenerate="onFieldRegenerate"
30
- @apply-fields="applyFields" />
31
+ @apply-fields="applyFields"
32
+ @send-message="sendMessage" />
31
33
  </div>
32
34
  </div>
33
35
  </div>
@@ -63,9 +63,12 @@
63
63
  :show-buttons="isBotMessageAndNotDefault"
64
64
  :loading="props.loading"
65
65
  :date-format="props.dateFormat"
66
+ :is-execution-plan="isExecutionPlan"
67
+ :is-last-message="props.isLastMessage"
66
68
  @copy-response="copyResponse"
67
69
  @submit-feedback="onSubmitFeedback"
68
- @apply-all="applyAllFields" />
70
+ @apply-all="applyAllFields"
71
+ @approve-proceed-plan="onApproveProceedPlan" />
69
72
  </div>
70
73
  </template>
71
74
 
@@ -93,12 +96,14 @@
93
96
  sender: 'user',
94
97
  userImage: undefined,
95
98
  date: () => new Date(),
96
- fields: () => []
99
+ fields: () => [],
100
+ isLastMessage: false
97
101
  })
98
102
 
99
103
  const emit = defineEmits<{
100
104
  (e: 'apply-fields', fields: AppliedFieldData[]): void
101
105
  (e: 'regenerate', fieldName: string): void
106
+ (e: 'send-message', prompt: string): void
102
107
  }>()
103
108
 
104
109
  const texts = useTexts()
@@ -128,6 +133,19 @@
128
133
  return ext ?? ''
129
134
  })
130
135
 
136
+ const isExecutionPlan = computed(() => {
137
+ return (
138
+ props.sender === 'bot' &&
139
+ !props.isWelcomeMessage &&
140
+ !!props.message &&
141
+ props.message.toLowerCase().includes('**execution plan**')
142
+ )
143
+ })
144
+
145
+ function onApproveProceedPlan() {
146
+ emit('send-message', texts.approveProceedPlan)
147
+ }
148
+
131
149
  function copyResponse() {
132
150
  if (!props.message) return
133
151
 
@@ -46,14 +46,27 @@
46
46
  </q-button>
47
47
  <q-button
48
48
  v-if="showApplyAll"
49
- :title="texts.applyAll"
49
+ :title="blockApplyAllButton ? undefined : texts.applyAll"
50
50
  class="q-chatbot__apply-all-button"
51
+ variant="bold"
51
52
  borderless
52
53
  :disabled="blockApplyAllButton"
53
54
  :readonly="blockApplyAllButton"
54
55
  :label="texts.applyAll"
55
56
  @click="onApplyAll">
56
- <q-icon icon="apply-all" />
57
+ <q-icon icon="apply" />
58
+ </q-button>
59
+ <q-button
60
+ v-if="showApprovePlan"
61
+ :title="blockApproveProceedButton ? undefined : texts.approveProceed"
62
+ class="q-chatbot__approve-proceed-plan-button"
63
+ variant="bold"
64
+ borderless
65
+ :disabled="blockApproveProceedButton"
66
+ :readonly="blockApproveProceedButton"
67
+ :label="texts.approveProceed"
68
+ @click="onApproveProceedPlan">
69
+ <q-icon icon="apply" />
57
70
  </q-button>
58
71
  </q-button-group>
59
72
  </div>
@@ -76,6 +89,7 @@
76
89
  (e: 'submit-feedback', feedback: number, comment: string): void
77
90
  (e: 'copy-response'): void
78
91
  (e: 'apply-all'): void
92
+ (e: 'approve-proceed-plan'): void
79
93
  }>()
80
94
  const texts = useTexts()
81
95
  const { getLastMessage } = useChatMessages()
@@ -84,10 +98,16 @@
84
98
  const feedbackComment = ref('')
85
99
  const currentFeedback = ref<number | null>(null)
86
100
  const blockApplyAll = ref(false)
101
+ const blockApproveProceed = ref(false)
102
+
87
103
  const blockApplyAllButton = computed(() => {
88
104
  return props.loading || blockApplyAll.value
89
105
  })
90
106
 
107
+ const blockApproveProceedButton = computed(() => {
108
+ return props.loading || blockApproveProceed.value || !props.isLastMessage
109
+ })
110
+
91
111
  const date = props.date || new Date()
92
112
 
93
113
  const lastMessage = getLastMessage()
@@ -98,6 +118,10 @@
98
118
  return lastMessage.fields && lastMessage.fields.length > 1
99
119
  })
100
120
 
121
+ const showApprovePlan = computed(() => {
122
+ return props.isExecutionPlan
123
+ })
124
+
101
125
  const commentButtons = [
102
126
  {
103
127
  id: 'confirm-btn',
@@ -170,6 +194,13 @@
170
194
  emit('apply-all')
171
195
  }
172
196
 
197
+ function onApproveProceedPlan() {
198
+ if (blockApproveProceed.value) return
199
+ blockApproveProceed.value = true
200
+
201
+ emit('approve-proceed-plan')
202
+ }
203
+
173
204
  function submitFeedback() {
174
205
  if (!currentFeedback.value) return
175
206
 
@@ -196,4 +196,78 @@ describe('ChatBotMessageButtons', () => {
196
196
  // The date should be in the current locale format
197
197
  expect(dateElement.text()).toBe(expectedFormat)
198
198
  })
199
+
200
+ it('renders approve plan button for execution plan messages', () => {
201
+ const wrapper = mount(ChatBotMessageButtons, {
202
+ props: {
203
+ ...props,
204
+ isExecutionPlan: true,
205
+ isLastMessage: true
206
+ }
207
+ })
208
+
209
+ const approveProceedButton = wrapper.find('.q-chatbot__approve-proceed-plan-button')
210
+
211
+ expect(approveProceedButton.exists()).toBe(true)
212
+ expect(approveProceedButton.text()).toBe('Approve & proceed')
213
+ })
214
+
215
+ it('disables approve plan button when not the last message', () => {
216
+ const wrapper = mount(ChatBotMessageButtons, {
217
+ props: {
218
+ ...props,
219
+ isExecutionPlan: true,
220
+ isLastMessage: false
221
+ }
222
+ })
223
+
224
+ const approveProceedButton = wrapper.find('.q-chatbot__approve-proceed-plan-button')
225
+ expect(approveProceedButton.exists()).toBe(true)
226
+ expect(approveProceedButton.attributes('disabled')).toBeDefined()
227
+ })
228
+
229
+ it('does not render approve plan button when not an execution plan', () => {
230
+ const wrapper = mount(ChatBotMessageButtons, {
231
+ props: {
232
+ ...props,
233
+ isExecutionPlan: false,
234
+ isLastMessage: true
235
+ }
236
+ })
237
+
238
+ const approveProceedButton = wrapper.find('.q-chatbot__approve-proceed-plan-button')
239
+ expect(approveProceedButton.exists()).toBe(false)
240
+ })
241
+
242
+ it('emits approve-proceed-plan event when approve button is clicked', async () => {
243
+ const wrapper = mount(ChatBotMessageButtons, {
244
+ props: {
245
+ ...props,
246
+ isExecutionPlan: true,
247
+ isLastMessage: true
248
+ }
249
+ })
250
+
251
+ const approveProceedButton = wrapper.find('.q-chatbot__approve-proceed-plan-button')
252
+ await approveProceedButton.trigger('click')
253
+
254
+ expect(wrapper.emitted()['approve-proceed-plan']).toBeTruthy()
255
+ })
256
+
257
+ it('does not emit approve-proceed-plan if the button has already been clicked', async () => {
258
+ const wrapper = mount(ChatBotMessageButtons, {
259
+ props: {
260
+ ...props,
261
+ isExecutionPlan: true,
262
+ isLastMessage: true
263
+ }
264
+ })
265
+
266
+ const approveProceedButton = wrapper.find('.q-chatbot__approve-proceed-plan-button')
267
+ await approveProceedButton.trigger('click')
268
+ await approveProceedButton.trigger('click') // Click again
269
+
270
+ expect(wrapper.emitted()['approve-proceed-plan']).toBeTruthy()
271
+ expect(wrapper.emitted()['approve-proceed-plan'].length).toBe(1) // Should only be emitted once
272
+ })
199
273
  })
@@ -28,6 +28,7 @@ exports[`ChatBotMessage > renders the component with default props 1`] = `
28
28
  <!--v-if--><span class="q-button__content"><span data-test="copy-content"></span> </span>
29
29
  </button>
30
30
  <!--v-if-->
31
+ <!--v-if-->
31
32
  </div>
32
33
  </div>
33
34
  <div class="q-chatbot__sender">11:47</div>
@@ -19,6 +19,7 @@ exports[`ChatBotMessageButtons > renders correctly with default props 1`] = `
19
19
  <!--v-if--><span class="q-button__content"><span data-test="copy-content"></span> </span>
20
20
  </button>
21
21
  <!--v-if-->
22
+ <!--v-if-->
22
23
  </div>
23
24
  </div>
24
25
  <div class="q-chatbot__sender">11:47</div>"
@@ -61,6 +61,11 @@ export type ChatBotMessageProps = {
61
61
  * Additional fields for the message
62
62
  */
63
63
  fields?: FieldData[]
64
+
65
+ /**
66
+ * Flag to indicate if this is the last message
67
+ */
68
+ isLastMessage?: boolean
64
69
  }
65
70
 
66
71
  export type ChatBotMessageButtonsProps = {
@@ -68,4 +73,6 @@ export type ChatBotMessageButtonsProps = {
68
73
  showButtons: boolean
69
74
  dateFormat: string
70
75
  date?: Date
76
+ isExecutionPlan?: boolean
77
+ isLastMessage?: boolean
71
78
  }
@@ -1,7 +1,17 @@
1
1
  <template>
2
2
  <div
3
+ v-if="!isExecutionPlan"
3
4
  class="markdown-renderer"
4
5
  v-html="renderedContent"></div>
6
+ <div
7
+ v-else
8
+ class="markdown-renderer">
9
+ <div v-html="executionPlanTitle"></div>
10
+ <div
11
+ v-if="executionPlanContent"
12
+ class="markdown-renderer__execution-plan-content"
13
+ v-html="executionPlanContent"></div>
14
+ </div>
5
15
  </template>
6
16
 
7
17
  <script setup lang="ts">
@@ -23,4 +33,31 @@
23
33
  if (props.plugins) props.plugins.forEach((plugin) => md.value.use(plugin))
24
34
 
25
35
  const renderedContent = computed(() => md.value.render(props.source))
36
+
37
+ const isExecutionPlan = computed(() => {
38
+ return props.source.toLowerCase().includes('**execution plan**')
39
+ })
40
+
41
+ const executionPlanTitle = computed(() => {
42
+ if (!isExecutionPlan.value) return ''
43
+
44
+ const lines = props.source.split('\n')
45
+ const titleLine = lines.find((line) => line.toLowerCase().includes('**execution plan**'))
46
+
47
+ return titleLine ? md.value.render(titleLine) : ''
48
+ })
49
+
50
+ const executionPlanContent = computed(() => {
51
+ if (!isExecutionPlan.value) return ''
52
+
53
+ const lines = props.source.split('\n')
54
+ const titleIndex = lines.findIndex((line) =>
55
+ line.toLowerCase().includes('**execution plan**')
56
+ )
57
+
58
+ if (titleIndex === -1) return ''
59
+
60
+ const contentLines = lines.slice(titleIndex + 1).join('\n')
61
+ return md.value.render(contentLines)
62
+ })
26
63
  </script>
@@ -20,4 +20,22 @@
20
20
  border-radius: 4px;
21
21
  font-size: 0.875rem;
22
22
  }
23
+
24
+ &__execution-plan-content {
25
+ background-color: #2d2d2d;
26
+ color: white;
27
+ padding: 0.75rem 1rem;
28
+ border-radius: 6px;
29
+ margin-top: 0.5rem;
30
+
31
+ ul,
32
+ ol {
33
+ margin: 0;
34
+ padding-left: 1.5rem;
35
+ }
36
+
37
+ p {
38
+ margin: 0;
39
+ }
40
+ }
23
41
  }
@@ -129,4 +129,100 @@ describe('useSSE', () => {
129
129
  expect(consoleWarnSpy).toHaveBeenCalledWith('Unknown event type: unknown_event')
130
130
  consoleWarnSpy.mockRestore()
131
131
  })
132
+
133
+ // Tests for handling events split across chunks (bug reproduction)
134
+ describe('Events split across multiple chunks', () => {
135
+ it('should handle event split in the middle of data field', async () => {
136
+ // Simulate network splitting an event across two chunks
137
+ const stream = createMockStream(['event: message\ndata: {"val', 'ue":"hello"}\n\n'])
138
+ mockedAxios.mockResolvedValue({ data: stream })
139
+
140
+ await useSSE({ url: '/sse' }, handlers)
141
+
142
+ expect(handlers.onMessage).toHaveBeenCalledWith('hello')
143
+ expect(handlers.onDone).toHaveBeenCalled()
144
+ })
145
+
146
+ it('should handle multiple events split across multiple chunks', async () => {
147
+ // Simulate the real scenario from the bug report
148
+ const stream = createMockStream([
149
+ 'event: message\ndata: {"value":"The"}\n\nevent: message\ndata: {"val',
150
+ 'ue":" capital"}\n\nevent: message\ndata: {"value":" of"}\n\n',
151
+ 'event: message\ndata: {"value":" France"}\n\n'
152
+ ])
153
+ mockedAxios.mockResolvedValue({ data: stream })
154
+
155
+ await useSSE({ url: '/sse' }, handlers)
156
+
157
+ // Should receive all 4 messages
158
+ expect(handlers.onMessage).toHaveBeenCalledTimes(4)
159
+ expect(handlers.onMessage).toHaveBeenNthCalledWith(1, 'The')
160
+ expect(handlers.onMessage).toHaveBeenNthCalledWith(2, ' capital')
161
+ expect(handlers.onMessage).toHaveBeenNthCalledWith(3, ' of')
162
+ expect(handlers.onMessage).toHaveBeenNthCalledWith(4, ' France')
163
+ expect(handlers.onDone).toHaveBeenCalled()
164
+ })
165
+
166
+ it('should handle event split between event name and data', async () => {
167
+ const stream = createMockStream([
168
+ 'event: message\n',
169
+ 'data: {"value":"split event"}\n\n'
170
+ ])
171
+ mockedAxios.mockResolvedValue({ data: stream })
172
+
173
+ await useSSE({ url: '/sse' }, handlers)
174
+
175
+ expect(handlers.onMessage).toHaveBeenCalledWith('split event')
176
+ expect(handlers.onDone).toHaveBeenCalled()
177
+ })
178
+
179
+ it('should handle event split right after event separator', async () => {
180
+ const stream = createMockStream([
181
+ 'event: message\ndata: {"value":"first"}\n\n',
182
+ 'event: message\ndata: {"value":"second"}\n\n'
183
+ ])
184
+ mockedAxios.mockResolvedValue({ data: stream })
185
+
186
+ await useSSE({ url: '/sse' }, handlers)
187
+
188
+ expect(handlers.onMessage).toHaveBeenCalledTimes(2)
189
+ expect(handlers.onMessage).toHaveBeenNthCalledWith(1, 'first')
190
+ expect(handlers.onMessage).toHaveBeenNthCalledWith(2, 'second')
191
+ expect(handlers.onDone).toHaveBeenCalled()
192
+ })
193
+
194
+ it('should handle complex JSON split across chunks', async () => {
195
+ const stream = createMockStream([
196
+ 'event: field_metadata\ndata: {"foo":"bar","nested":{"key',
197
+ '":"value"}}\n\n'
198
+ ])
199
+ mockedAxios.mockResolvedValue({ data: stream })
200
+
201
+ await useSSE({ url: '/sse' }, handlers)
202
+
203
+ expect(handlers.onFieldMetadata).toHaveBeenCalledWith({
204
+ foo: 'bar',
205
+ nested: { key: 'value' }
206
+ })
207
+ expect(handlers.onDone).toHaveBeenCalled()
208
+ })
209
+
210
+ it('should handle event split in very small chunks', async () => {
211
+ // Extreme case: each character in a separate chunk
212
+ const stream = createMockStream([
213
+ 'event: ',
214
+ 'message\n',
215
+ 'data: ',
216
+ '{"value":',
217
+ '"test"}',
218
+ '\n\n'
219
+ ])
220
+ mockedAxios.mockResolvedValue({ data: stream })
221
+
222
+ await useSSE({ url: '/sse' }, handlers)
223
+
224
+ expect(handlers.onMessage).toHaveBeenCalledWith('test')
225
+ expect(handlers.onDone).toHaveBeenCalled()
226
+ })
227
+ })
132
228
  })
@@ -30,6 +30,7 @@ export async function useSSE(config: AxiosRequestConfig, handlers: SSEvents) {
30
30
 
31
31
  const reader = stream.getReader()
32
32
  const decoder = new TextDecoder()
33
+ let buffer = '' // Buffer to accumulate incomplete events across chunks
33
34
 
34
35
  while (true) {
35
36
  const { done, value } = await reader.read()
@@ -39,7 +40,12 @@ export async function useSSE(config: AxiosRequestConfig, handlers: SSEvents) {
39
40
  }
40
41
 
41
42
  const chunk = decoder.decode(value, { stream: true })
42
- const events = chunk.split(/\n\n+/)
43
+ buffer += chunk // Append chunk to buffer
44
+ const events = buffer.split(/\n\n+/)
45
+
46
+ // Keep the last element in buffer (might be incomplete)
47
+ // Process all complete events (all except the last one)
48
+ buffer = events.pop() || ''
43
49
 
44
50
  for (const eventBlock of events) {
45
51
  const lines = eventBlock.trim().split('\n')
@@ -31,6 +31,8 @@ export function useTexts() {
31
31
  regenerateResponse: 'Regenerate response',
32
32
  generatingResponse: 'Generating',
33
33
  suggestionsForField: 'Suggestions for field:',
34
- fileUpload: 'Upload File'
34
+ fileUpload: 'Upload File',
35
+ approveProceed: 'Approve & proceed',
36
+ approveProceedPlan: 'Approve & proceed with plan'
35
37
  }
36
38
  }