Procházet zdrojové kódy

#5406 AI Agents revamp: chat actions

Aleksandr Trofimov před 1 měsícem
rodič
revize
d58276df10

+ 11 - 8
inc/classes/BxDolAiChatUi.php

@@ -165,8 +165,11 @@ class BxDolAiChatUi
             'role' => $sRole,
             'parts' => $aParts,
         ];
-        if ($sRole === 'assistant')
+        if ($sRole === 'assistant') {
             $aOut['actions'] = $aActions;
+            if ($aActions)
+                $aOut['metadata'] = ['actions' => $aActions];
+        }
 
         return $aOut;
     }
@@ -246,16 +249,16 @@ class BxDolAiChatUi
         $a = parse_url((string)$sUrl);
         if (($a['scheme'] ?? '') !== 'https' || empty($a['host']))
             return false;
+        if (isset($a['user']) || isset($a['pass']))
+            return false;
 
         $sHost = strtolower((string)$a['host']);
-        $aAllowed = ['hiweave.com', 'www.hiweave.com'];
-        if (defined('BX_DOL_URL_ROOT')) {
-            $aSite = parse_url(BX_DOL_URL_ROOT);
-            if (!empty($aSite['host']))
-                $aAllowed[] = strtolower((string)$aSite['host']);
-        }
+        if ($sHost === 'localhost' || str_ends_with($sHost, '.localhost'))
+            return false;
+        if (filter_var($sHost, FILTER_VALIDATE_IP))
+            return (bool)filter_var($sHost, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
 
-        return in_array($sHost, $aAllowed, true);
+        return true;
     }
 }
 

+ 4 - 2
inc/classes/BxDolAiTriggerChat.php

@@ -317,8 +317,10 @@ class BxDolAiTriggerChat extends BxDolAiTrigger
             $fEmit($this->sseEvent([
                 'type' => 'CUSTOM',
                 'name' => 'chat_actions',
-                'messageId' => $sId,
-                'value' => $aParsed['actions'],
+                'value' => [
+                    'messageId' => $sId,
+                    'actions' => $aParsed['actions'],
+                ],
             ]));
         }
         $aState = $this->resetActionsSseState();

+ 2 - 2
install/sql/system.sql

