mirror of
https://github.com/langgenius/dify.git
synced 2026-04-25 05:06:15 +08:00
feat: snippet canvas
This commit is contained in:
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import SnippetPage from '@/app/components/snippets'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { params } = props
|
||||
|
||||
return <SnippetPage snippetId={(await params).snippetId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@ -165,6 +165,21 @@ describe('AppDetailNav', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom header and navigation when provided', () => {
|
||||
render(
|
||||
<AppDetailNav
|
||||
navigation={navigation}
|
||||
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
|
||||
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={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', () => {
|
||||
|
||||
@ -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' && (
|
||||
<AppInfo expand={expand} />
|
||||
)}
|
||||
{iconType !== 'app' && (
|
||||
{!renderHeader && iconType !== 'app' && (
|
||||
<DatasetInfo expand={expand} />
|
||||
)}
|
||||
</div>
|
||||
@ -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 (
|
||||
<NavLink
|
||||
key={index}
|
||||
|
||||
@ -262,4 +262,20 @@ describe('NavLink Animation and Layout Issues', () => {
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button Mode', () => {
|
||||
it('should render as an interactive button when href is omitted', () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 = () => (
|
||||
<div className={cn(mode !== 'expand' && '-ml-1')}>
|
||||
@ -70,13 +77,32 @@ const NavLink = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (!href) {
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
onClick={onClick}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span
|
||||
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
|
||||
? 'ml-2 max-w-none opacity-100'
|
||||
: 'ml-0 max-w-0 opacity-0')}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={name}
|
||||
href={href}
|
||||
className={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')}
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
>
|
||||
{renderIcon()}
|
||||
|
||||
53
web/app/components/app-sidebar/snippet-info/index.tsx
Normal file
53
web/app/components/app-sidebar/snippet-info/index.tsx
Normal file
@ -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 (
|
||||
<div className={cn('flex flex-col', expand ? '' : 'p-1')}>
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(!expand && 'ml-1')}>
|
||||
<AppIcon
|
||||
size={expand ? 'large' : 'small'}
|
||||
iconType="emoji"
|
||||
icon={snippet.icon}
|
||||
background={snippet.iconBackground}
|
||||
/>
|
||||
</div>
|
||||
{expand && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-text-secondary system-md-semibold">
|
||||
{snippet.name}
|
||||
</div>
|
||||
{snippet.status && (
|
||||
<div className="pt-1">
|
||||
<Badge>{snippet.status}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{expand && snippet.description && (
|
||||
<p className="line-clamp-3 text-text-tertiary system-xs-regular">
|
||||
{snippet.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfo)
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className="flex items-center rounded-lg border-[0.5px] border-divider-subtle bg-[rgba(200,206,218,0.2)] p-[1px]">
|
||||
@ -75,12 +66,12 @@ const StudioRouteSwitch = ({ pageType, appsLabel, snippetsLabel }: { pageType: S
|
||||
}
|
||||
|
||||
const SnippetCreateCard = () => {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<div className="relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity">
|
||||
<div className="grow rounded-t-xl p-2">
|
||||
<div className="px-6 pb-1 pt-2 text-xs font-medium leading-[18px] text-text-tertiary">{t('createSnippet', { ns: 'app' })}</div>
|
||||
<div className="px-6 pb-1 pt-2 text-xs font-medium leading-[18px] text-text-tertiary">{t('create')}</div>
|
||||
<div className="mb-1 flex w-full items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary">
|
||||
<span aria-hidden className="i-ri-sticky-note-add-line mr-2 h-4 w-4 shrink-0" />
|
||||
{t('newApp.startFromBlank', { ns: 'app' })}
|
||||
@ -97,38 +88,40 @@ const SnippetCreateCard = () => {
|
||||
const SnippetCard = ({
|
||||
snippet,
|
||||
}: {
|
||||
snippet: StudioSnippet
|
||||
snippet: SnippetListItem
|
||||
}) => {
|
||||
return (
|
||||
<article className="group relative col-span-1 inline-flex h-[160px] flex-col rounded-xl border border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:shadow-lg">
|
||||
{snippet.status && (
|
||||
<div className="absolute right-0 top-0 rounded-bl-lg rounded-tr-xl bg-background-default-dimmed px-2 py-1 text-[10px] font-medium uppercase leading-3 text-text-placeholder">
|
||||
{snippet.status}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-[66px] items-center gap-3 px-[14px] pb-3 pt-[14px]">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-divider-regular bg-components-icon-bg-indigo-solid text-xl text-white">
|
||||
<span aria-hidden>{snippet.icon}</span>
|
||||
</div>
|
||||
<div className="w-0 grow py-[1px]">
|
||||
<div className="truncate text-sm font-semibold leading-5 text-text-secondary" title={snippet.name}>
|
||||
{snippet.name}
|
||||
<Link href={`/snippets/${snippet.id}`} className="group col-span-1">
|
||||
<article className="relative inline-flex h-[160px] w-full flex-col rounded-xl border border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg">
|
||||
{snippet.status && (
|
||||
<div className="absolute right-0 top-0 rounded-bl-lg rounded-tr-xl bg-background-default-dimmed px-2 py-1 text-[10px] font-medium uppercase leading-3 text-text-placeholder">
|
||||
{snippet.status}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-[66px] items-center gap-3 px-[14px] pb-3 pt-[14px]">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-divider-regular text-xl text-white" style={{ background: snippet.iconBackground }}>
|
||||
<span aria-hidden>{snippet.icon}</span>
|
||||
</div>
|
||||
<div className="w-0 grow py-[1px]">
|
||||
<div className="truncate text-sm font-semibold leading-5 text-text-secondary" title={snippet.name}>
|
||||
{snippet.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[58px] px-[14px] text-xs leading-normal text-text-tertiary">
|
||||
<div className="line-clamp-2" title={snippet.description}>
|
||||
{snippet.description}
|
||||
<div className="h-[58px] px-[14px] text-xs leading-normal text-text-tertiary">
|
||||
<div className="line-clamp-2" title={snippet.description}>
|
||||
{snippet.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto flex items-center gap-1 px-[14px] pb-3 pt-2 text-xs leading-4 text-text-tertiary">
|
||||
<span className="truncate">{snippet.author}</span>
|
||||
<span>·</span>
|
||||
<span className="truncate">{snippet.updatedAt}</span>
|
||||
<span>·</span>
|
||||
<span className="truncate">{snippet.usage}</span>
|
||||
</div>
|
||||
</article>
|
||||
<div className="mt-auto flex items-center gap-1 px-[14px] pb-3 pt-2 text-xs leading-4 text-text-tertiary">
|
||||
<span className="truncate">{snippet.author}</span>
|
||||
<span>·</span>
|
||||
<span className="truncate">{snippet.updatedAt}</span>
|
||||
<span>·</span>
|
||||
<span className="truncate">{snippet.usage}</span>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@ -274,18 +267,7 @@ const List: FC<Props> = ({
|
||||
return (data?.pages ?? []).flatMap(({ data: apps }) => apps)
|
||||
}, [data?.pages])
|
||||
|
||||
const snippetItems = useMemo<StudioSnippet[]>(() => ([
|
||||
{
|
||||
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()
|
||||
|
||||
171
web/app/components/snippets/__tests__/index.spec.tsx
Normal file
171
web/app/components/snippets/__tests__/index.spec.tsx
Normal file
@ -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 }) => (
|
||||
<div data-testid="workflow-default-context">{children}</div>
|
||||
),
|
||||
WorkflowWithInnerContext: ({ children, viewport }: { children: React.ReactNode, viewport?: { zoom?: number } }) => (
|
||||
<div data-testid="workflow-inner-context">
|
||||
<span data-testid="workflow-viewport-zoom">{viewport?.zoom ?? 'none'}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/context', () => ({
|
||||
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="workflow-context-provider">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar', () => ({
|
||||
default: ({
|
||||
renderHeader,
|
||||
renderNavigation,
|
||||
}: {
|
||||
renderHeader?: (modeState: string) => React.ReactNode
|
||||
renderNavigation?: (modeState: string) => React.ReactNode
|
||||
}) => (
|
||||
<div data-testid="app-sidebar">
|
||||
<div data-testid="app-sidebar-header">{renderHeader?.('expand')}</div>
|
||||
<div data-testid="app-sidebar-navigation">{renderNavigation?.('expand')}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar/nav-link', () => ({
|
||||
default: ({ name, onClick }: { name: string, onClick?: () => void }) => (
|
||||
<button type="button" onClick={onClick}>{name}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel', () => ({
|
||||
default: ({ components }: { components?: { left?: React.ReactNode, right?: React.ReactNode } }) => (
|
||||
<div data-testid="workflow-panel">
|
||||
<div data-testid="workflow-panel-left">{components?.left}</div>
|
||||
<div data-testid="workflow-panel-right">{components?.right}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
initialNodes: (nodes: unknown[]) => nodes,
|
||||
initialEdges: (edges: unknown[]) => edges,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-sortablejs', () => ({
|
||||
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
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(<SnippetPage snippetId="snippet-1" />)
|
||||
|
||||
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(<SnippetPage snippetId="snippet-1" />)
|
||||
|
||||
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(<SnippetPage snippetId="snippet-1" />)
|
||||
|
||||
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(<SnippetPage snippetId="missing-snippet" />)
|
||||
|
||||
expect(screen.getByText('snippet.notFoundTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.notFoundDescription')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -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 (
|
||||
<div className="relative mr-1 flex h-fit max-h-full w-[min(400px,calc(100vw-24px))] flex-col overflow-y-auto rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9">
|
||||
<div className="flex items-center pb-1 pl-4 pr-11 pt-3.5 text-text-primary system-xl-semibold">
|
||||
{field ? t('inputFieldPanel.editInputField', { ns: 'datasetPipeline' }) : t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-2.5 flex h-8 w-8 items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</button>
|
||||
<InputFieldForm
|
||||
initialData={initialData}
|
||||
supportFile
|
||||
onCancel={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
isEditMode={!!field}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetInputFieldEditor
|
||||
119
web/app/components/snippets/components/panel/index.tsx
Normal file
119
web/app/components/snippets/components/panel/index.tsx
Normal file
@ -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 (
|
||||
<div className="mr-1 flex h-full w-[min(400px,calc(100vw-24px))] flex-col rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
|
||||
<div className="flex items-start justify-between gap-3 px-4 pb-2 pt-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-text-primary system-xl-semibold">
|
||||
{t('panelTitle')}
|
||||
</div>
|
||||
<div className="pt-1 text-text-tertiary system-sm-regular">
|
||||
{t('panelDescription')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover"
|
||||
onClick={onClose}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-2">
|
||||
<Button variant="secondary" size="small" className="w-full justify-center gap-1" onClick={onAdd}>
|
||||
<span aria-hidden className="i-ri-add-line h-4 w-4" />
|
||||
{t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col overflow-y-auto">
|
||||
<div className="px-4 pb-1 pt-2 text-text-secondary system-xs-semibold-uppercase">
|
||||
{t('panelPrimaryGroup')}
|
||||
</div>
|
||||
<FieldListContainer
|
||||
className="flex flex-col gap-y-1 px-4 pb-2"
|
||||
inputFields={primaryFields}
|
||||
onListSortChange={list => onPrimarySortChange(toInputFields(list))}
|
||||
onRemoveField={handlePrimaryRemove}
|
||||
onEditField={handlePrimaryEdit}
|
||||
/>
|
||||
|
||||
<div className="px-4 py-2">
|
||||
<Divider type="horizontal" className="bg-divider-subtle" />
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-1 text-text-secondary system-xs-semibold-uppercase">
|
||||
{t('panelSecondaryGroup')}
|
||||
</div>
|
||||
<FieldListContainer
|
||||
className="flex flex-col gap-y-1 px-4 pb-4"
|
||||
inputFields={secondaryFields}
|
||||
onListSortChange={list => onSecondarySortChange(toInputFields(list))}
|
||||
onRemoveField={handleSecondaryRemove}
|
||||
onEditField={handleSecondaryEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(SnippetInputFieldPanel)
|
||||
29
web/app/components/snippets/components/publish-menu.tsx
Normal file
29
web/app/components/snippets/components/publish-menu.tsx
Normal file
@ -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 (
|
||||
<div className="w-80 rounded-2xl border border-components-panel-border bg-components-panel-bg p-4 shadow-[0px_20px_24px_-4px_rgba(9,9,11,0.08),0px_8px_8px_-4px_rgba(9,9,11,0.03)]">
|
||||
<div className="text-text-tertiary system-xs-semibold-uppercase">
|
||||
{t('publishMenuCurrentDraft')}
|
||||
</div>
|
||||
<div className="pt-1 text-text-secondary system-sm-medium">
|
||||
{uiMeta.autoSavedAt}
|
||||
</div>
|
||||
<Button variant="primary" size="small" className="mt-4 w-full justify-center">
|
||||
{t('publishButton')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PublishMenu
|
||||
106
web/app/components/snippets/components/snippet-children.tsx
Normal file
106
web/app/components/snippets/components/snippet-children.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-gradient-to-b from-background-body to-transparent" />
|
||||
|
||||
<SnippetHeader
|
||||
inputFieldCount={fields.length}
|
||||
onToggleInputPanel={onToggleInputPanel}
|
||||
onTogglePublishMenu={onTogglePublishMenu}
|
||||
/>
|
||||
|
||||
<SnippetWorkflowPanel
|
||||
fields={fields}
|
||||
editingField={editingField}
|
||||
isEditorOpen={isEditorOpen}
|
||||
isInputPanelOpen={isInputPanelOpen}
|
||||
onCloseInputPanel={onCloseInputPanel}
|
||||
onOpenEditor={onOpenEditor}
|
||||
onCloseEditor={onCloseEditor}
|
||||
onSubmitField={onSubmitField}
|
||||
onRemoveField={onRemoveField}
|
||||
onPrimarySortChange={onPrimarySortChange}
|
||||
onSecondarySortChange={onSecondarySortChange}
|
||||
/>
|
||||
|
||||
{isPublishMenuOpen && (
|
||||
<div className="absolute right-3 top-14 z-20">
|
||||
<PublishMenu uiMeta={uiMeta} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isInputPanelOpen && (
|
||||
<div className="pointer-events-none absolute inset-y-3 right-3 z-30 flex justify-end">
|
||||
<div className="pointer-events-auto h-full xl:hidden">
|
||||
<SnippetInputFieldPanel
|
||||
fields={fields}
|
||||
onClose={onCloseInputPanel}
|
||||
onAdd={() => onOpenEditor()}
|
||||
onEdit={onOpenEditor}
|
||||
onRemove={onRemoveField}
|
||||
onPrimarySortChange={onPrimarySortChange}
|
||||
onSecondarySortChange={onSecondarySortChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditorOpen && (
|
||||
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center bg-black/10 px-3 xl:hidden">
|
||||
<div className="pointer-events-auto w-full max-w-md">
|
||||
<SnippetInputFieldEditor
|
||||
field={editingField}
|
||||
onClose={onCloseEditor}
|
||||
onSubmit={onSubmitField}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetChildren
|
||||
@ -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 (
|
||||
<div className="absolute right-3 top-3 z-20 flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-text-secondary shadow-xs backdrop-blur"
|
||||
onClick={onToggleInputPanel}
|
||||
>
|
||||
<span className="text-[13px] font-medium leading-4">{t('inputFieldButton')}</span>
|
||||
<span className="rounded-md border border-divider-deep px-1.5 py-0.5 text-[10px] font-medium leading-3 text-text-tertiary">
|
||||
{inputFieldCount}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-text-accent shadow-xs backdrop-blur"
|
||||
>
|
||||
<span aria-hidden className="i-ri-play-mini-fill h-4 w-4" />
|
||||
<span className="text-[13px] font-medium leading-4">{t('testRunButton')}</span>
|
||||
<span className="rounded-md bg-state-accent-active px-1.5 py-0.5 text-[10px] font-semibold leading-3 text-text-accent">R</span>
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-lg bg-components-button-primary-bg px-3 py-2 text-white shadow-[0px_2px_2px_-1px_rgba(0,0,0,0.12),0px_1px_1px_-1px_rgba(0,0,0,0.12),0px_0px_0px_0.5px_rgba(9,9,11,0.05)]"
|
||||
onClick={onTogglePublishMenu}
|
||||
>
|
||||
<span className="text-[13px] font-medium leading-4">{t('publishButton')}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg text-text-tertiary shadow-xs"
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-2-line h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetHeader
|
||||
193
web/app/components/snippets/components/snippet-main.tsx
Normal file
193
web/app/components/snippets/components/snippet-main.tsx
Normal file
@ -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<WorkflowProps, 'nodes' | 'edges' | 'viewport'>
|
||||
|
||||
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<SnippetInputField[]>(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 (
|
||||
<div className="relative flex h-full overflow-hidden bg-background-body">
|
||||
<AppSideBar
|
||||
navigation={[]}
|
||||
renderHeader={mode => <SnippetInfo expand={mode === 'expand'} snippet={snippet} />}
|
||||
renderNavigation={mode => (
|
||||
<>
|
||||
<NavLink
|
||||
mode={mode}
|
||||
name={t('sectionOrchestrate')}
|
||||
iconMap={ORCHESTRATE_ICONS}
|
||||
active={activeSection === 'orchestrate'}
|
||||
onClick={() => setActiveSection('orchestrate')}
|
||||
/>
|
||||
<NavLink
|
||||
mode={mode}
|
||||
name={t('sectionEvaluation')}
|
||||
iconMap={EVALUATION_ICONS}
|
||||
active={activeSection === 'evaluation'}
|
||||
onClick={() => setActiveSection('evaluation')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative min-h-0 min-w-0 grow overflow-hidden">
|
||||
<div className="absolute inset-0 min-h-0 min-w-0 overflow-hidden">
|
||||
<WorkflowWithInnerContext
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
viewport={viewport ?? graph.viewport}
|
||||
>
|
||||
<SnippetChildren
|
||||
fields={fields}
|
||||
uiMeta={uiMeta}
|
||||
editingField={editingField}
|
||||
isEditorOpen={isEditorOpen}
|
||||
isInputPanelOpen={isInputPanelOpen}
|
||||
isPublishMenuOpen={isPublishMenuOpen}
|
||||
onToggleInputPanel={handleToggleInputPanel}
|
||||
onTogglePublishMenu={togglePublishMenu}
|
||||
onCloseInputPanel={handleCloseInputPanel}
|
||||
onOpenEditor={openEditor}
|
||||
onCloseEditor={closeEditor}
|
||||
onSubmitField={handleSubmitField}
|
||||
onRemoveField={handleRemoveField}
|
||||
onPrimarySortChange={handlePrimarySortChange}
|
||||
onSecondarySortChange={handleSecondarySortChange}
|
||||
/>
|
||||
</WorkflowWithInnerContext>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetMain
|
||||
111
web/app/components/snippets/components/workflow-panel.tsx
Normal file
111
web/app/components/snippets/components/workflow-panel.tsx
Normal file
@ -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 (
|
||||
<div className="hidden xl:flex">
|
||||
{isEditorOpen && (
|
||||
<SnippetInputFieldEditor
|
||||
field={editingField}
|
||||
onClose={onCloseEditor}
|
||||
onSubmit={onSubmitField}
|
||||
/>
|
||||
)}
|
||||
{isInputPanelOpen && (
|
||||
<SnippetInputFieldPanel
|
||||
fields={fields}
|
||||
onClose={onCloseInputPanel}
|
||||
onAdd={() => onOpenEditor()}
|
||||
onEdit={onOpenEditor}
|
||||
onRemove={onRemoveField}
|
||||
onPrimarySortChange={onPrimarySortChange}
|
||||
onSecondarySortChange={onSecondarySortChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetWorkflowPanel = ({
|
||||
fields,
|
||||
editingField,
|
||||
isEditorOpen,
|
||||
isInputPanelOpen,
|
||||
onCloseInputPanel,
|
||||
onOpenEditor,
|
||||
onCloseEditor,
|
||||
onSubmitField,
|
||||
onRemoveField,
|
||||
onPrimarySortChange,
|
||||
onSecondarySortChange,
|
||||
}: SnippetWorkflowPanelProps) => {
|
||||
const panelProps: PanelProps = useMemo(() => {
|
||||
return {
|
||||
components: {
|
||||
left: (
|
||||
<SnippetPanelOnLeft
|
||||
fields={fields}
|
||||
editingField={editingField}
|
||||
isEditorOpen={isEditorOpen}
|
||||
isInputPanelOpen={isInputPanelOpen}
|
||||
onCloseInputPanel={onCloseInputPanel}
|
||||
onOpenEditor={onOpenEditor}
|
||||
onCloseEditor={onCloseEditor}
|
||||
onSubmitField={onSubmitField}
|
||||
onRemoveField={onRemoveField}
|
||||
onPrimarySortChange={onPrimarySortChange}
|
||||
onSecondarySortChange={onSecondarySortChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
}
|
||||
}, [
|
||||
editingField,
|
||||
fields,
|
||||
isEditorOpen,
|
||||
isInputPanelOpen,
|
||||
onCloseEditor,
|
||||
onCloseInputPanel,
|
||||
onOpenEditor,
|
||||
onPrimarySortChange,
|
||||
onRemoveField,
|
||||
onSecondarySortChange,
|
||||
onSubmitField,
|
||||
])
|
||||
|
||||
return <Panel {...panelProps} />
|
||||
}
|
||||
|
||||
export default memo(SnippetWorkflowPanel)
|
||||
5
web/app/components/snippets/hooks/use-snippet-init.ts
Normal file
5
web/app/components/snippets/hooks/use-snippet-init.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { useSnippetDetail } from '@/service/use-snippets'
|
||||
|
||||
export const useSnippetInit = (snippetId: string) => {
|
||||
return useSnippetDetail(snippetId)
|
||||
}
|
||||
82
web/app/components/snippets/index.tsx
Normal file
82
web/app/components/snippets/index.tsx
Normal file
@ -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 (
|
||||
<div className="flex h-full items-center justify-center bg-background-body">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background-body px-6">
|
||||
<div className="w-full max-w-md rounded-2xl border border-divider-subtle bg-components-card-bg p-8 text-center shadow-sm">
|
||||
<div className="text-3xl font-semibold text-text-primary">404</div>
|
||||
<div className="pt-3 text-text-primary system-md-semibold">{t('notFoundTitle')}</div>
|
||||
<div className="pt-2 text-text-tertiary system-sm-regular">{t('notFoundDescription')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkflowWithDefaultContext
|
||||
edges={edgesData}
|
||||
nodes={nodesData}
|
||||
>
|
||||
<SnippetMain
|
||||
key={snippetId}
|
||||
snippetId={snippetId}
|
||||
payload={data}
|
||||
nodes={nodesData}
|
||||
edges={edgesData}
|
||||
viewport={data.graph.viewport}
|
||||
/>
|
||||
</WorkflowWithDefaultContext>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetPageWrapper = (props: SnippetPageProps) => {
|
||||
return (
|
||||
<WorkflowContextProvider>
|
||||
<SnippetPage {...props} />
|
||||
</WorkflowContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetPageWrapper
|
||||
44
web/app/components/snippets/store/index.ts
Normal file
44
web/app/components/snippets/store/index.ts
Normal file
@ -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<SnippetDetailUIState>(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),
|
||||
}))
|
||||
@ -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',
|
||||
|
||||
@ -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",
|
||||
|
||||
16
web/i18n/en-US/snippet.json
Normal file
16
web/i18n/en-US/snippet.json
Normal file
@ -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"
|
||||
}
|
||||
16
web/i18n/zh-Hans/snippet.json
Normal file
16
web/i18n/zh-Hans/snippet.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"create": "创建 Snippet",
|
||||
"inputFieldButton": "输入字段",
|
||||
"notFoundDescription": "未找到对应的 snippet 静态数据。",
|
||||
"notFoundTitle": "未找到 Snippet",
|
||||
"panelDescription": "定义允许 snippet 从其他节点接收数据的输入字段。",
|
||||
"panelPrimaryGroup": "核心输入",
|
||||
"panelSecondaryGroup": "可选输入",
|
||||
"panelTitle": "输入字段",
|
||||
"publishButton": "发布",
|
||||
"publishMenuCurrentDraft": "当前草稿未发布",
|
||||
"sectionEvaluation": "评测",
|
||||
"sectionOrchestrate": "编排",
|
||||
"testRunButton": "测试运行",
|
||||
"variableInspect": "变量查看"
|
||||
}
|
||||
50
web/models/snippet.ts
Normal file
50
web/models/snippet.ts
Normal file
@ -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
|
||||
}
|
||||
215
web/service/use-snippets.ts
Normal file
215
web/service/use-snippets.ts
Normal file
@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user