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
This commit is contained in:
Junyan Chin
2026-03-28 15:50:32 +08:00
committed by GitHub
parent 498d030da9
commit 4c904c2375
36 changed files with 543 additions and 344 deletions

View File

@@ -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')

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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: ""

View File

@@ -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

View File

@@ -7,7 +7,7 @@ metadata:
zh_Hans: Satori
description:
en_US: SatoriAdapter
zh_Hans: 古明地觉协议适配器
zh_Hans: Satori 协议适配器,支持多种平台的接入,请查看文档了解使用方式
icon: satori.png
spec:
config:

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<boolean>(false);
const [webhookUrl, setWebhookUrl] = useState<string>('');
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
const [copied, setCopied] = useState<boolean>(false);
const [extraCopied, setExtraCopied] = useState<boolean>(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<React.SetStateAction<boolean>>,
) => {
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<React.SetStateAction<boolean>>,
) => {
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 (
<Form {...form}>
<form
@@ -574,75 +510,19 @@ export default function BotForm({
)}
/>
{/* Webhook URL: shown after adapter is selected (edit mode only) */}
{showWebhook && (
<FormItem>
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
<div className="flex items-center gap-2">
<Input
value={webhookUrl}
readOnly
className="flex-1 bg-muted"
onClick={(e) => {
(e.target as HTMLInputElement).select();
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copyToClipboard(webhookUrl, setCopied)}
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
{extraWebhookUrl && (
<div className="flex items-center gap-2 mt-2">
<Input
value={extraWebhookUrl}
readOnly
className="flex-1 bg-muted"
onClick={(e) => {
(e.target as HTMLInputElement).select();
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
copyToClipboard(extraWebhookUrl, setExtraCopied)
}
>
{extraCopied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
)}
<FormDescription>
{extraWebhookUrl
? t('bots.webhookUrlHintEither')
: t('bots.webhookUrlHint')}
</FormDescription>
</FormItem>
)}
{showDynamicForm && filteredDynamicFormConfigList.length > 0 && (
{showDynamicForm && dynamicFormConfigList.length > 0 && (
<DynamicFormComponent
itemConfigList={filteredDynamicFormConfigList}
itemConfigList={dynamicFormConfigList}
initialValues={currentAdapterConfig}
onSubmit={(values) => {
form.setValue('adapter_config', values, {
shouldDirty: !isInitializing.current,
});
}}
systemContext={{
webhook_url: webhookUrl,
extra_webhook_url: extraWebhookUrl,
}}
/>
)}
</CardContent>

View File

@@ -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 (
<FormItem>
<FormLabel>{label}</FormLabel>
<div className="flex items-center gap-2">
<Input
value={url}
readOnly
className="flex-1 bg-muted"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleCopy(url, setCopied)}
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
{extraUrl && (
<div className="flex items-center gap-2 mt-2">
<Input
value={extraUrl}
readOnly
className="flex-1 bg-muted"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleCopy(extraUrl, setExtraCopied)}
>
{extraCopied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
)}
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</FormItem>
);
}
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<FormValues>({
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<string, object>;
});
return () => subscription.unsubscribe();
}, [form, itemConfigList]);
}, [form, editableItems]);
return (
<Form {...form}>
@@ -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 (
<WebhookUrlField
key={config.id}
label={extractI18nObject(config.label)}
description={
config.description
? extractI18nObject(config.description)
: undefined
}
url={webhookUrl}
extraUrl={extraWebhookUrl || undefined}
/>
);
}
// Boolean fields use a special inline layout
if (config.type === 'boolean') {
return (

View File

@@ -1313,7 +1313,7 @@ export default function HomeSidebar({
onClick={() => setApiKeyDialogOpen(true)}
tooltip={t('common.apiIntegration')}
>
<KeyRound className="size-4" />
<KeyRound className="size-4 text-blue-500" />
<span>{t('common.apiIntegration')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -1331,6 +1331,7 @@ export default function HomeSidebar({
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
className="text-blue-500"
>
<path d="M10.6144 17.7956C10.277 18.5682 9.20776 18.5682 8.8704 17.7956L7.99275 15.7854C7.21171 13.9966 5.80589 12.5726 4.0523 11.7942L1.63658 10.7219C.868536 10.381.868537 9.26368 1.63658 8.92276L3.97685 7.88394C5.77553 7.08552 7.20657 5.60881 7.97427 3.75892L8.8633 1.61673C9.19319.821767 10.2916.821765 10.6215 1.61673L11.5105 3.75894C12.2782 5.60881 13.7092 7.08552 15.5079 7.88394L17.8482 8.92276C18.6162 9.26368 18.6162 10.381 17.8482 10.7219L15.4325 11.7942C13.6789 12.5726 12.2731 13.9966 11.492 15.7854L10.6144 17.7956ZM4.53956 9.82234C6.8254 10.837 8.68402 12.5048 9.74238 14.7996 10.8008 12.5048 12.6594 10.837 14.9452 9.82234 12.6321 8.79557 10.7676 7.04647 9.74239 4.71088 8.71719 7.04648 6.85267 8.79557 4.53956 9.82234ZM19.4014 22.6899 19.6482 22.1242C20.0882 21.1156 20.8807 20.3125 21.8695 19.8732L22.6299 19.5353C23.0412 19.3526 23.0412 18.7549 22.6299 18.5722L21.9121 18.2532C20.8978 17.8026 20.0911 16.9698 19.6586 15.9269L19.4052 15.3156C19.2285 14.8896 18.6395 14.8896 18.4628 15.3156L18.2094 15.9269C17.777 16.9698 16.9703 17.8026 15.956 18.2532L15.2381 18.5722C14.8269 18.7549 14.8269 19.3526 15.2381 19.5353L15.9985 19.8732C16.9874 20.3125 17.7798 21.1156 18.2198 22.1242L18.4667 22.6899C18.6473 23.104 19.2207 23.104 19.4014 22.6899ZM18.3745 19.0469 18.937 18.4883 19.4878 19.0469 18.937 19.5898 18.3745 19.0469Z" />
</svg>

View File

@@ -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"
>
<path d="M13 9H21L11 24V15H4L13 0V9ZM11 11V7.22063L7.53238 13H13V17.3944L17.263 11H11Z"></path>
</svg>
@@ -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"
>
<path d="M2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM4 5V19H20V5H4ZM6 7H18V9H6V7ZM6 11H18V13H6V11ZM6 15H12V17H6V15Z"></path>
</svg>
@@ -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"
>
<path d="M13.5 2C13.5 2.44425 13.3069 2.84339 13 3.11805V5H18C19.6569 5 21 6.34315 21 8V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V8C3 6.34315 4.34315 5 6 5H11V3.11805C10.6931 2.84339 10.5 2.44425 10.5 2C10.5 1.17157 11.1716 0.5 12 0.5C12.8284 0.5 13.5 1.17157 13.5 2ZM6 7C5.44772 7 5 7.44772 5 8V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V8C19 7.44772 18.5523 7 18 7H13H11H6ZM2 10H0V16H2V10ZM22 10H24V16H22V10ZM9 14.5C9.82843 14.5 10.5 13.8284 10.5 13C10.5 12.1716 9.82843 11.5 9 11.5C8.17157 11.5 7.5 12.1716 7.5 13C7.5 13.8284 8.17157 14.5 9 14.5ZM15 14.5C15.8284 14.5 16.5 13.8284 16.5 13C16.5 12.1716 15.8284 11.5 15 11.5C14.1716 11.5 13.5 12.1716 13.5 13C13.5 13.8284 14.1716 14.5 15 14.5Z"></path>
</svg>
@@ -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"
>
<path d="M6 21.5C4.067 21.5 2.5 19.933 2.5 18C2.5 16.067 4.067 14.5 6 14.5C7.5852 14.5 8.92427 15.5539 9.35481 16.9992L15 16.9994V15L17 14.9994V9.24339L14.757 6.99938H9V9.00003H3V3.00003H9V4.99939H14.757L18 1.75739L22.2426 6.00003L19 9.24139V14.9994L21 15V21H15V18.9994L9.35499 19.0003C8.92464 20.4459 7.58543 21.5 6 21.5ZM6 16.5C5.17157 16.5 4.5 17.1716 4.5 18C4.5 18.8285 5.17157 19.5 6 19.5C6.82843 19.5 7.5 18.8285 7.5 18C7.5 17.1716 6.82843 16.5 6 16.5ZM19 17H17V19H19V17ZM18 4.58581L16.5858 6.00003L18 7.41424L19.4142 6.00003L18 4.58581ZM7 5.00003H5V7.00003H7V5.00003Z"></path>
</svg>
@@ -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"
>
<path d="M3 18.5V5C3 3.34315 4.34315 2 6 2H20C20.5523 2 21 2.44772 21 3V21C21 21.5523 20.5523 22 20 22H6.5C4.567 22 3 20.433 3 18.5ZM19 20V17H6.5C5.67157 17 5 17.6716 5 18.5C5 19.3284 5.67157 20 6.5 20H19ZM10 4H6C5.44772 4 5 4.44772 5 5V15.3368C5.45463 15.1208 5.9632 15 6.5 15H19V4H17V12L13.5 10L10 12V4Z"></path>
</svg>
@@ -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"
>
<path d="M7 5C7 2.79086 8.79086 1 11 1C13.2091 1 15 2.79086 15 5H18C18.5523 5 19 5.44772 19 6V9C21.2091 9 23 10.7909 23 13C23 15.2091 21.2091 17 19 17V20C19 20.5523 18.5523 21 18 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H7ZM11 3C9.89543 3 9 3.89543 9 5C9 5.23554 9.0403 5.45952 9.11355 5.66675C9.22172 5.97282 9.17461 6.31235 8.98718 6.57739C8.79974 6.84243 8.49532 7 8.17071 7H5V19H17V15.8293C17 15.5047 17.1576 15.2003 17.4226 15.0128C17.6877 14.8254 18.0272 14.7783 18.3332 14.8865C18.5405 14.9597 18.7645 15 19 15C20.1046 15 21 14.1046 21 13C21 11.8954 20.1046 11 19 11C18.7645 11 18.5405 11.0403 18.3332 11.1135C18.0272 11.2217 17.6877 11.1746 17.4226 10.9872C17.1576 10.7997 17 10.4953 17 10.1707V7H13.8293C13.5047 7 13.2003 6.84243 13.0128 6.57739C12.8254 6.31235 12.7783 5.97282 12.8865 5.66675C12.9597 5.45952 13 5.23555 13 5C13 3.89543 12.1046 3 11 3Z"></path>
</svg>
@@ -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"
>
<path d="M21 13.242V20H22V22H2V20H3V13.242C1.79401 12.435 1 11.0602 1 9.5C1 8.67286 1.25027 7.90335 1.67755 7.2612L4.5547 2.36088C4.80513 1.93859 5.26028 1.67578 5.76 1.67578H18.24C18.7397 1.67578 19.1949 1.93859 19.4453 2.36088L22.3225 7.2612C22.7497 7.90335 23 8.67286 23 9.5C23 11.0602 22.206 12.435 21 13.242ZM19 13.972C18.4511 14.0706 17.8794 14.0706 17.3305 13.972C16.1644 13.7566 15.1377 13.0712 14.5 12.1C13.8623 13.0712 12.8356 13.7566 11.6695 13.972C11.1206 14.0706 10.5489 14.0706 10 13.972C9.45108 14.0706 8.87938 14.0706 8.33053 13.972C7.16437 13.7566 6.13771 13.0712 5.5 12.1C4.86229 13.0712 3.83563 13.7566 2.66947 13.972C2.44883 14.0124 2.22434 14.0352 2 14.0404V20H5V15H10V20H19V13.972Z"></path>
</svg>
@@ -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"
>
<path d="M4.5 7.65311V16.3469L12 20.689L19.5 16.3469V7.65311L12 3.311L4.5 7.65311ZM12 1L21.5 6.5V17.5L12 23L2.5 17.5V6.5L12 1ZM6.49896 9.97065L11 12.5765V17.625H13V12.5765L17.501 9.97066L16.499 8.2398L12 10.8445L7.50104 8.2398L6.49896 9.97065Z"></path>
</svg>

View File

@@ -304,7 +304,7 @@ export default function KBForm({
{t('knowledge.noEnginesAvailable')}
</p>
<Link
href="/home/plugins"
href="/home/market?category=KnowledgeEngine"
className="text-sm text-primary hover:underline"
>
{t('knowledge.installEngineHint')}

View File

@@ -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 (
<SidebarDataProvider>
<HomeLayoutInner>{children}</HomeLayoutInner>
@@ -143,7 +166,9 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
</div>
</header>
<div className="flex-1 overflow-hidden p-4 pt-0">{mainContent}</div>
<div className="flex-1 overflow-hidden p-4 pt-0 min-w-0">
{mainContent}
</div>
<SurveyWidget />
</SidebarInset>

View File

@@ -147,23 +147,16 @@ export default function TrafficChart({
<h3 className="text-base font-semibold text-foreground mb-4">
{t('monitoring.trafficChart.title')}
</h3>
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground">
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground gap-2">
<svg
className="w-16 h-16 mb-4 text-muted-foreground/30"
fill="none"
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke="currentColor"
fill="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
<path d="M2 13H8V21H2V13ZM16 8H22V21H16V8ZM9 3H15V21H9V3ZM4 15V19H6V15H4ZM11 5V19H13V5H11ZM18 10V19H20V10H18Z"></path>
</svg>
<p className="text-sm font-medium">
{t('monitoring.trafficChart.noData')}
</p>
<div className="text-sm">{t('monitoring.trafficChart.noData')}</div>
</div>
</div>
);

View File

@@ -188,7 +188,7 @@ function MonitoringPageContent() {
};
return (
<div className="w-full h-full overflow-y-auto">
<div className="w-full h-full overflow-y-auto overflow-x-hidden">
{/* Filters and Refresh Button - Sticky */}
<div className="sticky top-[-1.5rem] z-10 -ml-[2rem] -mr-[1.5rem] -mt-[1.5rem] pt-[1.5rem] pb-4 bg-background">
<div className="ml-[2rem] mr-[1.5rem] px-[0.8rem]">
@@ -379,26 +379,18 @@ function MonitoringPageContent() {
{!loading &&
(!data || !data.messages || data.messages.length === 0) && (
<div className="text-center text-muted-foreground py-16">
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<svg
className="w-16 h-16 mx-auto mb-4 text-muted-foreground/30"
fill="none"
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke="currentColor"
fill="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
<path d="M6.45455 19L2 22.5V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V18C22 18.5523 21.5523 19 21 19H6.45455ZM4 18.3851L5.76282 17H20V5H4V18.3851Z"></path>
</svg>
<p className="text-base font-medium mb-2">
<div className="text-sm">
{t('monitoring.messageList.noMessages')}
</p>
<p className="text-sm">
{t('monitoring.messageList.noMessagesDescription')}
</p>
</div>
</div>
)}
</div>
@@ -600,23 +592,18 @@ function MonitoringPageContent() {
(!data ||
!data.modelCalls ||
data.modelCalls.length === 0) && (
<div className="text-center text-muted-foreground py-16">
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<svg
className="w-16 h-16 mx-auto mb-4 text-muted-foreground/30"
fill="none"
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke="currentColor"
fill="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
<path d="M10.6144 17.7956C10.277 18.5682 9.20776 18.5682 8.8704 17.7956L7.99275 15.7854C7.21171 13.9966 5.80589 12.5726 4.0523 11.7942L1.63658 10.7219C.868536 10.381.868537 9.26368 1.63658 8.92276L3.97685 7.88394C5.77553 7.08552 7.20657 5.60881 7.97427 3.75892L8.8633 1.61673C9.19319.821767 10.2916.821765 10.6215 1.61673L11.5105 3.75894C12.2782 5.60881 13.7092 7.08552 15.5079 7.88394L17.8482 8.92276C18.6162 9.26368 18.6162 10.381 17.8482 10.7219L15.4325 11.7942C13.6789 12.5726 12.2731 13.9966 11.492 15.7854L10.6144 17.7956ZM19.4014 22.6899 19.6482 22.1242C20.0882 21.1156 20.8807 20.3125 21.8695 19.8732L22.6299 19.5353C23.0412 19.3526 23.0412 18.7549 22.6299 18.5722L21.9121 18.2532C20.8978 17.8026 20.0911 16.9698 19.6586 15.9269L19.4052 15.3156C19.2285 14.8896 18.6395 14.8896 18.4628 15.3156L18.2094 15.9269C17.777 16.9698 16.9703 17.8026 15.956 18.2532L15.2381 18.5722C14.8269 18.7549 14.8269 19.3526 15.2381 19.5353L15.9985 19.8732C16.9874 20.3125 17.7798 21.1156 18.2198 22.1242L18.4667 22.6899C18.6473 23.104 19.2207 23.104 19.4014 22.6899Z"></path>
</svg>
<p className="text-base font-medium">
<div className="text-sm">
{t('monitoring.modelCalls.noData')}
</p>
</div>
</div>
)}
</div>
@@ -775,23 +762,18 @@ function MonitoringPageContent() {
{!loading &&
(!data || !data.errors || data.errors.length === 0) && (
<div className="text-center text-muted-foreground py-16">
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<svg
className="w-16 h-16 mx-auto mb-4 text-green-300 dark:text-green-600"
fill="none"
className="h-[3rem] w-[3rem] text-green-500 dark:text-green-600"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke="currentColor"
fill="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11.0026 16L6.75999 11.7574L8.17421 10.3431L11.0026 13.1716L16.6595 7.51472L18.0737 8.92893L11.0026 16Z"></path>
</svg>
<p className="text-base font-medium text-green-600 dark:text-green-400">
<div className="text-sm text-green-600 dark:text-green-400">
{t('monitoring.errors.noErrors')}
</p>
</div>
</div>
)}
</div>

View File

@@ -86,6 +86,8 @@ export default function PluginDetailContent({ id }: { id: string }) {
onFormSubmit={handleFormSubmit}
/>
</div>
{/* Divider */}
<div className="hidden md:block w-px bg-border shrink-0" />
{/* Right side - Readme */}
<div className="flex-1 overflow-y-auto overflow-x-hidden min-w-0">
<PluginReadme pluginAuthor={pluginAuthor} pluginName={pluginName} />

View File

@@ -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<string>('all');
const [componentFilter, setComponentFilter] = useState<string>(() => {
const category = searchParams.get('category');
if (category && validCategories.includes(category)) {
return category;
}
return 'all';
});
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<PluginTag[]>([]);
const [tagNames, setTagNames] = useState<Record<string, string>>({});
@@ -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
}, []);

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -701,6 +701,10 @@ export class BackendClient extends BaseHttpClient {
return this.get('/api/v1/system/info');
}
public updateWizardStatus(status: 'skipped' | 'completed'): Promise<void> {
return this.post('/api/v1/system/wizard/completed', { status });
}
public getAsyncTasks(): Promise<ApiRespAsyncTasks> {
return this.get('/api/v1/system/tasks');
}

View File

@@ -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<CloudServiceClient> => {
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<void> => {
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);

View File

@@ -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<string, unknown>;
runnerConfig: Record<string, unknown>;
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<string | null>(
restoredState.current?.selectedAdapter ?? null,
);
const [selectedRunner, setSelectedRunner] = useState<string | null>(
restoredState.current?.selectedRunner ?? null,
);
const [botName, setBotName] = useState(restoredState.current?.botName ?? '');
const [currentStep, setCurrentStep] = useState(0);
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(null);
const [selectedRunner, setSelectedRunner] = useState<string | null>(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<Record<string, unknown>>(
restoredState.current?.adapterConfig ?? {},
);
const [runnerConfig, setRunnerConfig] = useState<Record<string, unknown>>(
restoredState.current?.runnerConfig ?? {},
);
const [createdBotUuid, setCreatedBotUuid] = useState<string | null>(
restoredState.current?.createdBotUuid ?? null,
{},
);
const [runnerConfig, setRunnerConfig] = useState<Record<string, unknown>>({});
const [createdBotUuid, setCreatedBotUuid] = useState<string | null>(null);
const [webhookUrl, setWebhookUrl] = useState<string>('');
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
// ---- Remote data ----
const [adapters, setAdapters] = useState<Adapter[]>([]);
@@ -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<string, unknown>
| 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<string, unknown>
| 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 */}
<AlertDialog open={showSkipConfirm} onOpenChange={setShowSkipConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('wizard.skip')}</AlertDialogTitle>
<AlertDialogDescription>
<Dialog open={showSkipConfirm} onOpenChange={setShowSkipConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('wizard.skip')}</DialogTitle>
<DialogDescription>
{t('wizard.skipConfirmMessage')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={handleSkipConfirm}>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowSkipConfirm(false)}
disabled={isSkipping}
>
{t('wizard.prev')}
</Button>
<Button onClick={handleSkipConfirm} disabled={isSkipping}>
{isSkipping && (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
)}
{t('wizard.skipConfirmOk')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -722,6 +703,8 @@ function StepBotConfig({
isSavingBot,
botSaved,
onSaveBot,
webhookUrl,
extraWebhookUrl,
}: {
adapterConfigItems: IDynamicFormItemSchema[];
adapterConfigValues: Record<string, unknown>;
@@ -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<string, object>}
onSubmit={stableAdapterConfigCb}
systemContext={{ is_wizard: true }}
systemContext={{
is_wizard: true,
webhook_url: webhookUrl,
extra_webhook_url: extraWebhookUrl,
}}
/>
</CardContent>
</Card>
@@ -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 (
<div className="relative flex flex-col items-center justify-center h-full min-h-[400px]">
@@ -1065,7 +1067,8 @@ function StepDone() {
<p className="text-muted-foreground mt-2 text-center max-w-md">
{t('wizard.done.description')}
</p>
<Button className="mt-6" onClick={handleBack}>
<Button className="mt-6" onClick={handleBack} disabled={isCompleting}>
{isCompleting && <Loader2 className="w-4 h-4 mr-1.5 animate-spin" />}
{t('wizard.done.backToWorkbench')}
</Button>

View File

@@ -316,7 +316,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
<main
data-slot="sidebar-inset"
className={cn(
'bg-background relative flex w-full flex-1 flex-col',
'bg-background relative flex w-full flex-1 flex-col min-w-0',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
'dark:md:peer-data-[variant=inset]:border dark:md:peer-data-[variant=inset]:border-sidebar-border',
className,

View File

@@ -1128,6 +1128,8 @@ const enUS = {
botSaveSuccess: 'Bot configuration saved and enabled!',
createError: 'Failed to create resources',
spaceAuthError: 'Failed to initiate Space authorization',
skipSaveError: 'Failed to save skip status. Please try again.',
completeSaveError: 'Failed to save completion status. Please try again.',
step: {
platform: 'Platform',
botConfig: 'Bot Setup',

View File

@@ -1102,6 +1102,8 @@
botSaveSuccess: 'ボット設定が保存され、有効になりました!',
createError: 'リソースの作成に失敗しました',
spaceAuthError: 'Space 認証の開始に失敗しました',
skipSaveError: 'スキップ状態の保存に失敗しました。もう一度お試しください。',
completeSaveError: '完了状態の保存に失敗しました。もう一度お試しください。',
step: {
platform: 'プラットフォーム',
botConfig: 'ボット設定',

View File

@@ -1075,6 +1075,8 @@ const zhHans = {
botSaveSuccess: '机器人配置已保存并启用!',
createError: '创建资源失败',
spaceAuthError: '无法发起 Space 授权',
skipSaveError: '保存跳过状态失败,请重试。',
completeSaveError: '保存完成状态失败,请重试。',
step: {
platform: '平台接入',
botConfig: '机器人配置',

View File

@@ -1042,6 +1042,8 @@ const zhHant = {
botSaveSuccess: '機器人配置已儲存並啟用!',
createError: '建立資源失敗',
spaceAuthError: '無法發起 Space 授權',
skipSaveError: '儲存跳過狀態失敗,請重試。',
completeSaveError: '儲存完成狀態失敗,請重試。',
step: {
platform: '平台接入',
botConfig: '機器人配置',

View File

@@ -4,8 +4,9 @@
-webkit-text-size-adjust: 100%;
color: var(--color-fg-default);
background-color: transparent;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans',
Helvetica, Arial, sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica,
Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
word-wrap: break-word;
@@ -139,8 +140,9 @@
font-size: 85%;
background-color: var(--color-neutral-muted);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
'Liberation Mono', monospace;
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono',
monospace;
}
.markdown-body pre {