@@ -7051,8 +7051,8 @@ INSERT INTO `sys_agents_tools` (`type`, `title`, `docs`, `params`, `params_user`
 
 ('comment_get', 'Comment get', 'This tool allows agents to get single comment by global comment id.', '{}', NULL, 0, 0, 1, 'BxDolAIToolCmtsGetSingle', ''),
 
-('chat_artifact_save', 'Chat artifact save', 'Save one field from the conversation into sys_agents_chat_artifacts.', '{}', NULL, 0, 0, 1, 'BxDolAIToolChatArtifactSave', ''),
-('chat_goal_reached', 'Chat goal reached', 'Call once when the conversation goal is done. Fires agent_conversation_closed with reason=goal.', '{}', NULL, 0, 0, 1, 'BxDolAIToolChatGoalReached', '');
+('chat_artifact_save', 'Chat artifact save', 'Save one field from the conversation into sys_agents_chat_artifacts.', '{}', NULL, 0, 0, 0, 'BxDolAIToolChatArtifactSave', ''),
+('chat_goal_reached', 'Chat goal reached', 'Call once when the conversation goal is done. Fires agent_conversation_closed with reason=goal.', '{}', NULL, 0, 0, 0, 'BxDolAIToolChatGoalReached', '');
 
 CREATE TABLE IF NOT EXISTS `sys_agents_chat_history` (
   `id` bigint unsigned NOT NULL AUTO_INCREMENT,

+ 177 - 1
src/chat/index.tsx

@@ -15,6 +15,19 @@ export interface ChatOptions {
     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;
 
 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',
     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',
+    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',
 };
 
@@ -83,6 +99,80 @@ function getMessageText(message: UIMessage): string
         .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
 {
     if (endpoint)
@@ -98,6 +188,52 @@ function getChatThreadId(agentId: number | string, threadId?: string): string
     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({
     agentId,
     endpoint,
@@ -109,7 +245,9 @@ function ChatView({
 }: ChatOptions)
 {
     const [input, setInput] = useState('');
+    const [streamedActions, setStreamedActions] = useState<Record<string, ChatAction[]>>({});
     const messagesRef = useRef<HTMLDivElement>(null);
+    const messagesSnapshotRef = useRef<UIMessage[]>([]);
     const chatThreadId = getChatThreadId(agentId, threadId);
 
     const connection = useMemo(
@@ -122,13 +260,32 @@ function ChatView({
         persistence: true,
         threadId: chatThreadId,
         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(() => {
         const list = messagesRef.current;
         if (list)
             list.scrollTop = list.scrollHeight;
-    }, [messages, error]);
+    }, [messages, streamedActions, error]);
 
     function handleSubmit(event: FormEvent<HTMLFormElement>)
     {
@@ -142,12 +299,22 @@ function ChatView({
         void sendMessage(text);
     }
 
+    function handleReply(label: string)
+    {
+        const text = label.trim();
+        if (!text || isLoading)
+            return;
+
+        void sendMessage(text);
+    }
+
     return (
         <div className={`${CLASSES.root}${isLoading ? ' bx-ai-chat-loading' : ''}`}>
             <div ref={messagesRef} className={CLASSES.messages}>
                 {messages.map((message) => {
                     const isUser = message.role === 'user';
                     const text = getMessageText(message);
+                    const actions = isUser ? [] : getMessageActions(message, streamedActions[message.id]);
 
                     return (
                         <div
@@ -158,6 +325,15 @@ function ChatView({
                                 {formatting && !isUser && text
                                     ? <MarkdownContent text={text} />
                                     : (text || '\u00a0')}
+                                {!isUser
+                                    ? (
+                                        <MessageActions
+                                            actions={actions}
+                                            disabled={isLoading}
+                                            onReply={handleReply}
+                                        />
+                                    )
+                                    : null}
                             </div>
                         </div>
                     );

+ 22 - 0
studio/js/agents.js

@@ -294,6 +294,7 @@ BxDolStudioPageAgents.prototype._renderReadonlyAgentChat = function(el, aMessage
         html += '<div class="bx-ai-chat-message" style="display:flex;justify-content:' + (isUser ? 'flex-end' : 'flex-start') + ';margin:6px 0;">' +
             '<div class="bx-ai-chat-message-inner bx-def-padding-sec bx-def-round-corners" style="max-width:85%;background:' + (isUser ? '#2563eb' : '#fff') + ';color:' + (isUser ? '#fff' : 'inherit') + ';">' +
             this._escAgentChat(text).replace(/\n/g, '<br />') +
+            this._renderReadonlyAgentChatActions(m.actions || (m.metadata && m.metadata.actions) || []) +
             '</div></div>';
     }
     html += '</div></div>';
@@ -303,6 +304,27 @@ BxDolStudioPageAgents.prototype._renderReadonlyAgentChat = function(el, aMessage
         list.scrollTop = list.scrollHeight;
 };
 
+BxDolStudioPageAgents.prototype._renderReadonlyAgentChatActions = function(aActions) {
+    if (!aActions || !aActions.length)
+        return '';
+
+    var html = '<div class="bx-ai-chat-actions" style="display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.5rem;">';
+    for (var i = 0; i < aActions.length; i++) {
+        var a = aActions[i] || {};
+        var sLabel = this._escAgentChat(a.label || '');
+        if (!sLabel)
+            continue;
+        if (a.type === 'link' && a.url) {
+            html += '<a class="bx-btn bx-btn-small underline" href="' + this._escAgentChat(a.url) + '" target="_blank" rel="noopener noreferrer">' + sLabel + '</a>';
+        }
+        else {
+            html += '<span class="bx-btn bx-btn-small bx-btn-disabled">' + sLabel + '</span>';
+        }
+    }
+    html += '</div>';
+    return html;
+};
+
 BxDolStudioPageAgents.prototype._escAgentChat = function(s) {
     return String(s == null ? '' : s)
         .replace(/&/g, '&amp;')