Просмотр исходного кода

Ai chat form buttons (#5909)

* AI chat: "form" buttons open a site form in a popup over the chat

Agents can show a button which opens a site form (any API service form,
`module/method/&params[]=..`) over the chat, e.g. to connect a cloud
account without pasting keys into the chat. After the form is saved the
button `message` is sent as the visitor's answer, so the agent goes on.

- BxDolAiChatUi::sanitizeChatActions keeps `form` buttons with a vetted
  form_url and an optional message
- chat_buttons tool describes the new button type

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: AI chat "form" buttons open the site form in a popup

A `form` chat action loads `/api.php?r={form_url}` into a modal over the
chat, like the deploy block does. When the server answers the submit with
"done" (onFormEmpty) the modal closes and the action `message` is sent as
the user's answer; closing it with X sends nothing. form_url is checked
against the same `module/method/&params[]=` pattern the server uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
RomanL 4 дней назад
Родитель
Сommit
cfadc85a5e

+ 4 - 2
inc/classes/BxDolAIToolChatButtons.php

@@ -33,14 +33,16 @@ class BxDolAIToolChatButtons extends BxDolAITool
         return [
             new ArrayProperty(
                 name: 'buttons',
-                description: 'Buttons under this reply. reply = visitor tap sends that label as the next message; link = an https URL (site pages, help, registration). Yes/no labels are exactly "' . $sYes . '" and "' . $sNo . '" (site language). Example: [{"type":"reply","label":"' . $sYes . '"},{"type":"reply","label":"' . $sNo . '"}]',
+                description: 'Buttons under this reply. reply = visitor tap sends that label as the next message; link = an https URL (site pages, help, registration); form = opens a site form in a popup over the chat, form_url comes from a tool result only (never made up), after the form is saved the "message" text is sent as the visitor next message. Yes/no labels are exactly "' . $sYes . '" and "' . $sNo . '" (site language). Example: [{"type":"reply","label":"' . $sYes . '"},{"type":"reply","label":"' . $sNo . '"}]',
                 required: true,
                 items: new ObjectProperty(
                     name: 'button',
                     properties: [
-                        new ToolProperty('type', PropertyType::STRING, 'reply or link', true),
+                        new ToolProperty('type', PropertyType::STRING, 'reply, link or form', true),
                         new ToolProperty('label', PropertyType::STRING, 'Button text shown to the visitor', true),
                         new ToolProperty('url', PropertyType::STRING, 'Required for link. https URL.', false),
+                        new ToolProperty('form_url', PropertyType::STRING, 'Required for form. Exactly the form_url from a tool result, like "module/method/&params[]=1".', false),
+                        new ToolProperty('message', PropertyType::STRING, 'For form. Sent as the visitor message after the form is saved, in the site language, e.g. "DigitalOcean is connected".', false),
                     ]
                 ),
                 minItems: 1,

+ 11 - 1
inc/classes/BxDolAiChatUi.php

@@ -297,7 +297,7 @@ class BxDolAiChatUi
         $aOut = [];
         $aSeen = [];
         foreach ($aAll as $aItem) {
-            $sKey = ($aItem['type'] ?? '') . '|' . ($aItem['label'] ?? '') . '|' . ($aItem['url'] ?? '');
+            $sKey = ($aItem['type'] ?? '') . '|' . ($aItem['label'] ?? '') . '|' . ($aItem['url'] ?? ($aItem['form_url'] ?? ''));
             if (isset($aSeen[$sKey]))
                 continue;
             $aSeen[$sKey] = true;
@@ -338,6 +338,16 @@ class BxDolAiChatUi
                 if (!$this->isAllowedChatActionUrl($sUrl))
                     continue;
                 $aOut[] = ['type' => 'link', 'label' => $sLabel, 'url' => $sUrl];
+            } elseif ($sType === 'form') {
+                // form opened in a popup over the chat: 'module/method/&params[]=..' API service call
+                $sFormUrl = trim((string)($aItem['form_url'] ?? ''));
+                if (!preg_match('/^[a-z0-9_]+\/[a-z0-9_]+\/?(&params\[\]=[A-Za-z0-9_\-\.]*)*$/', $sFormUrl))
+                    continue;
+                $aForm = ['type' => 'form', 'label' => $sLabel, 'form_url' => $sFormUrl];
+                $sMessage = trim(strip_tags((string)($aItem['message'] ?? '')));
+                if ($sMessage !== '')
+                    $aForm['message'] = function_exists('mb_substr') ? mb_substr($sMessage, 0, 200) : substr($sMessage, 0, 200);
+                $aOut[] = $aForm;
             }
 
             if (count($aOut) >= 8)

+ 58 - 3
neo/packages/app/ui/molecules/ai-agent/chat-actions.js

@@ -2,19 +2,26 @@
 
 import { memo, useCallback, useEffect, useState } from 'react';
 import { View } from 'app/design/view';
-import { NeoButton, NeoButtonLink } from 'app/design/controls';
+import { NeoButton, NeoButtonLink, Modal } from 'app/design/controls';
+import { fetcher } from 'app/lib/fetcher';
 
 /**
- * The quick-reply / link buttons an assistant message can carry.
+ * The quick-reply / link / form buttons an assistant message can carry.
  *
  * Selection has one authority: `chosenReply`, derived by the parent from the user
  * message that actually followed this bubble. `pending` is only an optimistic bridge
  * covering the round-trip, and it is cleared as soon as the transcript confirms the
  * choice or the send reports failure — so a failed send can no longer leave a button
  * highlighted as if it had been answered.
+ *
+ * A form button opens the site form (`/api.php?r={formUrl}`) in a popup over the chat,
+ * like the deploy block does; once the form is saved the popup closes and the action
+ * `message` is sent as the user's answer, so the agent goes on by itself.
  */
 function ChatActionsImpl({ actions, onReply, repliesEnabled, chosenReply = '' }) {
     const [pending, setPending] = useState('');
+    const [form, setForm] = useState(null);
+    const [opening, setOpening] = useState(false);
     const selected = chosenReply || pending;
 
     useEffect(() => {
@@ -29,13 +36,43 @@ function ChatActionsImpl({ actions, onReply, repliesEnabled, chosenReply = '' })
         if (!sent) setPending('');
     }, [onReply]);
 
+    const openForm = useCallback(async (action) => {
+        setOpening(true);
+        try {
+            // Loaded on demand: the block renderer imports the component registry,
+            // which in turn imports this chat.
+            const [{ BlockByDataInt }, response] = await Promise.all([
+                import('app/components/block'),
+                fetcher(`/api.php?r=${action.formUrl}`),
+            ]);
+            setForm({
+                Block: BlockByDataInt,
+                block: { content: response?.data, designbox_id: 0, title: action.label },
+                message: action.message,
+            });
+        } finally {
+            setOpening(false);
+        }
+    }, []);
+
+    const closeForm = useCallback(() => setForm(null), []);
+
+    // The form calls onFormEmpty once the server answered the submit with "done";
+    // closing the popup with its X does not, so only a saved form sends the answer.
+    const completeForm = useCallback(() => {
+        const message = form?.message;
+        setForm(null);
+        if (message && repliesEnabled) void press(message);
+    }, [form, press, repliesEnabled]);
+
     if (!actions.length) return null;
 
     return (
         <View className="flex-row flex-wrap gap-2 mt-2 max-w-full">
             {actions.map((action, index) => {
                 const isLink = action.type === 'link';
-                const isSelected = !isLink && action.message === selected;
+                const isForm = action.type === 'form';
+                const isSelected = !isLink && !!action.message && action.message === selected;
                 // Index is part of the key because an agent may legitimately repeat a
                 // label within one message ("Other", "Other").
                 const key = `${action.type}:${action.label}:${index}`;
@@ -51,6 +88,19 @@ function ChatActionsImpl({ actions, onReply, repliesEnabled, chosenReply = '' })
                     // in-app browser.
                     return <NeoButtonLink key={key} {...buttonProps} href={action.url} target="_blank" />;
                 }
+                if (isForm) {
+                    // Like a link, a form stays usable after the turn is over; only the
+                    // answer it sends on save needs the turn to be open.
+                    return (
+                        <NeoButton
+                            key={key}
+                            {...buttonProps}
+                            selected={isSelected}
+                            disabled={opening}
+                            onPress={() => void openForm(action)}
+                        />
+                    );
+                }
                 return (
                     <NeoButton
                         key={key}
@@ -66,6 +116,11 @@ function ChatActionsImpl({ actions, onReply, repliesEnabled, chosenReply = '' })
                     />
                 );
             })}
+            {form && (
+                <Modal scrollable={true} title={form.block.title} onVisible={true} onClose={closeForm}>
+                    <form.Block block={form.block} onFormEmpty={completeForm} />
+                </Modal>
+            )}
         </View>
     );
 }

+ 10 - 1
neo/packages/app/ui/molecules/ai-agent/message-content.js

@@ -215,6 +215,7 @@ function readRawActions(message) {
 
 const SAFE_URL_SCHEME = /^(?:https?:|mailto:|tel:)/i;
 const HAS_SCHEME = /^[a-z][a-z\d+\-.]*:/i;
+const SAFE_FORM_URL = /^[a-z0-9_]+\/[a-z0-9_]+\/?(&params\[\]=[A-Za-z0-9_\-.]*)*$/;
 
 /**
  * Link targets are chosen by the model, so a prompt-injected `javascript:` or
@@ -230,7 +231,8 @@ function safeActionUrl(raw) {
 }
 
 /**
- * Normalize a message's actions into `{ type, label, url }` / `{ type, label, message }`.
+ * Normalize a message's actions into `{ type, label, url }` / `{ type, label, message }` /
+ * `{ type: 'form', label, formUrl, message }`.
  * `streamedActions` (from the live CUSTOM chunk) wins over whatever is on the
  * message, because the message copy may not have been persisted yet.
  */
@@ -243,6 +245,13 @@ export function messageActions(message, streamedActions) {
             if (!action || typeof action !== 'object') return null;
             const label = String(action.label || action.title || action.text || '').trim();
             if (!label) return null;
+            if (action.type === 'form') {
+                // A site form opened in a popup over the chat; same `module/method/&params[]=`
+                // API call the server already vetted, anything else is dropped.
+                const formUrl = String(action.form_url || '').trim();
+                if (!SAFE_FORM_URL.test(formUrl)) return null;
+                return { type: 'form', label, formUrl, message: String(action.message || '').trim() };
+            }
             const rawUrl = String(action.url || action.href || '').trim();
             const url = safeActionUrl(rawUrl);
             // An explicit `type` wins; otherwise the presence of a url means "link".