mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-28 04:16:50 +08:00
feat(web): add sidebar onboarding guide
This commit is contained in:
@@ -123,6 +123,7 @@ import {
|
||||
useWorkspaceQuotaStatus,
|
||||
} from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
||||
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
||||
import { SidebarGuide } from './SidebarGuide';
|
||||
|
||||
// Compare two version strings, returns true if v1 > v2
|
||||
function compareVersions(v1: string, v2: string): boolean {
|
||||
@@ -588,7 +589,7 @@ function NavItems({
|
||||
}
|
||||
// Non-entity entries (e.g. monitoring and the extension market) render as plain links.
|
||||
return (
|
||||
<SidebarMenuItem key={config.id}>
|
||||
<SidebarMenuItem key={config.id} data-sidebar-guide={config.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={selectedChild?.id === config.id}
|
||||
onClick={() => onChildClick(config)}
|
||||
@@ -1017,7 +1018,7 @@ function NavItems({
|
||||
// Popover flyout for collapsed sidebar
|
||||
if (showPopover) {
|
||||
return (
|
||||
<SidebarMenuItem key={config.id}>
|
||||
<SidebarMenuItem key={config.id} data-sidebar-guide={config.id}>
|
||||
<Popover
|
||||
open={popoverOpen[config.id] ?? false}
|
||||
onOpenChange={(open) =>
|
||||
@@ -1199,7 +1200,7 @@ function NavItems({
|
||||
onOpenChange={(open) => onSectionToggle(config.id, open)}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuItem data-sidebar-guide={config.id}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={false}
|
||||
@@ -2210,7 +2211,7 @@ export default function HomeSidebar({
|
||||
<SidebarFooter>
|
||||
{/* Models entry */}
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuItem data-sidebar-guide="models">
|
||||
<SidebarMenuButton
|
||||
onClick={() => openSettings('models')}
|
||||
tooltip={t('models.title')}
|
||||
@@ -2224,7 +2225,7 @@ export default function HomeSidebar({
|
||||
{/* API-key management is available only to authorized Workspace roles. */}
|
||||
{currentWorkspace?.permissions.includes('api_key.manage') && (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuItem data-sidebar-guide="api-integration">
|
||||
<SidebarMenuButton
|
||||
onClick={() => openSettings('apiIntegration')}
|
||||
tooltip={t('common.apiIntegration')}
|
||||
@@ -2456,6 +2457,7 @@ export default function HomeSidebar({
|
||||
onOpenChange={setVersionDialogOpen}
|
||||
release={latestRelease}
|
||||
/>
|
||||
<SidebarGuide />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Check, ChevronRight } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useSidebar } from '@/components/ui/sidebar';
|
||||
|
||||
const SIDEBAR_GUIDE_STORAGE_KEY = 'langbot_sidebar_guide_v1';
|
||||
const MIN_POPOVER_WIDTH = 196;
|
||||
const MAX_POPOVER_WIDTH = 320;
|
||||
const VIEWPORT_GAP = 12;
|
||||
|
||||
const GUIDE_STEP_IDS = [
|
||||
'monitoring',
|
||||
'bots',
|
||||
'pipelines',
|
||||
'knowledge',
|
||||
'plugins',
|
||||
'add-extension',
|
||||
'models',
|
||||
'api-integration',
|
||||
] as const;
|
||||
|
||||
type GuideStepId = (typeof GUIDE_STEP_IDS)[number];
|
||||
|
||||
type TargetRect = {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type PopoverPosition = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
function loadStoredStep(): number | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(SIDEBAR_GUIDE_STORAGE_KEY);
|
||||
if (stored === 'completed') return null;
|
||||
|
||||
const parsed = Number.parseInt(stored ?? '0', 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
||||
return Math.min(parsed, GUIDE_STEP_IDS.length - 1);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function storeGuideProgress(value: string) {
|
||||
try {
|
||||
localStorage.setItem(SIDEBAR_GUIDE_STORAGE_KEY, value);
|
||||
} catch {
|
||||
// The guide remains usable when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function getTarget(stepId: GuideStepId): HTMLElement | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
return document.querySelector<HTMLElement>(
|
||||
`[data-sidebar-guide="${stepId}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
function findAvailableStep(startIndex: number): number | null {
|
||||
for (let index = startIndex; index < GUIDE_STEP_IDS.length; index += 1) {
|
||||
const target = getTarget(GUIDE_STEP_IDS[index]);
|
||||
if (target) return index;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), Math.max(min, max));
|
||||
}
|
||||
|
||||
export function SidebarGuide() {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile, open, openMobile, setOpen, setOpenMobile } = useSidebar();
|
||||
const [stepIndex, setStepIndex] = useState<number | null>(loadStoredStep);
|
||||
const [targetRect, setTargetRect] = useState<TargetRect | null>(null);
|
||||
const [popoverPosition, setPopoverPosition] =
|
||||
useState<PopoverPosition | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const confirmButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const originalSidebarStateRef = useRef<{
|
||||
open: boolean;
|
||||
openMobile: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const activeStepId =
|
||||
stepIndex === null ? null : (GUIDE_STEP_IDS[stepIndex] ?? null);
|
||||
|
||||
const visibleStepCount = GUIDE_STEP_IDS.filter((stepId) =>
|
||||
getTarget(stepId),
|
||||
).length;
|
||||
|
||||
const visibleStepNumber = activeStepId
|
||||
? GUIDE_STEP_IDS.slice(0, stepIndex ?? 0).filter((stepId) =>
|
||||
getTarget(stepId),
|
||||
).length + 1
|
||||
: 0;
|
||||
const isPopoverReady = popoverPosition !== null;
|
||||
|
||||
const restoreSidebarState = useCallback(() => {
|
||||
const original = originalSidebarStateRef.current;
|
||||
if (!original) return;
|
||||
if (isMobile) {
|
||||
setOpenMobile(original.openMobile);
|
||||
} else {
|
||||
setOpen(original.open);
|
||||
}
|
||||
originalSidebarStateRef.current = null;
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
const completeGuide = useCallback(() => {
|
||||
storeGuideProgress('completed');
|
||||
setStepIndex(null);
|
||||
setTargetRect(null);
|
||||
setPopoverPosition(null);
|
||||
restoreSidebarState();
|
||||
}, [restoreSidebarState]);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
if (!activeStepId) return;
|
||||
const target = getTarget(activeStepId);
|
||||
if (!target) return;
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
const nextRect: TargetRect = {
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
setTargetRect(nextRect);
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const popoverHeight = popoverRef.current?.offsetHeight ?? 210;
|
||||
const rightSpace = viewportWidth - nextRect.right - VIEWPORT_GAP * 2;
|
||||
const leftSpace = nextRect.left - VIEWPORT_GAP * 2;
|
||||
|
||||
let width = Math.min(MAX_POPOVER_WIDTH, viewportWidth - VIEWPORT_GAP * 2);
|
||||
let left = VIEWPORT_GAP;
|
||||
let top = nextRect.bottom + VIEWPORT_GAP;
|
||||
|
||||
if (rightSpace >= MIN_POPOVER_WIDTH) {
|
||||
width = Math.min(MAX_POPOVER_WIDTH, rightSpace);
|
||||
left = nextRect.right + VIEWPORT_GAP;
|
||||
top = clamp(
|
||||
nextRect.top,
|
||||
VIEWPORT_GAP,
|
||||
viewportHeight - popoverHeight - VIEWPORT_GAP,
|
||||
);
|
||||
} else if (leftSpace >= MIN_POPOVER_WIDTH) {
|
||||
width = Math.min(MAX_POPOVER_WIDTH, leftSpace);
|
||||
left = nextRect.left - VIEWPORT_GAP - width;
|
||||
top = clamp(
|
||||
nextRect.top,
|
||||
VIEWPORT_GAP,
|
||||
viewportHeight - popoverHeight - VIEWPORT_GAP,
|
||||
);
|
||||
} else {
|
||||
top =
|
||||
nextRect.bottom + VIEWPORT_GAP + popoverHeight <= viewportHeight
|
||||
? nextRect.bottom + VIEWPORT_GAP
|
||||
: nextRect.top - popoverHeight - VIEWPORT_GAP;
|
||||
top = clamp(
|
||||
top,
|
||||
VIEWPORT_GAP,
|
||||
viewportHeight - popoverHeight - VIEWPORT_GAP,
|
||||
);
|
||||
}
|
||||
|
||||
setPopoverPosition({ left, top, width });
|
||||
}, [activeStepId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stepIndex === null) return;
|
||||
|
||||
if (!originalSidebarStateRef.current) {
|
||||
originalSidebarStateRef.current = { open, openMobile };
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
setOpenMobile(true);
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [isMobile, open, openMobile, setOpen, setOpenMobile, stepIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stepIndex === null) return;
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const availableIndex = findAvailableStep(stepIndex);
|
||||
if (availableIndex === null) {
|
||||
completeGuide();
|
||||
return;
|
||||
}
|
||||
if (availableIndex !== stepIndex) {
|
||||
storeGuideProgress(String(availableIndex));
|
||||
setStepIndex(availableIndex);
|
||||
}
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [completeGuide, stepIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeStepId) return;
|
||||
const target = getTarget(activeStepId);
|
||||
if (!target) return;
|
||||
|
||||
target.scrollIntoView({ block: 'nearest' });
|
||||
|
||||
const delayedMeasure = window.setTimeout(measure, 260);
|
||||
const resizeObserver = new ResizeObserver(measure);
|
||||
resizeObserver.observe(target);
|
||||
window.addEventListener('resize', measure);
|
||||
window.addEventListener('scroll', measure, true);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(delayedMeasure);
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('resize', measure);
|
||||
window.removeEventListener('scroll', measure, true);
|
||||
};
|
||||
}, [activeStepId, measure]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPopoverReady) return;
|
||||
measure();
|
||||
confirmButtonRef.current?.focus();
|
||||
}, [activeStepId, isPopoverReady, measure]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stepIndex === null) return;
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const blockKeyboardNavigation = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
confirmButtonRef.current?.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', blockKeyboardNavigation, true);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener('keydown', blockKeyboardNavigation, true);
|
||||
};
|
||||
}, [stepIndex]);
|
||||
|
||||
function handleConfirm() {
|
||||
if (stepIndex === null) return;
|
||||
const nextIndex = findAvailableStep(stepIndex + 1);
|
||||
if (nextIndex === null) {
|
||||
completeGuide();
|
||||
return;
|
||||
}
|
||||
|
||||
storeGuideProgress(String(nextIndex));
|
||||
setTargetRect(null);
|
||||
setPopoverPosition(null);
|
||||
setStepIndex(nextIndex);
|
||||
}
|
||||
|
||||
if (
|
||||
!activeStepId ||
|
||||
!targetRect ||
|
||||
!popoverPosition ||
|
||||
typeof document === 'undefined'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isLastVisibleStep =
|
||||
findAvailableStep((stepIndex ?? GUIDE_STEP_IDS.length - 1) + 1) === null;
|
||||
const titleId = `sidebar-guide-${activeStepId}-title`;
|
||||
|
||||
return createPortal(
|
||||
<div data-testid="sidebar-guide">
|
||||
<div
|
||||
className="fixed inset-0 z-[90] cursor-default"
|
||||
aria-hidden="true"
|
||||
onClick={(event) => event.preventDefault()}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none fixed z-[91] rounded-md ring-2 ring-blue-500 ring-offset-2 ring-offset-background transition-[top,left,width,height] duration-200"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
top: targetRect.top,
|
||||
left: targetRect.left,
|
||||
width: targetRect.width,
|
||||
height: targetRect.height,
|
||||
boxShadow: '0 0 0 9999px rgb(15 23 42 / 0.56)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className="fixed z-[92] rounded-lg border bg-popover p-4 text-popover-foreground shadow-xl"
|
||||
style={popoverPosition}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||
{t('sidebarGuide.label')}
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{t('sidebarGuide.progress', {
|
||||
current: visibleStepNumber,
|
||||
total: visibleStepCount,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<h2 id={titleId} className="text-base font-semibold">
|
||||
{t(`sidebarGuide.steps.${activeStepId}.title`)}
|
||||
</h2>
|
||||
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">
|
||||
{t(`sidebarGuide.steps.${activeStepId}.description`)}
|
||||
</p>
|
||||
<Button
|
||||
ref={confirmButtonRef}
|
||||
type="button"
|
||||
className="mt-4 w-full"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{isLastVisibleStep ? (
|
||||
<Check className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
{isLastVisibleStep
|
||||
? t('sidebarGuide.finish')
|
||||
: t('sidebarGuide.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,52 @@
|
||||
const enUS = {
|
||||
sidebarGuide: {
|
||||
label: 'Workspace tour',
|
||||
progress: '{{current}} of {{total}}',
|
||||
confirm: 'Got it',
|
||||
finish: 'Finish tour',
|
||||
steps: {
|
||||
monitoring: {
|
||||
title: 'Dashboard',
|
||||
description:
|
||||
'Review bot activity, model usage, message volume, and system performance at a glance.',
|
||||
},
|
||||
bots: {
|
||||
title: 'Bots',
|
||||
description:
|
||||
'Connect LangBot to chat platforms and manage each bot connection from here.',
|
||||
},
|
||||
pipelines: {
|
||||
title: 'Processors',
|
||||
description:
|
||||
'Build reusable AI pipelines, agents, and event processors that power your bots.',
|
||||
},
|
||||
knowledge: {
|
||||
title: 'Knowledge bases',
|
||||
description:
|
||||
'Organize documents and external knowledge sources used to improve model responses.',
|
||||
},
|
||||
plugins: {
|
||||
title: 'Installed extensions',
|
||||
description:
|
||||
'Manage installed plugins, MCP servers, and skills, including their runtime status.',
|
||||
},
|
||||
'add-extension': {
|
||||
title: 'Add extensions',
|
||||
description:
|
||||
'Install capabilities from the marketplace, GitHub, or a local extension package.',
|
||||
},
|
||||
models: {
|
||||
title: 'Model configuration',
|
||||
description:
|
||||
'Configure model providers and choose the language, embedding, and other models LangBot uses.',
|
||||
},
|
||||
'api-integration': {
|
||||
title: 'API integration',
|
||||
description:
|
||||
'Create API keys and configure external access to LangBot services and MCP.',
|
||||
},
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
home: 'Home',
|
||||
extensions: 'Extensions',
|
||||
|
||||
@@ -1,4 +1,52 @@
|
||||
const jaJP = {
|
||||
sidebarGuide: {
|
||||
label: 'ワークスペースツアー',
|
||||
progress: '{{current}} / {{total}}',
|
||||
confirm: '確認しました',
|
||||
finish: 'ツアーを完了',
|
||||
steps: {
|
||||
monitoring: {
|
||||
title: 'ダッシュボード',
|
||||
description:
|
||||
'ボットの稼働状況、モデルの使用量、メッセージ数、システムの状態をまとめて確認できます。',
|
||||
},
|
||||
bots: {
|
||||
title: 'ボット',
|
||||
description:
|
||||
'チャットプラットフォームに接続し、各ボットを作成・管理します。',
|
||||
},
|
||||
pipelines: {
|
||||
title: 'プロセッサー',
|
||||
description:
|
||||
'ボットを動かす AI パイプライン、エージェント、イベントプロセッサーを作成します。',
|
||||
},
|
||||
knowledge: {
|
||||
title: 'ナレッジベース',
|
||||
description:
|
||||
'ドキュメントや外部ナレッジを管理し、モデルの回答精度を高めます。',
|
||||
},
|
||||
plugins: {
|
||||
title: 'インストール済み拡張機能',
|
||||
description:
|
||||
'プラグイン、MCP サーバー、スキルと、それぞれの実行状態を管理します。',
|
||||
},
|
||||
'add-extension': {
|
||||
title: '拡張機能を追加',
|
||||
description:
|
||||
'マーケット、GitHub、ローカルパッケージから新しい機能を追加します。',
|
||||
},
|
||||
models: {
|
||||
title: 'モデル設定',
|
||||
description:
|
||||
'モデルプロバイダーと、LangBot が使用する言語・埋め込みモデルなどを設定します。',
|
||||
},
|
||||
'api-integration': {
|
||||
title: 'API 連携',
|
||||
description:
|
||||
'API キーを作成し、LangBot サービスや MCP への外部アクセスを設定します。',
|
||||
},
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
home: 'ホーム',
|
||||
extensions: '拡張機能',
|
||||
|
||||
@@ -1,4 +1,48 @@
|
||||
const zhHans = {
|
||||
sidebarGuide: {
|
||||
label: '工作台导览',
|
||||
progress: '第 {{current}} 项,共 {{total}} 项',
|
||||
confirm: '我知道了',
|
||||
finish: '完成引导',
|
||||
steps: {
|
||||
monitoring: {
|
||||
title: '仪表盘',
|
||||
description: '集中查看机器人活动、模型调用、消息量和系统运行情况。',
|
||||
},
|
||||
bots: {
|
||||
title: '机器人',
|
||||
description: '连接聊天平台,并在这里创建和管理每一个机器人。',
|
||||
},
|
||||
pipelines: {
|
||||
title: '处理器',
|
||||
description:
|
||||
'创建可复用的 AI 流水线、智能体和事件处理器,为机器人提供能力。',
|
||||
},
|
||||
knowledge: {
|
||||
title: '知识库',
|
||||
description:
|
||||
'管理文档和外部知识源,帮助模型生成更准确、更贴合业务的回复。',
|
||||
},
|
||||
plugins: {
|
||||
title: '已安装扩展',
|
||||
description: '管理已安装的插件、MCP 服务和技能,并查看它们的运行状态。',
|
||||
},
|
||||
'add-extension': {
|
||||
title: '添加扩展',
|
||||
description: '从扩展市场、GitHub 或本地安装包为 LangBot 添加新能力。',
|
||||
},
|
||||
models: {
|
||||
title: '模型配置',
|
||||
description:
|
||||
'配置模型供应商,以及 LangBot 使用的语言模型、嵌入模型等。',
|
||||
},
|
||||
'api-integration': {
|
||||
title: 'API 集成',
|
||||
description:
|
||||
'创建 API 密钥,并配置外部系统访问 LangBot 服务和 MCP 的方式。',
|
||||
},
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
home: '首页',
|
||||
extensions: '扩展',
|
||||
|
||||
@@ -1,4 +1,48 @@
|
||||
const zhHant = {
|
||||
sidebarGuide: {
|
||||
label: '工作台導覽',
|
||||
progress: '第 {{current}} 項,共 {{total}} 項',
|
||||
confirm: '我知道了',
|
||||
finish: '完成導覽',
|
||||
steps: {
|
||||
monitoring: {
|
||||
title: '儀表板',
|
||||
description: '集中查看機器人活動、模型呼叫、訊息量和系統運行情況。',
|
||||
},
|
||||
bots: {
|
||||
title: '機器人',
|
||||
description: '連接聊天平台,並在這裡建立和管理每一個機器人。',
|
||||
},
|
||||
pipelines: {
|
||||
title: '處理器',
|
||||
description:
|
||||
'建立可重複使用的 AI 流程、代理和事件處理器,為機器人提供能力。',
|
||||
},
|
||||
knowledge: {
|
||||
title: '知識庫',
|
||||
description:
|
||||
'管理文件和外部知識來源,協助模型產生更準確、更符合業務的回覆。',
|
||||
},
|
||||
plugins: {
|
||||
title: '已安裝擴充功能',
|
||||
description: '管理已安裝的外掛、MCP 服務和技能,並查看它們的運行狀態。',
|
||||
},
|
||||
'add-extension': {
|
||||
title: '新增擴充功能',
|
||||
description: '從擴充功能市集、GitHub 或本機安裝包為 LangBot 新增能力。',
|
||||
},
|
||||
models: {
|
||||
title: '模型設定',
|
||||
description:
|
||||
'設定模型供應商,以及 LangBot 使用的語言模型、嵌入模型等。',
|
||||
},
|
||||
'api-integration': {
|
||||
title: 'API 整合',
|
||||
description:
|
||||
'建立 API 金鑰,並設定外部系統存取 LangBot 服務和 MCP 的方式。',
|
||||
},
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
home: '首頁',
|
||||
extensions: '擴展',
|
||||
|
||||
@@ -1295,6 +1295,9 @@ export async function installLangBotApiMocks(
|
||||
({ authenticated, language, storage }) => {
|
||||
localStorage.setItem('langbot_language', language);
|
||||
localStorage.setItem('extensions_group_by_type', 'false');
|
||||
if (!Object.hasOwn(storage, 'langbot_sidebar_guide_v1')) {
|
||||
localStorage.setItem('langbot_sidebar_guide_v1', 'completed');
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
localStorage.setItem('token', 'playwright-token');
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
const guideSteps = [
|
||||
'Dashboard',
|
||||
'Bots',
|
||||
'Processors',
|
||||
'Knowledge bases',
|
||||
'Installed extensions',
|
||||
'Add extensions',
|
||||
'Model configuration',
|
||||
'API integration',
|
||||
];
|
||||
|
||||
test('sidebar guide blocks navigation until every step is confirmed', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
storage: { langbot_sidebar_guide_v1: '0' },
|
||||
});
|
||||
|
||||
await page.goto('/home/monitoring');
|
||||
|
||||
const guide = page.getByTestId('sidebar-guide');
|
||||
await expect(guide).toHaveCount(1);
|
||||
await expect(guide.getByRole('dialog')).toBeVisible();
|
||||
await expect(guide.getByText('1 of 8')).toBeVisible();
|
||||
|
||||
const dashboardTarget = page.locator('[data-sidebar-guide="monitoring"]');
|
||||
const targetBox = await dashboardTarget.boundingBox();
|
||||
expect(targetBox).not.toBeNull();
|
||||
const elementAtTargetCenter = await page.evaluate(
|
||||
({ x, y }) => {
|
||||
const element = document.elementFromPoint(x, y);
|
||||
return element?.closest('[data-testid="sidebar-guide"]') !== null;
|
||||
},
|
||||
{
|
||||
x: targetBox!.x + targetBox!.width / 2,
|
||||
y: targetBox!.y + targetBox!.height / 2,
|
||||
},
|
||||
);
|
||||
expect(elementAtTargetCenter).toBe(true);
|
||||
|
||||
for (const [index, title] of guideSteps.entries()) {
|
||||
await expect(
|
||||
guide.getByRole('heading', { name: title, exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
const buttonName =
|
||||
index === guideSteps.length - 1 ? 'Finish tour' : 'Got it';
|
||||
await guide.getByRole('button', { name: buttonName }).click();
|
||||
}
|
||||
|
||||
await expect(guide).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => localStorage.getItem('langbot_sidebar_guide_v1')),
|
||||
)
|
||||
.toBe('completed');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Extension' }).click();
|
||||
await expect(page).toHaveURL(/\/home\/add-extension$/);
|
||||
|
||||
await page.reload();
|
||||
await expect(guide).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('sidebar guide popover stays inside a narrow viewport', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 760 });
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
storage: { langbot_sidebar_guide_v1: '0' },
|
||||
});
|
||||
|
||||
await page.goto('/home/monitoring');
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Dashboard' });
|
||||
await expect(dialog).toBeVisible();
|
||||
const dialogBox = await dialog.boundingBox();
|
||||
expect(dialogBox).not.toBeNull();
|
||||
expect(dialogBox!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(dialogBox!.y).toBeGreaterThanOrEqual(0);
|
||||
expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(390);
|
||||
expect(dialogBox!.y + dialogBox!.height).toBeLessThanOrEqual(760);
|
||||
});
|
||||
Reference in New Issue
Block a user