From 9f28575903d6346c7e58d37e86ceaad61024ea4d Mon Sep 17 00:00:00 2001 From: JzoNg Date: Fri, 20 Mar 2026 15:11:33 +0800 Subject: [PATCH] feat(web): add snippets api --- web/app/components/apps/list.tsx | 2 +- web/contract/console/snippets.ts | 342 ++++++++++++++++++++++++ web/contract/router.ts | 54 ++++ web/service/use-snippets.mock.ts | 215 +++++++++++++++ web/service/use-snippets.ts | 442 +++++++++++++++++-------------- web/types/snippet.ts | 146 ++++++++++ 6 files changed, 1008 insertions(+), 193 deletions(-) create mode 100644 web/contract/console/snippets.ts create mode 100644 web/service/use-snippets.mock.ts create mode 100644 web/types/snippet.ts diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 409b0e60cf..68f4999197 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -18,7 +18,7 @@ import { CheckModal } from '@/hooks/use-pay' import dynamic from '@/next/dynamic' import Link from '@/next/link' import { useInfiniteAppList } from '@/service/use-apps' -import { getSnippetListMock } from '@/service/use-snippets' +import { getSnippetListMock } from '@/service/use-snippets.mock' import { cn } from '@/utils/classnames' import AppCard from './app-card' import { AppCardSkeleton } from './app-card-skeleton' diff --git a/web/contract/console/snippets.ts b/web/contract/console/snippets.ts new file mode 100644 index 0000000000..82a5838436 --- /dev/null +++ b/web/contract/console/snippets.ts @@ -0,0 +1,342 @@ +import type { + CreateSnippetPayload, + IncrementSnippetUseCountResponse, + PublishSnippetWorkflowResponse, + Snippet, + SnippetDraftConfig, + SnippetDraftNodeRunPayload, + SnippetDraftRunPayload, + SnippetDraftSyncPayload, + SnippetDraftSyncResponse, + SnippetImportPayload, + SnippetIterationNodeRunPayload, + SnippetListResponse, + SnippetLoopNodeRunPayload, + SnippetWorkflow, + UpdateSnippetPayload, + WorkflowNodeExecution, + WorkflowNodeExecutionListResponse, + WorkflowRunDetail, + WorkflowRunPagination, +} from '@/types/snippet' +import { type } from '@orpc/contract' +import { base } from '../base' + +export const listCustomizedSnippetsContract = base + .route({ + path: '/workspaces/current/customized-snippets', + method: 'GET', + }) + .input(type<{ + query: { + page: number + limit: number + keyword?: string + is_published?: boolean + } + }>()) + .output(type()) + +export const createCustomizedSnippetContract = base + .route({ + path: '/workspaces/current/customized-snippets', + method: 'POST', + }) + .input(type<{ + body: CreateSnippetPayload + }>()) + .output(type()) + +export const getCustomizedSnippetContract = base + .route({ + path: '/workspaces/current/customized-snippets/{snippetId}', + method: 'GET', + }) + .input(type<{ + params: { + snippetId: string + } + }>()) + .output(type()) + +export const updateCustomizedSnippetContract = base + .route({ + path: '/workspaces/current/customized-snippets/{snippetId}', + method: 'PATCH', + }) + .input(type<{ + params: { + snippetId: string + } + body: UpdateSnippetPayload + }>()) + .output(type()) + +export const deleteCustomizedSnippetContract = base + .route({ + path: '/workspaces/current/customized-snippets/{snippetId}', + method: 'DELETE', + }) + .input(type<{ + params: { + snippetId: string + } + }>()) + .output(type()) + +export const exportCustomizedSnippetContract = base + .route({ + path: '/workspaces/current/customized-snippets/{snippetId}/export', + method: 'GET', + }) + .input(type<{ + params: { + snippetId: string + } + query: { + include_secret?: 'true' | 'false' + } + }>()) + .output(type()) + +export const importCustomizedSnippetContract = base + .route({ + path: '/workspaces/current/customized-snippets/imports', + method: 'POST', + }) + .input(type<{ + body: SnippetImportPayload + }>()) + .output(type()) + +export const confirmSnippetImportContract = base + .route({ + path: '/workspaces/current/customized-snippets/imports/{importId}/confirm', + method: 'POST', + }) + .input(type<{ + params: { + importId: string + } + }>()) + .output(type()) + +export const checkSnippetDependenciesContract = base + .route({ + path: '/workspaces/current/customized-snippets/{snippetId}/check-dependencies', + method: 'GET', + }) + .input(type<{ + params: { + snippetId: string + } + }>()) + .output(type()) + +export const incrementSnippetUseCountContract = base + .route({ + path: '/workspaces/current/customized-snippets/{snippetId}/use-count/increment', + method: 'POST', + }) + .input(type<{ + params: { + snippetId: string + } + }>()) + .output(type()) + +export const getSnippetDraftWorkflowContract = base + .route({ + path: '/snippets/{snippetId}/workflows/draft', + method: 'GET', + }) + .input(type<{ + params: { + snippetId: string + } + }>()) + .output(type()) + +export const syncSnippetDraftWorkflowContract = base + .route({ + path: '/snippets/{snippetId}/workflows/draft', + method: 'POST', + }) + .input(type<{ + params: { + snippetId: string + } + body: SnippetDraftSyncPayload + }>()) + .output(type()) + +export const getSnippetDraftConfigContract = base + .route({ + path: '/snippets/{snippetId}/workflows/draft/config', + method: 'GET', + }) + .input(type<{ + params: { + snippetId: string + } + }>()) + .output(type()) + +export const getSnippetPublishedWorkflowContract = base + .route({ + path: '/snippets/{snippetId}/workflows/publish', + method: 'GET', + }) + .input(type<{ + params: { + snippetId: string + } + }>()) + .output(type()) + +export const publishSnippetWorkflowContract = base + .route({ + path: '/snippets/{snippetId}/workflows/publish', + method: 'POST', + }) + .input(type<{ + params: { + snippetId: string + } + }>()) + .output(type()) + +export const getSnippetDefaultBlockConfigsContract = base + .route({ + path: '/snippets/{snippetId}/workflows/default-workflow-block-configs', + method: 'GET', + }) + .input(type<{ + params: { + snippetId: string + } + }>()) + .output(type()) + +export const listSnippetWorkflowRunsContract = base + .route({ + path: '/snippets/{snippetId}/workflow-runs', + method: 'GET', + }) + .input(type<{ + params: { + snippetId: string + } + query: { + last_id?: string + limit?: number + } + }>()) + .output(type()) + +export const getSnippetWorkflowRunDetailContract = base + .route({ + path: '/snippets/{snippetId}/workflow-runs/{runId}', + method: 'GET', + }) + .input(type<{ + params: { + snippetId: string + runId: string + } + }>()) + .output(type()) + +export const listSnippetWorkflowRunNodeExecutionsContract = base + .route({ + path: '/snippets/{snippetId}/workflow-runs/{runId}/node-executions', + method: 'GET', + }) + .input(type<{ + params: { + snippetId: string + runId: string + } + }>()) + .output(type()) + +export const runSnippetDraftNodeContract = base + .route({ + path: '/snippets/{snippetId}/workflows/draft/nodes/{nodeId}/run', + method: 'POST', + }) + .input(type<{ + params: { + snippetId: string + nodeId: string + } + body: SnippetDraftNodeRunPayload + }>()) + .output(type()) + +export const getSnippetDraftNodeLastRunContract = base + .route({ + path: '/snippets/{snippetId}/workflows/draft/nodes/{nodeId}/last-run', + method: 'GET', + }) + .input(type<{ + params: { + snippetId: string + nodeId: string + } + }>()) + .output(type()) + +export const runSnippetDraftIterationNodeContract = base + .route({ + path: '/snippets/{snippetId}/workflows/draft/iteration/nodes/{nodeId}/run', + method: 'POST', + }) + .input(type<{ + params: { + snippetId: string + nodeId: string + } + body: SnippetIterationNodeRunPayload + }>()) + .output(type()) + +export const runSnippetDraftLoopNodeContract = base + .route({ + path: '/snippets/{snippetId}/workflows/draft/loop/nodes/{nodeId}/run', + method: 'POST', + }) + .input(type<{ + params: { + snippetId: string + nodeId: string + } + body: SnippetLoopNodeRunPayload + }>()) + .output(type()) + +export const runSnippetDraftWorkflowContract = base + .route({ + path: '/snippets/{snippetId}/workflows/draft/run', + method: 'POST', + }) + .input(type<{ + params: { + snippetId: string + } + body: SnippetDraftRunPayload + }>()) + .output(type()) + +export const stopSnippetWorkflowTaskContract = base + .route({ + path: '/snippets/{snippetId}/workflow-runs/tasks/{taskId}/stop', + method: 'POST', + }) + .input(type<{ + params: { + snippetId: string + taskId: string + } + }>()) + .output(type()) diff --git a/web/contract/router.ts b/web/contract/router.ts index d325cac660..57053dfc11 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -15,6 +15,33 @@ import { import { changePreferredProviderTypeContract, modelProvidersModelsContract } from './console/model-providers' import { notificationContract, notificationDismissContract } from './console/notification' import { pluginCheckInstalledContract, pluginLatestVersionsContract } from './console/plugins' +import { + checkSnippetDependenciesContract, + confirmSnippetImportContract, + createCustomizedSnippetContract, + deleteCustomizedSnippetContract, + exportCustomizedSnippetContract, + getCustomizedSnippetContract, + getSnippetDefaultBlockConfigsContract, + getSnippetDraftConfigContract, + getSnippetDraftNodeLastRunContract, + getSnippetDraftWorkflowContract, + getSnippetPublishedWorkflowContract, + getSnippetWorkflowRunDetailContract, + importCustomizedSnippetContract, + incrementSnippetUseCountContract, + listCustomizedSnippetsContract, + listSnippetWorkflowRunNodeExecutionsContract, + listSnippetWorkflowRunsContract, + publishSnippetWorkflowContract, + runSnippetDraftIterationNodeContract, + runSnippetDraftLoopNodeContract, + runSnippetDraftNodeContract, + runSnippetDraftWorkflowContract, + stopSnippetWorkflowTaskContract, + syncSnippetDraftWorkflowContract, + updateCustomizedSnippetContract, +} from './console/snippets' import { systemFeaturesContract } from './console/system' import { triggerOAuthConfigContract, @@ -74,6 +101,33 @@ export const consoleRouterContract = { checkInstalled: pluginCheckInstalledContract, latestVersions: pluginLatestVersionsContract, }, + snippets: { + list: listCustomizedSnippetsContract, + create: createCustomizedSnippetContract, + detail: getCustomizedSnippetContract, + update: updateCustomizedSnippetContract, + delete: deleteCustomizedSnippetContract, + export: exportCustomizedSnippetContract, + import: importCustomizedSnippetContract, + confirmImport: confirmSnippetImportContract, + checkDependencies: checkSnippetDependenciesContract, + incrementUseCount: incrementSnippetUseCountContract, + draftWorkflow: getSnippetDraftWorkflowContract, + syncDraftWorkflow: syncSnippetDraftWorkflowContract, + draftConfig: getSnippetDraftConfigContract, + publishedWorkflow: getSnippetPublishedWorkflowContract, + publishWorkflow: publishSnippetWorkflowContract, + defaultBlockConfigs: getSnippetDefaultBlockConfigsContract, + workflowRuns: listSnippetWorkflowRunsContract, + workflowRunDetail: getSnippetWorkflowRunDetailContract, + workflowRunNodeExecutions: listSnippetWorkflowRunNodeExecutionsContract, + runDraftNode: runSnippetDraftNodeContract, + lastDraftNodeRun: getSnippetDraftNodeLastRunContract, + runDraftIterationNode: runSnippetDraftIterationNodeContract, + runDraftLoopNode: runSnippetDraftLoopNodeContract, + runDraftWorkflow: runSnippetDraftWorkflowContract, + stopWorkflowTask: stopSnippetWorkflowTaskContract, + }, billing: { invoices: invoicesContract, bindPartnerStack: bindPartnerStackContract, diff --git a/web/service/use-snippets.mock.ts b/web/service/use-snippets.mock.ts new file mode 100644 index 0000000000..222652e0e4 --- /dev/null +++ b/web/service/use-snippets.mock.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() +} diff --git a/web/service/use-snippets.ts b/web/service/use-snippets.ts index 222652e0e4..6688fc801f 100644 --- a/web/service/use-snippets.ts +++ b/web/service/use-snippets.ts @@ -1,215 +1,273 @@ -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' +import type { + SnippetCanvasData, + SnippetDetailPayload, + SnippetDetail as SnippetDetailUIModel, + SnippetInputField as SnippetInputFieldUIModel, + SnippetListItem as SnippetListItemUIModel, +} from '@/models/snippet' +import type { + CreateSnippetPayload, + Snippet as SnippetContract, + SnippetListResponse, + SnippetWorkflow, + UpdateSnippetPayload, +} from '@/types/snippet' +import { + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import dayjs from 'dayjs' +import { consoleClient, consoleQuery } from '@/service/client' -const NAME_SPACE = 'snippets' +type SnippetListParams = { + page?: number + limit?: number + keyword?: string + is_published?: boolean +} -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', - }, -]) +type SnippetSummary = Pick -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 DEFAULT_SNIPPET_LIST_PARAMS = { + page: 1, + limit: 30, +} satisfies Required> -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 DEFAULT_GRAPH: SnippetCanvasData = { + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, +} -const getSnippetDetailMock = (snippetId: string): SnippetDetailPayload | null => { - const snippet = getSnippetListMock().find(item => item.id === snippetId) - if (!snippet) - return null +const toMilliseconds = (timestamp?: number) => { + if (!timestamp) + return undefined - const inputFields = getSnippetInputFieldsMock() + return timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000 +} + +const formatTimestamp = (timestamp?: number) => { + const milliseconds = toMilliseconds(timestamp) + if (!milliseconds) + return '' + + return dayjs(milliseconds).format('YYYY-MM-DD HH:mm') +} + +const getSnippetIcon = (iconInfo: SnippetContract['icon_info']) => { + return typeof iconInfo?.icon === 'string' ? iconInfo.icon : '' +} + +const getSnippetIconBackground = (iconInfo: SnippetContract['icon_info']) => { + return typeof iconInfo?.icon_background === 'string' ? iconInfo.icon_background : '' +} + +const toSnippetListItem = (snippet: SnippetSummary): SnippetListItemUIModel => { + return { + id: snippet.id, + name: snippet.name, + description: snippet.description, + author: '', + updatedAt: formatTimestamp(snippet.updated_at), + usage: String(snippet.use_count ?? 0), + icon: getSnippetIcon(snippet.icon_info), + iconBackground: getSnippetIconBackground(snippet.icon_info), + status: undefined, + } +} + +const toSnippetDetail = (snippet: SnippetContract): SnippetDetailUIModel => { + return { + ...toSnippetListItem(snippet), + } +} + +const toSnippetCanvasData = (workflow?: SnippetWorkflow): SnippetCanvasData => { + const graph = workflow?.graph + + if (!graph || typeof graph !== 'object') + return DEFAULT_GRAPH + + const graphRecord = graph as Record return { - snippet, - graph: getSnippetGraphMock(), + nodes: Array.isArray(graphRecord.nodes) ? graphRecord.nodes as SnippetCanvasData['nodes'] : DEFAULT_GRAPH.nodes, + edges: Array.isArray(graphRecord.edges) ? graphRecord.edges as SnippetCanvasData['edges'] : DEFAULT_GRAPH.edges, + viewport: graphRecord.viewport && typeof graphRecord.viewport === 'object' + ? graphRecord.viewport as SnippetCanvasData['viewport'] + : DEFAULT_GRAPH.viewport, + } +} + +const toSnippetDetailPayload = (snippet: SnippetContract, workflow?: SnippetWorkflow): SnippetDetailPayload => { + const inputFields = Array.isArray(snippet.input_fields) + ? snippet.input_fields as SnippetInputFieldUIModel[] + : [] + + return { + snippet: toSnippetDetail(snippet), + graph: toSnippetCanvasData(workflow), inputFields, uiMeta: { inputFieldCount: inputFields.length, - checklistCount: 2, - autoSavedAt: 'Auto-saved · a few seconds ago', + checklistCount: 0, + autoSavedAt: formatTimestamp(workflow?.updated_at ?? snippet.updated_at), }, } } -export const useSnippetDetail = (snippetId: string) => { - return useQuery({ - queryKey: [NAME_SPACE, 'detail', snippetId], - queryFn: async () => getSnippetDetailMock(snippetId), - enabled: !!snippetId, +const normalizeSnippetListParams = (params: SnippetListParams) => { + return { + page: params.page ?? DEFAULT_SNIPPET_LIST_PARAMS.page, + limit: params.limit ?? DEFAULT_SNIPPET_LIST_PARAMS.limit, + ...(params.keyword ? { keyword: params.keyword } : {}), + ...(typeof params.is_published === 'boolean' ? { is_published: params.is_published } : {}), + } +} + +const isNotFoundError = (error: unknown) => { + return !!error && typeof error === 'object' && 'status' in error && error.status === 404 +} + +export const useSnippetList = (params: SnippetListParams = {}, options?: { enabled?: boolean }) => { + const query = normalizeSnippetListParams(params) + + return useQuery(consoleQuery.snippets.list.queryOptions({ + input: { query }, + enabled: options?.enabled ?? true, + })) +} + +export const useSnippetListItems = (params: SnippetListParams = {}, options?: { enabled?: boolean }) => { + return useQuery({ + queryKey: [...consoleQuery.snippets.list.queryKey({ input: { query: normalizeSnippetListParams(params) } }), 'items'], + queryFn: async () => { + const response = await consoleClient.snippets.list({ + query: normalizeSnippetListParams(params), + }) + + return response.data.map(toSnippetListItem) + }, + enabled: options?.enabled ?? true, }) } -export const publishSnippet = async (_snippetId: string) => { - return Promise.resolve() +export const useSnippetApiDetail = (snippetId: string) => { + return useQuery(consoleQuery.snippets.detail.queryOptions({ + input: { + params: { snippetId }, + }, + enabled: !!snippetId, + })) } -export const runSnippet = async (_snippetId: string) => { - return Promise.resolve() +export const useSnippetDetail = (snippetId: string) => { + return useQuery({ + queryKey: [...consoleQuery.snippets.detail.queryKey({ + input: { + params: { snippetId }, + }, + }), 'payload'], + enabled: !!snippetId, + queryFn: async () => { + try { + const [snippet, workflow] = await Promise.all([ + consoleClient.snippets.detail({ + params: { snippetId }, + }), + consoleClient.snippets.draftWorkflow({ + params: { snippetId }, + }).catch((error) => { + if (isNotFoundError(error)) + return undefined + + throw error + }), + ]) + + return toSnippetDetailPayload(snippet, workflow) + } + catch (error) { + if (isNotFoundError(error)) + return null + + throw error + } + }, + }) } -export const updateSnippetInputFields = async (_snippetId: string, _fields: SnippetInputField[]) => { - return Promise.resolve() +export const useCreateSnippetMutation = () => { + const queryClient = useQueryClient() + + return useMutation(consoleQuery.snippets.create.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.snippets.key(), + }) + }, + })) +} + +export const useUpdateSnippetMutation = () => { + const queryClient = useQueryClient() + + return useMutation({ + ...consoleQuery.snippets.update.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.snippets.key(), + }) + }, + }), + }) +} + +export const useDeleteSnippetMutation = () => { + const queryClient = useQueryClient() + + return useMutation({ + ...consoleQuery.snippets.delete.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.snippets.key(), + }) + }, + }), + }) +} + +export const usePublishSnippetMutation = () => { + const queryClient = useQueryClient() + + return useMutation({ + ...consoleQuery.snippets.publishWorkflow.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.snippets.key(), + }) + }, + }), + }) +} + +export const useIncrementSnippetUseCountMutation = () => { + const queryClient = useQueryClient() + + return useMutation({ + ...consoleQuery.snippets.incrementUseCount.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.snippets.key(), + }) + }, + }), + }) +} + +export type { + CreateSnippetPayload, + SnippetListResponse, + UpdateSnippetPayload, } diff --git a/web/types/snippet.ts b/web/types/snippet.ts new file mode 100644 index 0000000000..76473ab23f --- /dev/null +++ b/web/types/snippet.ts @@ -0,0 +1,146 @@ +export type SnippetType = 'node' | 'group' + +export type SnippetIconInfo = Record + +export type SnippetInputField = Record + +export type Snippet = { + id: string + name: string + description: string + type: SnippetType + is_published: boolean + version: string + use_count: number + icon_info: SnippetIconInfo + input_fields: SnippetInputField[] + created_at: number + updated_at: number +} + +export type SnippetListItem = Omit + +export type SnippetListResponse = { + data: SnippetListItem[] + page: number + limit: number + total: number + has_more: boolean +} + +export type CreateSnippetPayload = { + name: string + description?: string + type?: SnippetType + icon_info?: SnippetIconInfo + input_fields?: SnippetInputField[] +} + +export type UpdateSnippetPayload = { + name?: string + description?: string + icon_info?: SnippetIconInfo +} + +export type SnippetImportPayload = { + mode?: string + yaml_content?: string + yaml_url?: string + snippet_id?: string + name?: string + description?: string +} + +export type IncrementSnippetUseCountResponse = { + result: string + use_count: number +} + +export type SnippetWorkflow = { + id: string + graph: Record + features: Record + hash: string + created_at: number + updated_at: number +} + +export type SnippetDraftSyncPayload = { + graph?: Record + hash?: string + environment_variables?: Record[] + conversation_variables?: Record[] + input_variables?: Record[] +} + +export type SnippetDraftSyncResponse = { + result: string + hash: string + updated_at: number +} + +export type SnippetDraftConfig = { + parallel_depth_limit: number +} + +export type PublishSnippetWorkflowResponse = { + result: string + created_at: number +} + +export type WorkflowRunDetail = { + id: string + version: string + status: 'running' | 'succeeded' | 'failed' | 'stopped' | 'partial-succeeded' + elapsed_time: number + total_tokens: number + total_steps: number + created_at: number + finished_at: number + exceptions_count: number +} + +export type WorkflowRunPagination = { + limit: number + has_more: boolean + data: WorkflowRunDetail[] +} + +export type WorkflowNodeExecution = { + id: string + index: number + node_id: string + node_type: string + title: string + inputs: Record + process_data: Record + outputs: Record + status: string + error: string + elapsed_time: number + created_at: number + finished_at: number +} + +export type WorkflowNodeExecutionListResponse = { + data: WorkflowNodeExecution[] +} + +export type SnippetDraftNodeRunPayload = { + inputs?: Record + query?: string + files?: Record[] +} + +export type SnippetDraftRunPayload = { + inputs?: Record + files?: Record[] +} + +export type SnippetIterationNodeRunPayload = { + inputs?: Record +} + +export type SnippetLoopNodeRunPayload = { + inputs?: Record +}