diff --git a/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx b/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx index 96b9fe7a84..b022e3fac9 100644 --- a/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx +++ b/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx @@ -4,13 +4,16 @@ import type { CodeLanguage } from '../../code/types' import type { GenRes } from '@/service/debug' import { useBoolean } from 'ahooks' import * as React from 'react' -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { GetCodeGeneratorResModal } from '@/app/components/app/configuration/config/code-generator/get-code-generator-res' import { ActionButton } from '@/app/components/base/action-button' import { Generator } from '@/app/components/base/icons/src/vender/other' import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' import { useHooksStore } from '../../../hooks-store' +import { useStore } from '../../../store' +import { BlockEnum } from '../../../types' +import ContextGenerateModal from '../../tool/components/context-generate-modal' type Props = { nodeId: string @@ -28,12 +31,39 @@ const CodeGenerateBtn: FC = ({ onGenerated, }) => { const [showAutomatic, { setTrue: showAutomaticTrue, setFalse: showAutomaticFalse }] = useBoolean(false) + const nodes = useStore(s => s.nodes) const handleAutomaticRes = useCallback((res: GenRes) => { onGenerated?.(res.modified) showAutomaticFalse() }, [onGenerated, showAutomaticFalse]) const configsMap = useHooksStore(s => s.configsMap) + const parseExtractorNodeId = useCallback((id: string) => { + const marker = '_ext_' + const index = id.lastIndexOf(marker) + if (index < 0) + return null + const parentId = id.slice(0, index) + const paramKey = id.slice(index + marker.length) + if (!parentId || !paramKey) + return null + return { parentId, paramKey } + }, []) + + const contextGenerateConfig = useMemo(() => { + const targetNode = nodes.find(node => node.id === nodeId) + const isCodeNode = targetNode?.data?.type === BlockEnum.Code + const parentNodeId = (targetNode?.data as { parent_node_id?: string })?.parent_node_id + const parsed = parseExtractorNodeId(nodeId) + if (!isCodeNode || !parentNodeId || !parsed?.paramKey) + return null + return { + toolNodeId: parentNodeId || parsed.parentId, + paramKey: parsed.paramKey, + codeNodeId: nodeId, + } + }, [nodeId, nodes, parseExtractorNodeId]) + return (
= ({ {showAutomatic && ( - + contextGenerateConfig + ? ( + + ) + : ( + + ) )}
) diff --git a/web/app/components/workflow/nodes/tool/components/context-generate-modal/index.tsx b/web/app/components/workflow/nodes/tool/components/context-generate-modal/index.tsx new file mode 100644 index 0000000000..a64001b4c3 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/context-generate-modal/index.tsx @@ -0,0 +1,538 @@ +'use client' +import type { FC } from 'react' +import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types' +import type { OutputVar } from '@/app/components/workflow/nodes/code/types' +import type { ContextGenerateMessage, ContextGenerateResponse } from '@/service/debug' +import type { AppModeEnum, CompletionParams, Model, ModelModeType } from '@/types/app' +import { + RiSendPlaneLine, +} from '@remixicon/react' +import { useSessionStorageState } from 'ahooks' +import useBoolean from 'ahooks/lib/useBoolean' +import * as React from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import Loading from '@/app/components/base/loading' +import Modal from '@/app/components/base/modal' +import Toast from '@/app/components/base/toast' +import LoadingAnim from '@/app/components/base/chat/chat/loading-anim' +import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' +import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' +import VersionSelector from '@/app/components/app/configuration/config/automatic/version-selector' +import ResPlaceholder from '@/app/components/app/configuration/config/automatic/res-placeholder' +import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' +import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update' +import { useHooksStore } from '@/app/components/workflow/hooks-store' +import { useStore, useWorkflowStore } from '@/app/components/workflow/store' +import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' +import { NodeRunningStatus, VarType } from '@/app/components/workflow/types' +import { generateContext } from '@/service/debug' +import { cn } from '@/utils/classnames' +import useContextGenData from './use-context-gen-data' + +type Props = { + isShow: boolean + onClose: () => void + toolNodeId: string + paramKey: string + codeNodeId: string +} + +const minCodeHeight = 220 +const minOutputHeight = 160 +const splitHandleHeight = 6 + +const normalizeCodeLanguage = (value?: string) => { + if (value === CodeLanguage.javascript) + return CodeLanguage.javascript + if (value === CodeLanguage.python3) + return CodeLanguage.python3 + return CodeLanguage.python3 +} + +const normalizeOutputs = (outputs?: Record) => { + const next: OutputVar = {} + Object.entries(outputs || {}).forEach(([key, value]) => { + const type = Object.values(VarType).includes(value?.type as VarType) + ? value.type as VarType + : VarType.string + next[key] = { + type, + children: null, + } + }) + return next +} + +const mapOutputsToResponse = (outputs?: OutputVar) => { + const next: Record = {} + Object.entries(outputs || {}).forEach(([key, value]) => { + next[key] = { type: value.type } + }) + return next +} + +const ContextGenerateModal: FC = ({ + isShow, + onClose, + toolNodeId, + paramKey, + codeNodeId, +}) => { + const { t } = useTranslation() + const configsMap = useHooksStore(s => s.configsMap) + const nodes = useStore(s => s.nodes) + const workflowStore = useWorkflowStore() + const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate() + + const flowId = configsMap?.flowId || '' + const storageKey = useMemo(() => { + const segments = [flowId || 'unknown', toolNodeId, paramKey].filter(Boolean) + return segments.join('-') + }, [flowId, paramKey, toolNodeId]) + + const codeNode = useMemo(() => { + return nodes.find(node => node.id === codeNodeId) + }, [codeNodeId, nodes]) + const codeNodeData = codeNode?.data as CodeNodeType | undefined + + const fallbackVersion = useMemo(() => { + if (!codeNodeData) + return null + return { + variables: (codeNodeData.variables || []).map(variable => ({ + variable: variable.variable, + value_selector: Array.isArray(variable.value_selector) ? variable.value_selector : [], + })), + code_language: codeNodeData.code_language, + code: codeNodeData.code || '', + outputs: mapOutputsToResponse(codeNodeData.outputs), + message: '', + error: '', + } + }, [codeNodeData]) + + const { + versions, + addVersion, + current, + currentVersionIndex, + setCurrentVersionIndex, + } = useContextGenData({ + storageKey, + }) + + const [promptMessages, setPromptMessages] = useSessionStorageState( + `${storageKey}-messages`, + { defaultValue: [] }, + ) + + const [inputValue, setInputValue] = useState('') + const [isGenerating, { setTrue: setGeneratingTrue, setFalse: setGeneratingFalse }] = useBoolean(false) + + const defaultCompletionParams = { + temperature: 0.7, + max_tokens: 0, + top_p: 0, + echo: false, + stop: [], + presence_penalty: 0, + frequency_penalty: 0, + } + const localModel = localStorage.getItem('auto-gen-model') + ? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model + : null + const [model, setModel] = React.useState(localModel || { + name: '', + provider: '', + mode: AppModeEnum.CHAT as unknown as ModelModeType.chat, + completion_params: defaultCompletionParams, + }) + + const { + defaultModel, + } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration) + + useEffect(() => { + if (defaultModel) { + const localModel = localStorage.getItem('auto-gen-model') + ? JSON.parse(localStorage.getItem('auto-gen-model') || '') + : null + if (localModel) { + setModel({ + ...localModel, + completion_params: { + ...defaultCompletionParams, + ...localModel.completion_params, + }, + }) + } + else { + setModel(prev => ({ + ...prev, + name: defaultModel.model, + provider: defaultModel.provider.provider, + })) + } + } + }, [defaultModel]) + + const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => { + const newModel = { + ...model, + provider: newValue.provider, + name: newValue.modelId, + mode: newValue.mode as ModelModeType, + } + setModel(newModel) + localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) + }, [model]) + + const handleCompletionParamsChange = useCallback((newParams: FormValue) => { + const newModel = { + ...model, + completion_params: newParams as CompletionParams, + } + setModel(newModel) + localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) + }, [model]) + + const chatListRef = useRef(null) + useEffect(() => { + if (!chatListRef.current) + return + chatListRef.current.scrollTop = chatListRef.current.scrollHeight + }, [promptMessages, isGenerating]) + + const handleGenerate = useCallback(async () => { + const trimmed = inputValue.trim() + if (!trimmed || isGenerating) + return + if (!flowId || !toolNodeId || !paramKey) + return + + const nextMessages = [...(promptMessages || []), { role: 'user', content: trimmed }] + setPromptMessages(nextMessages) + setInputValue('') + setGeneratingTrue() + try { + const response = await generateContext({ + workflow_id: flowId, + node_id: toolNodeId, + parameter_name: paramKey, + language: normalizeCodeLanguage(current?.code_language || codeNodeData?.code_language) as 'python3' | 'javascript', + prompt_messages: nextMessages, + model_config: { + provider: model.provider, + name: model.name, + completion_params: model.completion_params, + }, + }) + + if (response.error) { + Toast.notify({ + type: 'error', + message: response.error, + }) + return + } + + const assistantMessage = response.message || t('nodes.tool.contextGenerate.defaultAssistantMessage', { ns: 'workflow' }) + setPromptMessages([...nextMessages, { role: 'assistant', content: assistantMessage }]) + addVersion(response) + } + finally { + setGeneratingFalse() + } + }, [ + addVersion, + codeNodeData?.code_language, + current?.code_language, + flowId, + inputValue, + isGenerating, + model.completion_params, + model.name, + model.provider, + paramKey, + promptMessages, + setPromptMessages, + setGeneratingFalse, + setGeneratingTrue, + t, + toolNodeId, + ]) + + const displayVersion = current || fallbackVersion + const displayCodeLanguage = normalizeCodeLanguage(displayVersion?.code_language) + const displayOutputData = useMemo(() => { + if (!displayVersion) + return {} + return { + variables: displayVersion.variables, + outputs: displayVersion.outputs, + } + }, [displayVersion]) + + const applyToNode = useCallback((closeOnApply: boolean) => { + if (!current || !codeNodeData) + return + + const nextOutputs = normalizeOutputs(current.outputs) + const nextVariables = current.variables.map(item => ({ + variable: item.variable, + value_selector: Array.isArray(item.value_selector) ? item.value_selector : [], + })) + + handleNodeDataUpdateWithSyncDraft({ + id: codeNodeId, + data: { + ...codeNodeData, + code_language: normalizeCodeLanguage(current.code_language), + code: current.code, + outputs: nextOutputs, + variables: nextVariables, + }, + }) + + if (closeOnApply) + onClose() + }, [codeNodeData, codeNodeId, current, handleNodeDataUpdateWithSyncDraft, onClose]) + + const handleRun = useCallback(() => { + if (!codeNodeId) + return + if (current) + applyToNode(false) + const store = workflowStore.getState() + store.setInitShowLastRunTab(true) + store.setPendingSingleRun({ + nodeId: codeNodeId, + action: 'run', + }) + }, [applyToNode, codeNodeId, current, workflowStore]) + + const isRunning = useMemo(() => { + const target = nodes.find(node => node.id === codeNodeId) + return target?.data?._singleRunningStatus === NodeRunningStatus.Running + }, [codeNodeId, nodes]) + + const rightContainerRef = useRef(null) + const [codePanelHeight, setCodePanelHeight] = useState(360) + const draggingRef = useRef(false) + const dragStartRef = useRef({ startY: 0, startHeight: 0 }) + + const handleResizeStart = useCallback((event: React.MouseEvent) => { + draggingRef.current = true + dragStartRef.current = { + startY: event.clientY, + startHeight: codePanelHeight, + } + document.body.style.userSelect = 'none' + }, [codePanelHeight]) + + useEffect(() => { + const handleMouseMove = (event: MouseEvent) => { + if (!draggingRef.current) + return + + const containerHeight = rightContainerRef.current?.offsetHeight || 0 + const maxHeight = Math.max(minCodeHeight, containerHeight - minOutputHeight - splitHandleHeight) + const delta = event.clientY - dragStartRef.current.startY + const nextHeight = Math.min(Math.max(dragStartRef.current.startHeight + delta, minCodeHeight), maxHeight) + setCodePanelHeight(nextHeight) + } + + const handleMouseUp = () => { + if (draggingRef.current) { + draggingRef.current = false + document.body.style.userSelect = '' + } + } + + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + return () => { + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + } + }, []) + + const canRun = !!displayVersion?.code || !!codeNodeData?.code + + return ( + +
+
+
+ {t('nodes.tool.contextGenerate.title', { ns: 'workflow' })} +
+
+ +
+
+ {(promptMessages || []).map((message, index) => { + const isUser = message.role === 'user' + return ( +
+
+ {message.content} +
+
+ ) + })} + {isGenerating && ( +
+
+ + {t('nodes.tool.contextGenerate.generating', { ns: 'workflow' })} +
+
+ )} +
+
+ setInputValue(e.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') + handleGenerate() + }} + placeholder={t('nodes.tool.contextGenerate.inputPlaceholder', { ns: 'workflow' }) as string} + disabled={isGenerating} + /> + +
+
+
+
+
+
+ {t('nodes.tool.contextGenerate.codeBlock', { ns: 'workflow' })} +
+ {versions.length > 0 && ( + + )} +
+
+ + +
+
+
+ {isGenerating && !displayVersion && ( +
+ +
+ {t('nodes.tool.contextGenerate.generating', { ns: 'workflow' })} +
+
+ )} + {!isGenerating && !displayVersion && ( + + )} + {displayVersion && ( +
+
+
+ {t('nodes.tool.contextGenerate.code', { ns: 'workflow' })} +
+
+ +
+
+
+
+
+
+
+ {t('nodes.tool.contextGenerate.output', { ns: 'workflow' })} +
+
+ +
+
+
+ )} +
+
+
+ + ) +} + +export default React.memo(ContextGenerateModal) diff --git a/web/app/components/workflow/nodes/tool/components/context-generate-modal/use-context-gen-data.ts b/web/app/components/workflow/nodes/tool/components/context-generate-modal/use-context-gen-data.ts new file mode 100644 index 0000000000..259e7afa99 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/context-generate-modal/use-context-gen-data.ts @@ -0,0 +1,38 @@ +import type { ContextGenerateResponse } from '@/service/debug' +import { useSessionStorageState } from 'ahooks' +import { useCallback } from 'react' + +type Params = { + storageKey: string +} + +const keyPrefix = 'context-gen-' + +const useContextGenData = ({ storageKey }: Params) => { + const [versions, setVersions] = useSessionStorageState(`${keyPrefix}${storageKey}-versions`, { + defaultValue: [], + }) + + const [currentVersionIndex, setCurrentVersionIndex] = useSessionStorageState(`${keyPrefix}${storageKey}-version-index`, { + defaultValue: 0, + }) + + const current = versions?.[currentVersionIndex || 0] + + const addVersion = useCallback((version: ContextGenerateResponse) => { + setCurrentVersionIndex(() => versions?.length || 0) + setVersions((prev) => { + return [...(prev || []), version] + }) + }, [setCurrentVersionIndex, setVersions, versions?.length]) + + return { + versions, + addVersion, + currentVersionIndex, + setCurrentVersionIndex, + current, + } +} + +export default useContextGenData diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index 33eb6ae3d8..8df276e949 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -33,6 +33,7 @@ import { cn } from '@/utils/classnames' import SubGraphModal from '../sub-graph-modal' import AgentHeaderBar from './agent-header-bar' import Placeholder from './placeholder' +import ContextGenerateModal from '../context-generate-modal' /** * Matches agent context variable syntax: {{@nodeId.context@}} @@ -165,6 +166,7 @@ const MixedVariableTextInput = ({ const { nodesMap: nodesMetaDataMap } = useNodesMetaData() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const [isSubGraphModalOpen, setIsSubGraphModalOpen] = useState(false) + const [isContextGenerateModalOpen, setIsContextGenerateModalOpen] = useState(false) const nodesByIdMap = useMemo(() => { return availableNodes.reduce((acc, node) => { @@ -517,6 +519,7 @@ const MixedVariableTextInput = ({ ensureAssembleExtractorNode() onChange?.(assemblePlaceholder, VarKindTypeEnum.mixed, null) setControlPromptEditorRerenderKey(Date.now()) + setIsContextGenerateModalOpen(true) return [extractorNodeId, 'result'] }, [assembleExtractorNodeId, assemblePlaceholder, ensureAssembleExtractorNode, onChange, paramKey, setControlPromptEditorRerenderKey, toolNodeId]) @@ -537,6 +540,10 @@ const MixedVariableTextInput = ({ setIsSubGraphModalOpen(false) }, []) + const handleCloseContextGenerateModal = useCallback(() => { + setIsContextGenerateModalOpen(false) + }, []) + const sourceVariable: ValueSelector | undefined = detectedAgentFromValue ? [detectedAgentFromValue.nodeId, 'context'] : undefined @@ -610,6 +617,15 @@ const MixedVariableTextInput = ({ agentNodeId={detectedAgentFromValue.nodeId} /> )} + {toolNodeId && paramKey && ( + + )}
) } diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 76050edabb..2451fca0f8 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -769,11 +769,23 @@ const translation = { 'assignedVarsDescription': 'Assigned variables must be writable variables, such as conversation variables.', }, tool: { + assembleVariables: 'Assemble Variables', authorize: 'Authorize', inputVars: 'Input Variables', settings: 'Settings', insertPlaceholder1: 'Type or press', insertPlaceholder2: 'insert variable', + contextGenerate: { + title: 'Assemble Variables', + codeBlock: 'Code Block', + code: 'Code', + output: 'Output', + run: 'Run', + apply: 'Apply', + generating: 'Generating...', + inputPlaceholder: 'Ask for change...', + defaultAssistantMessage: 'I\'ve finished, please have a check on it.', + }, outputVars: { text: 'tool generated content', files: { diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 6d00f279a3..3e5b0979e1 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -769,11 +769,23 @@ const translation = { 'assignedVarsDescription': '赋值变量必须是可写入的变量,例如会话变量。', }, tool: { + assembleVariables: '组合变量', authorize: '授权', inputVars: '输入变量', settings: '设置', insertPlaceholder1: '键入', insertPlaceholder2: '插入变量', + contextGenerate: { + title: '组合变量', + codeBlock: '代码块', + code: '代码', + output: '输出', + run: '运行', + apply: '应用', + generating: '生成中...', + inputPlaceholder: '输入修改需求...', + defaultAssistantMessage: '已完成,请检查。', + }, outputVars: { text: '工具生成的内容', files: { diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index bd4bc720f6..3f729ecaee 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -747,6 +747,7 @@ const translation = { 'varNotSet': '未設置變數', }, tool: { + assembleVariables: '組合變數', authorize: '授權', inputVars: '輸入變數', outputVars: { @@ -763,6 +764,17 @@ const translation = { insertPlaceholder2: '插入變數', insertPlaceholder1: '輸入或按壓', settings: '設定', + contextGenerate: { + title: '組合變數', + codeBlock: '程式碼區塊', + code: '程式碼', + output: '輸出', + run: '執行', + apply: '套用', + generating: '生成中...', + inputPlaceholder: '輸入修改需求...', + defaultAssistantMessage: '已完成,請檢查。', + }, }, questionClassifiers: { model: '模型', diff --git a/web/service/debug.ts b/web/service/debug.ts index 850f3dfc24..120162e96e 100644 --- a/web/service/debug.ts +++ b/web/service/debug.ts @@ -25,6 +25,38 @@ export type CodeGenRes = { error?: string } +export type ContextGenerateMessage = { + role: 'user' | 'assistant' | 'system' + content: string +} + +export type ContextGenerateRequest = { + workflow_id: string + node_id: string + parameter_name: string + language?: 'python3' | 'javascript' + prompt_messages: ContextGenerateMessage[] + model_config: { + provider: string + name: string + completion_params?: Record + } +} + +export type ContextGenerateVariable = { + variable: string + value_selector: string[] +} + +export type ContextGenerateResponse = { + variables: ContextGenerateVariable[] + code_language: string + code: string + outputs: Record + message: string + error: string +} + export const sendChatMessage = async (appId: string, body: Record, { onData, onCompleted, onThought, onFile, onError, getAbortController, onMessageEnd, onMessageReplace }: { onData: IOnData onCompleted: IOnCompleted @@ -93,6 +125,12 @@ export const generateRule = (body: Record) => { }) } +export const generateContext = (body: ContextGenerateRequest) => { + return post('/context-generate', { + body, + }) +} + export const fetchModelParams = (providerName: string, modelId: string) => { return get(`workspaces/current/model-providers/${providerName}/models/parameter-rules`, { params: {