|
@@ -15,6 +15,19 @@ export interface ChatOptions {
|
|
|
threadId?: string;
|
|
threadId?: string;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+type ChatActionReply = {
|
|
|
|
|
+ type: 'reply';
|
|
|
|
|
+ label: string;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+type ChatActionLink = {
|
|
|
|
|
+ type: 'link';
|
|
|
|
|
+ label: string;
|
|
|
|
|
+ url: string;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+type ChatAction = ChatActionReply | ChatActionLink;
|
|
|
|
|
+
|
|
|
declare const sUrlRoot: string | undefined;
|
|
declare const sUrlRoot: string | undefined;
|
|
|
|
|
|
|
|
const CLASSES = {
|
|
const CLASSES = {
|
|
@@ -29,6 +42,9 @@ const CLASSES = {
|
|
|
bubbleUser: 'bx-ai-chat-message-inner max-w-[85%] px-3 py-2 rounded-2xl rounded-br-md text-sm leading-relaxed break-words whitespace-pre-wrap bg-blue-600 text-white dark:bg-blue-500',
|
|
bubbleUser: 'bx-ai-chat-message-inner max-w-[85%] px-3 py-2 rounded-2xl rounded-br-md text-sm leading-relaxed break-words whitespace-pre-wrap bg-blue-600 text-white dark:bg-blue-500',
|
|
|
bubbleAssistant: 'bx-ai-chat-message-inner max-w-[85%] px-3 py-2 rounded-2xl rounded-bl-md text-sm leading-relaxed break-words whitespace-pre-wrap bg-white text-gray-800 ring-1 ring-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:ring-gray-600',
|
|
bubbleAssistant: 'bx-ai-chat-message-inner max-w-[85%] px-3 py-2 rounded-2xl rounded-bl-md text-sm leading-relaxed break-words whitespace-pre-wrap bg-white text-gray-800 ring-1 ring-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:ring-gray-600',
|
|
|
markdown: 'bx-ai-chat-markdown bx-def-vanilla-html bx-def-vh-sm max-w-none whitespace-normal',
|
|
markdown: 'bx-ai-chat-markdown bx-def-vanilla-html bx-def-vh-sm max-w-none whitespace-normal',
|
|
|
|
|
+ actions: 'bx-ai-chat-actions flex flex-wrap gap-1.5 mt-2',
|
|
|
|
|
+ action: 'bx-ai-chat-action bx-btn bx-btn-small',
|
|
|
|
|
+ actionLink: 'bx-ai-chat-action bx-btn bx-btn-small underline',
|
|
|
error: 'bx-ai-chat-error text-sm text-red-600 dark:text-red-400 px-1',
|
|
error: 'bx-ai-chat-error text-sm text-red-600 dark:text-red-400 px-1',
|
|
|
};
|
|
};
|
|
|
|
|
|
|
@@ -83,6 +99,80 @@ function getMessageText(message: UIMessage): string
|
|
|
.join('');
|
|
.join('');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function isAllowedChatActionUrl(url: string): boolean
|
|
|
|
|
+{
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = new URL(url);
|
|
|
|
|
+ return parsed.protocol === 'https:';
|
|
|
|
|
+ }
|
|
|
|
|
+ catch {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function sanitizeChatActions(raw: unknown): ChatAction[]
|
|
|
|
|
+{
|
|
|
|
|
+ if (!Array.isArray(raw))
|
|
|
|
|
+ return [];
|
|
|
|
|
+
|
|
|
|
|
+ const out: ChatAction[] = [];
|
|
|
|
|
+ for (const item of raw) {
|
|
|
|
|
+ if (!item || typeof item !== 'object')
|
|
|
|
|
+ continue;
|
|
|
|
|
+
|
|
|
|
|
+ const type = String((item as { type?: unknown }).type ?? '').toLowerCase().trim();
|
|
|
|
|
+ const label = String((item as { label?: unknown }).label ?? '').trim().slice(0, 80);
|
|
|
|
|
+ if (!label)
|
|
|
|
|
+ continue;
|
|
|
|
|
+
|
|
|
|
|
+ if (type === 'reply') {
|
|
|
|
|
+ out.push({ type: 'reply', label });
|
|
|
|
|
+ }
|
|
|
|
|
+ else if (type === 'link') {
|
|
|
|
|
+ const url = String((item as { url?: unknown }).url ?? '').trim();
|
|
|
|
|
+ if (!isAllowedChatActionUrl(url))
|
|
|
|
|
+ continue;
|
|
|
|
|
+ out.push({ type: 'link', label, url });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (out.length >= 8)
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return out;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getMessageActions(message: UIMessage, streamed?: ChatAction[]): ChatAction[]
|
|
|
|
|
+{
|
|
|
|
|
+ const extra = message as UIMessage & { actions?: unknown };
|
|
|
|
|
+ const fromMessage = sanitizeChatActions(extra.actions ?? extra.metadata?.actions);
|
|
|
|
|
+ if (fromMessage.length)
|
|
|
|
|
+ return fromMessage;
|
|
|
|
|
+
|
|
|
|
|
+ return streamed && streamed.length ? streamed : [];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function parseChatActionsEvent(data: unknown): { messageId: string; actions: ChatAction[] } | null
|
|
|
|
|
+{
|
|
|
|
|
+ if (Array.isArray(data)) {
|
|
|
|
|
+ const actions = sanitizeChatActions(data);
|
|
|
|
|
+ return actions.length ? { messageId: '', actions } : null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!data || typeof data !== 'object')
|
|
|
|
|
+ return null;
|
|
|
|
|
+
|
|
|
|
|
+ const payload = data as { messageId?: unknown; actions?: unknown };
|
|
|
|
|
+ const actions = sanitizeChatActions(payload.actions ?? data);
|
|
|
|
|
+ if (!actions.length)
|
|
|
|
|
+ return null;
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ messageId: typeof payload.messageId === 'string' ? payload.messageId : '',
|
|
|
|
|
+ actions,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function getChatEndpoint(agentId: number | string, endpoint?: string): string
|
|
function getChatEndpoint(agentId: number | string, endpoint?: string): string
|
|
|
{
|
|
{
|
|
|
if (endpoint)
|
|
if (endpoint)
|
|
@@ -98,6 +188,52 @@ function getChatThreadId(agentId: number | string, threadId?: string): string
|
|
|
return threadId || `agent:${agentId}`;
|
|
return threadId || `agent:${agentId}`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function MessageActions({
|
|
|
|
|
+ actions,
|
|
|
|
|
+ disabled,
|
|
|
|
|
+ onReply,
|
|
|
|
|
+}: {
|
|
|
|
|
+ actions: ChatAction[];
|
|
|
|
|
+ disabled: boolean;
|
|
|
|
|
+ onReply: (label: string) => void;
|
|
|
|
|
+})
|
|
|
|
|
+{
|
|
|
|
|
+ if (!actions.length)
|
|
|
|
|
+ return null;
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className={CLASSES.actions}>
|
|
|
|
|
+ {actions.map((action, index) => {
|
|
|
|
|
+ if (action.type === 'link') {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <a
|
|
|
|
|
+ key={`${action.type}-${index}-${action.label}`}
|
|
|
|
|
+ className={CLASSES.actionLink}
|
|
|
|
|
+ href={action.url}
|
|
|
|
|
+ target="_blank"
|
|
|
|
|
+ rel="noopener noreferrer"
|
|
|
|
|
+ >
|
|
|
|
|
+ {action.label}
|
|
|
|
|
+ </a>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={`${action.type}-${index}-${action.label}`}
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ className={`${CLASSES.action}${disabled ? ' bx-btn-disabled opacity-70' : ''}`}
|
|
|
|
|
+ disabled={disabled}
|
|
|
|
|
+ onClick={() => onReply(action.label)}
|
|
|
|
|
+ >
|
|
|
|
|
+ {action.label}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function ChatView({
|
|
function ChatView({
|
|
|
agentId,
|
|
agentId,
|
|
|
endpoint,
|
|
endpoint,
|
|
@@ -109,7 +245,9 @@ function ChatView({
|
|
|
}: ChatOptions)
|
|
}: ChatOptions)
|
|
|
{
|
|
{
|
|
|
const [input, setInput] = useState('');
|
|
const [input, setInput] = useState('');
|
|
|
|
|
+ const [streamedActions, setStreamedActions] = useState<Record<string, ChatAction[]>>({});
|
|
|
const messagesRef = useRef<HTMLDivElement>(null);
|
|
const messagesRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
+ const messagesSnapshotRef = useRef<UIMessage[]>([]);
|
|
|
const chatThreadId = getChatThreadId(agentId, threadId);
|
|
const chatThreadId = getChatThreadId(agentId, threadId);
|
|
|
|
|
|
|
|
const connection = useMemo(
|
|
const connection = useMemo(
|
|
@@ -122,13 +260,32 @@ function ChatView({
|
|
|
persistence: true,
|
|
persistence: true,
|
|
|
threadId: chatThreadId,
|
|
threadId: chatThreadId,
|
|
|
initialMessages: initialMessages ?? [],
|
|
initialMessages: initialMessages ?? [],
|
|
|
|
|
+ onCustomEvent: (eventType, data) => {
|
|
|
|
|
+ if (eventType !== 'chat_actions')
|
|
|
|
|
+ return;
|
|
|
|
|
+
|
|
|
|
|
+ const parsed = parseChatActionsEvent(data);
|
|
|
|
|
+ if (!parsed)
|
|
|
|
|
+ return;
|
|
|
|
|
+
|
|
|
|
|
+ const messageId = parsed.messageId || [...messagesSnapshotRef.current]
|
|
|
|
|
+ .reverse()
|
|
|
|
|
+ .find((message) => message.role === 'assistant')
|
|
|
|
|
+ ?.id;
|
|
|
|
|
+ if (!messageId)
|
|
|
|
|
+ return;
|
|
|
|
|
+
|
|
|
|
|
+ setStreamedActions((current) => ({ ...current, [messageId]: parsed.actions }));
|
|
|
|
|
+ },
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ messagesSnapshotRef.current = messages;
|
|
|
|
|
+
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
const list = messagesRef.current;
|
|
const list = messagesRef.current;
|
|
|
if (list)
|
|
if (list)
|
|
|
list.scrollTop = list.scrollHeight;
|
|
list.scrollTop = list.scrollHeight;
|
|
|
- }, [messages, error]);
|
|
|
|
|
|
|
+ }, [messages, streamedActions, error]);
|
|
|
|
|
|
|
|
function handleSubmit(event: FormEvent<HTMLFormElement>)
|
|
function handleSubmit(event: FormEvent<HTMLFormElement>)
|
|
|
{
|
|
{
|
|
@@ -142,12 +299,22 @@ function ChatView({
|
|
|
void sendMessage(text);
|
|
void sendMessage(text);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ function handleReply(label: string)
|
|
|
|
|
+ {
|
|
|
|
|
+ const text = label.trim();
|
|
|
|
|
+ if (!text || isLoading)
|
|
|
|
|
+ return;
|
|
|
|
|
+
|
|
|
|
|
+ void sendMessage(text);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
<div className={`${CLASSES.root}${isLoading ? ' bx-ai-chat-loading' : ''}`}>
|
|
<div className={`${CLASSES.root}${isLoading ? ' bx-ai-chat-loading' : ''}`}>
|
|
|
<div ref={messagesRef} className={CLASSES.messages}>
|
|
<div ref={messagesRef} className={CLASSES.messages}>
|
|
|
{messages.map((message) => {
|
|
{messages.map((message) => {
|
|
|
const isUser = message.role === 'user';
|
|
const isUser = message.role === 'user';
|
|
|
const text = getMessageText(message);
|
|
const text = getMessageText(message);
|
|
|
|
|
+ const actions = isUser ? [] : getMessageActions(message, streamedActions[message.id]);
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<div
|
|
<div
|
|
@@ -158,6 +325,15 @@ function ChatView({
|
|
|
{formatting && !isUser && text
|
|
{formatting && !isUser && text
|
|
|
? <MarkdownContent text={text} />
|
|
? <MarkdownContent text={text} />
|
|
|
: (text || '\u00a0')}
|
|
: (text || '\u00a0')}
|
|
|
|
|
+ {!isUser
|
|
|
|
|
+ ? (
|
|
|
|
|
+ <MessageActions
|
|
|
|
|
+ actions={actions}
|
|
|
|
|
+ disabled={isLoading}
|
|
|
|
|
+ onReply={handleReply}
|
|
|
|
|
+ />
|
|
|
|
|
+ )
|
|
|
|
|
+ : null}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|