@sybilion/uilib 1.2.20 → 1.2.22
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/esm/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.js +120 -9
- package/dist/esm/components/ui/Chat/ChatMessage/ChatMessage.js +2 -1
- package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +12 -18
- package/dist/esm/components/ui/InteractiveContent/InteractiveContent.styl.js +1 -1
- package/dist/esm/contexts/chat-context.js +56 -7
- package/dist/esm/index.js +1 -1
- package/dist/esm/types/src/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.d.ts +7 -1
- package/dist/esm/types/src/contexts/chat-context.d.ts +12 -2
- package/package.json +1 -1
- package/src/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.tsx +152 -8
- package/src/components/ui/Chat/ChatMessage/ChatMessage.tsx +5 -1
- package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +15 -15
- package/src/components/ui/InteractiveContent/InteractiveContent.styl +17 -0
- package/src/contexts/chat-context.tsx +91 -11
|
@@ -56,6 +56,20 @@ const injectBold = (content) => {
|
|
|
56
56
|
length: matches[0].length,
|
|
57
57
|
};
|
|
58
58
|
};
|
|
59
|
+
/** `_italic_` (underscore) — avoids clashing with `* ` bullet markers. */
|
|
60
|
+
const injectItalic = (content) => {
|
|
61
|
+
const matches = content.match(/(?<![A-Za-z0-9])_([^_\n]+?)_(?![A-Za-z0-9])/);
|
|
62
|
+
if (!matches || matches.index === undefined)
|
|
63
|
+
return null;
|
|
64
|
+
if (isInsideHtmlListOrTable(content, matches.index)) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
elem: jsx("em", { children: matches[1] }),
|
|
69
|
+
index: matches.index,
|
|
70
|
+
length: matches[0].length,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
59
73
|
const injectBullet = (content) => {
|
|
60
74
|
// Match bullet points: * or - followed by space
|
|
61
75
|
// Match at start of string/line or after whitespace/colon (for nested bullets)
|
|
@@ -229,6 +243,33 @@ const isAllowedHref = (href) => {
|
|
|
229
243
|
return true;
|
|
230
244
|
return false;
|
|
231
245
|
};
|
|
246
|
+
const normalizeWwwToHttps = (raw) => {
|
|
247
|
+
const t = raw.trim();
|
|
248
|
+
if (/^www\./i.test(t))
|
|
249
|
+
return `https://${t}`;
|
|
250
|
+
return t;
|
|
251
|
+
};
|
|
252
|
+
/** Strip ASCII closing punctuation often pasted after URLs. */
|
|
253
|
+
const stripTrailingUrlPunctuation = (s) => {
|
|
254
|
+
let u = s;
|
|
255
|
+
while (u.length > 0 && /[.,;:!?)}\]]$/u.test(u)) {
|
|
256
|
+
u = u.slice(0, -1);
|
|
257
|
+
}
|
|
258
|
+
return u;
|
|
259
|
+
};
|
|
260
|
+
function linkTargetRelForHref(href, explicitTarget) {
|
|
261
|
+
if (explicitTarget) {
|
|
262
|
+
const t = explicitTarget.trim();
|
|
263
|
+
if (t.toLowerCase() === '_blank') {
|
|
264
|
+
return { target: '_blank', rel: 'noopener noreferrer' };
|
|
265
|
+
}
|
|
266
|
+
return { target: t || undefined, rel: undefined };
|
|
267
|
+
}
|
|
268
|
+
if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href)) {
|
|
269
|
+
return { target: '_blank', rel: 'noopener noreferrer' };
|
|
270
|
+
}
|
|
271
|
+
return { target: undefined, rel: undefined };
|
|
272
|
+
}
|
|
232
273
|
const runFormattingPipeline = (text, injectors) => {
|
|
233
274
|
let result = [text];
|
|
234
275
|
try {
|
|
@@ -260,6 +301,76 @@ const runFormattingPipeline = (text, injectors) => {
|
|
|
260
301
|
}
|
|
261
302
|
return result;
|
|
262
303
|
};
|
|
304
|
+
const linkLabelInjectors = [
|
|
305
|
+
injectHTMLTags,
|
|
306
|
+
injectBold,
|
|
307
|
+
injectItalic,
|
|
308
|
+
injectNewlines,
|
|
309
|
+
];
|
|
310
|
+
const injectMarkdownLink = (content) => {
|
|
311
|
+
const matches = content.match(/\[([^\]]+)\]\(([^)]+)\)/);
|
|
312
|
+
if (!matches || matches.index === undefined)
|
|
313
|
+
return null;
|
|
314
|
+
if (isInsideHtmlListOrTable(content, matches.index)) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
const href = normalizeWwwToHttps(matches[2].trim());
|
|
318
|
+
if (!isAllowedHref(href))
|
|
319
|
+
return null;
|
|
320
|
+
const { target, rel } = linkTargetRelForHref(href, undefined);
|
|
321
|
+
const label = matches[1];
|
|
322
|
+
return {
|
|
323
|
+
elem: (jsx("a", { href: href, target: target, rel: rel, children: runFormattingPipeline(label, linkLabelInjectors) })),
|
|
324
|
+
index: matches.index,
|
|
325
|
+
length: matches[0].length,
|
|
326
|
+
};
|
|
327
|
+
};
|
|
328
|
+
const injectAutolinkUrl = (content) => {
|
|
329
|
+
const candidates = [];
|
|
330
|
+
const reHttp = /https?:\/\/[^\s<>"']+/gi;
|
|
331
|
+
let hm;
|
|
332
|
+
while ((hm = reHttp.exec(content)) !== null) {
|
|
333
|
+
const href = stripTrailingUrlPunctuation(hm[0]);
|
|
334
|
+
if (href.length >= (href.startsWith('https://') ? 8 : 7) &&
|
|
335
|
+
isAllowedHref(href) &&
|
|
336
|
+
/^https?:\/\//i.test(href)) {
|
|
337
|
+
candidates.push({
|
|
338
|
+
index: hm.index,
|
|
339
|
+
length: href.length,
|
|
340
|
+
href,
|
|
341
|
+
display: href,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const reWww = /(^|[^A-Za-z0-9/])(www\.[^\s<>"']+)/gi;
|
|
346
|
+
while ((hm = reWww.exec(content)) !== null) {
|
|
347
|
+
const prefix = hm[1] ?? '';
|
|
348
|
+
const rawWww = hm[2];
|
|
349
|
+
const body = stripTrailingUrlPunctuation(rawWww);
|
|
350
|
+
const href = `https://${body}`;
|
|
351
|
+
if (body.length > 4 && isAllowedHref(href)) {
|
|
352
|
+
candidates.push({
|
|
353
|
+
index: hm.index + prefix.length,
|
|
354
|
+
length: body.length,
|
|
355
|
+
href,
|
|
356
|
+
display: body,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (candidates.length === 0)
|
|
361
|
+
return null;
|
|
362
|
+
candidates.sort((a, b) => a.index - b.index);
|
|
363
|
+
const c = candidates[0];
|
|
364
|
+
if (isInsideHtmlListOrTable(content, c.index)) {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
const { target, rel } = linkTargetRelForHref(c.href, undefined);
|
|
368
|
+
return {
|
|
369
|
+
elem: (jsx("a", { href: c.href, target: target, rel: rel, children: c.display })),
|
|
370
|
+
index: c.index,
|
|
371
|
+
length: c.length,
|
|
372
|
+
};
|
|
373
|
+
};
|
|
263
374
|
const injectAnchor = (content) => {
|
|
264
375
|
const regex = /<a\s+([^>]+)>([\s\S]*?)<\/a\s*>/i;
|
|
265
376
|
const matches = content.match(regex);
|
|
@@ -281,15 +392,9 @@ const injectAnchor = (content) => {
|
|
|
281
392
|
if (!href || !isAllowedHref(href)) {
|
|
282
393
|
return null;
|
|
283
394
|
}
|
|
284
|
-
const
|
|
285
|
-
const target = opensNewTab ? '_blank' : targetRaw || undefined;
|
|
286
|
-
const rel = target === '_blank' ? 'noopener noreferrer' : undefined;
|
|
395
|
+
const { target, rel } = linkTargetRelForHref(href, targetRaw || undefined);
|
|
287
396
|
return {
|
|
288
|
-
elem: (jsx("a", { href: href, target: target, rel: rel, children: runFormattingPipeline(inner,
|
|
289
|
-
injectHTMLTags,
|
|
290
|
-
injectBold,
|
|
291
|
-
injectNewlines,
|
|
292
|
-
]) })),
|
|
397
|
+
elem: (jsx("a", { href: href, target: target, rel: rel, children: runFormattingPipeline(inner, linkLabelInjectors) })),
|
|
293
398
|
index: matches.index,
|
|
294
399
|
length: matches[0].length,
|
|
295
400
|
};
|
|
@@ -297,16 +402,22 @@ const injectAnchor = (content) => {
|
|
|
297
402
|
const applyFormatting = (text) => runFormattingPipeline(text, [
|
|
298
403
|
injectHeaders,
|
|
299
404
|
injectAnchor,
|
|
405
|
+
injectMarkdownLink,
|
|
300
406
|
injectHTMLTags,
|
|
301
407
|
injectBullet,
|
|
302
408
|
injectBold,
|
|
409
|
+
injectItalic,
|
|
410
|
+
injectAutolinkUrl,
|
|
303
411
|
injectNewlines,
|
|
304
412
|
]);
|
|
305
413
|
const applyFormattingInline = (text) => runFormattingPipeline(text, [
|
|
306
414
|
injectAnchor,
|
|
415
|
+
injectMarkdownLink,
|
|
307
416
|
injectHTMLTags,
|
|
308
417
|
injectBold,
|
|
418
|
+
injectItalic,
|
|
419
|
+
injectAutolinkUrl,
|
|
309
420
|
injectNewlines,
|
|
310
421
|
]);
|
|
311
422
|
|
|
312
|
-
export { applyFormatting, applyFormattingInline, convertMarkdownTableToHTML, injectAnchor, injectBold, injectBullet, injectHeaders, injectNewlines };
|
|
423
|
+
export { applyFormatting, applyFormattingInline, convertMarkdownTableToHTML, injectAnchor, injectBold, injectBullet, injectHeaders, injectItalic, injectNewlines };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
2
2
|
import cn from 'classnames';
|
|
3
|
+
import { InteractiveContent } from '../../InteractiveContent/InteractiveContent.js';
|
|
3
4
|
import { TextShimmer } from '../../TextShimmer/TextShimmer.js';
|
|
4
5
|
import { MessageRole, GENERATING_DASHBOARD_SYSTEM_TEXT } from '../Chat.types.js';
|
|
5
6
|
import { AgentMessageContent } from './AgentMessageContent.js';
|
|
@@ -9,7 +10,7 @@ import { UserCsvAttachmentBubble } from './UserCsvAttachmentBubble.js';
|
|
|
9
10
|
function ChatMessage({ role, text, userCsvAttachment, onQuickReply, suppressedQuickReplyKeys, quickReplyDisabled, isLastMessage = true, scriptContinue, onScriptContinue, renderMessageChart, }) {
|
|
10
11
|
const isAssistant = role === MessageRole.ASSISTANT;
|
|
11
12
|
const isSystem = role === MessageRole.SYSTEM;
|
|
12
|
-
return (jsx("div", { className: cn(S.root, S[`role-${role}`]), children: isSystem ? (jsx("div", { className: S.text, children: text === GENERATING_DASHBOARD_SYSTEM_TEXT ? (jsx(TextShimmer, { as: "span", children: text })) : (text) })) : isAssistant ? (jsx(AgentMessageContent, { text: text, onQuickReply: onQuickReply, suppressedQuickReplyKeys: suppressedQuickReplyKeys, quickReplyDisabled: quickReplyDisabled, isLastMessage: isLastMessage, scriptContinue: scriptContinue, onScriptContinue: onScriptContinue, renderMessageChart: renderMessageChart })) : (jsxs("div", { className: S.userColumn, children: [jsx("div", { className: S.text, children: text }), userCsvAttachment ? (jsx(UserCsvAttachmentBubble, { attachment: userCsvAttachment })) : null] })) }));
|
|
13
|
+
return (jsx("div", { className: cn(S.root, S[`role-${role}`]), children: isSystem ? (jsx("div", { className: S.text, children: text === GENERATING_DASHBOARD_SYSTEM_TEXT ? (jsx(TextShimmer, { as: "span", children: text })) : (text) })) : isAssistant ? (jsx(AgentMessageContent, { text: text, onQuickReply: onQuickReply, suppressedQuickReplyKeys: suppressedQuickReplyKeys, quickReplyDisabled: quickReplyDisabled, isLastMessage: isLastMessage, scriptContinue: scriptContinue, onScriptContinue: onScriptContinue, renderMessageChart: renderMessageChart })) : (jsxs("div", { className: S.userColumn, children: [jsx("div", { className: S.text, children: jsx(InteractiveContent, { text: text }) }), userCsvAttachment ? (jsx(UserCsvAttachmentBubble, { attachment: userCsvAttachment })) : null] })) }));
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export { ChatMessage };
|
|
@@ -3,7 +3,7 @@ import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
|
|
3
3
|
import { MessageRole, GENERATING_DASHBOARD_SYSTEM_TEXT } from '../Chat.types.js';
|
|
4
4
|
import { isGraphIntakeAssistantStepComplete, matchUserTextToQuickReply, parseScriptLine, textHasQuickReplyMarkers, branchKeysUsedFromChatHistory, branchKeysUsedByUserMessages, extractQuickReplyLabelKeyPairsFromText, entryBranchKeyBeforeLastAssistant, isPresetScriptGraph, branchesFromPresetScriptGraph } from '../ChatMessage/presetScript.js';
|
|
5
5
|
import { usedPresetIdsFromMessages, formatChatTranscript } from '../chat-preset-utils.js';
|
|
6
|
-
import { useChatsForScopeId, useChat, isChatEmpty } from '../../../../contexts/chat-context.js';
|
|
6
|
+
import { useChatsForScopeId, useChat, useChatOutboundPending, isChatEmpty } from '../../../../contexts/chat-context.js';
|
|
7
7
|
import useEvent from '../../../../hooks/useEvent.js';
|
|
8
8
|
import { useIsMobile } from '../../../../hooks/useIsMobile.js';
|
|
9
9
|
import { useQueryParams } from '../../../../hooks/useQueryParams.js';
|
|
@@ -25,11 +25,13 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
25
25
|
const effectiveScopeId = scopeId ?? NO_SCOPE_FALLBACK;
|
|
26
26
|
const isMobile = useIsMobile();
|
|
27
27
|
const { chatPanelContainer, isOpen: sidebarNavOpen, setOpen: setSidebarNavOpen, chatWidthPx, setChatWidthPx, getShellWidth, setChatPanelOpen, } = useSidebar();
|
|
28
|
+
const [localUiBusy, setLocalUiBusy] = useState(false);
|
|
28
29
|
const { chats, currentChatId, setCurrentChatId, newChat, sendMessage, addMessage, removeMessageById, } = useChatsForScopeId(effectiveScopeId);
|
|
29
30
|
const chat = useChat(effectiveScopeId, currentChatId);
|
|
31
|
+
const isOutboundPending = useChatOutboundPending(effectiveScopeId, currentChatId);
|
|
32
|
+
const isLoading = isOutboundPending || localUiBusy;
|
|
30
33
|
const { searchParams, addSearchParams, removeSearchParams, mutateSearchParams, } = useQueryParams();
|
|
31
34
|
const chatOpen = searchParams.has(CHAT_QUERY_PARAM);
|
|
32
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
33
35
|
const [isOpen, setIsOpen] = useState(false);
|
|
34
36
|
/** `?prompt=` deep link text for one-shot composer pre-fill (see ChatPrompt). */
|
|
35
37
|
const [promptLinkPrefill, setPromptLinkPrefill] = useState(null);
|
|
@@ -215,7 +217,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
215
217
|
if (quickReplyLockRef.current)
|
|
216
218
|
return;
|
|
217
219
|
quickReplyLockRef.current = true;
|
|
218
|
-
|
|
220
|
+
setLocalUiBusy(true);
|
|
219
221
|
setUsedScriptBranchKeysByChat(prev => ({
|
|
220
222
|
...prev,
|
|
221
223
|
[chatId]: [...new Set([...(prev[chatId] || []), branchKey])],
|
|
@@ -266,7 +268,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
266
268
|
logger.error('Error resolving preset quick reply:', error);
|
|
267
269
|
}
|
|
268
270
|
finally {
|
|
269
|
-
|
|
271
|
+
setLocalUiBusy(false);
|
|
270
272
|
quickReplyLockRef.current = false;
|
|
271
273
|
}
|
|
272
274
|
})();
|
|
@@ -274,7 +276,6 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
274
276
|
}
|
|
275
277
|
endLocalDemoFlow(chatId);
|
|
276
278
|
void (async () => {
|
|
277
|
-
setIsLoading(true);
|
|
278
279
|
try {
|
|
279
280
|
await sendMessage(displayLabel);
|
|
280
281
|
onMessage?.(displayLabel);
|
|
@@ -282,9 +283,6 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
282
283
|
catch (error) {
|
|
283
284
|
logger.error('Error sending chat message:', error);
|
|
284
285
|
}
|
|
285
|
-
finally {
|
|
286
|
-
setIsLoading(false);
|
|
287
|
-
}
|
|
288
286
|
})();
|
|
289
287
|
}, [
|
|
290
288
|
currentChatId,
|
|
@@ -337,7 +335,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
337
335
|
if (quickReplyLockRef.current)
|
|
338
336
|
return;
|
|
339
337
|
quickReplyLockRef.current = true;
|
|
340
|
-
|
|
338
|
+
setLocalUiBusy(true);
|
|
341
339
|
const newAnswers = {
|
|
342
340
|
...intake.answers,
|
|
343
341
|
[intake.scriptStepId]: message,
|
|
@@ -381,7 +379,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
381
379
|
logger.error('Error advancing freeform preset script:', e);
|
|
382
380
|
}
|
|
383
381
|
finally {
|
|
384
|
-
|
|
382
|
+
setLocalUiBusy(false);
|
|
385
383
|
quickReplyLockRef.current = false;
|
|
386
384
|
}
|
|
387
385
|
})();
|
|
@@ -401,7 +399,6 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
401
399
|
}
|
|
402
400
|
}
|
|
403
401
|
}
|
|
404
|
-
setIsLoading(true);
|
|
405
402
|
try {
|
|
406
403
|
if (chatId)
|
|
407
404
|
endLocalDemoFlow(chatId);
|
|
@@ -411,9 +408,6 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
411
408
|
catch (error) {
|
|
412
409
|
logger.error('Error sending chat message:', error);
|
|
413
410
|
}
|
|
414
|
-
finally {
|
|
415
|
-
setIsLoading(false);
|
|
416
|
-
}
|
|
417
411
|
}, [
|
|
418
412
|
currentChatId,
|
|
419
413
|
chat?.messages,
|
|
@@ -443,7 +437,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
443
437
|
await handlePromptSubmit(preset.text);
|
|
444
438
|
return;
|
|
445
439
|
}
|
|
446
|
-
|
|
440
|
+
setLocalUiBusy(true);
|
|
447
441
|
try {
|
|
448
442
|
if (!currentChatId)
|
|
449
443
|
return;
|
|
@@ -518,7 +512,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
518
512
|
logger.error('Error sending chat message:', error);
|
|
519
513
|
}
|
|
520
514
|
finally {
|
|
521
|
-
|
|
515
|
+
setLocalUiBusy(false);
|
|
522
516
|
}
|
|
523
517
|
};
|
|
524
518
|
const activeScript = currentChatId
|
|
@@ -550,7 +544,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
550
544
|
return;
|
|
551
545
|
const chatId = currentChatId;
|
|
552
546
|
scriptAdvanceLockRef.current = true;
|
|
553
|
-
|
|
547
|
+
setLocalUiBusy(true);
|
|
554
548
|
addMessage(chatId, MessageRole.USER, step.buttonLabel);
|
|
555
549
|
void (async () => {
|
|
556
550
|
try {
|
|
@@ -593,7 +587,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
593
587
|
logger.error('Error advancing preset script:', error);
|
|
594
588
|
}
|
|
595
589
|
finally {
|
|
596
|
-
|
|
590
|
+
setLocalUiBusy(false);
|
|
597
591
|
scriptAdvanceLockRef.current = false;
|
|
598
592
|
}
|
|
599
593
|
})();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import styleInject from 'style-inject';
|
|
2
2
|
|
|
3
|
-
var css_248z = ".InteractiveContent_root__FHnlY strong{font-weight:600}";
|
|
3
|
+
var css_248z = ".InteractiveContent_root__FHnlY strong{font-weight:600}.InteractiveContent_root__FHnlY em{font-style:italic}.InteractiveContent_root__FHnlY a{color:var(--sb-green-600);text-decoration:underline;text-underline-offset:2px}.InteractiveContent_root__FHnlY a:hover{color:var(--sb-green-700)}.dark .InteractiveContent_root__FHnlY a{color:var(--sb-green-400)}.dark .InteractiveContent_root__FHnlY a:hover{color:var(--sb-green-300)}";
|
|
4
4
|
var S = {"root":"InteractiveContent_root__FHnlY"};
|
|
5
5
|
styleInject(css_248z);
|
|
6
6
|
|
|
@@ -7,6 +7,10 @@ import { LS } from '@homecode/ui';
|
|
|
7
7
|
const CHATS_PREFIX = 'chats-';
|
|
8
8
|
const CHAT_SCOPE_IDS_REGISTRY_KEY = 'chat-scope-ids';
|
|
9
9
|
const ChatContext = createContext(undefined);
|
|
10
|
+
/** Stable composite key; avoids collisions if `scopeId` contains `:`. */
|
|
11
|
+
function outboundPendingKey(scopeId, chatSessionId) {
|
|
12
|
+
return `${scopeId}\0${chatSessionId}`;
|
|
13
|
+
}
|
|
10
14
|
function getCurrentChatIdKey(scopeId) {
|
|
11
15
|
return `chat-current-id-${scopeId}`;
|
|
12
16
|
}
|
|
@@ -86,6 +90,26 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
|
|
|
86
90
|
return {};
|
|
87
91
|
return loadChatsFromLS(userSwitchKey).currentChatId;
|
|
88
92
|
});
|
|
93
|
+
const [outboundPendingByKey, setOutboundPendingByKey] = useState({});
|
|
94
|
+
const beginOutboundPending = useCallback((scopeId, chatSessionId) => {
|
|
95
|
+
const key = outboundPendingKey(scopeId, chatSessionId);
|
|
96
|
+
setOutboundPendingByKey(prev => ({
|
|
97
|
+
...prev,
|
|
98
|
+
[key]: (prev[key] ?? 0) + 1,
|
|
99
|
+
}));
|
|
100
|
+
}, []);
|
|
101
|
+
const endOutboundPending = useCallback((scopeId, chatSessionId) => {
|
|
102
|
+
const key = outboundPendingKey(scopeId, chatSessionId);
|
|
103
|
+
setOutboundPendingByKey(prev => {
|
|
104
|
+
const next = { ...prev };
|
|
105
|
+
const n = (next[key] ?? 0) - 1;
|
|
106
|
+
if (n <= 0)
|
|
107
|
+
delete next[key];
|
|
108
|
+
else
|
|
109
|
+
next[key] = n;
|
|
110
|
+
return next;
|
|
111
|
+
});
|
|
112
|
+
}, []);
|
|
89
113
|
const getChatsForScopeId = useCallback((scopeId) => chats[scopeId] ?? [], [chats]);
|
|
90
114
|
const getCurrentChatId = useCallback((scopeId) => {
|
|
91
115
|
const v = currentChatId[scopeId];
|
|
@@ -211,12 +235,14 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
|
|
|
211
235
|
userCsvAttachment: message.userCsvAttachment,
|
|
212
236
|
});
|
|
213
237
|
}
|
|
238
|
+
const pendingChatSessionId = targetChatId;
|
|
239
|
+
beginOutboundPending(scopeId, pendingChatSessionId);
|
|
214
240
|
try {
|
|
215
|
-
const data = await sendChatMessageFn(apiPayload,
|
|
216
|
-
if (data.session_id && data.session_id !==
|
|
241
|
+
const data = await sendChatMessageFn(apiPayload, pendingChatSessionId);
|
|
242
|
+
if (data.session_id && data.session_id !== pendingChatSessionId) {
|
|
217
243
|
setChats(prev => {
|
|
218
244
|
const scopeChats = prev[scopeId] ?? [];
|
|
219
|
-
const updatedChats = scopeChats.map(chat => chat.session_id ===
|
|
245
|
+
const updatedChats = scopeChats.map(chat => chat.session_id === pendingChatSessionId
|
|
220
246
|
? { ...chat, session_id: data.session_id }
|
|
221
247
|
: chat);
|
|
222
248
|
const chatsKey = getChatsKey(scopeId);
|
|
@@ -225,21 +251,32 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
|
|
|
225
251
|
});
|
|
226
252
|
setCurrentChatId(scopeId, data.session_id);
|
|
227
253
|
}
|
|
228
|
-
addMessage(scopeId, data.session_id ? data.session_id :
|
|
254
|
+
addMessage(scopeId, data.session_id ? data.session_id : pendingChatSessionId, MessageRole.ASSISTANT, data.response);
|
|
229
255
|
return data.response;
|
|
230
256
|
}
|
|
231
257
|
catch (error) {
|
|
232
258
|
const errorMessage = error instanceof Error
|
|
233
259
|
? error.message
|
|
234
260
|
: 'Sorry, I encountered an error processing your message. Please try again.';
|
|
235
|
-
addMessage(scopeId,
|
|
261
|
+
addMessage(scopeId, pendingChatSessionId, MessageRole.ASSISTANT, errorMessage);
|
|
236
262
|
throw error;
|
|
237
263
|
}
|
|
238
|
-
|
|
264
|
+
finally {
|
|
265
|
+
endOutboundPending(scopeId, pendingChatSessionId);
|
|
266
|
+
}
|
|
267
|
+
}, [
|
|
268
|
+
addMessage,
|
|
269
|
+
beginOutboundPending,
|
|
270
|
+
endOutboundPending,
|
|
271
|
+
getCurrentChatId,
|
|
272
|
+
sendChatMessageFn,
|
|
273
|
+
setCurrentChatId,
|
|
274
|
+
]);
|
|
239
275
|
useEffect(() => {
|
|
240
276
|
if (userSwitchKey === null) {
|
|
241
277
|
setChats({});
|
|
242
278
|
setCurrentChatIdState({});
|
|
279
|
+
setOutboundPendingByKey({});
|
|
243
280
|
return;
|
|
244
281
|
}
|
|
245
282
|
const loaded = loadChatsFromLS(userSwitchKey);
|
|
@@ -263,6 +300,7 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
|
|
|
263
300
|
getChatsForScopeId,
|
|
264
301
|
getCurrentChatId,
|
|
265
302
|
deleteChat,
|
|
303
|
+
outboundPendingByKey,
|
|
266
304
|
}, children: children }));
|
|
267
305
|
}
|
|
268
306
|
const isChatEmpty = (chat) => chat?.messages.length === 0;
|
|
@@ -282,15 +320,26 @@ function useChat(scopeId, chatId) {
|
|
|
282
320
|
null);
|
|
283
321
|
}, [scopeId, chatId, getChatsForScopeId]);
|
|
284
322
|
}
|
|
323
|
+
function useChatOutboundPending(scopeId, chatSessionId) {
|
|
324
|
+
const { outboundPendingByKey } = useChats();
|
|
325
|
+
return useMemo(() => {
|
|
326
|
+
if (!scopeId || !chatSessionId)
|
|
327
|
+
return false;
|
|
328
|
+
const key = outboundPendingKey(scopeId, chatSessionId);
|
|
329
|
+
return (outboundPendingByKey[key] ?? 0) > 0;
|
|
330
|
+
}, [scopeId, chatSessionId, outboundPendingByKey]);
|
|
331
|
+
}
|
|
285
332
|
function useChatsForScopeId(scopeId) {
|
|
286
333
|
const { getChatsForScopeId, getCurrentChatId, setCurrentChatId, newChat, addMessage, removeMessageById, sendMessage, deleteChat, } = useChats();
|
|
287
334
|
const chats = getChatsForScopeId(scopeId);
|
|
288
335
|
const currentChatId = getCurrentChatId(scopeId);
|
|
289
336
|
const currentChat = useChat(scopeId, currentChatId ?? undefined);
|
|
337
|
+
const isOutboundPending = useChatOutboundPending(scopeId, currentChatId);
|
|
290
338
|
return {
|
|
291
339
|
chats,
|
|
292
340
|
currentChat,
|
|
293
341
|
currentChatId,
|
|
342
|
+
isOutboundPending,
|
|
294
343
|
setCurrentChatId: (targetId) => setCurrentChatId(scopeId, targetId),
|
|
295
344
|
newChat: () => newChat(scopeId),
|
|
296
345
|
addMessage: (chatId, role, text, options) => addMessage(scopeId, chatId, role, text, options),
|
|
@@ -309,4 +358,4 @@ function useCurrentChat(scopeId) {
|
|
|
309
358
|
return useChat(scopeId, chatId ?? undefined);
|
|
310
359
|
}
|
|
311
360
|
|
|
312
|
-
export { ChatContext, ChatProvider, isChatEmpty, useChat, useChats, useChatsForDataset, useChatsForScopeId, useCurrentChat };
|
|
361
|
+
export { ChatContext, ChatProvider, isChatEmpty, outboundPendingKey, useChat, useChatOutboundPending, useChats, useChatsForDataset, useChatsForScopeId, useCurrentChat };
|
package/dist/esm/index.js
CHANGED
|
@@ -4,7 +4,7 @@ export { DEFAULT_THEME_ACTIVE_COLOR } from './docs/lib/theme.js';
|
|
|
4
4
|
export { SybilionAuthProvider, createSybilionApiFetch, getSybilionApiOriginFromSdk, sybilionApiFetch, useSybilionApiFetch, useSybilionAuth } from './sybilion-auth/SybilionAuthProvider.js';
|
|
5
5
|
export { SYBILION_AUTH_LOGIN_PATH, normalizeApiBaseUrl } from './sybilion-auth/authPaths.js';
|
|
6
6
|
export { exchangeAuth0AccessTokenForSybilionJwt } from './sybilion-auth/exchangeSybilionToken.js';
|
|
7
|
-
export { ChatContext, ChatProvider, isChatEmpty, useChat, useChats, useChatsForDataset, useChatsForScopeId, useCurrentChat } from './contexts/chat-context.js';
|
|
7
|
+
export { ChatContext, ChatProvider, isChatEmpty, outboundPendingKey, useChat, useChatOutboundPending, useChats, useChatsForDataset, useChatsForScopeId, useCurrentChat } from './contexts/chat-context.js';
|
|
8
8
|
export { AnalysesSelector } from './components/ui/AnalysesSelector/AnalysesSelector.js';
|
|
9
9
|
export { AnalysisLineIcon } from './components/ui/AnalysisLineIcon/AnalysisLineIcon.js';
|
|
10
10
|
export { AppHeaderHost, AppHeaderPortal } from './components/ui/AppHeader/AppHeader.js';
|
|
@@ -9,6 +9,12 @@ declare const injectBold: (content: string) => {
|
|
|
9
9
|
index: number;
|
|
10
10
|
length: number;
|
|
11
11
|
};
|
|
12
|
+
/** `_italic_` (underscore) — avoids clashing with `* ` bullet markers. */
|
|
13
|
+
declare const injectItalic: (content: string) => {
|
|
14
|
+
elem: import("react/jsx-runtime").JSX.Element;
|
|
15
|
+
index: number;
|
|
16
|
+
length: number;
|
|
17
|
+
};
|
|
12
18
|
declare const injectBullet: (content: string) => {
|
|
13
19
|
elem: import("react/jsx-runtime").JSX.Element;
|
|
14
20
|
index: number;
|
|
@@ -28,4 +34,4 @@ type Injector = (content: string) => {
|
|
|
28
34
|
declare const injectAnchor: Injector;
|
|
29
35
|
declare const applyFormatting: (text: string) => React.ReactNode[];
|
|
30
36
|
declare const applyFormattingInline: (text: string) => React.ReactNode[];
|
|
31
|
-
export { injectHeaders, injectAnchor, injectBold, injectBullet, injectNewlines, convertMarkdownTableToHTML, applyFormatting, applyFormattingInline, };
|
|
37
|
+
export { injectHeaders, injectAnchor, injectBold, injectItalic, injectBullet, injectNewlines, convertMarkdownTableToHTML, applyFormatting, applyFormattingInline, };
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ReactNode } from 'react';
|
|
2
|
-
import { type Chat, type ChatSendMessagePayload, type UserCsvAttachment
|
|
2
|
+
import { type Chat, type ChatSendMessagePayload, MessageRole, type UserCsvAttachment } from '#uilib/components/ui/Chat/Chat.types';
|
|
3
3
|
import type { ChatResponse } from '#uilib/types/chat-api.types';
|
|
4
4
|
export type SendChatMessageFn = (message: string, targetChatId: string) => Promise<ChatResponse>;
|
|
5
|
-
export type { ChatSendMessagePayload, UserCsvAttachment } from '#uilib/components/ui/Chat/Chat.types';
|
|
5
|
+
export type { ChatSendMessagePayload, UserCsvAttachment, } from '#uilib/components/ui/Chat/Chat.types';
|
|
6
6
|
export type AddChatMessageOptions = {
|
|
7
7
|
userCsvAttachment?: UserCsvAttachment;
|
|
8
8
|
};
|
|
@@ -16,8 +16,15 @@ export interface ChatContextType {
|
|
|
16
16
|
getChatsForScopeId: (scopeId: string) => Chat[];
|
|
17
17
|
getCurrentChatId: (scopeId: string) => string | null;
|
|
18
18
|
deleteChat: (scopeId: string, sessionId: string) => void;
|
|
19
|
+
/**
|
|
20
|
+
* Ref-count of in-flight `sendChatMessage` requests keyed by
|
|
21
|
+
* `outboundPendingKey(scopeId, chatSessionId)` (session id at send start).
|
|
22
|
+
*/
|
|
23
|
+
outboundPendingByKey: Readonly<Record<string, number>>;
|
|
19
24
|
}
|
|
20
25
|
declare const ChatContext: import("react").Context<ChatContextType>;
|
|
26
|
+
/** Stable composite key; avoids collisions if `scopeId` contains `:`. */
|
|
27
|
+
export declare function outboundPendingKey(scopeId: string, chatSessionId: string): string;
|
|
21
28
|
export interface ChatProviderProps {
|
|
22
29
|
children: ReactNode;
|
|
23
30
|
/** When null, chat state is cleared (logged out). When set, only LS rows for scopes starting with `${userId}-` are loaded. */
|
|
@@ -28,10 +35,12 @@ export declare function ChatProvider({ children, userSwitchKey, sendChatMessage:
|
|
|
28
35
|
export declare const isChatEmpty: (chat: Chat | null) => boolean;
|
|
29
36
|
export declare function useChats(): ChatContextType;
|
|
30
37
|
export declare function useChat(scopeId: string | undefined, chatId: string | undefined): Chat | null;
|
|
38
|
+
export declare function useChatOutboundPending(scopeId: string | undefined | null, chatSessionId: string | null | undefined): boolean;
|
|
31
39
|
export declare function useChatsForScopeId(scopeId: string): {
|
|
32
40
|
chats: Chat[];
|
|
33
41
|
currentChat: Chat;
|
|
34
42
|
currentChatId: string;
|
|
43
|
+
isOutboundPending: boolean;
|
|
35
44
|
setCurrentChatId: (targetId: string) => void;
|
|
36
45
|
newChat: () => string;
|
|
37
46
|
addMessage: (chatId: string, role: MessageRole, text: string, options?: AddChatMessageOptions) => string;
|
|
@@ -44,6 +53,7 @@ export declare function useChatsForDataset(scopeId: string): {
|
|
|
44
53
|
chats: Chat[];
|
|
45
54
|
currentChat: Chat;
|
|
46
55
|
currentChatId: string;
|
|
56
|
+
isOutboundPending: boolean;
|
|
47
57
|
setCurrentChatId: (targetId: string) => void;
|
|
48
58
|
newChat: () => string;
|
|
49
59
|
addMessage: (chatId: string, role: MessageRole, text: string, options?: AddChatMessageOptions) => string;
|
package/package.json
CHANGED
|
@@ -77,6 +77,23 @@ const injectBold = (content: string) => {
|
|
|
77
77
|
};
|
|
78
78
|
};
|
|
79
79
|
|
|
80
|
+
/** `_italic_` (underscore) — avoids clashing with `* ` bullet markers. */
|
|
81
|
+
const injectItalic = (content: string) => {
|
|
82
|
+
const matches = content.match(/(?<![A-Za-z0-9])_([^_\n]+?)_(?![A-Za-z0-9])/);
|
|
83
|
+
|
|
84
|
+
if (!matches || matches.index === undefined) return null;
|
|
85
|
+
|
|
86
|
+
if (isInsideHtmlListOrTable(content, matches.index)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
elem: <em>{matches[1]}</em>,
|
|
92
|
+
index: matches.index,
|
|
93
|
+
length: matches[0].length,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
80
97
|
const injectBullet = (content: string) => {
|
|
81
98
|
// Match bullet points: * or - followed by space
|
|
82
99
|
// Match at start of string/line or after whitespace/colon (for nested bullets)
|
|
@@ -281,6 +298,38 @@ const isAllowedHref = (href: string): boolean => {
|
|
|
281
298
|
return false;
|
|
282
299
|
};
|
|
283
300
|
|
|
301
|
+
const normalizeWwwToHttps = (raw: string): string => {
|
|
302
|
+
const t = raw.trim();
|
|
303
|
+
if (/^www\./i.test(t)) return `https://${t}`;
|
|
304
|
+
return t;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/** Strip ASCII closing punctuation often pasted after URLs. */
|
|
308
|
+
const stripTrailingUrlPunctuation = (s: string): string => {
|
|
309
|
+
let u = s;
|
|
310
|
+
while (u.length > 0 && /[.,;:!?)}\]]$/u.test(u)) {
|
|
311
|
+
u = u.slice(0, -1);
|
|
312
|
+
}
|
|
313
|
+
return u;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
function linkTargetRelForHref(
|
|
317
|
+
href: string,
|
|
318
|
+
explicitTarget?: string,
|
|
319
|
+
): { target?: string; rel?: string } {
|
|
320
|
+
if (explicitTarget) {
|
|
321
|
+
const t = explicitTarget.trim();
|
|
322
|
+
if (t.toLowerCase() === '_blank') {
|
|
323
|
+
return { target: '_blank', rel: 'noopener noreferrer' };
|
|
324
|
+
}
|
|
325
|
+
return { target: t || undefined, rel: undefined };
|
|
326
|
+
}
|
|
327
|
+
if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href)) {
|
|
328
|
+
return { target: '_blank', rel: 'noopener noreferrer' };
|
|
329
|
+
}
|
|
330
|
+
return { target: undefined, rel: undefined };
|
|
331
|
+
}
|
|
332
|
+
|
|
284
333
|
type Injector = (
|
|
285
334
|
content: string,
|
|
286
335
|
) => { elem: React.ReactNode; index: number; length: number } | null;
|
|
@@ -325,6 +374,100 @@ const runFormattingPipeline = (
|
|
|
325
374
|
return result;
|
|
326
375
|
};
|
|
327
376
|
|
|
377
|
+
const linkLabelInjectors: Injector[] = [
|
|
378
|
+
injectHTMLTags,
|
|
379
|
+
injectBold,
|
|
380
|
+
injectItalic,
|
|
381
|
+
injectNewlines,
|
|
382
|
+
];
|
|
383
|
+
|
|
384
|
+
const injectMarkdownLink: Injector = (content: string) => {
|
|
385
|
+
const matches = content.match(/\[([^\]]+)\]\(([^)]+)\)/);
|
|
386
|
+
|
|
387
|
+
if (!matches || matches.index === undefined) return null;
|
|
388
|
+
|
|
389
|
+
if (isInsideHtmlListOrTable(content, matches.index)) {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const href = normalizeWwwToHttps(matches[2].trim());
|
|
394
|
+
if (!isAllowedHref(href)) return null;
|
|
395
|
+
|
|
396
|
+
const { target, rel } = linkTargetRelForHref(href, undefined);
|
|
397
|
+
const label = matches[1];
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
elem: (
|
|
401
|
+
<a href={href} target={target} rel={rel}>
|
|
402
|
+
{runFormattingPipeline(label, linkLabelInjectors)}
|
|
403
|
+
</a>
|
|
404
|
+
),
|
|
405
|
+
index: matches.index,
|
|
406
|
+
length: matches[0].length,
|
|
407
|
+
};
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const injectAutolinkUrl: Injector = (content: string) => {
|
|
411
|
+
type Cand = { index: number; length: number; href: string; display: string };
|
|
412
|
+
|
|
413
|
+
const candidates: Cand[] = [];
|
|
414
|
+
|
|
415
|
+
const reHttp = /https?:\/\/[^\s<>"']+/gi;
|
|
416
|
+
let hm: RegExpExecArray | null;
|
|
417
|
+
while ((hm = reHttp.exec(content)) !== null) {
|
|
418
|
+
const href = stripTrailingUrlPunctuation(hm[0]);
|
|
419
|
+
if (
|
|
420
|
+
href.length >= (href.startsWith('https://') ? 8 : 7) &&
|
|
421
|
+
isAllowedHref(href) &&
|
|
422
|
+
/^https?:\/\//i.test(href)
|
|
423
|
+
) {
|
|
424
|
+
candidates.push({
|
|
425
|
+
index: hm.index,
|
|
426
|
+
length: href.length,
|
|
427
|
+
href,
|
|
428
|
+
display: href,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const reWww = /(^|[^A-Za-z0-9/])(www\.[^\s<>"']+)/gi;
|
|
434
|
+
while ((hm = reWww.exec(content)) !== null) {
|
|
435
|
+
const prefix = hm[1] ?? '';
|
|
436
|
+
const rawWww = hm[2];
|
|
437
|
+
const body = stripTrailingUrlPunctuation(rawWww);
|
|
438
|
+
const href = `https://${body}`;
|
|
439
|
+
if (body.length > 4 && isAllowedHref(href)) {
|
|
440
|
+
candidates.push({
|
|
441
|
+
index: hm.index + prefix.length,
|
|
442
|
+
length: body.length,
|
|
443
|
+
href,
|
|
444
|
+
display: body,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (candidates.length === 0) return null;
|
|
450
|
+
|
|
451
|
+
candidates.sort((a, b) => a.index - b.index);
|
|
452
|
+
const c = candidates[0]!;
|
|
453
|
+
|
|
454
|
+
if (isInsideHtmlListOrTable(content, c.index)) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const { target, rel } = linkTargetRelForHref(c.href, undefined);
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
elem: (
|
|
462
|
+
<a href={c.href} target={target} rel={rel}>
|
|
463
|
+
{c.display}
|
|
464
|
+
</a>
|
|
465
|
+
),
|
|
466
|
+
index: c.index,
|
|
467
|
+
length: c.length,
|
|
468
|
+
};
|
|
469
|
+
};
|
|
470
|
+
|
|
328
471
|
const injectAnchor: Injector = (content: string) => {
|
|
329
472
|
const regex = /<a\s+([^>]+)>([\s\S]*?)<\/a\s*>/i;
|
|
330
473
|
const matches = content.match(regex);
|
|
@@ -356,18 +499,12 @@ const injectAnchor: Injector = (content: string) => {
|
|
|
356
499
|
return null;
|
|
357
500
|
}
|
|
358
501
|
|
|
359
|
-
const
|
|
360
|
-
const target = opensNewTab ? '_blank' : targetRaw || undefined;
|
|
361
|
-
const rel = target === '_blank' ? 'noopener noreferrer' : undefined;
|
|
502
|
+
const { target, rel } = linkTargetRelForHref(href, targetRaw || undefined);
|
|
362
503
|
|
|
363
504
|
return {
|
|
364
505
|
elem: (
|
|
365
506
|
<a href={href} target={target} rel={rel}>
|
|
366
|
-
{runFormattingPipeline(inner,
|
|
367
|
-
injectHTMLTags,
|
|
368
|
-
injectBold,
|
|
369
|
-
injectNewlines,
|
|
370
|
-
])}
|
|
507
|
+
{runFormattingPipeline(inner, linkLabelInjectors)}
|
|
371
508
|
</a>
|
|
372
509
|
),
|
|
373
510
|
index: matches.index!,
|
|
@@ -379,17 +516,23 @@ const applyFormatting = (text: string): React.ReactNode[] =>
|
|
|
379
516
|
runFormattingPipeline(text, [
|
|
380
517
|
injectHeaders,
|
|
381
518
|
injectAnchor,
|
|
519
|
+
injectMarkdownLink,
|
|
382
520
|
injectHTMLTags,
|
|
383
521
|
injectBullet,
|
|
384
522
|
injectBold,
|
|
523
|
+
injectItalic,
|
|
524
|
+
injectAutolinkUrl,
|
|
385
525
|
injectNewlines,
|
|
386
526
|
]);
|
|
387
527
|
|
|
388
528
|
const applyFormattingInline = (text: string): React.ReactNode[] =>
|
|
389
529
|
runFormattingPipeline(text, [
|
|
390
530
|
injectAnchor,
|
|
531
|
+
injectMarkdownLink,
|
|
391
532
|
injectHTMLTags,
|
|
392
533
|
injectBold,
|
|
534
|
+
injectItalic,
|
|
535
|
+
injectAutolinkUrl,
|
|
393
536
|
injectNewlines,
|
|
394
537
|
]);
|
|
395
538
|
|
|
@@ -397,6 +540,7 @@ export {
|
|
|
397
540
|
injectHeaders,
|
|
398
541
|
injectAnchor,
|
|
399
542
|
injectBold,
|
|
543
|
+
injectItalic,
|
|
400
544
|
injectBullet,
|
|
401
545
|
injectNewlines,
|
|
402
546
|
convertMarkdownTableToHTML,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import cn from 'classnames';
|
|
2
2
|
|
|
3
|
+
import { InteractiveContent } from '#uilib/components/ui/InteractiveContent';
|
|
4
|
+
|
|
3
5
|
import { TextShimmer } from '../../TextShimmer';
|
|
4
6
|
import {
|
|
5
7
|
type ChatMessageProps,
|
|
@@ -48,7 +50,9 @@ export function ChatMessage({
|
|
|
48
50
|
/>
|
|
49
51
|
) : (
|
|
50
52
|
<div className={S.userColumn}>
|
|
51
|
-
<div className={S.text}>
|
|
53
|
+
<div className={S.text}>
|
|
54
|
+
<InteractiveContent text={text} />
|
|
55
|
+
</div>
|
|
52
56
|
{userCsvAttachment ? (
|
|
53
57
|
<UserCsvAttachmentBubble attachment={userCsvAttachment} />
|
|
54
58
|
) : null}
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import {
|
|
27
27
|
isChatEmpty,
|
|
28
28
|
useChat,
|
|
29
|
+
useChatOutboundPending,
|
|
29
30
|
useChatsForScopeId,
|
|
30
31
|
} from '#uilib/contexts/chat-context';
|
|
31
32
|
import useEvent from '#uilib/hooks/useEvent';
|
|
@@ -112,6 +113,7 @@ export function useChatPanelChromeModel({
|
|
|
112
113
|
getShellWidth,
|
|
113
114
|
setChatPanelOpen,
|
|
114
115
|
} = useSidebar();
|
|
116
|
+
const [localUiBusy, setLocalUiBusy] = useState(false);
|
|
115
117
|
const {
|
|
116
118
|
chats,
|
|
117
119
|
currentChatId,
|
|
@@ -122,6 +124,11 @@ export function useChatPanelChromeModel({
|
|
|
122
124
|
removeMessageById,
|
|
123
125
|
} = useChatsForScopeId(effectiveScopeId);
|
|
124
126
|
const chat = useChat(effectiveScopeId, currentChatId);
|
|
127
|
+
const isOutboundPending = useChatOutboundPending(
|
|
128
|
+
effectiveScopeId,
|
|
129
|
+
currentChatId,
|
|
130
|
+
);
|
|
131
|
+
const isLoading = isOutboundPending || localUiBusy;
|
|
125
132
|
|
|
126
133
|
const {
|
|
127
134
|
searchParams,
|
|
@@ -130,7 +137,6 @@ export function useChatPanelChromeModel({
|
|
|
130
137
|
mutateSearchParams,
|
|
131
138
|
} = useQueryParams();
|
|
132
139
|
const chatOpen = searchParams.has(CHAT_QUERY_PARAM);
|
|
133
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
134
140
|
const [isOpen, setIsOpen] = useState(false);
|
|
135
141
|
/** `?prompt=` deep link text for one-shot composer pre-fill (see ChatPrompt). */
|
|
136
142
|
const [promptLinkPrefill, setPromptLinkPrefill] = useState<string | null>(
|
|
@@ -355,7 +361,7 @@ export function useChatPanelChromeModel({
|
|
|
355
361
|
) {
|
|
356
362
|
if (quickReplyLockRef.current) return;
|
|
357
363
|
quickReplyLockRef.current = true;
|
|
358
|
-
|
|
364
|
+
setLocalUiBusy(true);
|
|
359
365
|
setUsedScriptBranchKeysByChat(prev => ({
|
|
360
366
|
...prev,
|
|
361
367
|
[chatId]: [...new Set([...(prev[chatId] || []), branchKey])],
|
|
@@ -413,7 +419,7 @@ export function useChatPanelChromeModel({
|
|
|
413
419
|
} catch (error) {
|
|
414
420
|
logger.error('Error resolving preset quick reply:', error);
|
|
415
421
|
} finally {
|
|
416
|
-
|
|
422
|
+
setLocalUiBusy(false);
|
|
417
423
|
quickReplyLockRef.current = false;
|
|
418
424
|
}
|
|
419
425
|
})();
|
|
@@ -422,14 +428,11 @@ export function useChatPanelChromeModel({
|
|
|
422
428
|
|
|
423
429
|
endLocalDemoFlow(chatId);
|
|
424
430
|
void (async () => {
|
|
425
|
-
setIsLoading(true);
|
|
426
431
|
try {
|
|
427
432
|
await sendMessage(displayLabel);
|
|
428
433
|
onMessage?.(displayLabel);
|
|
429
434
|
} catch (error) {
|
|
430
435
|
logger.error('Error sending chat message:', error);
|
|
431
|
-
} finally {
|
|
432
|
-
setIsLoading(false);
|
|
433
436
|
}
|
|
434
437
|
})();
|
|
435
438
|
},
|
|
@@ -499,7 +502,7 @@ export function useChatPanelChromeModel({
|
|
|
499
502
|
) {
|
|
500
503
|
if (quickReplyLockRef.current) return;
|
|
501
504
|
quickReplyLockRef.current = true;
|
|
502
|
-
|
|
505
|
+
setLocalUiBusy(true);
|
|
503
506
|
const newAnswers = {
|
|
504
507
|
...intake.answers,
|
|
505
508
|
[intake.scriptStepId]: message,
|
|
@@ -544,7 +547,7 @@ export function useChatPanelChromeModel({
|
|
|
544
547
|
} catch (e) {
|
|
545
548
|
logger.error('Error advancing freeform preset script:', e);
|
|
546
549
|
} finally {
|
|
547
|
-
|
|
550
|
+
setLocalUiBusy(false);
|
|
548
551
|
quickReplyLockRef.current = false;
|
|
549
552
|
}
|
|
550
553
|
})();
|
|
@@ -569,15 +572,12 @@ export function useChatPanelChromeModel({
|
|
|
569
572
|
}
|
|
570
573
|
}
|
|
571
574
|
|
|
572
|
-
setIsLoading(true);
|
|
573
575
|
try {
|
|
574
576
|
if (chatId) endLocalDemoFlow(chatId);
|
|
575
577
|
await sendMessage(message);
|
|
576
578
|
onMessage?.(message);
|
|
577
579
|
} catch (error) {
|
|
578
580
|
logger.error('Error sending chat message:', error);
|
|
579
|
-
} finally {
|
|
580
|
-
setIsLoading(false);
|
|
581
581
|
}
|
|
582
582
|
},
|
|
583
583
|
[
|
|
@@ -613,7 +613,7 @@ export function useChatPanelChromeModel({
|
|
|
613
613
|
return;
|
|
614
614
|
}
|
|
615
615
|
|
|
616
|
-
|
|
616
|
+
setLocalUiBusy(true);
|
|
617
617
|
try {
|
|
618
618
|
if (!currentChatId) return;
|
|
619
619
|
setScriptCompleteByChatId(prev => {
|
|
@@ -683,7 +683,7 @@ export function useChatPanelChromeModel({
|
|
|
683
683
|
} catch (error) {
|
|
684
684
|
logger.error('Error sending chat message:', error);
|
|
685
685
|
} finally {
|
|
686
|
-
|
|
686
|
+
setLocalUiBusy(false);
|
|
687
687
|
}
|
|
688
688
|
};
|
|
689
689
|
|
|
@@ -717,7 +717,7 @@ export function useChatPanelChromeModel({
|
|
|
717
717
|
|
|
718
718
|
const chatId = currentChatId;
|
|
719
719
|
scriptAdvanceLockRef.current = true;
|
|
720
|
-
|
|
720
|
+
setLocalUiBusy(true);
|
|
721
721
|
addMessage(chatId, MessageRole.USER, step.buttonLabel);
|
|
722
722
|
|
|
723
723
|
void (async () => {
|
|
@@ -761,7 +761,7 @@ export function useChatPanelChromeModel({
|
|
|
761
761
|
} catch (error) {
|
|
762
762
|
logger.error('Error advancing preset script:', error);
|
|
763
763
|
} finally {
|
|
764
|
-
|
|
764
|
+
setLocalUiBusy(false);
|
|
765
765
|
scriptAdvanceLockRef.current = false;
|
|
766
766
|
}
|
|
767
767
|
})();
|
|
@@ -1,3 +1,20 @@
|
|
|
1
1
|
.root
|
|
2
2
|
strong
|
|
3
3
|
font-weight 600
|
|
4
|
+
|
|
5
|
+
em
|
|
6
|
+
font-style italic
|
|
7
|
+
|
|
8
|
+
a
|
|
9
|
+
color var(--sb-green-600)
|
|
10
|
+
text-decoration underline
|
|
11
|
+
text-underline-offset 2px
|
|
12
|
+
|
|
13
|
+
&:hover
|
|
14
|
+
color var(--sb-green-700)
|
|
15
|
+
|
|
16
|
+
:global(.dark) &
|
|
17
|
+
color var(--sb-green-400)
|
|
18
|
+
|
|
19
|
+
&:hover
|
|
20
|
+
color var(--sb-green-300)
|
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
type Chat,
|
|
13
13
|
type ChatSendMessagePayload,
|
|
14
14
|
type Message,
|
|
15
|
-
type UserCsvAttachment,
|
|
16
15
|
MessageRole,
|
|
16
|
+
type UserCsvAttachment,
|
|
17
17
|
} from '#uilib/components/ui/Chat/Chat.types';
|
|
18
18
|
import { stripJsonDashboardFences } from '#uilib/lib/dashboard-spec/stripJsonDashboardFences';
|
|
19
19
|
import type { ChatResponse } from '#uilib/types/chat-api.types';
|
|
@@ -24,7 +24,10 @@ export type SendChatMessageFn = (
|
|
|
24
24
|
targetChatId: string,
|
|
25
25
|
) => Promise<ChatResponse>;
|
|
26
26
|
|
|
27
|
-
export type {
|
|
27
|
+
export type {
|
|
28
|
+
ChatSendMessagePayload,
|
|
29
|
+
UserCsvAttachment,
|
|
30
|
+
} from '#uilib/components/ui/Chat/Chat.types';
|
|
28
31
|
|
|
29
32
|
const CHATS_PREFIX = 'chats-';
|
|
30
33
|
const CHAT_SCOPE_IDS_REGISTRY_KEY = 'chat-scope-ids';
|
|
@@ -57,10 +60,20 @@ export interface ChatContextType {
|
|
|
57
60
|
getChatsForScopeId: (scopeId: string) => Chat[];
|
|
58
61
|
getCurrentChatId: (scopeId: string) => string | null;
|
|
59
62
|
deleteChat: (scopeId: string, sessionId: string) => void;
|
|
63
|
+
/**
|
|
64
|
+
* Ref-count of in-flight `sendChatMessage` requests keyed by
|
|
65
|
+
* `outboundPendingKey(scopeId, chatSessionId)` (session id at send start).
|
|
66
|
+
*/
|
|
67
|
+
outboundPendingByKey: Readonly<Record<string, number>>;
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
const ChatContext = createContext<ChatContextType | undefined>(undefined);
|
|
63
71
|
|
|
72
|
+
/** Stable composite key; avoids collisions if `scopeId` contains `:`. */
|
|
73
|
+
export function outboundPendingKey(scopeId: string, chatSessionId: string) {
|
|
74
|
+
return `${scopeId}\0${chatSessionId}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
64
77
|
function getCurrentChatIdKey(scopeId: string) {
|
|
65
78
|
return `chat-current-id-${scopeId}`;
|
|
66
79
|
}
|
|
@@ -158,6 +171,35 @@ export function ChatProvider({
|
|
|
158
171
|
return loadChatsFromLS(userSwitchKey).currentChatId;
|
|
159
172
|
});
|
|
160
173
|
|
|
174
|
+
const [outboundPendingByKey, setOutboundPendingByKey] = useState<
|
|
175
|
+
Record<string, number>
|
|
176
|
+
>({});
|
|
177
|
+
|
|
178
|
+
const beginOutboundPending = useCallback(
|
|
179
|
+
(scopeId: string, chatSessionId: string) => {
|
|
180
|
+
const key = outboundPendingKey(scopeId, chatSessionId);
|
|
181
|
+
setOutboundPendingByKey(prev => ({
|
|
182
|
+
...prev,
|
|
183
|
+
[key]: (prev[key] ?? 0) + 1,
|
|
184
|
+
}));
|
|
185
|
+
},
|
|
186
|
+
[],
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const endOutboundPending = useCallback(
|
|
190
|
+
(scopeId: string, chatSessionId: string) => {
|
|
191
|
+
const key = outboundPendingKey(scopeId, chatSessionId);
|
|
192
|
+
setOutboundPendingByKey(prev => {
|
|
193
|
+
const next = { ...prev };
|
|
194
|
+
const n = (next[key] ?? 0) - 1;
|
|
195
|
+
if (n <= 0) delete next[key];
|
|
196
|
+
else next[key] = n;
|
|
197
|
+
return next;
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
[],
|
|
201
|
+
);
|
|
202
|
+
|
|
161
203
|
const getChatsForScopeId = useCallback(
|
|
162
204
|
(scopeId: string): Chat[] => chats[scopeId] ?? [],
|
|
163
205
|
[chats],
|
|
@@ -324,19 +366,27 @@ export function ChatProvider({
|
|
|
324
366
|
if (typeof message === 'string') {
|
|
325
367
|
addMessage(scopeId, targetChatId, MessageRole.USER, message);
|
|
326
368
|
} else {
|
|
327
|
-
addMessage(
|
|
328
|
-
|
|
329
|
-
|
|
369
|
+
addMessage(
|
|
370
|
+
scopeId,
|
|
371
|
+
targetChatId,
|
|
372
|
+
MessageRole.USER,
|
|
373
|
+
message.displayText,
|
|
374
|
+
{
|
|
375
|
+
userCsvAttachment: message.userCsvAttachment,
|
|
376
|
+
},
|
|
377
|
+
);
|
|
330
378
|
}
|
|
331
379
|
|
|
380
|
+
const pendingChatSessionId = targetChatId;
|
|
381
|
+
beginOutboundPending(scopeId, pendingChatSessionId);
|
|
332
382
|
try {
|
|
333
|
-
const data = await sendChatMessageFn(apiPayload,
|
|
383
|
+
const data = await sendChatMessageFn(apiPayload, pendingChatSessionId);
|
|
334
384
|
|
|
335
|
-
if (data.session_id && data.session_id !==
|
|
385
|
+
if (data.session_id && data.session_id !== pendingChatSessionId) {
|
|
336
386
|
setChats(prev => {
|
|
337
387
|
const scopeChats = prev[scopeId] ?? [];
|
|
338
388
|
const updatedChats = scopeChats.map(chat =>
|
|
339
|
-
chat.session_id ===
|
|
389
|
+
chat.session_id === pendingChatSessionId
|
|
340
390
|
? { ...chat, session_id: data.session_id! }
|
|
341
391
|
: chat,
|
|
342
392
|
);
|
|
@@ -351,7 +401,7 @@ export function ChatProvider({
|
|
|
351
401
|
|
|
352
402
|
addMessage(
|
|
353
403
|
scopeId,
|
|
354
|
-
data.session_id ? data.session_id :
|
|
404
|
+
data.session_id ? data.session_id : pendingChatSessionId,
|
|
355
405
|
MessageRole.ASSISTANT,
|
|
356
406
|
data.response,
|
|
357
407
|
);
|
|
@@ -363,17 +413,32 @@ export function ChatProvider({
|
|
|
363
413
|
? error.message
|
|
364
414
|
: 'Sorry, I encountered an error processing your message. Please try again.';
|
|
365
415
|
|
|
366
|
-
addMessage(
|
|
416
|
+
addMessage(
|
|
417
|
+
scopeId,
|
|
418
|
+
pendingChatSessionId,
|
|
419
|
+
MessageRole.ASSISTANT,
|
|
420
|
+
errorMessage,
|
|
421
|
+
);
|
|
367
422
|
throw error;
|
|
423
|
+
} finally {
|
|
424
|
+
endOutboundPending(scopeId, pendingChatSessionId);
|
|
368
425
|
}
|
|
369
426
|
},
|
|
370
|
-
[
|
|
427
|
+
[
|
|
428
|
+
addMessage,
|
|
429
|
+
beginOutboundPending,
|
|
430
|
+
endOutboundPending,
|
|
431
|
+
getCurrentChatId,
|
|
432
|
+
sendChatMessageFn,
|
|
433
|
+
setCurrentChatId,
|
|
434
|
+
],
|
|
371
435
|
);
|
|
372
436
|
|
|
373
437
|
useEffect(() => {
|
|
374
438
|
if (userSwitchKey === null) {
|
|
375
439
|
setChats({});
|
|
376
440
|
setCurrentChatIdState({});
|
|
441
|
+
setOutboundPendingByKey({});
|
|
377
442
|
return;
|
|
378
443
|
}
|
|
379
444
|
|
|
@@ -404,6 +469,7 @@ export function ChatProvider({
|
|
|
404
469
|
getChatsForScopeId,
|
|
405
470
|
getCurrentChatId,
|
|
406
471
|
deleteChat,
|
|
472
|
+
outboundPendingByKey,
|
|
407
473
|
}}
|
|
408
474
|
>
|
|
409
475
|
{children}
|
|
@@ -436,6 +502,18 @@ export function useChat(
|
|
|
436
502
|
}, [scopeId, chatId, getChatsForScopeId]);
|
|
437
503
|
}
|
|
438
504
|
|
|
505
|
+
export function useChatOutboundPending(
|
|
506
|
+
scopeId: string | undefined | null,
|
|
507
|
+
chatSessionId: string | null | undefined,
|
|
508
|
+
): boolean {
|
|
509
|
+
const { outboundPendingByKey } = useChats();
|
|
510
|
+
return useMemo(() => {
|
|
511
|
+
if (!scopeId || !chatSessionId) return false;
|
|
512
|
+
const key = outboundPendingKey(scopeId, chatSessionId);
|
|
513
|
+
return (outboundPendingByKey[key] ?? 0) > 0;
|
|
514
|
+
}, [scopeId, chatSessionId, outboundPendingByKey]);
|
|
515
|
+
}
|
|
516
|
+
|
|
439
517
|
export function useChatsForScopeId(scopeId: string) {
|
|
440
518
|
const {
|
|
441
519
|
getChatsForScopeId,
|
|
@@ -450,11 +528,13 @@ export function useChatsForScopeId(scopeId: string) {
|
|
|
450
528
|
const chats = getChatsForScopeId(scopeId);
|
|
451
529
|
const currentChatId = getCurrentChatId(scopeId);
|
|
452
530
|
const currentChat = useChat(scopeId, currentChatId ?? undefined);
|
|
531
|
+
const isOutboundPending = useChatOutboundPending(scopeId, currentChatId);
|
|
453
532
|
|
|
454
533
|
return {
|
|
455
534
|
chats,
|
|
456
535
|
currentChat,
|
|
457
536
|
currentChatId,
|
|
537
|
+
isOutboundPending,
|
|
458
538
|
setCurrentChatId: (targetId: string) => setCurrentChatId(scopeId, targetId),
|
|
459
539
|
newChat: () => newChat(scopeId),
|
|
460
540
|
addMessage: (
|