diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 71658cc0..332c8ec7 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -70,12 +70,17 @@ class BotService: 'lark', ]: webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300') + extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '') webhook_url = f'/bots/{bot_uuid}' adapter_runtime_values['webhook_url'] = webhook_url adapter_runtime_values['webhook_full_url'] = f'{webhook_prefix}{webhook_url}' + adapter_runtime_values['extra_webhook_full_url'] = ( + f'{extra_webhook_prefix}{webhook_url}' if extra_webhook_prefix else '' + ) else: adapter_runtime_values['webhook_url'] = None adapter_runtime_values['webhook_full_url'] = None + adapter_runtime_values['extra_webhook_full_url'] = None persistence_bot['adapter_runtime_values'] = adapter_runtime_values diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index b8f541ab..98d0d01a 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -2,6 +2,7 @@ admins: [] api: port: 5300 webhook_prefix: 'http://127.0.0.1:5300' + extra_webhook_prefix: '' command: enable: true prefix: diff --git a/tests/unit_tests/config/test_webhook_display_prefix.py b/tests/unit_tests/config/test_webhook_display_prefix.py index 8331befc..a8521ddf 100644 --- a/tests/unit_tests/config/test_webhook_display_prefix.py +++ b/tests/unit_tests/config/test_webhook_display_prefix.py @@ -91,14 +91,15 @@ class TestWebhookDisplayPrefix: def test_default_webhook_prefix(self): """Test that the default webhook display prefix is correctly set""" - cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}} + cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}} # Should have the default value assert cfg['api']['webhook_prefix'] == 'http://127.0.0.1:5300' + assert cfg['api']['extra_webhook_prefix'] == '' def test_webhook_prefix_env_override(self): """Test overriding webhook_prefix via environment variable""" - cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}} + cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}} # Set environment variable os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com:8080' @@ -112,7 +113,7 @@ class TestWebhookDisplayPrefix: def test_webhook_prefix_with_custom_domain(self): """Test webhook_prefix with custom domain""" - cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}} + cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}} # Set to a custom domain os.environ['API__WEBHOOK_PREFIX'] = 'https://bot.mycompany.com' @@ -126,7 +127,7 @@ class TestWebhookDisplayPrefix: def test_webhook_prefix_with_subdirectory(self): """Test webhook_prefix with subdirectory path""" - cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}} + cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}} # Set to a URL with subdirectory os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com/langbot' @@ -138,6 +139,37 @@ class TestWebhookDisplayPrefix: # Cleanup del os.environ['API__WEBHOOK_PREFIX'] + def test_extra_webhook_prefix_default_empty(self): + """Test that extra_webhook_prefix defaults to empty string""" + cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}} + + bot_uuid = 'test-bot-uuid' + webhook_prefix = cfg['api'].get('webhook_prefix', 'http://127.0.0.1:5300') + extra_webhook_prefix = cfg['api'].get('extra_webhook_prefix', '') + webhook_url = f'/bots/{bot_uuid}' + + assert f'{webhook_prefix}{webhook_url}' == 'http://127.0.0.1:5300/bots/test-bot-uuid' + # extra should be empty when not configured + assert extra_webhook_prefix == '' + + def test_extra_webhook_prefix_env_override(self): + """Test overriding extra_webhook_prefix via environment variable""" + cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}} + + os.environ['API__EXTRA_WEBHOOK_PREFIX'] = 'https://extra.example.com' + + result = _apply_env_overrides_to_config(cfg) + + assert result['api']['extra_webhook_prefix'] == 'https://extra.example.com' + + bot_uuid = 'test-bot-uuid' + extra_prefix = result['api']['extra_webhook_prefix'] + webhook_url = f'/bots/{bot_uuid}' + assert f'{extra_prefix}{webhook_url}' == 'https://extra.example.com/bots/test-bot-uuid' + + # Cleanup + del os.environ['API__EXTRA_WEBHOOK_PREFIX'] + if __name__ == '__main__': pytest.main([__file__, '-v']) diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index 1e243957..712bf603 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -117,8 +117,9 @@ export default function BotForm({ useState([]); const [, setIsLoading] = useState(false); const [webhookUrl, setWebhookUrl] = useState(''); - const webhookInputRef = React.useRef(null); + const [extraWebhookUrl, setExtraWebhookUrl] = useState(''); const [copied, setCopied] = useState(false); + const [extraCopied, setExtraCopied] = useState(false); // Watch adapter and adapter_config for filtering const currentAdapter = form.watch('adapter'); @@ -149,73 +150,42 @@ export default function BotForm({ } }, [currentAdapter, currentAdapterConfig, dynamicFormConfigList]); - // 复制到剪贴板的辅助函数 - 使用页面上的真实input元素 - const copyToClipboard = () => { - console.log('[Copy] Attempting to copy from input element'); - - const inputElement = webhookInputRef.current; - if (!inputElement) { - console.error('[Copy] Input element not found'); - return; + // 复制到剪贴板的辅助函数 + const copyToClipboard = ( + text: string, + setStatus: React.Dispatch>, + ) => { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard + .writeText(text) + .then(() => { + setStatus(true); + setTimeout(() => setStatus(false), 2000); + }) + .catch(() => { + // 降级:创建临时textarea复制 + fallbackCopy(text, setStatus); + }); + } else { + fallbackCopy(text, setStatus); } + }; - try { - // 确保input元素可见且未被禁用 - inputElement.disabled = false; - inputElement.readOnly = false; - - // 聚焦并选中所有文本 - inputElement.focus(); - inputElement.select(); - - // 尝试使用现代API - if (navigator.clipboard && navigator.clipboard.writeText) { - console.log( - '[Copy] Using Clipboard API with input value:', - inputElement.value, - ); - navigator.clipboard - .writeText(inputElement.value) - .then(() => { - console.log('[Copy] Clipboard API success'); - inputElement.blur(); // 取消选中 - inputElement.readOnly = true; - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }) - .catch((err) => { - console.error( - '[Copy] Clipboard API failed, trying execCommand:', - err, - ); - // 降级到execCommand - const successful = document.execCommand('copy'); - console.log('[Copy] execCommand result:', successful); - inputElement.blur(); - inputElement.readOnly = true; - if (successful) { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }); - } else { - // 直接使用execCommand - console.log( - '[Copy] Using execCommand with input value:', - inputElement.value, - ); - const successful = document.execCommand('copy'); - console.log('[Copy] execCommand result:', successful); - inputElement.blur(); - inputElement.readOnly = true; - if (successful) { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - } - } catch (err) { - console.error('[Copy] Copy failed:', err); - inputElement.readOnly = true; + const fallbackCopy = ( + text: string, + setStatus: React.Dispatch>, + ) => { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + const successful = document.execCommand('copy'); + document.body.removeChild(textarea); + if (successful) { + setStatus(true); + setTimeout(() => setStatus(false), 2000); } }; @@ -240,6 +210,7 @@ export default function BotForm({ } else { setWebhookUrl(''); } + setExtraWebhookUrl(val.extra_webhook_full_url || ''); }) .catch((err) => { toast.error( @@ -249,6 +220,7 @@ export default function BotForm({ } else { form.reset(); setWebhookUrl(''); + setExtraWebhookUrl(''); } }); } @@ -321,14 +293,20 @@ export default function BotForm({ setAdapterNameToDynamicConfigMap(adapterNameToDynamicConfigMap); } - async function getBotConfig( - botId: string, - ): Promise & { webhook_full_url?: string }> { + async function getBotConfig(botId: string): Promise< + z.infer & { + webhook_full_url?: string; + extra_webhook_full_url?: string; + } + > { return new Promise((resolve, reject) => { httpClient .getBot(botId) .then((res) => { const bot = res.bot; + const runtimeValues = bot.adapter_runtime_values as + | Record + | undefined; resolve({ adapter: bot.adapter, description: bot.description, @@ -336,10 +314,12 @@ export default function BotForm({ adapter_config: bot.adapter_config, enable: bot.enable ?? true, use_pipeline_uuid: bot.use_pipeline_uuid ?? '', - webhook_full_url: bot.adapter_runtime_values - ? ((bot.adapter_runtime_values as Record) - .webhook_full_url as string) - : undefined, + webhook_full_url: runtimeValues?.webhook_full_url as + | string + | undefined, + extra_webhook_full_url: runtimeValues?.extra_webhook_full_url as + | string + | undefined, }); }) .catch((err) => { @@ -536,7 +516,6 @@ export default function BotForm({ {t('bots.webhookUrl')}
copyToClipboard(webhookUrl, setCopied)} > {copied ? ( @@ -559,8 +538,37 @@ export default function BotForm({ {t('common.copy')}
+ {extraWebhookUrl && ( +
+ { + (e.target as HTMLInputElement).select(); + }} + /> + +
+ )}

- {t('bots.webhookUrlHint')} + {extraWebhookUrl + ? t('bots.webhookUrlHintEither') + : t('bots.webhookUrlHint')}

)} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 1a5d5267..a917993d 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -284,6 +284,8 @@ const enUS = { webhookUrlCopied: 'Webhook URL copied', webhookUrlHint: 'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button', + webhookUrlHintEither: + 'Use either of the two URLs above in your platform configuration', logLevel: 'Log Level', allLevels: 'All Levels', selectLevel: 'Select Level', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 475affaa..47b9c93f 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -289,6 +289,8 @@ webhookUrlCopied: 'Webhook URL をコピーしました', webhookUrlHint: '入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください', + webhookUrlHintEither: + '上記の2つのURLのいずれかをプラットフォーム設定に使用してください', logLevel: 'ログレベル', allLevels: 'すべてのレベル', selectLevel: 'レベルを選択', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index ce354829..867340ad 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -273,6 +273,7 @@ const zhHans = { webhookUrlCopied: 'Webhook 地址已复制', webhookUrlHint: '点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮', + webhookUrlHintEither: '以上两个地址任选其一填入平台配置即可', logLevel: '日志级别', allLevels: '全部级别', selectLevel: '选择级别', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index d6bfc6d6..352ee849 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -272,6 +272,7 @@ const zhHant = { webhookUrlCopied: 'Webhook 位址已複製', webhookUrlHint: '點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕', + webhookUrlHintEither: '以上兩個地址任選其一填入平台配置即可', logLevel: '日誌級別', allLevels: '全部級別', selectLevel: '選擇級別',