diff --git a/packages/frontend/core/public/onboarding/ai-onboarding.general.1.mov b/packages/frontend/core/public/onboarding/ai-onboarding.general.1.mov
new file mode 100644
index 0000000000..c40c4da634
Binary files /dev/null and b/packages/frontend/core/public/onboarding/ai-onboarding.general.1.mov differ
diff --git a/packages/frontend/core/public/onboarding/ai-onboarding.general.2.mov b/packages/frontend/core/public/onboarding/ai-onboarding.general.2.mov
new file mode 100644
index 0000000000..5aa94503a8
Binary files /dev/null and b/packages/frontend/core/public/onboarding/ai-onboarding.general.2.mov differ
diff --git a/packages/frontend/core/public/onboarding/ai-onboarding.general.3.mov b/packages/frontend/core/public/onboarding/ai-onboarding.general.3.mov
new file mode 100644
index 0000000000..eb6813f533
Binary files /dev/null and b/packages/frontend/core/public/onboarding/ai-onboarding.general.3.mov differ
diff --git a/packages/frontend/core/public/onboarding/ai-onboarding.general.4.mov b/packages/frontend/core/public/onboarding/ai-onboarding.general.4.mov
new file mode 100644
index 0000000000..0862aea60b
Binary files /dev/null and b/packages/frontend/core/public/onboarding/ai-onboarding.general.4.mov differ
diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/base-style.css.ts b/packages/frontend/core/src/components/affine/ai-onboarding/base-style.css.ts
new file mode 100644
index 0000000000..6fb370d83c
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/ai-onboarding/base-style.css.ts
@@ -0,0 +1,29 @@
+import { cssVar } from '@toeverything/theme';
+import { style } from '@vanilla-extract/css';
+
+export const dialogOverlay = style({
+ background: `linear-gradient(95deg, transparent 0px, ${cssVar('backgroundPrimaryColor')} 400px)`,
+});
+
+export const slideTransition = style({
+ transition: 'all 0.3s',
+
+ selectors: {
+ '&.preEnter, &.exiting': {
+ opacity: 0,
+ position: 'absolute',
+ },
+ '&.preEnter.left, &.exiting.left': {
+ transform: 'translateX(-100%)',
+ },
+ '&.preEnter.right, &.exiting.right': {
+ transform: 'translateX(100%)',
+ },
+ '&.exited:not([data-force-render="true"])': {
+ display: 'none',
+ },
+ '&.exited[data-force-render="true"]': {
+ visibility: 'hidden',
+ },
+ },
+});
diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/edgeless.dialog.tsx b/packages/frontend/core/src/components/affine/ai-onboarding/edgeless.dialog.tsx
new file mode 100644
index 0000000000..899709367c
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/ai-onboarding/edgeless.dialog.tsx
@@ -0,0 +1,9 @@
+import type { BaseAIOnboardingDialogProps } from './type';
+
+export const AIOnboardingEdgeless = ({
+ onDismiss: _,
+}: BaseAIOnboardingDialogProps) => {
+ return (
+
{/* TODO: open edgeless in cloud workspace for the first time */}
+ );
+};
diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/general.dialog.css.ts b/packages/frontend/core/src/components/affine/ai-onboarding/general.dialog.css.ts
new file mode 100644
index 0000000000..7500df128d
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/ai-onboarding/general.dialog.css.ts
@@ -0,0 +1,79 @@
+import { cssVar } from '@toeverything/theme';
+import { style } from '@vanilla-extract/css';
+
+export const dialog = style({
+ maxWidth: 400,
+ width: 'calc(100% - 32px)',
+ padding: 0,
+ boxShadow: 'none',
+ '::after': {
+ content: '""',
+ position: 'absolute',
+ borderRadius: 'inherit',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ boxShadow: cssVar('menuShadow'),
+ pointerEvents: 'none',
+ },
+});
+export const dialogContent = style({
+ overflow: 'hidden',
+ width: '100%',
+ height: '100%',
+ borderRadius: 'inherit',
+});
+
+export const videoHeader = style({
+ borderRadius: 'inherit',
+ borderBottomLeftRadius: 0,
+ borderBottomRightRadius: 0,
+ width: '100%',
+ height: 225,
+ overflow: 'hidden',
+});
+export const videoWrapper = style({
+ width: '100%',
+ height: '100%',
+ position: 'relative',
+ overflow: 'hidden',
+});
+export const video = style({
+ position: 'absolute',
+ left: -2,
+ top: -2,
+ width: 'calc(100% + 4px)',
+ height: 'calc(100% + 4px)',
+});
+
+export const title = style({
+ padding: '20px 24px 8px 24px',
+ fontSize: cssVar('fontH6'),
+ fontWeight: 600,
+ lineHeight: '26px',
+ color: cssVar('textPrimaryColor'),
+});
+export const description = style({
+ padding: '0px 24px',
+ fontSize: cssVar('fontBase'),
+ lineHeight: '24px',
+ minHeight: 48,
+ fontWeight: 400,
+ color: cssVar('textPrimaryColor'),
+});
+export const link = style({
+ color: cssVar('textEmphasisColor'),
+ textDecoration: 'underline',
+});
+
+export const footer = style({
+ padding: '20px 28px',
+ gap: 12,
+ display: 'flex',
+ justifyContent: 'flex-end',
+});
+
+export const skipButton = style({
+ fontWeight: 500,
+});
diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/general.dialog.tsx b/packages/frontend/core/src/components/affine/ai-onboarding/general.dialog.tsx
new file mode 100644
index 0000000000..ce3a9f0080
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/ai-onboarding/general.dialog.tsx
@@ -0,0 +1,198 @@
+import { Button, Modal } from '@affine/component';
+import { openSettingModalAtom } from '@affine/core/atoms';
+import { useBlurRoot } from '@affine/core/hooks/use-blur-root';
+import { CurrentWorkspaceService } from '@affine/core/modules/workspace';
+import { WorkspaceFlavour } from '@affine/env/workspace';
+import { Trans } from '@affine/i18n';
+import { useAFFiNEI18N } from '@affine/i18n/hooks';
+import { useLiveData, useService } from '@toeverything/infra';
+import { useSetAtom } from 'jotai';
+import type { ReactNode } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+import * as baseStyles from './base-style.css';
+import * as styles from './general.dialog.css';
+import { Slider } from './slider';
+import type { BaseAIOnboardingDialogProps } from './type';
+
+type PlayListItem = { video: string; title: ReactNode; desc: ReactNode };
+type Translate = ReturnType;
+
+const getPlayList = (t: Translate): Array => [
+ {
+ video: '/onboarding/ai-onboarding.general.1.mov',
+ title: t['com.affine.ai-onboarding.general.1.title'](),
+ desc: t['com.affine.ai-onboarding.general.1.description'](),
+ },
+ {
+ video: '/onboarding/ai-onboarding.general.2.mov',
+ title: t['com.affine.ai-onboarding.general.2.title'](),
+ desc: t['com.affine.ai-onboarding.general.2.description'](),
+ },
+ {
+ video: '/onboarding/ai-onboarding.general.3.mov',
+ title: t['com.affine.ai-onboarding.general.3.title'](),
+ desc: t['com.affine.ai-onboarding.general.3.description'](),
+ },
+ {
+ video: '/onboarding/ai-onboarding.general.4.mov',
+ title: t['com.affine.ai-onboarding.general.4.title'](),
+ desc: t['com.affine.ai-onboarding.general.4.description'](),
+ },
+ {
+ video: '/onboarding/ai-onboarding.general.1.mov',
+ title: t['com.affine.ai-onboarding.general.5.title'](),
+ desc: (
+
+ ),
+ }}
+ />
+ ),
+ },
+];
+
+export const AIOnboardingGeneral = ({
+ onDismiss,
+}: BaseAIOnboardingDialogProps) => {
+ const videoWrapperRef = useRef(null);
+ const prevVideoRef = useRef(null);
+ const currentWorkspace = useLiveData(
+ useService(CurrentWorkspaceService).currentWorkspace$
+ );
+ const isCloud = currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD;
+ const t = useAFFiNEI18N();
+ const [open, setOpen] = useState(true);
+ const [index, setIndex] = useState(0);
+ const list = useMemo(() => getPlayList(t), [t]);
+ const setSettingModal = useSetAtom(openSettingModalAtom);
+ useBlurRoot(open && isCloud);
+
+ const isFirst = index === 0;
+ const isLast = index === list.length - 1;
+
+ const closeAndDismiss = useCallback(() => {
+ setOpen(false);
+ onDismiss();
+ }, [onDismiss]);
+ const goToPricingPlans = useCallback(() => {
+ setSettingModal({
+ open: true,
+ activeTab: 'plans',
+ scrollAnchor: 'aiPricingPlan',
+ });
+ closeAndDismiss();
+ }, [closeAndDismiss, setSettingModal]);
+ const onClose = useCallback(() => setOpen(false), []);
+ const onPrev = useCallback(() => {
+ setIndex(i => Math.max(0, i - 1));
+ }, []);
+ const onNext = useCallback(() => {
+ setIndex(i => Math.min(list.length - 1, i + 1));
+ }, [list.length]);
+
+ const videoRenderer = useCallback(
+ ({ video }: PlayListItem) => (
+
+
+
+ ),
+ []
+ );
+ const titleRenderer = useCallback(
+ ({ title }: PlayListItem) => {title}
,
+ []
+ );
+ const descriptionRenderer = useCallback(
+ ({ desc }: PlayListItem) => {desc}
,
+ []
+ );
+
+ useEffect(() => {
+ const videoWrapper = videoWrapperRef.current;
+ if (!videoWrapper) return;
+
+ const videos = videoWrapper.querySelectorAll('video');
+ const video = videos[index];
+ if (!video) return;
+
+ if (prevVideoRef.current) {
+ prevVideoRef.current.pause();
+ }
+
+ video.play().catch(console.error);
+ prevVideoRef.current = video;
+ }, [index]);
+
+ return isCloud ? (
+
+
+
+ rootRef={videoWrapperRef}
+ className={styles.videoHeader}
+ items={list}
+ activeIndex={index}
+ preload={5}
+ itemRenderer={videoRenderer}
+ />
+
+
+
+ items={list}
+ activeIndex={index}
+ itemRenderer={titleRenderer}
+ transitionDuration={400}
+ />
+
+ items={list}
+ activeIndex={index}
+ itemRenderer={descriptionRenderer}
+ transitionDuration={500}
+ />
+
+
+
+
+
+ ) : null;
+};
diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/index.tsx b/packages/frontend/core/src/components/affine/ai-onboarding/index.tsx
new file mode 100644
index 0000000000..e8de47d50e
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/ai-onboarding/index.tsx
@@ -0,0 +1,39 @@
+import { Suspense, useCallback, useState } from 'react';
+
+import { AIOnboardingEdgeless } from './edgeless.dialog';
+import { AIOnboardingGeneral } from './general.dialog';
+import { AIOnboardingLocal } from './local.dialog';
+import { AIOnboardingType } from './type';
+
+const useDismiss = (key: AIOnboardingType) => {
+ const [dismiss, setDismiss] = useState(localStorage.getItem(key) === 'true');
+
+ const onDismiss = useCallback(() => {
+ setDismiss(true);
+ localStorage.setItem(key, 'true');
+ }, [key]);
+
+ return [dismiss, onDismiss] as const;
+};
+
+export const AIOnboarding = () => {
+ const [dismissGeneral, onDismissGeneral] = useDismiss(
+ AIOnboardingType.GENERAL
+ );
+ const [dismissEdgeless, onDismissEdgeless] = useDismiss(
+ AIOnboardingType.EDGELESS
+ );
+ const [dismissLocal, onDismissLocal] = useDismiss(AIOnboardingType.LOCAL);
+
+ return (
+
+ {dismissGeneral ? null : (
+
+ )}
+ {dismissEdgeless ? null : (
+
+ )}
+ {dismissLocal ? null : }
+
+ );
+};
diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/local.dialog.tsx b/packages/frontend/core/src/components/affine/ai-onboarding/local.dialog.tsx
new file mode 100644
index 0000000000..2e14f2f8e2
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/ai-onboarding/local.dialog.tsx
@@ -0,0 +1,5 @@
+import type { BaseAIOnboardingDialogProps } from './type';
+
+export const AIOnboardingLocal = (_: BaseAIOnboardingDialogProps) => {
+ return {/* TODO: open local workspace for the first time */}
;
+};
diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/slider.css.ts b/packages/frontend/core/src/components/affine/ai-onboarding/slider.css.ts
new file mode 100644
index 0000000000..557b8df692
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/ai-onboarding/slider.css.ts
@@ -0,0 +1,16 @@
+import { style } from '@vanilla-extract/css';
+
+export const slider = style({
+ overflow: 'clip',
+});
+
+export const sliderContent = style({
+ display: 'flex',
+ height: '100%',
+ willChange: 'transform',
+});
+
+export const slideItem = style({
+ width: 0,
+ flex: 1,
+});
diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/slider.tsx b/packages/frontend/core/src/components/affine/ai-onboarding/slider.tsx
new file mode 100644
index 0000000000..15fe40b10b
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/ai-onboarding/slider.tsx
@@ -0,0 +1,58 @@
+import clsx from 'clsx';
+import { type HTMLAttributes, type Ref } from 'react';
+
+import * as styles from './slider.css';
+
+export interface SliderProps extends HTMLAttributes {
+ items: T[];
+ activeIndex?: number;
+ itemRenderer?: (item: T, index: number) => React.ReactNode;
+ /**
+ * preload next and previous slides
+ */
+ preload?: number;
+ transitionDuration?: number;
+ transitionTimingFunction?: string;
+
+ rootRef?: Ref;
+}
+
+/**
+ * TODO: extract to @affine/ui
+ * @returns
+ */
+export const Slider = ({
+ rootRef,
+ items,
+ className,
+ preload = 1,
+ activeIndex = 0,
+ transitionDuration = 300,
+ transitionTimingFunction = 'cubic-bezier(.33,.36,0,1)',
+ itemRenderer,
+ ...attrs
+}: SliderProps) => {
+ const count = items.length;
+ const unit = Math.floor(100 / count);
+
+ return (
+
+
+ {items?.map((item, index) => (
+
+ {preload === undefined || Math.abs(index - activeIndex) <= preload
+ ? itemRenderer?.(item, index)
+ : null}
+
+ ))}
+
+
+ );
+};
diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/type.ts b/packages/frontend/core/src/components/affine/ai-onboarding/type.ts
new file mode 100644
index 0000000000..cf1306738f
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/ai-onboarding/type.ts
@@ -0,0 +1,8 @@
+export interface BaseAIOnboardingDialogProps {
+ onDismiss: () => void;
+}
+export enum AIOnboardingType {
+ GENERAL = 'dismissAiOnboarding',
+ EDGELESS = 'dismissAiOnboardingEdgeless',
+ LOCAL = 'dismissAiOnboardingLocal',
+}
diff --git a/packages/frontend/core/src/hooks/use-blur-root.ts b/packages/frontend/core/src/hooks/use-blur-root.ts
new file mode 100644
index 0000000000..63bb356070
--- /dev/null
+++ b/packages/frontend/core/src/hooks/use-blur-root.ts
@@ -0,0 +1,14 @@
+import { useEffect } from 'react';
+
+export const useBlurRoot = (blur: boolean) => {
+ // blur modal background, can't use css: `backdrop-filter: blur()`,
+ // because it won't behave as expected on client side (texts over transparent window are not blurred)
+ useEffect(() => {
+ const appDom = document.querySelector('#app') as HTMLElement;
+ if (!appDom) return;
+ appDom.style.filter = blur ? 'blur(7px)' : 'none';
+ return () => {
+ appDom.style.filter = 'none';
+ };
+ }, [blur]);
+};
diff --git a/packages/frontend/core/src/layouts/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx
index 9a2d43879d..1bdff51663 100644
--- a/packages/frontend/core/src/layouts/workspace-layout.tsx
+++ b/packages/frontend/core/src/layouts/workspace-layout.tsx
@@ -22,6 +22,7 @@ import { matchPath } from 'react-router-dom';
import { Map as YMap } from 'yjs';
import { openQuickSearchModalAtom, openSettingModalAtom } from '../atoms';
+import { AIOnboarding } from '../components/affine/ai-onboarding';
import { AppContainer } from '../components/affine/app-container';
import { SyncAwareness } from '../components/affine/awareness';
import {
@@ -100,6 +101,8 @@ export const WorkspaceLayout = function WorkspaceLayout({
}>
{children}
+ {/* should show after workspace loaded */}
+
);
diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json
index 9e5335c835..3d983a0380 100644
--- a/packages/frontend/i18n/src/resources/en.json
+++ b/packages/frontend/i18n/src/resources/en.json
@@ -1279,5 +1279,20 @@
"unnamed": "unnamed",
"upgradeBrowser": "Please upgrade to the latest version of Chrome for the best experience.",
"will be moved to Trash": "{{title}} will be moved to Trash",
- "will delete member": "will delete member"
+ "will delete member": "will delete member",
+ "com.affine.ai-onboarding.general.1.title": "Meet AFFiNE AI",
+ "com.affine.ai-onboarding.general.1.description": "Lets you think bigger, create faster, work smarter and save time for every project.",
+ "com.affine.ai-onboarding.general.2.title": "Chat with AFFiNE AI",
+ "com.affine.ai-onboarding.general.2.description": "Get instant insights to all your questions.",
+ "com.affine.ai-onboarding.general.3.title": "Edit Inline with AFFiNE AI",
+ "com.affine.ai-onboarding.general.3.description": "Perfect tone, spelling, and summaries in seconds.",
+ "com.affine.ai-onboarding.general.4.title": "Make it Real with AFFiNE AI",
+ "com.affine.ai-onboarding.general.4.description": "From concept to completion, turn ideas into reality.",
+ "com.affine.ai-onboarding.general.5.title": "AFFiNE AI is ready",
+ "com.affine.ai-onboarding.general.5.description": "Go to {{link}} for learn more details about AFFiNE AI.",
+ "com.affine.ai-onboarding.general.skip": "Alert me later",
+ "com.affine.ai-onboarding.general.next": "Next",
+ "com.affine.ai-onboarding.general.prev": "Back",
+ "com.affine.ai-onboarding.general.try-for-free": "Tree for Free",
+ "com.affine.ai-onboarding.general.purchase": "Get Unlimited Usage"
}
diff --git a/tests/kit/electron.ts b/tests/kit/electron.ts
index c8770a8607..c9b5895660 100644
--- a/tests/kit/electron.ts
+++ b/tests/kit/electron.ts
@@ -36,6 +36,11 @@ export const test = base.extend<{
}>({
page: async ({ electronApp }, use) => {
const page = await electronApp.firstWindow();
+ await page.evaluate(() => {
+ window.localStorage.setItem('dismissAiOnboarding', 'true');
+ window.localStorage.setItem('dismissAiOnboardingEdgeless', 'true');
+ window.localStorage.setItem('dismissAiOnboardingLocal', 'true');
+ });
// wait for blocksuite to be loaded
await page.waitForSelector('v-line');
if (enableCoverage) {
diff --git a/tests/kit/playwright.ts b/tests/kit/playwright.ts
index ba5f827aaa..ad02e76e39 100644
--- a/tests/kit/playwright.ts
+++ b/tests/kit/playwright.ts
@@ -36,6 +36,9 @@ type CurrentDocCollection = {
export const skipOnboarding = async (context: BrowserContext) => {
await context.addInitScript(() => {
window.localStorage.setItem('app_config', '{"onBoarding":false}');
+ window.localStorage.setItem('dismissAiOnboarding', 'true');
+ window.localStorage.setItem('dismissAiOnboardingEdgeless', 'true');
+ window.localStorage.setItem('dismissAiOnboardingLocal', 'true');
});
};
diff --git a/tests/storybook/.storybook/preview.tsx b/tests/storybook/.storybook/preview.tsx
index 651bef1678..1e18eb1f4b 100644
--- a/tests/storybook/.storybook/preview.tsx
+++ b/tests/storybook/.storybook/preview.tsx
@@ -67,6 +67,9 @@ localStorage.clear();
// do not show onboarding for storybook
window.localStorage.setItem('app_config', '{"onBoarding":false}');
+window.localStorage.setItem('dismissAiOnboarding', 'true');
+window.localStorage.setItem('dismissAiOnboardingEdgeless', 'true');
+window.localStorage.setItem('dismissAiOnboardingLocal', 'true');
const services = new ServiceCollection();