From 4c904c2375680539f3e30914b2996cf38764e6e6 Mon Sep 17 00:00:00 2001 From: Junyan Chin Date: Sat, 28 Mar 2026 15:50:32 +0800 Subject: [PATCH] Fix/frontend optimizations (#2088) * fix(web): auto-redirect to wizard on first visit and change sidebar icons to blue * refactor(wizard): use backend metadata table instead of localStorage for wizard completion state - Add wizard_completed field to system info API (read from metadata table) - Add POST /api/v1/system/wizard/completed endpoint to mark wizard done - Frontend home layout checks systemInfo.wizard_completed for auto-redirect - Wizard calls markWizardCompleted API on skip/finish - Ensures consistent behavior across all browsers on the same instance * fix(wizard): update systemInfo in memory before navigation to prevent redirect loop * fix(monitoring): prevent horizontal overflow and unify empty state styles * fix(wizard): use Object.assign for systemInfo and await wizard completion API - Replace systemInfo reassignment with Object.assign in all 3 locations to preserve object identity across module imports - Await markWizardCompleted() POST in wizard skip/finish handlers instead of fire-and-forget to ensure backend persistence - Always re-fetch systemInfo in home layout to get latest wizard_completed state from backend * fix(wizard): prevent redirect loop by blocking navigation on failed status save - Refactor wizard_completed (boolean) to wizard_status (string: none/skipped/completed) - Remove ALL localStorage usage from wizard page (form state persistence) - Replace AlertDialogAction with Button so skip dialog stays open during POST - Add loading spinners for skip and complete actions - If POST fails, show error toast and keep dialog/button active for retry - If POST succeeds, update in-memory state and navigate * fix(wizard): fix row[0].value bug causing GET /info to always return wizard_status=none conn.execute(select(Entity)) returns Row with raw column values, not ORM entities. row[0] is the key column (a string), so row[0].value raises AttributeError which was silently swallowed by except-pass, making the GET endpoint always return wizard_status=none regardless of DB state. * fix(wizard): replace AlertDialog with Dialog for skip confirmation to remove slide animation * chore: optimize toast in wizard * fix(wizard): set default token value for Telegram adapter and initialize adapter config in wizard * feat(web): move webhook URL to dynamic form system, add market category filter, fix layout overflow - Add 'webhook-url' dynamic form field type rendered as read-only input with copy button, defined in adapter YAML specs instead of hardcoded in BotForm. Supports show_if conditions for optional-webhook adapters. - Remove hardcoded webhook display logic from BotForm.tsx, pass webhook URLs via systemContext to DynamicFormComponent. - Fetch webhook URLs after bot creation in wizard and pass to Step 1. - Support ?category= query param on /home/market page for filtering by component type (mirrors langbot-space behavior). - Link 'install knowledge engine' hint to /home/market?category=KnowledgeEngine. - Fix SidebarInset missing min-w-0 causing content overflow when sidebar is expanded. - Add vertical divider between plugin detail config and readme panels. - Fix infinite re-render loop in DynamicFormComponent by memoizing editableItems array. * fix: lint * fix(web): change systemInfo to const to satisfy prefer-const lint rule * fix: update adapter descriptions for clarity and usage requirements --- .../pkg/api/http/controller/groups/system.py | 44 ++++ .../pkg/platform/sources/aiocqhttp.yaml | 4 +- src/langbot/pkg/platform/sources/discord.yaml | 3 +- src/langbot/pkg/platform/sources/lark.yaml | 23 +- src/langbot/pkg/platform/sources/line.yaml | 22 +- .../pkg/platform/sources/officialaccount.yaml | 12 +- .../pkg/platform/sources/openclaw_weixin.yaml | 6 +- .../pkg/platform/sources/qqofficial.yaml | 12 +- src/langbot/pkg/platform/sources/satori.yaml | 2 +- src/langbot/pkg/platform/sources/slack.yaml | 14 +- .../pkg/platform/sources/telegram.yaml | 4 +- .../pkg/platform/sources/wechatpad.yaml | 4 +- src/langbot/pkg/platform/sources/wecom.yaml | 12 +- .../pkg/platform/sources/wecombot.yaml | 16 +- src/langbot/pkg/platform/sources/wecomcs.yaml | 12 +- .../home/bots/components/bot-form/BotForm.tsx | 134 +--------- .../dynamic-form/DynamicFormComponent.tsx | 132 +++++++++- .../components/home-sidebar/HomeSidebar.tsx | 3 +- .../home-sidebar/sidbarConfigList.tsx | 8 + .../knowledge/components/kb-form/KBForm.tsx | 2 +- web/src/app/home/layout.tsx | 31 ++- .../overview-cards/TrafficChart.tsx | 19 +- web/src/app/home/monitoring/page.tsx | 62 ++--- .../app/home/plugins/PluginDetailContent.tsx | 2 + .../plugin-market/PluginMarketComponent.tsx | 30 ++- web/src/app/infra/entities/api/index.ts | 1 + web/src/app/infra/entities/form/dynamic.ts | 1 + web/src/app/infra/http/BackendClient.ts | 4 + web/src/app/infra/http/index.ts | 9 +- web/src/app/wizard/page.tsx | 239 +++++++++--------- web/src/components/ui/sidebar.tsx | 2 +- web/src/i18n/locales/en-US.ts | 2 + web/src/i18n/locales/ja-JP.ts | 2 + web/src/i18n/locales/zh-Hans.ts | 2 + web/src/i18n/locales/zh-Hant.ts | 2 + web/src/styles/github-markdown.css | 10 +- 36 files changed, 543 insertions(+), 344 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 4e606dd5..280985aa 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -1,7 +1,9 @@ import quart +import sqlalchemy from .. import group from .....utils import constants +from .....entity.persistence.metadata import Metadata @group.group_class('system', '/api/v1/system') @@ -9,6 +11,19 @@ class SystemRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE) async def _() -> str: + # Read wizard_status from metadata table + # Possible values: 'skipped', 'completed'; absent key means 'none' + wizard_status = 'none' + try: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_status') + ) + row = result.first() + if row: + wizard_status = row.value + except Exception: + pass + return self.success( data={ 'version': constants.semantic_version, @@ -27,9 +42,38 @@ class SystemRouterGroup(group.RouterGroup): 'disable_models_service', False ), 'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}), + 'wizard_status': wizard_status, } ) + @self.route('/wizard/completed', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + """Mark wizard status in metadata table. + + Accepts JSON body: { "status": "skipped" | "completed" } + """ + data = await quart.request.get_json(silent=True) or {} + status = data.get('status', 'completed') + if status not in ('skipped', 'completed'): + return self.http_status(400, 400, f'Invalid wizard status: {status}') + + try: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_status') + ) + if result.first(): + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_status').values(value=status) + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(Metadata).values(key='wizard_status', value=status) + ) + except Exception as e: + return self.http_status(500, 500, f'Failed to update wizard status: {e}') + + return self.success(data={}) + @self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: task_type = quart.request.args.get('type') diff --git a/src/langbot/pkg/platform/sources/aiocqhttp.yaml b/src/langbot/pkg/platform/sources/aiocqhttp.yaml index a3ced8e2..7c530c19 100644 --- a/src/langbot/pkg/platform/sources/aiocqhttp.yaml +++ b/src/langbot/pkg/platform/sources/aiocqhttp.yaml @@ -6,8 +6,8 @@ metadata: en_US: OneBot v11 zh_Hans: OneBot v11 description: - en_US: OneBot v11 Adapter - zh_Hans: OneBot v11 适配器,请查看文档了解使用方式 + en_US: OneBot v11 Adapter, used for QQ bots + zh_Hans: OneBot v11 适配器,用于接入 QQ 机器人协议端,请查看文档了解使用方式 icon: onebot.png spec: config: diff --git a/src/langbot/pkg/platform/sources/discord.yaml b/src/langbot/pkg/platform/sources/discord.yaml index f000c2d9..57392994 100644 --- a/src/langbot/pkg/platform/sources/discord.yaml +++ b/src/langbot/pkg/platform/sources/discord.yaml @@ -7,7 +7,8 @@ metadata: zh_Hans: Discord description: en_US: Discord Adapter - zh_Hans: Discord 适配器,请查看文档了解使用方式 + zh_Hans: Discord 适配器,需要可连接 Discord 服务器的网络环境 + ja_JP: Discord アダプター icon: discord.svg spec: config: diff --git a/src/langbot/pkg/platform/sources/lark.yaml b/src/langbot/pkg/platform/sources/lark.yaml index 7db5cdaa..2b84d415 100644 --- a/src/langbot/pkg/platform/sources/lark.yaml +++ b/src/langbot/pkg/platform/sources/lark.yaml @@ -6,8 +6,9 @@ metadata: en_US: Lark zh_Hans: 飞书 description: - en_US: Lark Adapter - zh_Hans: 飞书适配器,请查看文档了解使用方式 + en_US: Lark Adapter, supports both long connection and Webhook modes. Please refer to the documentation for usage details. + zh_Hans: 飞书适配器,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式 + ja_JP: Lark アダプター、長期接続およびWebhookモードの両方をサポートしています。使用方法の詳細については、ドキュメントを参照してください。 icon: lark.svg spec: config: @@ -45,6 +46,20 @@ spec: type: boolean required: true default: false + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + description: + en_US: Copy this URL and paste it into your Lark app's webhook configuration + zh_Hans: 复制此地址并粘贴到飞书应用的 Webhook 配置中 + type: webhook-url + required: false + default: "" + show_if: + field: enable-webhook + operator: eq + value: true - name: encrypt-key label: en_US: Encrypt Key @@ -55,6 +70,10 @@ spec: type: string required: true default: "" + show_if: + field: enable-webhook + operator: eq + value: true - name: enable-stream-reply label: en_US: Enable Stream Reply Mode diff --git a/src/langbot/pkg/platform/sources/line.yaml b/src/langbot/pkg/platform/sources/line.yaml index 5b399337..1944cc81 100644 --- a/src/langbot/pkg/platform/sources/line.yaml +++ b/src/langbot/pkg/platform/sources/line.yaml @@ -6,13 +6,27 @@ metadata: en_US: LINE zh_Hans: LINE description: - en_US: LINE Adapter - zh_Hans: LINE适配器,请查看文档了解使用方式 - ja_JP: LINEアダプター、ドキュメントを参照してください - zh_Hant: LINE適配器,請查看文檔了解使用方式 + en_US: LINE Adapter, requires a public URL to receive LINE message pushes, please refer to the documentation for usage details + zh_Hans: LINE适配器,需要公网地址以接收 LINE 消息推送,请查看文档了解使用方式 + ja_JP: LINEアダプター、LINEのメッセージプッシュを受信するためにパブリックURLが必要です。使用方法の詳細については、ドキュメントを参照してください。 + zh_Hant: LINE適配器,需要公网地址以接收 LINE 消息推送,请查看文档了解使用方式 icon: line.png spec: config: + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + ja_JP: Webhook コールバック URL + zh_Hant: Webhook 回調地址 + description: + en_US: Copy this URL and paste it into your LINE channel's webhook configuration + zh_Hans: 复制此地址并粘贴到 LINE 频道的 Webhook 配置中 + ja_JP: この URL をコピーして LINE チャンネルの Webhook 設定に貼り付けてください + zh_Hant: 複製此地址並粘貼到 LINE 頻道的 Webhook 配置中 + type: webhook-url + required: false + default: "" - name: channel_access_token label: en_US: Channel access token diff --git a/src/langbot/pkg/platform/sources/officialaccount.yaml b/src/langbot/pkg/platform/sources/officialaccount.yaml index fda0a912..c42a7c88 100644 --- a/src/langbot/pkg/platform/sources/officialaccount.yaml +++ b/src/langbot/pkg/platform/sources/officialaccount.yaml @@ -7,10 +7,20 @@ metadata: zh_Hans: 微信公众号 description: en_US: Official Account Adapter - zh_Hans: 微信公众号适配器,请查看文档了解使用方式 + zh_Hans: 微信公众号适配器,需要公网地址以接收消息推送,请查看文档了解使用方式 icon: officialaccount.png spec: config: + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + description: + en_US: Copy this URL and paste it into your Official Account webhook configuration + zh_Hans: 复制此地址并粘贴到微信公众号的 Webhook 配置中 + type: webhook-url + required: false + default: "" - name: token label: en_US: Token diff --git a/src/langbot/pkg/platform/sources/openclaw_weixin.yaml b/src/langbot/pkg/platform/sources/openclaw_weixin.yaml index c5400a59..c203d760 100644 --- a/src/langbot/pkg/platform/sources/openclaw_weixin.yaml +++ b/src/langbot/pkg/platform/sources/openclaw_weixin.yaml @@ -4,10 +4,10 @@ metadata: name: openclaw-weixin label: en_US: OpenClaw WeChat - zh_Hans: OpenClaw 微信 + zh_Hans: 个人微信机器人 description: en_US: OpenClaw WeChat adapter, supports personal WeChat via QR code login - zh_Hans: OpenClaw 微信适配器,通过扫码登录支持个人微信 + zh_Hans: 微信官方个人助手,扫码即可登录使用 icon: wechat.png spec: config: @@ -27,7 +27,7 @@ spec: zh_Hans: 令牌 description: en_US: Bearer token obtained after QR code login authorization. Leave empty to trigger QR code login on startup. - zh_Hans: 扫码登录授权后获取的 Bearer 令牌。留空则启动时自动触发扫码登录。 + zh_Hans: 扫码登录授权后获取的 Bearer 令牌。请留空并保存,将在启动时输出二维码到日志,扫码后即可自动登录。 type: string required: false default: "" diff --git a/src/langbot/pkg/platform/sources/qqofficial.yaml b/src/langbot/pkg/platform/sources/qqofficial.yaml index 54d800bb..a374265a 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.yaml +++ b/src/langbot/pkg/platform/sources/qqofficial.yaml @@ -7,10 +7,20 @@ metadata: zh_Hans: QQ 官方 API description: en_US: QQ Official API (Webhook) - zh_Hans: QQ 官方 API (Webhook),请查看文档了解使用方式 + zh_Hans: QQ 官方 API (Webhook),需要公网地址以接收消息推送,请查看文档了解使用方式 icon: qqofficial.svg spec: config: + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + description: + en_US: Copy this URL and paste it into your QQ Official API webhook configuration + zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中 + type: webhook-url + required: false + default: "" - name: appid label: en_US: App ID diff --git a/src/langbot/pkg/platform/sources/satori.yaml b/src/langbot/pkg/platform/sources/satori.yaml index e473aa9d..e1635286 100644 --- a/src/langbot/pkg/platform/sources/satori.yaml +++ b/src/langbot/pkg/platform/sources/satori.yaml @@ -7,7 +7,7 @@ metadata: zh_Hans: Satori description: en_US: SatoriAdapter - zh_Hans: 古明地觉协议适配器 + zh_Hans: Satori 协议适配器,支持多种平台的接入,请查看文档了解使用方式 icon: satori.png spec: config: diff --git a/src/langbot/pkg/platform/sources/slack.yaml b/src/langbot/pkg/platform/sources/slack.yaml index 13d303bb..2f3a438b 100644 --- a/src/langbot/pkg/platform/sources/slack.yaml +++ b/src/langbot/pkg/platform/sources/slack.yaml @@ -7,10 +7,22 @@ metadata: zh_Hans: Slack description: en_US: Slack Adapter - zh_Hans: Slack 适配器,请查看文档了解使用方式 + zh_Hans: Slack 适配器,需要公网地址以接收 Slack 消息推送,请查看文档了解使用方式 + ja_JP: Slack アダプター、Slackのメッセージプッシュを受信するためにパブリックURLが必要です。使用方法の詳細については、ドキュメントを参照してください。 + zh_Hant: Slack 适配器,需要公网地址以接收 Slack 消息推送,请查看文档了解使用方式 icon: slack.png spec: config: + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + description: + en_US: Copy this URL and paste it into your Slack app's event subscription configuration + zh_Hans: 复制此地址并粘贴到 Slack 应用的事件订阅配置中 + type: webhook-url + required: false + default: "" - name: bot_token label: en_US: Bot Token diff --git a/src/langbot/pkg/platform/sources/telegram.yaml b/src/langbot/pkg/platform/sources/telegram.yaml index d29c359e..14c84289 100644 --- a/src/langbot/pkg/platform/sources/telegram.yaml +++ b/src/langbot/pkg/platform/sources/telegram.yaml @@ -7,7 +7,7 @@ metadata: zh_Hans: 电报 description: en_US: Telegram Adapter - zh_Hans: 电报适配器,请查看文档了解使用方式 + zh_Hans: Telegram 适配器,请查看文档了解使用方式 icon: telegram.svg spec: config: @@ -17,7 +17,7 @@ spec: zh_Hans: 令牌 type: string required: true - default: "" + default: "token_from_botfather" - name: markdown_card label: en_US: Markdown Card diff --git a/src/langbot/pkg/platform/sources/wechatpad.yaml b/src/langbot/pkg/platform/sources/wechatpad.yaml index b936dcae..f1e1b674 100644 --- a/src/langbot/pkg/platform/sources/wechatpad.yaml +++ b/src/langbot/pkg/platform/sources/wechatpad.yaml @@ -4,10 +4,10 @@ metadata: name: wechatpad label: en_US: WeChatPad - zh_CN: WeChatPad(个人微信ipad) + zh_Hans: WeChatPad(个人微信ipad) description: en_US: WeChatPad Adapter - zh_CN: WeChatPad 适配器 + zh_Hans: WeChatPad 适配器,基于WeChatPad的个人微信解决方案,请查看文档了解使用方式 icon: wechatpad.png spec: config: diff --git a/src/langbot/pkg/platform/sources/wecom.yaml b/src/langbot/pkg/platform/sources/wecom.yaml index c732f699..ecbb51ba 100644 --- a/src/langbot/pkg/platform/sources/wecom.yaml +++ b/src/langbot/pkg/platform/sources/wecom.yaml @@ -7,10 +7,20 @@ metadata: zh_Hans: 企业微信 description: en_US: WeCom Adapter - zh_Hans: 企业微信适配器,请查看文档了解使用方式 + zh_Hans: 企业微信内部机器人,请查看文档了解使用方式 icon: wecom.png spec: config: + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + description: + en_US: Copy this URL and paste it into your WeCom app's webhook configuration + zh_Hans: 复制此地址并粘贴到企业微信应用的 Webhook 配置中 + type: webhook-url + required: false + default: "" - name: corpid label: en_US: Corpid diff --git a/src/langbot/pkg/platform/sources/wecombot.yaml b/src/langbot/pkg/platform/sources/wecombot.yaml index bf851a44..7b5c34ba 100644 --- a/src/langbot/pkg/platform/sources/wecombot.yaml +++ b/src/langbot/pkg/platform/sources/wecombot.yaml @@ -7,7 +7,7 @@ metadata: zh_Hans: 企业微信智能机器人 description: en_US: WeComBot Adapter - zh_Hans: 企业微信智能机器人适配器,请查看文档了解使用方式 + zh_Hans: 企业微信智能机器人,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式 icon: wecombot.png spec: config: @@ -35,6 +35,20 @@ spec: type: boolean required: true default: false + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + description: + en_US: Copy this URL and paste it into your WeComBot webhook configuration + zh_Hans: 复制此地址并粘贴到企业微信智能机器人的 Webhook 配置中 + type: webhook-url + required: false + default: "" + show_if: + field: enable-webhook + operator: eq + value: true - name: Secret label: en_US: Secret diff --git a/src/langbot/pkg/platform/sources/wecomcs.yaml b/src/langbot/pkg/platform/sources/wecomcs.yaml index a1be068e..f28cad75 100644 --- a/src/langbot/pkg/platform/sources/wecomcs.yaml +++ b/src/langbot/pkg/platform/sources/wecomcs.yaml @@ -7,10 +7,20 @@ metadata: zh_Hans: 企业微信客服 description: en_US: WeComCSAdapter - zh_Hans: 企业微信客服适配器 + zh_Hans: 企业微信对外客服机器人,需要公网地址以接收消息推送,请查看文档了解使用方式 icon: wecom.png spec: config: + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + description: + en_US: Copy this URL and paste it into your WeCom Customer Service webhook configuration + zh_Hans: 复制此地址并粘贴到企业微信客服的 Webhook 配置中 + type: webhook-url + required: false + default: "" - name: corpid label: en_US: Corpid 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 7048aab7..8fdcd9bb 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { IChooseAdapterEntity, IPipelineEntity, @@ -19,9 +19,7 @@ import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; -import { Copy, Check } from 'lucide-react'; -import { Button } from '@/components/ui/button'; import { Form, FormControl, @@ -110,30 +108,11 @@ export default function BotForm({ const [, setIsLoading] = useState(false); const [webhookUrl, setWebhookUrl] = useState(''); 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'); const currentAdapterConfig = form.watch('adapter_config'); - // Derive the filtered config list via useMemo instead of useEffect+setState - // to avoid creating new array references that would cause DynamicFormComponent - // to re-subscribe its form.watch, re-emit values, and trigger an infinite loop. - // Only depend on the specific field we care about (enable-webhook) rather than - // the entire currentAdapterConfig object, which changes on every emission. - const enableWebhook = currentAdapterConfig?.['enable-webhook']; - const filteredDynamicFormConfigList = useMemo(() => { - if (currentAdapter === 'lark' && enableWebhook === false) { - // Hide encrypt-key field when webhook is disabled - return dynamicFormConfigList.filter( - (config) => config.name !== 'encrypt-key', - ); - } - // For non-Lark adapters or when webhook is enabled/undefined, show all fields - return dynamicFormConfigList; - }, [currentAdapter, enableWebhook, dynamicFormConfigList]); - // Notify parent when dirty state changes const { isDirty } = form.formState; useEffect(() => { @@ -144,43 +123,6 @@ export default function BotForm({ setBotFormValues(); }, []); - 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(() => { - fallbackCopy(text, setStatus); - }); - } else { - fallbackCopy(text, setStatus); - } - }; - - 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); - } - }; - function setBotFormValues() { isInitializing.current = true; initBotFormComponent().then(() => { @@ -384,12 +326,6 @@ export default function BotForm({ } } - // --- Webhook URL display helper --- - const showWebhook = - initBotId && - webhookUrl && - (currentAdapter !== 'lark' || enableWebhook !== false); - return (
- {/* Webhook URL: shown after adapter is selected (edit mode only) */} - {showWebhook && ( - - {t('bots.webhookUrl')} -
- { - (e.target as HTMLInputElement).select(); - }} - /> - -
- {extraWebhookUrl && ( -
- { - (e.target as HTMLInputElement).select(); - }} - /> - -
- )} - - {extraWebhookUrl - ? t('bots.webhookUrlHintEither') - : t('bots.webhookUrlHint')} - -
- )} - - {showDynamicForm && filteredDynamicFormConfigList.length > 0 && ( + {showDynamicForm && dynamicFormConfigList.length > 0 && ( { form.setValue('adapter_config', values, { shouldDirty: !isInitializing.current, }); }} + systemContext={{ + webhook_url: webhookUrl, + extra_webhook_url: extraWebhookUrl, + }} /> )} diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index f9e86815..672b57e6 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -11,10 +11,13 @@ import { FormMessage, } from '@/components/ui/form'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; -import { useEffect, useRef } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Copy, Check } from 'lucide-react'; /** * Resolve the value referenced by a `show_if.field` string. @@ -40,6 +43,89 @@ function resolveShowIfValue( return externalDependentValues?.[field]; } +/** + * Display-only component for webhook URL fields. + * Rendered outside of react-hook-form binding since the value is + * read-only and comes from systemContext, not user input. + */ +function WebhookUrlField({ + label, + description, + url, + extraUrl, +}: { + label: string; + description?: string; + url: string; + extraUrl?: string; +}) { + const [copied, setCopied] = useState(false); + const [extraCopied, setExtraCopied] = useState(false); + + const handleCopy = (text: string, setter: (v: boolean) => void) => { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard + .writeText(text) + .then(() => { + setter(true); + setTimeout(() => setter(false), 2000); + }) + .catch(() => {}); + } + }; + + return ( + + {label} +
+ (e.target as HTMLInputElement).select()} + /> + +
+ {extraUrl && ( +
+ (e.target as HTMLInputElement).select()} + /> + +
+ )} + {description && ( +

{description}

+ )} +
+ ); +} + export default function DynamicFormComponent({ itemConfigList, onSubmit, @@ -99,9 +185,16 @@ export default function DynamicFormComponent({ return value; }; + // Filter out display-only field types (e.g. webhook-url) that should not + // participate in form state, validation, or value emission. + const editableItems = useMemo( + () => itemConfigList.filter((item) => item.type !== 'webhook-url'), + [itemConfigList], + ); + // 根据 itemConfigList 动态生成 zod schema const formSchema = z.object( - itemConfigList.reduce( + editableItems.reduce( (acc, item) => { let fieldSchema; switch (item.type) { @@ -179,7 +272,7 @@ export default function DynamicFormComponent({ const form = useForm({ resolver: zodResolver(formSchema), - defaultValues: itemConfigList.reduce((acc, item) => { + defaultValues: editableItems.reduce((acc, item) => { // 优先使用 initialValues,如果没有则使用默认值 const rawValue = initialValues?.[item.name] ?? item.default; return { @@ -207,7 +300,7 @@ export default function DynamicFormComponent({ if (initialValues && hasRealChange) { // 合并默认值和初始值 - const mergedValues = itemConfigList.reduce( + const mergedValues = editableItems.reduce( (acc, item) => { const rawValue = initialValues[item.name] ?? item.default; acc[item.name] = normalizeFieldValue(item, rawValue) as object; @@ -222,7 +315,7 @@ export default function DynamicFormComponent({ previousInitialValues.current = initialValues; } - }, [initialValues, form, itemConfigList]); + }, [initialValues, form, editableItems]); // Get reactive form values for conditional rendering const watchedValues = form.watch(); @@ -238,7 +331,7 @@ export default function DynamicFormComponent({ // even if the user saves without modifying any field. // form.watch(callback) only fires on subsequent changes, not on mount. const formValues = form.getValues(); - const initialFinalValues = itemConfigList.reduce( + const initialFinalValues = editableItems.reduce( (acc, item) => { acc[item.name] = formValues[item.name] ?? item.default; return acc; @@ -258,7 +351,7 @@ export default function DynamicFormComponent({ const subscription = form.watch(() => { const formValues = form.getValues(); - const finalValues = itemConfigList.reduce( + const finalValues = editableItems.reduce( (acc, item) => { acc[item.name] = formValues[item.name] ?? item.default; return acc; @@ -269,7 +362,7 @@ export default function DynamicFormComponent({ previousInitialValues.current = finalValues as Record; }); return () => subscription.unsubscribe(); - }, [form, itemConfigList]); + }, [form, editableItems]); return ( @@ -307,6 +400,29 @@ export default function DynamicFormComponent({ // All fields are disabled when editing (creation_settings are immutable) const isFieldDisabled = !!isEditing; + // Webhook URL fields are display-only; render outside of form binding + if (config.type === 'webhook-url') { + const webhookUrl = (systemContext?.webhook_url as string) || ''; + const extraWebhookUrl = + (systemContext?.extra_webhook_url as string) || ''; + + if (!webhookUrl) return null; + + return ( + + ); + } + // Boolean fields use a special inline layout if (config.type === 'boolean') { return ( diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 0c0d1cf8..da83499d 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -1313,7 +1313,7 @@ export default function HomeSidebar({ onClick={() => setApiKeyDialogOpen(true)} tooltip={t('common.apiIntegration')} > - + {t('common.apiIntegration')} @@ -1331,6 +1331,7 @@ export default function HomeSidebar({ viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" + className="text-blue-500" > diff --git a/web/src/app/home/components/home-sidebar/sidbarConfigList.tsx b/web/src/app/home/components/home-sidebar/sidbarConfigList.tsx index 50a6c98b..fab6d407 100644 --- a/web/src/app/home/components/home-sidebar/sidbarConfigList.tsx +++ b/web/src/app/home/components/home-sidebar/sidbarConfigList.tsx @@ -15,6 +15,7 @@ export const sidebarConfigList = [ xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" + className="text-blue-500" > @@ -37,6 +38,7 @@ export const sidebarConfigList = [ xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" + className="text-blue-500" > @@ -57,6 +59,7 @@ export const sidebarConfigList = [ xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" + className="text-blue-500" > @@ -78,6 +81,7 @@ export const sidebarConfigList = [ xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" + className="text-blue-500" > @@ -99,6 +103,7 @@ export const sidebarConfigList = [ xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" + className="text-blue-500" > @@ -122,6 +127,7 @@ export const sidebarConfigList = [ xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" + className="text-blue-500" > @@ -143,6 +149,7 @@ export const sidebarConfigList = [ xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" + className="text-blue-500" > @@ -164,6 +171,7 @@ export const sidebarConfigList = [ xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" + className="text-blue-500" > diff --git a/web/src/app/home/knowledge/components/kb-form/KBForm.tsx b/web/src/app/home/knowledge/components/kb-form/KBForm.tsx index 3d620cd1..025bdb92 100644 --- a/web/src/app/home/knowledge/components/kb-form/KBForm.tsx +++ b/web/src/app/home/knowledge/components/kb-form/KBForm.tsx @@ -304,7 +304,7 @@ export default function KBForm({ {t('knowledge.noEnginesAvailable')}

{t('knowledge.installEngineHint')} diff --git a/web/src/app/home/layout.tsx b/web/src/app/home/layout.tsx index cdcd5373..a9e62c47 100644 --- a/web/src/app/home/layout.tsx +++ b/web/src/app/home/layout.tsx @@ -15,8 +15,13 @@ import { useSidebarData, } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { I18nObject } from '@/app/infra/entities/common'; -import { userInfo, initializeUserInfo } from '@/app/infra/http'; -import { usePathname } from 'next/navigation'; +import { + userInfo, + systemInfo, + initializeUserInfo, + initializeSystemInfo, +} from '@/app/infra/http'; +import { usePathname, useRouter } from 'next/navigation'; import Link from 'next/link'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { CircleHelp } from 'lucide-react'; @@ -50,6 +55,8 @@ export default function HomeLayout({ }: Readonly<{ children: React.ReactNode; }>) { + const router = useRouter(); + // Initialize user info if not already initialized useEffect(() => { if (!userInfo) { @@ -57,6 +64,22 @@ export default function HomeLayout({ } }, []); + // Auto-redirect to wizard on first visit (wizard not yet completed on this instance) + useEffect(() => { + const checkWizard = async () => { + try { + // Always re-fetch to ensure we have the latest wizard_status from backend + await initializeSystemInfo(); + if (systemInfo.wizard_status === 'none') { + router.replace('/wizard'); + } + } catch { + // If fetching system info fails, don't redirect + } + }; + checkWizard(); + }, [router]); + return ( {children} @@ -143,7 +166,9 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) { -
{mainContent}
+
+ {mainContent} +
diff --git a/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx b/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx index b419cb7b..f6b5d6e7 100644 --- a/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx +++ b/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx @@ -147,23 +147,16 @@ export default function TrafficChart({

{t('monitoring.trafficChart.title')}

-
+
- + -

- {t('monitoring.trafficChart.noData')} -

+
{t('monitoring.trafficChart.noData')}
); diff --git a/web/src/app/home/monitoring/page.tsx b/web/src/app/home/monitoring/page.tsx index b3937aad..80661ea7 100644 --- a/web/src/app/home/monitoring/page.tsx +++ b/web/src/app/home/monitoring/page.tsx @@ -188,7 +188,7 @@ function MonitoringPageContent() { }; return ( -
+
{/* Filters and Refresh Button - Sticky */}
@@ -379,26 +379,18 @@ function MonitoringPageContent() { {!loading && (!data || !data.messages || data.messages.length === 0) && ( -
+
- + -

+

{t('monitoring.messageList.noMessages')} -

-

- {t('monitoring.messageList.noMessagesDescription')} -

+
)}
@@ -600,23 +592,18 @@ function MonitoringPageContent() { (!data || !data.modelCalls || data.modelCalls.length === 0) && ( -
+
- + -

+

{t('monitoring.modelCalls.noData')} -

+
)}
@@ -775,23 +762,18 @@ function MonitoringPageContent() { {!loading && (!data || !data.errors || data.errors.length === 0) && ( -
+
- + -

+

{t('monitoring.errors.noErrors')} -

+
)}
diff --git a/web/src/app/home/plugins/PluginDetailContent.tsx b/web/src/app/home/plugins/PluginDetailContent.tsx index 285cf331..71a66533 100644 --- a/web/src/app/home/plugins/PluginDetailContent.tsx +++ b/web/src/app/home/plugins/PluginDetailContent.tsx @@ -86,6 +86,8 @@ export default function PluginDetailContent({ id }: { id: string }) { onFormSubmit={handleFormSubmit} />
+ {/* Divider */} +
{/* Right side - Readme */}
diff --git a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx index 9f20c8c2..dca07ddc 100644 --- a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useCallback, useRef, Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; import { Input } from '@/components/ui/input'; import { Select, @@ -46,9 +47,24 @@ function MarketPageContent({ installPlugin: (plugin: PluginV4) => void; }) { const { t } = useTranslation(); + const searchParams = useSearchParams(); + + const validCategories = [ + 'Tool', + 'Command', + 'EventListener', + 'KnowledgeEngine', + 'Parser', + ]; const [searchQuery, setSearchQuery] = useState(''); - const [componentFilter, setComponentFilter] = useState('all'); + const [componentFilter, setComponentFilter] = useState(() => { + const category = searchParams.get('category'); + if (category && validCategories.includes(category)) { + return category; + } + return 'all'; + }); const [selectedTags, setSelectedTags] = useState([]); const [availableTags, setAvailableTags] = useState([]); const [tagNames, setTagNames] = useState>({}); @@ -284,6 +300,18 @@ function MarketPageContent({ setComponentFilter(value); setCurrentPage(1); setPlugins([]); + + // Update URL query param to keep it in sync + const params = new URLSearchParams(window.location.search); + if (value === 'all') { + params.delete('category'); + } else { + params.set('category', value); + } + const newUrl = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname; + window.history.replaceState({}, '', newUrl); // fetchPlugins will be called by useEffect when componentFilter changes }, []); diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index b9af3d93..26881cda 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -260,6 +260,7 @@ export interface ApiRespSystemInfo { allow_modify_login_info: boolean; disable_models_service: boolean; limitation: SystemLimitation; + wizard_status: string; // 'none' | 'skipped' | 'completed' } export interface RagMigrationStatusResp { diff --git a/web/src/app/infra/entities/form/dynamic.ts b/web/src/app/infra/entities/form/dynamic.ts index 3f57b0f9..f13871eb 100644 --- a/web/src/app/infra/entities/form/dynamic.ts +++ b/web/src/app/infra/entities/form/dynamic.ts @@ -42,6 +42,7 @@ export enum DynamicFormItemType { KNOWLEDGE_BASE_MULTI_SELECTOR = 'knowledge-base-multi-selector', PLUGIN_SELECTOR = 'plugin-selector', BOT_SELECTOR = 'bot-selector', + WEBHOOK_URL = 'webhook-url', } export interface IFileConfig { diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index d234ddd1..c2e2b247 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -701,6 +701,10 @@ export class BackendClient extends BaseHttpClient { return this.get('/api/v1/system/info'); } + public updateWizardStatus(status: 'skipped' | 'completed'): Promise { + return this.post('/api/v1/system/wizard/completed', { status }); + } + public getAsyncTasks(): Promise { return this.get('/api/v1/system/tasks'); } diff --git a/web/src/app/infra/http/index.ts b/web/src/app/infra/http/index.ts index 3ef8761d..1da49ba9 100644 --- a/web/src/app/infra/http/index.ts +++ b/web/src/app/infra/http/index.ts @@ -3,7 +3,7 @@ import { CloudServiceClient } from './CloudServiceClient'; import { ApiRespSystemInfo } from '@/app/infra/entities/api'; // 系统信息 -export let systemInfo: ApiRespSystemInfo = { +export const systemInfo: ApiRespSystemInfo = { debug: false, version: '', edition: 'community', @@ -16,6 +16,7 @@ export let systemInfo: ApiRespSystemInfo = { max_pipelines: -1, max_extensions: -1, }, + wizard_status: 'none', }; // 用户信息 @@ -50,7 +51,7 @@ if (typeof window !== 'undefined' && systemInfo.cloud_service_url === '') { backendClient .getSystemInfo() .then((info) => { - systemInfo = info; + Object.assign(systemInfo, info); cloudServiceClient.updateBaseURL(info.cloud_service_url); }) .catch((error) => { @@ -65,7 +66,7 @@ if (typeof window !== 'undefined' && systemInfo.cloud_service_url === '') { export const getCloudServiceClient = async (): Promise => { if (systemInfo.cloud_service_url === '') { try { - systemInfo = await backendClient.getSystemInfo(); + Object.assign(systemInfo, await backendClient.getSystemInfo()); // 更新 cloud service client 的 baseURL cloudServiceClient.updateBaseURL(systemInfo.cloud_service_url); } catch (error) { @@ -90,7 +91,7 @@ export const getCloudServiceClientSync = (): CloudServiceClient => { */ export const initializeSystemInfo = async (): Promise => { try { - systemInfo = await backendClient.getSystemInfo(); + Object.assign(systemInfo, await backendClient.getSystemInfo()); cloudServiceClient.updateBaseURL(systemInfo.cloud_service_url); } catch (error) { console.error('Failed to initialize system info:', error); diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index b9e32d8a..e5a2f907 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -18,6 +18,7 @@ import { import { httpClient } from '@/app/infra/http/HttpClient'; import { userInfo, + systemInfo, initializeUserInfo, initializeSystemInfo, } from '@/app/infra/http'; @@ -29,6 +30,7 @@ import { } from '@/app/infra/entities/pipeline'; import { DynamicFormItemConfig, + getDefaultValues, parseDynamicFormItemType, } from '@/app/home/components/dynamic-form/DynamicFormItemConfig'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; @@ -47,63 +49,20 @@ import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { cn } from '@/lib/utils'; import { LanguageSelector } from '@/components/ui/language-selector'; import { - AlertDialog, - AlertDialogAction, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -interface WizardState { - currentStep: number; - selectedAdapter: string | null; - selectedRunner: string | null; - botName: string; - botDescription: string; - adapterConfig: Record; - runnerConfig: Record; - createdBotUuid: string | null; -} - -const WIZARD_STORAGE_KEY = 'langbot_wizard_state'; - const TOTAL_STEPS = 4; -// --------------------------------------------------------------------------- -// Persistence helpers -// --------------------------------------------------------------------------- - -function loadWizardState(): WizardState | null { - if (typeof window === 'undefined') return null; - try { - const raw = localStorage.getItem(WIZARD_STORAGE_KEY); - if (!raw) return null; - return JSON.parse(raw) as WizardState; - } catch { - return null; - } -} - -function saveWizardState(state: WizardState): void { - if (typeof window === 'undefined') return; - try { - localStorage.setItem(WIZARD_STORAGE_KEY, JSON.stringify(state)); - } catch { - // localStorage may be full - silently ignore - } -} - -function clearWizardState(): void { - if (typeof window === 'undefined') return; - localStorage.removeItem(WIZARD_STORAGE_KEY); -} - // --------------------------------------------------------------------------- // Main Wizard Page (full-screen, no sidebar) // --------------------------------------------------------------------------- @@ -113,30 +72,19 @@ export default function WizardPage() { const router = useRouter(); // ---- Wizard state ---- - const restoredState = useRef(loadWizardState()); - const [currentStep, setCurrentStep] = useState( - restoredState.current?.currentStep ?? 0, - ); - const [selectedAdapter, setSelectedAdapter] = useState( - restoredState.current?.selectedAdapter ?? null, - ); - const [selectedRunner, setSelectedRunner] = useState( - restoredState.current?.selectedRunner ?? null, - ); - const [botName, setBotName] = useState(restoredState.current?.botName ?? ''); + const [currentStep, setCurrentStep] = useState(0); + const [selectedAdapter, setSelectedAdapter] = useState(null); + const [selectedRunner, setSelectedRunner] = useState(null); + const [botName, setBotName] = useState(''); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [botDescription, _setBotDescription] = useState( - restoredState.current?.botDescription ?? '', - ); + const [botDescription, _setBotDescription] = useState(''); const [adapterConfig, setAdapterConfig] = useState>( - restoredState.current?.adapterConfig ?? {}, - ); - const [runnerConfig, setRunnerConfig] = useState>( - restoredState.current?.runnerConfig ?? {}, - ); - const [createdBotUuid, setCreatedBotUuid] = useState( - restoredState.current?.createdBotUuid ?? null, + {}, ); + const [runnerConfig, setRunnerConfig] = useState>({}); + const [createdBotUuid, setCreatedBotUuid] = useState(null); + const [webhookUrl, setWebhookUrl] = useState(''); + const [extraWebhookUrl, setExtraWebhookUrl] = useState(''); // ---- Remote data ---- const [adapters, setAdapters] = useState([]); @@ -149,29 +97,6 @@ export default function WizardPage() { const [isSavingBot, setIsSavingBot] = useState(false); const [botSaved, setBotSaved] = useState(false); - // ---- Persist state on every change ---- - useEffect(() => { - saveWizardState({ - currentStep, - selectedAdapter, - selectedRunner, - botName, - botDescription, - adapterConfig, - runnerConfig, - createdBotUuid, - }); - }, [ - currentStep, - selectedAdapter, - selectedRunner, - botName, - botDescription, - adapterConfig, - runnerConfig, - createdBotUuid, - ]); - // ---- Fetch remote data ---- useEffect(() => { let cancelled = false; @@ -300,16 +225,34 @@ export default function WizardPage() { : selectedAdapter; setBotName(defaultName); + const defaultConfig = adapter + ? getDefaultValues(adapter.spec.config) + : {}; + const bot: Bot = { name: defaultName, description: '', adapter: selectedAdapter, - adapter_config: {}, + adapter_config: defaultConfig, enable: false, }; const resp = await httpClient.createBot(bot); setCreatedBotUuid(resp.uuid); - toast.success(t('wizard.botCreateSuccess')); + + // Fetch runtime info to get webhook URL(s) + try { + const botData = await httpClient.getBot(resp.uuid); + const runtimeValues = botData.bot.adapter_runtime_values as + | Record + | undefined; + setWebhookUrl((runtimeValues?.webhook_full_url as string) || ''); + setExtraWebhookUrl( + (runtimeValues?.extra_webhook_full_url as string) || '', + ); + } catch { + // Non-critical — webhook URL display is optional + } + // Advance to Step 1 setCurrentStep(1); } catch (err) { @@ -338,6 +281,20 @@ export default function WizardPage() { enable: true, }); setBotSaved(true); + + // Re-fetch runtime info to get updated webhook URL(s) + try { + const botData = await httpClient.getBot(createdBotUuid); + const runtimeValues = botData.bot.adapter_runtime_values as + | Record + | undefined; + setWebhookUrl((runtimeValues?.webhook_full_url as string) || ''); + setExtraWebhookUrl( + (runtimeValues?.extra_webhook_full_url as string) || '', + ); + } catch { + // Non-critical + } } catch (err) { const apiErr = err as { msg?: string }; toast.error( @@ -403,7 +360,6 @@ export default function WizardPage() { use_pipeline_uuid: pipelineResp.uuid, }); - toast.success(t('wizard.createSuccess')); setCurrentStep(3); } catch (err) { const apiErr = err as { msg?: string }; @@ -442,11 +398,24 @@ export default function WizardPage() { // ---- Skip handler ---- const [showSkipConfirm, setShowSkipConfirm] = useState(false); + const [isSkipping, setIsSkipping] = useState(false); - const handleSkipConfirm = useCallback(() => { - clearWizardState(); + const handleSkipConfirm = useCallback(async () => { + if (systemInfo.wizard_status === 'none') { + setIsSkipping(true); + try { + await httpClient.updateWizardStatus('skipped'); + systemInfo.wizard_status = 'skipped'; + } catch { + toast.error(t('wizard.skipSaveError')); + setIsSkipping(false); + return; // Dialog stays open — user can retry + } + setIsSkipping(false); + } + setShowSkipConfirm(false); router.push('/home'); - }, [router]); + }, [router, t]); // ---- Render ---- @@ -563,6 +532,8 @@ export default function WizardPage() { isSavingBot={isSavingBot} botSaved={botSaved} onSaveBot={handleSaveBot} + webhookUrl={webhookUrl} + extraWebhookUrl={extraWebhookUrl} /> )} {currentStep === 2 && ( @@ -623,21 +594,31 @@ export default function WizardPage() { )} {/* Skip confirmation dialog */} - - - - {t('wizard.skip')} - + + + + {t('wizard.skip')} + {t('wizard.skipConfirmMessage')} - - - - + + + + + + + +
); } @@ -722,6 +703,8 @@ function StepBotConfig({ isSavingBot, botSaved, onSaveBot, + webhookUrl, + extraWebhookUrl, }: { adapterConfigItems: IDynamicFormItemSchema[]; adapterConfigValues: Record; @@ -732,6 +715,8 @@ function StepBotConfig({ isSavingBot: boolean; botSaved: boolean; onSaveBot: () => void; + webhookUrl: string; + extraWebhookUrl: string; }) { const { t } = useTranslation(); @@ -787,7 +772,11 @@ function StepBotConfig({ itemConfigList={adapterConfigItems} initialValues={adapterConfigValues as Record} onSubmit={stableAdapterConfigCb} - systemContext={{ is_wizard: true }} + systemContext={{ + is_wizard: true, + webhook_url: webhookUrl, + extra_webhook_url: extraWebhookUrl, + }} /> @@ -1037,10 +1026,23 @@ function StepDone() { })), ); - const handleBack = useCallback(() => { - clearWizardState(); + const [isCompleting, setIsCompleting] = useState(false); + + const handleBack = useCallback(async () => { + if (systemInfo.wizard_status === 'none') { + setIsCompleting(true); + try { + await httpClient.updateWizardStatus('completed'); + systemInfo.wizard_status = 'completed'; + } catch { + toast.error(t('wizard.completeSaveError')); + setIsCompleting(false); + return; // Don't navigate — let user retry + } + setIsCompleting(false); + } router.push('/home/bots'); - }, [router]); + }, [router, t]); return (
@@ -1065,7 +1067,8 @@ function StepDone() {

{t('wizard.done.description')}

- diff --git a/web/src/components/ui/sidebar.tsx b/web/src/components/ui/sidebar.tsx index a8028930..ae7fd1ab 100644 --- a/web/src/components/ui/sidebar.tsx +++ b/web/src/components/ui/sidebar.tsx @@ -316,7 +316,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {