@@ -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.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()
+}