From 7a722773c7ffa7e633a2cf51b9574a933229f10e Mon Sep 17 00:00:00 2001 From: zxhlyh Date: Fri, 13 Mar 2026 17:45:04 +0800 Subject: [PATCH] feat: snippet canvas --- .../snippets/[snippetId]/page.tsx | 11 + .../app-sidebar/__tests__/index.spec.tsx | 15 ++ web/app/components/app-sidebar/index.tsx | 12 +- .../nav-link/__tests__/index.spec.tsx | 16 ++ .../components/app-sidebar/nav-link/index.tsx | 36 ++- .../app-sidebar/snippet-info/index.tsx | 53 +++++ .../components/apps/__tests__/list.spec.tsx | 9 +- web/app/components/apps/list.tsx | 86 +++---- .../snippets/__tests__/index.spec.tsx | 171 ++++++++++++++ .../components/input-field-editor.tsx | 54 +++++ .../snippets/components/panel/index.tsx | 119 ++++++++++ .../snippets/components/publish-menu.tsx | 29 +++ .../snippets/components/snippet-children.tsx | 106 +++++++++ .../components/snippet-header/index.tsx | 61 +++++ .../snippets/components/snippet-main.tsx | 193 ++++++++++++++++ .../snippets/components/workflow-panel.tsx | 111 +++++++++ .../snippets/hooks/use-snippet-init.ts | 5 + web/app/components/snippets/index.tsx | 82 +++++++ web/app/components/snippets/store/index.ts | 44 ++++ web/i18n-config/resources.ts | 3 + web/i18n/en-US/app.json | 7 - web/i18n/en-US/snippet.json | 16 ++ web/i18n/zh-Hans/snippet.json | 16 ++ web/models/snippet.ts | 50 ++++ web/service/use-snippets.ts | 215 ++++++++++++++++++ 25 files changed, 1449 insertions(+), 71 deletions(-) create mode 100644 web/app/(commonLayout)/snippets/[snippetId]/page.tsx create mode 100644 web/app/components/app-sidebar/snippet-info/index.tsx create mode 100644 web/app/components/snippets/__tests__/index.spec.tsx create mode 100644 web/app/components/snippets/components/input-field-editor.tsx create mode 100644 web/app/components/snippets/components/panel/index.tsx create mode 100644 web/app/components/snippets/components/publish-menu.tsx create mode 100644 web/app/components/snippets/components/snippet-children.tsx create mode 100644 web/app/components/snippets/components/snippet-header/index.tsx create mode 100644 web/app/components/snippets/components/snippet-main.tsx create mode 100644 web/app/components/snippets/components/workflow-panel.tsx create mode 100644 web/app/components/snippets/hooks/use-snippet-init.ts create mode 100644 web/app/components/snippets/index.tsx create mode 100644 web/app/components/snippets/store/index.ts create mode 100644 web/i18n/en-US/snippet.json create mode 100644 web/i18n/zh-Hans/snippet.json create mode 100644 web/models/snippet.ts create mode 100644 web/service/use-snippets.ts diff --git a/web/app/(commonLayout)/snippets/[snippetId]/page.tsx b/web/app/(commonLayout)/snippets/[snippetId]/page.tsx new file mode 100644 index 0000000000..0d37e83e2b --- /dev/null +++ b/web/app/(commonLayout)/snippets/[snippetId]/page.tsx @@ -0,0 +1,11 @@ +import SnippetPage from '@/app/components/snippets' + +const Page = async (props: { + params: Promise<{ snippetId: string }> +}) => { + const { params } = props + + return +} + +export default Page diff --git a/web/app/components/app-sidebar/__tests__/index.spec.tsx b/web/app/components/app-sidebar/__tests__/index.spec.tsx index 89db80e0f1..cf685b33a5 100644 --- a/web/app/components/app-sidebar/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/index.spec.tsx @@ -165,6 +165,21 @@ describe('AppDetailNav', () => { ) expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument() }) + + it('should render custom header and navigation when provided', () => { + render( +
} + renderNavigation={mode =>
} + />, + ) + + expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand') + expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand') + expect(screen.queryByTestId('app-info')).not.toBeInTheDocument() + expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument() + }) }) describe('Workflow canvas mode', () => { diff --git a/web/app/components/app-sidebar/index.tsx b/web/app/components/app-sidebar/index.tsx index e24b005d01..ee046e4f30 100644 --- a/web/app/components/app-sidebar/index.tsx +++ b/web/app/components/app-sidebar/index.tsx @@ -27,12 +27,16 @@ export type IAppDetailNavProps = { disabled?: boolean }> extraInfo?: (modeState: string) => React.ReactNode + renderHeader?: (modeState: string) => React.ReactNode + renderNavigation?: (modeState: string) => React.ReactNode } const AppDetailNav = ({ navigation, extraInfo, iconType = 'app', + renderHeader, + renderNavigation, }: IAppDetailNavProps) => { const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({ appSidebarExpand: state.appSidebarExpand, @@ -104,10 +108,11 @@ const AppDetailNav = ({ expand ? 'p-2' : 'p-1', )} > - {iconType === 'app' && ( + {renderHeader?.(appSidebarExpand)} + {!renderHeader && iconType === 'app' && ( )} - {iconType !== 'app' && ( + {!renderHeader && iconType !== 'app' && ( )}
@@ -136,7 +141,8 @@ const AppDetailNav = ({ expand ? 'px-3 py-2' : 'p-3', )} > - {navigation.map((item, index) => { + {renderNavigation?.(appSidebarExpand)} + {!renderNavigation && navigation.map((item, index) => { return ( { expect(iconWrapper).toHaveClass('-ml-1') }) }) + + describe('Button Mode', () => { + it('should render as an interactive button when href is omitted', () => { + const onClick = vi.fn() + + render() + + const buttonElement = screen.getByText('Orchestrate').closest('button') + expect(buttonElement).not.toBeNull() + expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active') + expect(buttonElement).toHaveClass('text-text-accent-light-mode-only') + + buttonElement?.click() + expect(onClick).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/web/app/components/app-sidebar/nav-link/index.tsx b/web/app/components/app-sidebar/nav-link/index.tsx index d69ed8590e..b9c0ee7345 100644 --- a/web/app/components/app-sidebar/nav-link/index.tsx +++ b/web/app/components/app-sidebar/nav-link/index.tsx @@ -14,13 +14,15 @@ export type NavIcon = React.ComponentType< export type NavLinkProps = { name: string - href: string + href?: string iconMap: { selected: NavIcon normal: NavIcon } mode?: string disabled?: boolean + active?: boolean + onClick?: () => void } const NavLink = ({ @@ -29,6 +31,8 @@ const NavLink = ({ iconMap, mode = 'expand', disabled = false, + active, + onClick, }: NavLinkProps) => { const segment = useSelectedLayoutSegment() const formattedSegment = (() => { @@ -39,8 +43,11 @@ const NavLink = ({ return res })() - const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment + const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false) const NavIcon = isActive ? iconMap.selected : iconMap.normal + const linkClassName = cn(isActive + ? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold' + : 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1') const renderIcon = () => (
@@ -70,13 +77,32 @@ const NavLink = ({ ) } + if (!href) { + return ( + + ) + } + return ( {renderIcon()} diff --git a/web/app/components/app-sidebar/snippet-info/index.tsx b/web/app/components/app-sidebar/snippet-info/index.tsx new file mode 100644 index 0000000000..f0d0c59957 --- /dev/null +++ b/web/app/components/app-sidebar/snippet-info/index.tsx @@ -0,0 +1,53 @@ +'use client' + +import type { SnippetDetail } from '@/models/snippet' +import * as React from 'react' +import AppIcon from '@/app/components/base/app-icon' +import Badge from '@/app/components/base/badge' +import { cn } from '@/utils/classnames' + +type SnippetInfoProps = { + expand: boolean + snippet: SnippetDetail +} + +const SnippetInfo = ({ + expand, + snippet, +}: SnippetInfoProps) => { + return ( +
+
+
+
+ +
+ {expand && ( +
+
+ {snippet.name} +
+ {snippet.status && ( +
+ {snippet.status} +
+ )} +
+ )} +
+ {expand && snippet.description && ( +

+ {snippet.description} +

+ )} +
+
+ ) +} + +export default React.memo(SnippetInfo) diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 51c3834791..9ded28cd9a 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -278,9 +278,10 @@ describe('List', () => { it('should render the snippets create card and fake snippet card', () => { renderList({ pageType: 'snippets' }) - expect(screen.getByText('app.createSnippet')).toBeInTheDocument() - expect(screen.getByText('app.studio.fakeSnippet.name')).toBeInTheDocument() - expect(screen.getByText('app.studio.fakeSnippet.description')).toBeInTheDocument() + expect(screen.getByText('snippet.create')).toBeInTheDocument() + expect(screen.getByText('Tone Rewriter')).toBeInTheDocument() + expect(screen.getByText('Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Tone Rewriter/i })).toHaveAttribute('href', '/snippets/snippet-1') expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument() expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument() }) @@ -291,7 +292,7 @@ describe('List', () => { const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'missing snippet' } }) - expect(screen.queryByText('app.studio.fakeSnippet.name')).not.toBeInTheDocument() + expect(screen.queryByText('Tone Rewriter')).not.toBeInTheDocument() expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument() }) diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index f5ea7ce36e..90dd84cb9e 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { StudioPageType } from '.' +import type { SnippetListItem } from '@/models/snippet' import type { App } from '@/types/app' import { useDebounceFn } from 'ahooks' import dynamic from 'next/dynamic' @@ -17,6 +18,7 @@ import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' import { CheckModal } from '@/hooks/use-pay' import { useInfiniteAppList } from '@/service/use-apps' +import { getSnippetListMock } from '@/service/use-snippets' import { cn } from '@/utils/classnames' import AppCard from './app-card' import { AppCardSkeleton } from './app-card-skeleton' @@ -36,17 +38,6 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro ssr: false, }) -type StudioSnippet = { - id: string - name: string - description: string - author: string - updatedAt: string - usage: string - icon: string - status?: string -} - const StudioRouteSwitch = ({ pageType, appsLabel, snippetsLabel }: { pageType: StudioPageType, appsLabel: string, snippetsLabel: string }) => { return (
@@ -75,12 +66,12 @@ const StudioRouteSwitch = ({ pageType, appsLabel, snippetsLabel }: { pageType: S } const SnippetCreateCard = () => { - const { t } = useTranslation() + const { t } = useTranslation('snippet') return (
-
{t('createSnippet', { ns: 'app' })}
+
{t('create')}
{t('newApp.startFromBlank', { ns: 'app' })} @@ -97,38 +88,40 @@ const SnippetCreateCard = () => { const SnippetCard = ({ snippet, }: { - snippet: StudioSnippet + snippet: SnippetListItem }) => { return ( -
- {snippet.status && ( -
- {snippet.status} -
- )} -
-
- {snippet.icon} -
-
-
- {snippet.name} + +
+ {snippet.status && ( +
+ {snippet.status} +
+ )} +
+
+ {snippet.icon} +
+
+
+ {snippet.name} +
-
-
-
- {snippet.description} +
+
+ {snippet.description} +
-
-
- {snippet.author} - · - {snippet.updatedAt} - · - {snippet.usage} -
-
+
+ {snippet.author} + · + {snippet.updatedAt} + · + {snippet.usage} +
+ + ) } @@ -274,18 +267,7 @@ const List: FC = ({ return (data?.pages ?? []).flatMap(({ data: apps }) => apps) }, [data?.pages]) - const snippetItems = useMemo(() => ([ - { - id: 'snippet-1', - name: t('studio.fakeSnippet.name', { ns: 'app' }), - description: t('studio.fakeSnippet.description', { ns: 'app' }), - author: t('studio.fakeSnippet.author', { ns: 'app' }), - updatedAt: t('studio.fakeSnippet.updatedAt', { ns: 'app' }), - usage: t('studio.fakeSnippet.usage', { ns: 'app' }), - icon: '🪄', - status: t('studio.fakeSnippet.status', { ns: 'app' }), - }, - ]), [t]) + const snippetItems = useMemo(() => getSnippetListMock(), []) const filteredSnippetItems = useMemo(() => { const normalizedKeywords = snippetKeywords.trim().toLowerCase() diff --git a/web/app/components/snippets/__tests__/index.spec.tsx b/web/app/components/snippets/__tests__/index.spec.tsx new file mode 100644 index 0000000000..5b9aa9daf6 --- /dev/null +++ b/web/app/components/snippets/__tests__/index.spec.tsx @@ -0,0 +1,171 @@ +import type { SnippetDetailPayload } from '@/models/snippet' +import { fireEvent, render, screen } from '@testing-library/react' +import { PipelineInputVarType } from '@/models/pipeline' +import SnippetPage from '..' +import { useSnippetDetailStore } from '../store' + +const mockUseSnippetDetail = vi.fn() + +vi.mock('@/service/use-snippets', () => ({ + useSnippetDetail: (snippetId: string) => mockUseSnippetDetail(snippetId), +})) + +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: () => ({ + data: undefined, + }), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: () => 'desktop', + MediaType: { mobile: 'mobile', desktop: 'desktop' }, +})) + +vi.mock('@/app/components/workflow', () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + WorkflowWithInnerContext: ({ children, viewport }: { children: React.ReactNode, viewport?: { zoom?: number } }) => ( +
+ {viewport?.zoom ?? 'none'} + {children} +
+ ), +})) + +vi.mock('@/app/components/workflow/context', () => ({ + WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +vi.mock('@/app/components/app-sidebar', () => ({ + default: ({ + renderHeader, + renderNavigation, + }: { + renderHeader?: (modeState: string) => React.ReactNode + renderNavigation?: (modeState: string) => React.ReactNode + }) => ( +
+
{renderHeader?.('expand')}
+
{renderNavigation?.('expand')}
+
+ ), +})) + +vi.mock('@/app/components/app-sidebar/nav-link', () => ({ + default: ({ name, onClick }: { name: string, onClick?: () => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/panel', () => ({ + default: ({ components }: { components?: { left?: React.ReactNode, right?: React.ReactNode } }) => ( +
+
{components?.left}
+
{components?.right}
+
+ ), +})) + +vi.mock('@/app/components/workflow/utils', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + initialNodes: (nodes: unknown[]) => nodes, + initialEdges: (edges: unknown[]) => edges, + } +}) + +vi.mock('react-sortablejs', () => ({ + ReactSortable: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +const mockSnippetDetail: SnippetDetailPayload = { + snippet: { + id: 'snippet-1', + name: 'Tone Rewriter', + description: 'A static snippet mock.', + author: 'Evan', + updatedAt: 'Updated 2h ago', + usage: 'Used 19 times', + icon: '🪄', + iconBackground: '#E0EAFF', + status: 'Draft', + }, + graph: { + viewport: { x: 0, y: 0, zoom: 1 }, + nodes: [], + edges: [], + }, + inputFields: [ + { + type: PipelineInputVarType.textInput, + label: 'Blog URL', + variable: 'blog_url', + required: true, + options: [], + placeholder: 'Paste a source article URL', + max_length: 256, + }, + ], + uiMeta: { + inputFieldCount: 1, + checklistCount: 2, + autoSavedAt: 'Auto-saved · a few seconds ago', + }, +} + +describe('SnippetPage', () => { + beforeEach(() => { + vi.clearAllMocks() + useSnippetDetailStore.getState().reset() + mockUseSnippetDetail.mockReturnValue({ + data: mockSnippetDetail, + isLoading: false, + }) + }) + + it('should render the snippet detail shell', () => { + render() + + expect(screen.getByText('Tone Rewriter')).toBeInTheDocument() + expect(screen.getByText('A static snippet mock.')).toBeInTheDocument() + expect(screen.getByTestId('app-sidebar')).toBeInTheDocument() + expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument() + expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument() + expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument() + expect(screen.getByTestId('workflow-viewport-zoom').textContent).toBe('1') + }) + + it('should open the input field panel and editor', () => { + render() + + fireEvent.click(screen.getAllByRole('button', { name: /snippet\.inputFieldButton/i })[0]) + expect(screen.getAllByText('snippet.panelTitle').length).toBeGreaterThan(0) + + fireEvent.click(screen.getAllByRole('button', { name: /datasetPipeline\.inputFieldPanel\.addInputField/i })[0]) + expect(screen.getAllByText('datasetPipeline.inputFieldPanel.addInputField').length).toBeGreaterThan(1) + }) + + it('should toggle the publish menu', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /snippet\.publishButton/i })) + expect(screen.getByText('snippet.publishMenuCurrentDraft')).toBeInTheDocument() + }) + + it('should render a controlled not found state', () => { + mockUseSnippetDetail.mockReturnValue({ + data: null, + isLoading: false, + }) + + render() + + expect(screen.getByText('snippet.notFoundTitle')).toBeInTheDocument() + expect(screen.getByText('snippet.notFoundDescription')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/snippets/components/input-field-editor.tsx b/web/app/components/snippets/components/input-field-editor.tsx new file mode 100644 index 0000000000..99362857e0 --- /dev/null +++ b/web/app/components/snippets/components/input-field-editor.tsx @@ -0,0 +1,54 @@ +'use client' + +import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types' +import type { SnippetInputField } from '@/models/snippet' +import { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import InputFieldForm from '@/app/components/rag-pipeline/components/panel/input-field/editor/form' +import { convertFormDataToINputField, convertToInputFieldFormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/utils' + +type SnippetInputFieldEditorProps = { + field?: SnippetInputField | null + onClose: () => void + onSubmit: (field: SnippetInputField) => void +} + +const SnippetInputFieldEditor = ({ + field, + onClose, + onSubmit, +}: SnippetInputFieldEditorProps) => { + const { t } = useTranslation() + + const initialData = useMemo(() => { + return convertToInputFieldFormData(field || undefined) + }, [field]) + + const handleSubmit = useCallback((value: FormData) => { + onSubmit(convertFormDataToINputField(value)) + }, [onSubmit]) + + return ( +
+
+ {field ? t('inputFieldPanel.editInputField', { ns: 'datasetPipeline' }) : t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })} +
+ + +
+ ) +} + +export default SnippetInputFieldEditor diff --git a/web/app/components/snippets/components/panel/index.tsx b/web/app/components/snippets/components/panel/index.tsx new file mode 100644 index 0000000000..c949a35f14 --- /dev/null +++ b/web/app/components/snippets/components/panel/index.tsx @@ -0,0 +1,119 @@ +'use client' + +import type { SortableItem } from '@/app/components/rag-pipeline/components/panel/input-field/field-list/types' +import type { SnippetInputField } from '@/models/snippet' +import { memo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import Divider from '@/app/components/base/divider' +import FieldListContainer from '@/app/components/rag-pipeline/components/panel/input-field/field-list/field-list-container' + +type SnippetInputFieldPanelProps = { + fields: SnippetInputField[] + onClose: () => void + onAdd: () => void + onEdit: (field: SnippetInputField) => void + onRemove: (index: number) => void + onPrimarySortChange: (fields: SnippetInputField[]) => void + onSecondarySortChange: (fields: SnippetInputField[]) => void +} + +const toInputFields = (list: SortableItem[]) => { + return list.map((item) => { + const { id: _id, chosen: _chosen, selected: _selected, ...field } = item + return field + }) +} + +const SnippetInputFieldPanel = ({ + fields, + onClose, + onAdd, + onEdit, + onRemove, + onPrimarySortChange, + onSecondarySortChange, +}: SnippetInputFieldPanelProps) => { + const { t } = useTranslation('snippet') + const primaryFields = fields.slice(0, 2) + const secondaryFields = fields.slice(2) + + const handlePrimaryRemove = useCallback((index: number) => { + onRemove(index) + }, [onRemove]) + + const handleSecondaryRemove = useCallback((index: number) => { + onRemove(index + primaryFields.length) + }, [onRemove, primaryFields.length]) + + const handlePrimaryEdit = useCallback((id: string) => { + const field = primaryFields.find(item => item.variable === id) + if (field) + onEdit(field) + }, [onEdit, primaryFields]) + + const handleSecondaryEdit = useCallback((id: string) => { + const field = secondaryFields.find(item => item.variable === id) + if (field) + onEdit(field) + }, [onEdit, secondaryFields]) + + return ( +
+
+
+
+ {t('panelTitle')} +
+
+ {t('panelDescription')} +
+
+ +
+ +
+ +
+ +
+
+ {t('panelPrimaryGroup')} +
+ onPrimarySortChange(toInputFields(list))} + onRemoveField={handlePrimaryRemove} + onEditField={handlePrimaryEdit} + /> + +
+ +
+ +
+ {t('panelSecondaryGroup')} +
+ onSecondarySortChange(toInputFields(list))} + onRemoveField={handleSecondaryRemove} + onEditField={handleSecondaryEdit} + /> +
+
+ ) +} + +export default memo(SnippetInputFieldPanel) diff --git a/web/app/components/snippets/components/publish-menu.tsx b/web/app/components/snippets/components/publish-menu.tsx new file mode 100644 index 0000000000..f5a57763df --- /dev/null +++ b/web/app/components/snippets/components/publish-menu.tsx @@ -0,0 +1,29 @@ +'use client' + +import type { SnippetDetailUIModel } from '@/models/snippet' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' + +const PublishMenu = ({ + uiMeta, +}: { + uiMeta: SnippetDetailUIModel +}) => { + const { t } = useTranslation('snippet') + + return ( +
+
+ {t('publishMenuCurrentDraft')} +
+
+ {uiMeta.autoSavedAt} +
+ +
+ ) +} + +export default PublishMenu diff --git a/web/app/components/snippets/components/snippet-children.tsx b/web/app/components/snippets/components/snippet-children.tsx new file mode 100644 index 0000000000..0bf08530a1 --- /dev/null +++ b/web/app/components/snippets/components/snippet-children.tsx @@ -0,0 +1,106 @@ +'use client' + +import type { SnippetDetailUIModel, SnippetInputField } from '@/models/snippet' +import SnippetInputFieldEditor from './input-field-editor' +import SnippetInputFieldPanel from './panel' +import PublishMenu from './publish-menu' +import SnippetHeader from './snippet-header' +import SnippetWorkflowPanel from './workflow-panel' + +type SnippetChildrenProps = { + fields: SnippetInputField[] + uiMeta: SnippetDetailUIModel + editingField: SnippetInputField | null + isEditorOpen: boolean + isInputPanelOpen: boolean + isPublishMenuOpen: boolean + onToggleInputPanel: () => void + onTogglePublishMenu: () => void + onCloseInputPanel: () => void + onOpenEditor: (field?: SnippetInputField | null) => void + onCloseEditor: () => void + onSubmitField: (field: SnippetInputField) => void + onRemoveField: (index: number) => void + onPrimarySortChange: (fields: SnippetInputField[]) => void + onSecondarySortChange: (fields: SnippetInputField[]) => void +} + +const SnippetChildren = ({ + fields, + uiMeta, + editingField, + isEditorOpen, + isInputPanelOpen, + isPublishMenuOpen, + onToggleInputPanel, + onTogglePublishMenu, + onCloseInputPanel, + onOpenEditor, + onCloseEditor, + onSubmitField, + onRemoveField, + onPrimarySortChange, + onSecondarySortChange, +}: SnippetChildrenProps) => { + return ( + <> +
+ + + + + + {isPublishMenuOpen && ( +
+ +
+ )} + + {isInputPanelOpen && ( +
+
+ onOpenEditor()} + onEdit={onOpenEditor} + onRemove={onRemoveField} + onPrimarySortChange={onPrimarySortChange} + onSecondarySortChange={onSecondarySortChange} + /> +
+
+ )} + + {isEditorOpen && ( +
+
+ +
+
+ )} + + ) +} + +export default SnippetChildren diff --git a/web/app/components/snippets/components/snippet-header/index.tsx b/web/app/components/snippets/components/snippet-header/index.tsx new file mode 100644 index 0000000000..aba4efadbd --- /dev/null +++ b/web/app/components/snippets/components/snippet-header/index.tsx @@ -0,0 +1,61 @@ +'use client' + +import { useTranslation } from 'react-i18next' + +type SnippetHeaderProps = { + inputFieldCount: number + onToggleInputPanel: () => void + onTogglePublishMenu: () => void +} + +const SnippetHeader = ({ + inputFieldCount, + onToggleInputPanel, + onTogglePublishMenu, +}: SnippetHeaderProps) => { + const { t } = useTranslation('snippet') + + return ( +
+ + + + +
+ +
+ + +
+ ) +} + +export default SnippetHeader diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx new file mode 100644 index 0000000000..fd9bd14373 --- /dev/null +++ b/web/app/components/snippets/components/snippet-main.tsx @@ -0,0 +1,193 @@ +'use client' + +import type { NavIcon } from '@/app/components/app-sidebar/nav-link' +import type { WorkflowProps } from '@/app/components/workflow' +import type { SnippetDetailPayload, SnippetInputField } from '@/models/snippet' +import { + RiFlaskFill, + RiFlaskLine, + RiGitBranchFill, + RiGitBranchLine, +} from '@remixicon/react' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useShallow } from 'zustand/react/shallow' +import AppSideBar from '@/app/components/app-sidebar' +import NavLink from '@/app/components/app-sidebar/nav-link' +import SnippetInfo from '@/app/components/app-sidebar/snippet-info' +import { useStore as useAppStore } from '@/app/components/app/store' +import Toast from '@/app/components/base/toast' +import { WorkflowWithInnerContext } from '@/app/components/workflow' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { useSnippetDetailStore } from '../store' +import SnippetChildren from './snippet-children' + +type SnippetMainProps = { + payload: SnippetDetailPayload + snippetId: string +} & Pick + +const ORCHESTRATE_ICONS: { normal: NavIcon, selected: NavIcon } = { + normal: RiGitBranchLine, + selected: RiGitBranchFill, +} + +const EVALUATION_ICONS: { normal: NavIcon, selected: NavIcon } = { + normal: RiFlaskLine, + selected: RiFlaskFill, +} + +const SnippetMain = ({ + payload, + snippetId, + nodes, + edges, + viewport, +}: SnippetMainProps) => { + const { t } = useTranslation('snippet') + const { graph, snippet, uiMeta } = payload + const media = useBreakpoints() + const isMobile = media === MediaType.mobile + const [fields, setFields] = useState(payload.inputFields) + const setAppSidebarExpand = useAppStore(state => state.setAppSidebarExpand) + const { + activeSection, + editingField, + isEditorOpen, + isInputPanelOpen, + isPublishMenuOpen, + closeEditor, + openEditor, + reset, + setActiveSection, + setInputPanelOpen, + toggleInputPanel, + togglePublishMenu, + } = useSnippetDetailStore(useShallow(state => ({ + activeSection: state.activeSection, + editingField: state.editingField, + isEditorOpen: state.isEditorOpen, + isInputPanelOpen: state.isInputPanelOpen, + isPublishMenuOpen: state.isPublishMenuOpen, + closeEditor: state.closeEditor, + openEditor: state.openEditor, + reset: state.reset, + setActiveSection: state.setActiveSection, + setInputPanelOpen: state.setInputPanelOpen, + toggleInputPanel: state.toggleInputPanel, + togglePublishMenu: state.togglePublishMenu, + }))) + + useEffect(() => { + reset() + }, [reset, snippetId]) + + useEffect(() => { + const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand' + const mode = isMobile ? 'collapse' : 'expand' + setAppSidebarExpand(isMobile ? mode : localeMode) + }, [isMobile, setAppSidebarExpand]) + + const primaryFields = useMemo(() => fields.slice(0, 2), [fields]) + const secondaryFields = useMemo(() => fields.slice(2), [fields]) + + const handlePrimarySortChange = (newFields: SnippetInputField[]) => { + setFields([...newFields, ...secondaryFields]) + } + + const handleSecondarySortChange = (newFields: SnippetInputField[]) => { + setFields([...primaryFields, ...newFields]) + } + + const handleRemoveField = (index: number) => { + setFields(current => current.filter((_, currentIndex) => currentIndex !== index)) + } + + const handleSubmitField = (field: SnippetInputField) => { + const originalVariable = editingField?.variable + const duplicated = fields.some(item => item.variable === field.variable && item.variable !== originalVariable) + + if (duplicated) { + Toast.notify({ + type: 'error', + message: t('inputFieldPanel.error.variableDuplicate', { ns: 'datasetPipeline' }), + }) + return + } + + if (originalVariable) + setFields(current => current.map(item => item.variable === originalVariable ? field : item)) + else + setFields(current => [...current, field]) + + closeEditor() + } + + const handleToggleInputPanel = () => { + if (isInputPanelOpen) + closeEditor() + toggleInputPanel() + } + + const handleCloseInputPanel = () => { + closeEditor() + setInputPanelOpen(false) + } + + return ( +
+ } + renderNavigation={mode => ( + <> + setActiveSection('orchestrate')} + /> + setActiveSection('evaluation')} + /> + + )} + /> + +
+
+ + + +
+
+
+ ) +} + +export default SnippetMain diff --git a/web/app/components/snippets/components/workflow-panel.tsx b/web/app/components/snippets/components/workflow-panel.tsx new file mode 100644 index 0000000000..e59ba6d2b6 --- /dev/null +++ b/web/app/components/snippets/components/workflow-panel.tsx @@ -0,0 +1,111 @@ +'use client' + +import type { PanelProps } from '@/app/components/workflow/panel' +import type { SnippetInputField } from '@/models/snippet' +import { memo, useMemo } from 'react' +import Panel from '@/app/components/workflow/panel' +import SnippetInputFieldEditor from './input-field-editor' +import SnippetInputFieldPanel from './panel' + +type SnippetWorkflowPanelProps = { + fields: SnippetInputField[] + editingField: SnippetInputField | null + isEditorOpen: boolean + isInputPanelOpen: boolean + onCloseInputPanel: () => void + onOpenEditor: (field?: SnippetInputField | null) => void + onCloseEditor: () => void + onSubmitField: (field: SnippetInputField) => void + onRemoveField: (index: number) => void + onPrimarySortChange: (fields: SnippetInputField[]) => void + onSecondarySortChange: (fields: SnippetInputField[]) => void +} + +const SnippetPanelOnLeft = ({ + fields, + editingField, + isEditorOpen, + isInputPanelOpen, + onCloseInputPanel, + onOpenEditor, + onCloseEditor, + onSubmitField, + onRemoveField, + onPrimarySortChange, + onSecondarySortChange, +}: SnippetWorkflowPanelProps) => { + return ( +
+ {isEditorOpen && ( + + )} + {isInputPanelOpen && ( + onOpenEditor()} + onEdit={onOpenEditor} + onRemove={onRemoveField} + onPrimarySortChange={onPrimarySortChange} + onSecondarySortChange={onSecondarySortChange} + /> + )} +
+ ) +} + +const SnippetWorkflowPanel = ({ + fields, + editingField, + isEditorOpen, + isInputPanelOpen, + onCloseInputPanel, + onOpenEditor, + onCloseEditor, + onSubmitField, + onRemoveField, + onPrimarySortChange, + onSecondarySortChange, +}: SnippetWorkflowPanelProps) => { + const panelProps: PanelProps = useMemo(() => { + return { + components: { + left: ( + + ), + }, + } + }, [ + editingField, + fields, + isEditorOpen, + isInputPanelOpen, + onCloseEditor, + onCloseInputPanel, + onOpenEditor, + onPrimarySortChange, + onRemoveField, + onSecondarySortChange, + onSubmitField, + ]) + + return +} + +export default memo(SnippetWorkflowPanel) diff --git a/web/app/components/snippets/hooks/use-snippet-init.ts b/web/app/components/snippets/hooks/use-snippet-init.ts new file mode 100644 index 0000000000..a5978603cd --- /dev/null +++ b/web/app/components/snippets/hooks/use-snippet-init.ts @@ -0,0 +1,5 @@ +import { useSnippetDetail } from '@/service/use-snippets' + +export const useSnippetInit = (snippetId: string) => { + return useSnippetDetail(snippetId) +} diff --git a/web/app/components/snippets/index.tsx b/web/app/components/snippets/index.tsx new file mode 100644 index 0000000000..690a8d6376 --- /dev/null +++ b/web/app/components/snippets/index.tsx @@ -0,0 +1,82 @@ +'use client' + +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import Loading from '@/app/components/base/loading' +import WorkflowWithDefaultContext from '@/app/components/workflow' +import { WorkflowContextProvider } from '@/app/components/workflow/context' +import { + initialEdges, + initialNodes, +} from '@/app/components/workflow/utils' +import SnippetMain from './components/snippet-main' +import { useSnippetInit } from './hooks/use-snippet-init' + +type SnippetPageProps = { + snippetId: string +} + +const SnippetPage = ({ + snippetId, +}: SnippetPageProps) => { + const { t } = useTranslation('snippet') + const { data, isLoading } = useSnippetInit(snippetId) + const nodesData = useMemo(() => { + if (!data) + return [] + + return initialNodes(data.graph.nodes, data.graph.edges) + }, [data]) + const edgesData = useMemo(() => { + if (!data) + return [] + + return initialEdges(data.graph.edges, data.graph.nodes) + }, [data]) + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (!data) { + return ( +
+
+
404
+
{t('notFoundTitle')}
+
{t('notFoundDescription')}
+
+
+ ) + } + + return ( + + + + ) +} + +const SnippetPageWrapper = (props: SnippetPageProps) => { + return ( + + + + ) +} + +export default SnippetPageWrapper diff --git a/web/app/components/snippets/store/index.ts b/web/app/components/snippets/store/index.ts new file mode 100644 index 0000000000..c0c86d6aa2 --- /dev/null +++ b/web/app/components/snippets/store/index.ts @@ -0,0 +1,44 @@ +'use client' + +import type { SnippetInputField, SnippetSection } from '@/models/snippet' +import { create } from 'zustand' + +type SnippetDetailUIState = { + activeSection: SnippetSection + isInputPanelOpen: boolean + isPublishMenuOpen: boolean + isPreviewMode: boolean + isEditorOpen: boolean + editingField: SnippetInputField | null + setActiveSection: (section: SnippetSection) => void + setInputPanelOpen: (value: boolean) => void + toggleInputPanel: () => void + setPublishMenuOpen: (value: boolean) => void + togglePublishMenu: () => void + setPreviewMode: (value: boolean) => void + openEditor: (field?: SnippetInputField | null) => void + closeEditor: () => void + reset: () => void +} + +const initialState = { + activeSection: 'orchestrate' as SnippetSection, + isInputPanelOpen: false, + isPublishMenuOpen: false, + isPreviewMode: false, + editingField: null, + isEditorOpen: false, +} + +export const useSnippetDetailStore = create(set => ({ + ...initialState, + setActiveSection: activeSection => set({ activeSection }), + setInputPanelOpen: isInputPanelOpen => set({ isInputPanelOpen }), + toggleInputPanel: () => set(state => ({ isInputPanelOpen: !state.isInputPanelOpen, isPublishMenuOpen: false })), + setPublishMenuOpen: isPublishMenuOpen => set({ isPublishMenuOpen }), + togglePublishMenu: () => set(state => ({ isPublishMenuOpen: !state.isPublishMenuOpen })), + setPreviewMode: isPreviewMode => set({ isPreviewMode }), + openEditor: (editingField = null) => set({ editingField, isEditorOpen: true, isInputPanelOpen: true }), + closeEditor: () => set({ editingField: null, isEditorOpen: false }), + reset: () => set(initialState), +})) diff --git a/web/i18n-config/resources.ts b/web/i18n-config/resources.ts index 857440a1ee..6cca6e97cb 100644 --- a/web/i18n-config/resources.ts +++ b/web/i18n-config/resources.ts @@ -25,6 +25,7 @@ import type plugin from '../i18n/en-US/plugin.json' import type register from '../i18n/en-US/register.json' import type runLog from '../i18n/en-US/run-log.json' import type share from '../i18n/en-US/share.json' +import type snippet from '../i18n/en-US/snippet.json' import type time from '../i18n/en-US/time.json' import type tools from '../i18n/en-US/tools.json' import type workflow from '../i18n/en-US/workflow.json' @@ -58,6 +59,7 @@ export type Resources = { register: typeof register runLog: typeof runLog share: typeof share + snippet: typeof snippet time: typeof time tools: typeof tools workflow: typeof workflow @@ -91,6 +93,7 @@ export const namespaces = [ 'register', 'runLog', 'share', + 'snippet', 'time', 'tools', 'workflow', diff --git a/web/i18n/en-US/app.json b/web/i18n/en-US/app.json index afbd352ddf..370f994004 100644 --- a/web/i18n/en-US/app.json +++ b/web/i18n/en-US/app.json @@ -35,7 +35,6 @@ "communityIntro": "Discuss with team members, contributors and developers on different channels.", "createApp": "CREATE APP", "createFromConfigFile": "Create from DSL file", - "createSnippet": "CREATE SNIPPET", "deleteAppConfirmContent": "Deleting the app is irreversible. Users will no longer be able to access your app, and all prompt configurations and logs will be permanently deleted.", "deleteAppConfirmTitle": "Delete this app?", "dslUploader.browse": "Browse", @@ -210,12 +209,6 @@ "structOutput.structured": "Structured", "structOutput.structuredTip": "Structured Outputs is a feature that ensures the model will always generate responses that adhere to your supplied JSON Schema", "studio.apps": "Apps", - "studio.fakeSnippet.author": "Evan", - "studio.fakeSnippet.description": "Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.", - "studio.fakeSnippet.name": "Tone Rewriter", - "studio.fakeSnippet.status": "Draft", - "studio.fakeSnippet.updatedAt": "Updated 2h ago", - "studio.fakeSnippet.usage": "Used 19 times", "studio.filters.allCreators": "All creators", "studio.filters.creators": "Creators", "studio.filters.reset": "Reset", diff --git a/web/i18n/en-US/snippet.json b/web/i18n/en-US/snippet.json new file mode 100644 index 0000000000..9c273b27e3 --- /dev/null +++ b/web/i18n/en-US/snippet.json @@ -0,0 +1,16 @@ +{ + "create": "CREATE SNIPPET", + "inputFieldButton": "Input Field", + "notFoundDescription": "The requested snippet mock was not found.", + "notFoundTitle": "Snippet not found", + "panelDescription": "Defines the input fields that allow the snippet to receive data from other nodes.", + "panelPrimaryGroup": "Core inputs", + "panelSecondaryGroup": "Optional inputs", + "panelTitle": "Input Field", + "publishButton": "Publish", + "publishMenuCurrentDraft": "Current draft unpublished", + "sectionEvaluation": "Evaluation", + "sectionOrchestrate": "Orchestrate", + "testRunButton": "Test run", + "variableInspect": "Variable Inspect" +} diff --git a/web/i18n/zh-Hans/snippet.json b/web/i18n/zh-Hans/snippet.json new file mode 100644 index 0000000000..d3f735c2e7 --- /dev/null +++ b/web/i18n/zh-Hans/snippet.json @@ -0,0 +1,16 @@ +{ + "create": "创建 Snippet", + "inputFieldButton": "输入字段", + "notFoundDescription": "未找到对应的 snippet 静态数据。", + "notFoundTitle": "未找到 Snippet", + "panelDescription": "定义允许 snippet 从其他节点接收数据的输入字段。", + "panelPrimaryGroup": "核心输入", + "panelSecondaryGroup": "可选输入", + "panelTitle": "输入字段", + "publishButton": "发布", + "publishMenuCurrentDraft": "当前草稿未发布", + "sectionEvaluation": "评测", + "sectionOrchestrate": "编排", + "testRunButton": "测试运行", + "variableInspect": "变量查看" +} diff --git a/web/models/snippet.ts b/web/models/snippet.ts new file mode 100644 index 0000000000..5471782ab0 --- /dev/null +++ b/web/models/snippet.ts @@ -0,0 +1,50 @@ +import type { Viewport } from 'reactflow' +import type { Edge, Node } from '@/app/components/workflow/types' +import type { InputVar } from '@/models/pipeline' + +export type SnippetSection = 'orchestrate' | 'evaluation' + +export type SnippetListItem = { + id: string + name: string + description: string + author: string + updatedAt: string + usage: string + icon: string + iconBackground: string + status?: string +} + +export type SnippetDetail = { + id: string + name: string + description: string + author: string + updatedAt: string + usage: string + icon: string + iconBackground: string + status?: string +} + +export type SnippetCanvasData = { + nodes: Node[] + edges: Edge[] + viewport: Viewport +} + +export type SnippetInputField = InputVar + +export type SnippetDetailUIModel = { + inputFieldCount: number + checklistCount: number + autoSavedAt: string +} + +export type SnippetDetailPayload = { + snippet: SnippetDetail + graph: SnippetCanvasData + inputFields: SnippetInputField[] + uiMeta: SnippetDetailUIModel +} diff --git a/web/service/use-snippets.ts b/web/service/use-snippets.ts new file mode 100644 index 0000000000..222652e0e4 --- /dev/null +++ b/web/service/use-snippets.ts @@ -0,0 +1,215 @@ +import type { Node } from '@/app/components/workflow/types' +import type { SnippetDetailPayload, SnippetInputField, SnippetListItem } from '@/models/snippet' +import { useQuery } from '@tanstack/react-query' +import codeDefault from '@/app/components/workflow/nodes/code/default' +import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' +import httpDefault from '@/app/components/workflow/nodes/http/default' +import { Method } from '@/app/components/workflow/nodes/http/types' +import llmDefault from '@/app/components/workflow/nodes/llm/default' +import questionClassifierDefault from '@/app/components/workflow/nodes/question-classifier/default' +import { BlockEnum, PromptRole } from '@/app/components/workflow/types' +import { PipelineInputVarType } from '@/models/pipeline' +import { AppModeEnum } from '@/types/app' + +const NAME_SPACE = 'snippets' + +export const getSnippetListMock = (): SnippetListItem[] => ([ + { + id: 'snippet-1', + name: 'Tone Rewriter', + description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.', + author: 'Evan', + updatedAt: 'Updated 2h ago', + usage: 'Used 19 times', + icon: '🪄', + iconBackground: '#E0EAFF', + status: 'Draft', + }, +]) + +const getSnippetInputFieldsMock = (): SnippetInputField[] => ([ + { + type: PipelineInputVarType.textInput, + label: 'Blog URL', + variable: 'blog_url', + required: true, + placeholder: 'Paste a source article URL', + options: [], + max_length: 256, + }, + { + type: PipelineInputVarType.textInput, + label: 'Target Platforms', + variable: 'platforms', + required: true, + placeholder: 'X, LinkedIn, Instagram', + options: [], + max_length: 128, + }, + { + type: PipelineInputVarType.textInput, + label: 'Tone', + variable: 'tone', + required: false, + placeholder: 'Concise and executive-ready', + options: [], + max_length: 48, + }, + { + type: PipelineInputVarType.textInput, + label: 'Max Length', + variable: 'max_length', + required: false, + placeholder: 'Set an ideal output length', + options: [], + max_length: 48, + }, +]) + +const getSnippetGraphMock = (): SnippetDetailPayload['graph'] => ({ + viewport: { x: 120, y: 30, zoom: 0.9 }, + nodes: [ + { + id: 'question-classifier', + position: { x: 280, y: 208 }, + data: { + ...questionClassifierDefault.defaultValue, + title: 'Question Classifier', + desc: 'After-sales related questions', + type: BlockEnum.QuestionClassifier, + query_variable_selector: ['sys', 'query'], + model: { + provider: 'openai', + name: 'gpt-4o', + mode: AppModeEnum.CHAT, + completion_params: { + temperature: 0.2, + }, + }, + classes: [ + { + id: '1', + name: 'HTTP Request', + }, + { + id: '2', + name: 'LLM', + }, + { + id: '3', + name: 'Code', + }, + ], + } as unknown as Node['data'], + }, + { + id: 'http-request', + position: { x: 670, y: 72 }, + data: { + ...httpDefault.defaultValue, + title: 'HTTP Request', + desc: 'POST https://api.example.com/content/rewrite', + type: BlockEnum.HttpRequest, + method: Method.post, + url: 'https://api.example.com/content/rewrite', + headers: 'Content-Type: application/json', + } as unknown as Node['data'], + }, + { + id: 'llm', + position: { x: 670, y: 248 }, + data: { + ...llmDefault.defaultValue, + title: 'LLM', + desc: 'GPT-4o', + type: BlockEnum.LLM, + model: { + provider: 'openai', + name: 'gpt-4o', + mode: AppModeEnum.CHAT, + completion_params: { + temperature: 0.7, + }, + }, + prompt_template: [{ + role: PromptRole.system, + text: 'Rewrite the content with the requested tone.', + }], + } as unknown as Node['data'], + }, + { + id: 'code', + position: { x: 670, y: 424 }, + data: { + ...codeDefault.defaultValue, + title: 'Code', + desc: 'Python', + type: BlockEnum.Code, + code_language: CodeLanguage.python3, + code: 'def main(text: str) -> dict:\n return {"content": text.strip()}', + } as unknown as Node['data'], + }, + ], + edges: [ + { + id: 'edge-question-http', + source: 'question-classifier', + sourceHandle: '1', + target: 'http-request', + targetHandle: 'target', + }, + { + id: 'edge-question-llm', + source: 'question-classifier', + sourceHandle: '2', + target: 'llm', + targetHandle: 'target', + }, + { + id: 'edge-question-code', + source: 'question-classifier', + sourceHandle: '3', + target: 'code', + targetHandle: 'target', + }, + ], +}) + +const getSnippetDetailMock = (snippetId: string): SnippetDetailPayload | null => { + const snippet = getSnippetListMock().find(item => item.id === snippetId) + if (!snippet) + return null + + const inputFields = getSnippetInputFieldsMock() + + return { + snippet, + graph: getSnippetGraphMock(), + inputFields, + uiMeta: { + inputFieldCount: inputFields.length, + checklistCount: 2, + autoSavedAt: 'Auto-saved · a few seconds ago', + }, + } +} + +export const useSnippetDetail = (snippetId: string) => { + return useQuery({ + queryKey: [NAME_SPACE, 'detail', snippetId], + queryFn: async () => getSnippetDetailMock(snippetId), + enabled: !!snippetId, + }) +} + +export const publishSnippet = async (_snippetId: string) => { + return Promise.resolve() +} + +export const runSnippet = async (_snippetId: string) => { + return Promise.resolve() +} + +export const updateSnippetInputFields = async (_snippetId: string, _fields: SnippetInputField[]) => { + return Promise.resolve() +}