Compare commits

..

13 Commits

59 changed files with 1437 additions and 1247 deletions

View File

@ -6,6 +6,11 @@ from typing import Any, Protocol, cast
import json_repair
from core.llm_generator.output_models import (
CodeNodeStructuredOutput,
InstructionModifyOutput,
SuggestedQuestionsOutput,
)
from core.llm_generator.output_parser.rule_config_generator import RuleConfigGeneratorOutputParser
from core.llm_generator.output_parser.suggested_questions_after_answer import SuggestedQuestionsAfterAnswerOutputParser
from core.llm_generator.prompts import (
@ -470,7 +475,7 @@ class LLMGenerator:
*prompt_messages,
]
from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output
from core.llm_generator.output_parser.structured_output import invoke_llm_with_pydantic_model
# Get model instance and schema
provider = model_config.get("provider", "")
@ -487,15 +492,13 @@ class LLMGenerator:
return cls._error_response(f"Model schema not found for {model_name}")
model_parameters = model_config.get("completion_params", {})
json_schema = cls._get_code_node_json_schema()
try:
response = invoke_llm_with_structured_output(
response = invoke_llm_with_pydantic_model(
provider=provider,
model_schema=model_schema,
model_instance=model_instance,
prompt_messages=complete_messages,
json_schema=json_schema,
output_model=CodeNodeStructuredOutput,
model_parameters=model_parameters,
stream=False,
tenant_id=tenant_id,
@ -541,7 +544,7 @@ class LLMGenerator:
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output
from core.llm_generator.output_parser.structured_output import invoke_llm_with_pydantic_model
from services.workflow_service import WorkflowService
# Get workflow context (reuse existing logic)
@ -602,15 +605,13 @@ class LLMGenerator:
completion_params = model_config.get("completion_params", {}) if model_config else {}
model_parameters = {**completion_params, "max_tokens": 256}
json_schema = cls._get_suggested_questions_json_schema()
try:
response = invoke_llm_with_structured_output(
response = invoke_llm_with_pydantic_model(
provider=model_instance.provider,
model_schema=model_schema,
model_instance=model_instance,
prompt_messages=prompt_messages,
json_schema=json_schema,
output_model=SuggestedQuestionsOutput,
model_parameters=model_parameters,
stream=False,
tenant_id=tenant_id,
@ -644,58 +645,6 @@ Sources: {", ".join(sources)}
Target: {parameter_info.get("name")}({param_type}) - {param_desc}
Output 3 short, practical questions in {language}."""
@classmethod
def _get_suggested_questions_json_schema(cls) -> dict:
"""Return JSON Schema for suggested questions."""
return {
"type": "object",
"properties": {
"questions": {
"type": "array",
"items": {"type": "string"},
"minItems": 3,
"maxItems": 3,
"description": "3 suggested questions",
},
},
"required": ["questions"],
}
@classmethod
def _get_code_node_json_schema(cls) -> dict:
"""Return JSON Schema for structured output."""
return {
"type": "object",
"properties": {
"variables": {
"type": "array",
"items": {
"type": "object",
"properties": {
"variable": {"type": "string", "description": "Variable name in code"},
"value_selector": {
"type": "array",
"items": {"type": "string"},
"description": "Path like [node_id, output_name]",
},
},
"required": ["variable", "value_selector"],
},
},
"code": {"type": "string", "description": "Generated code with main function"},
"outputs": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {"type": {"type": "string"}},
},
"description": "Output definitions, key is output name",
},
"explanation": {"type": "string", "description": "Brief explanation of the code"},
},
"required": ["variables", "code", "outputs", "explanation"],
}
@classmethod
def _get_upstream_nodes(cls, graph_dict: Mapping[str, Any], node_id: str) -> list[dict]:
"""
@ -1011,6 +960,10 @@ Parameter: {parameter_info.get("name")} ({param_type}) - {parameter_info.get("de
provider=model_config.get("provider", ""),
model=model_config.get("name", ""),
)
model_name = model_config.get("name", "")
model_schema = model_instance.model_type_instance.get_model_schema(model_name, model_instance.credentials)
if not model_schema:
return {"error": f"Model schema not found for {model_name}"}
match node_type:
case "llm" | "agent":
system_prompt = LLM_MODIFY_PROMPT_SYSTEM
@ -1034,20 +987,18 @@ Parameter: {parameter_info.get("name")} ({param_type}) - {parameter_info.get("de
model_parameters = {"temperature": 0.4}
try:
response: LLMResult = model_instance.invoke_llm(
prompt_messages=list(prompt_messages), model_parameters=model_parameters, stream=False
)
from core.llm_generator.output_parser.structured_output import invoke_llm_with_pydantic_model
generated_raw = response.message.get_text_content()
first_brace = generated_raw.find("{")
last_brace = generated_raw.rfind("}")
if first_brace == -1 or last_brace == -1 or last_brace < first_brace:
raise ValueError(f"Could not find a valid JSON object in response: {generated_raw}")
json_str = generated_raw[first_brace : last_brace + 1]
data = json_repair.loads(json_str)
if not isinstance(data, dict):
raise TypeError(f"Expected a JSON object, but got {type(data).__name__}")
return data
response = invoke_llm_with_pydantic_model(
provider=model_instance.provider,
model_schema=model_schema,
model_instance=model_instance,
prompt_messages=list(prompt_messages),
output_model=InstructionModifyOutput,
model_parameters=model_parameters,
stream=False,
)
return response.structured_output or {}
except InvokeError as e:
error = str(e)
return {"error": f"Failed to generate code. Error: {error}"}

View File

@ -0,0 +1,34 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
from core.variables.types import SegmentType
from core.workflow.nodes.base.entities import VariableSelector
class SuggestedQuestionsOutput(BaseModel):
model_config = ConfigDict(extra="forbid")
questions: list[str] = Field(min_length=3, max_length=3)
class CodeNodeOutput(BaseModel):
model_config = ConfigDict(extra="forbid")
type: SegmentType
class CodeNodeStructuredOutput(BaseModel):
model_config = ConfigDict(extra="forbid")
variables: list[VariableSelector]
code: str
outputs: dict[str, CodeNodeOutput]
explanation: str
class InstructionModifyOutput(BaseModel):
model_config = ConfigDict(extra="forbid")
modified: str
message: str

View File

@ -2,10 +2,10 @@ import json
from collections.abc import Generator, Mapping, Sequence
from copy import deepcopy
from enum import StrEnum
from typing import Any, Literal, cast, overload
from typing import Any, Literal, TypeVar, cast, overload
import json_repair
from pydantic import TypeAdapter, ValidationError
from pydantic import BaseModel, TypeAdapter, ValidationError
from core.llm_generator.output_parser.errors import OutputParserError
from core.llm_generator.output_parser.file_ref import convert_file_refs_in_output
@ -44,6 +44,9 @@ class SpecialModelType(StrEnum):
OLLAMA = "ollama"
T = TypeVar("T", bound=BaseModel)
@overload
def invoke_llm_with_structured_output(
*,
@ -129,7 +132,6 @@ def invoke_llm_with_structured_output(
file IDs in the output will be automatically converted to File objects.
:return: full response or stream response chunk generator result
"""
# handle native json schema
model_parameters_with_json_schema: dict[str, Any] = {
**(model_parameters or {}),
@ -234,6 +236,87 @@ def invoke_llm_with_structured_output(
return generator()
@overload
def invoke_llm_with_pydantic_model(
*,
provider: str,
model_schema: AIModelEntity,
model_instance: ModelInstance,
prompt_messages: Sequence[PromptMessage],
output_model: type[T],
model_parameters: Mapping | None = None,
tools: Sequence[PromptMessageTool] | None = None,
stop: list[str] | None = None,
stream: Literal[False] = False,
user: str | None = None,
callbacks: list[Callback] | None = None,
tenant_id: str | None = None,
) -> LLMResultWithStructuredOutput: ...
def invoke_llm_with_pydantic_model(
*,
provider: str,
model_schema: AIModelEntity,
model_instance: ModelInstance,
prompt_messages: Sequence[PromptMessage],
output_model: type[T],
model_parameters: Mapping | None = None,
tools: Sequence[PromptMessageTool] | None = None,
stop: list[str] | None = None,
stream: bool = False,
user: str | None = None,
callbacks: list[Callback] | None = None,
tenant_id: str | None = None,
) -> LLMResultWithStructuredOutput:
"""
Invoke large language model with a Pydantic output model.
This helper generates a JSON schema from the Pydantic model, invokes the
structured-output LLM path, and validates the result in non-streaming mode.
"""
if stream:
raise ValueError("invoke_llm_with_pydantic_model only supports stream=False")
json_schema = _schema_from_pydantic(output_model)
result = invoke_llm_with_structured_output(
provider=provider,
model_schema=model_schema,
model_instance=model_instance,
prompt_messages=prompt_messages,
json_schema=json_schema,
model_parameters=model_parameters,
tools=tools,
stop=stop,
stream=False,
user=user,
callbacks=callbacks,
tenant_id=tenant_id,
)
structured_output = result.structured_output
if structured_output is None:
raise OutputParserError("Structured output is empty")
validated_output = _validate_structured_output(output_model, structured_output)
return result.model_copy(update={"structured_output": validated_output})
def _schema_from_pydantic(output_model: type[BaseModel]) -> dict[str, Any]:
return output_model.model_json_schema()
def _validate_structured_output(
output_model: type[T],
structured_output: Mapping[str, Any],
) -> dict[str, Any]:
try:
validated_output = output_model.model_validate(structured_output)
except ValidationError as exc:
raise OutputParserError(f"Structured output validation failed: {exc}") from exc
return validated_output.model_dump(mode="python")
def _handle_native_json_schema(
provider: str,
model_schema: AIModelEntity,

View File

@ -106,14 +106,17 @@ class MentionGraphService:
]
structured_output = {
"type": "object",
"properties": {
parameter_schema.name: {
"type": parameter_schema.type,
"description": parameter_schema.description,
}
},
"required": [parameter_schema.name],
"schema": {
"type": "object",
"properties": {
parameter_schema.name: {
"type": parameter_schema.type,
"description": parameter_schema.description,
}
},
"required": [parameter_schema.name],
"additionalProperties": False,
}
}
return {

View File

@ -2,9 +2,13 @@ from decimal import Decimal
from unittest.mock import MagicMock, patch
import pytest
from pydantic import BaseModel, ConfigDict
from core.llm_generator.output_parser.errors import OutputParserError
from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output
from core.llm_generator.output_parser.structured_output import (
invoke_llm_with_pydantic_model,
invoke_llm_with_structured_output,
)
from core.model_runtime.entities.llm_entities import (
LLMResult,
LLMResultChunk,
@ -461,3 +465,68 @@ def test_model_specific_schema_preparation():
# For Gemini, the schema should not have additionalProperties and boolean should be converted to string
assert "json_schema" in call_args.kwargs["model_parameters"]
class ExampleOutput(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
def test_structured_output_with_pydantic_model():
model_schema = get_model_entity("openai", "gpt-4o", support_structure_output=True)
model_instance = get_model_instance()
model_instance.invoke_llm.return_value = LLMResult(
model="gpt-4o",
message=AssistantPromptMessage(content='{"name": "test"}'),
usage=create_mock_usage(prompt_tokens=8, completion_tokens=4),
)
prompt_messages = [UserPromptMessage(content="Return a JSON object with name.")]
result = invoke_llm_with_pydantic_model(
provider="openai",
model_schema=model_schema,
model_instance=model_instance,
prompt_messages=prompt_messages,
output_model=ExampleOutput,
stream=False,
)
assert isinstance(result, LLMResultWithStructuredOutput)
assert result.structured_output == {"name": "test"}
def test_structured_output_with_pydantic_model_streaming_rejected():
model_schema = get_model_entity("openai", "gpt-4o", support_structure_output=True)
model_instance = get_model_instance()
with pytest.raises(ValueError):
invoke_llm_with_pydantic_model(
provider="openai",
model_schema=model_schema,
model_instance=model_instance,
prompt_messages=[UserPromptMessage(content="test")],
output_model=ExampleOutput,
stream=True,
)
def test_structured_output_with_pydantic_model_validation_error():
model_schema = get_model_entity("openai", "gpt-4o", support_structure_output=True)
model_instance = get_model_instance()
model_instance.invoke_llm.return_value = LLMResult(
model="gpt-4o",
message=AssistantPromptMessage(content='{"name": 123}'),
usage=create_mock_usage(prompt_tokens=8, completion_tokens=4),
)
with pytest.raises(OutputParserError):
invoke_llm_with_pydantic_model(
provider="openai",
model_schema=model_schema,
model_instance=model_instance,
prompt_messages=[UserPromptMessage(content="test")],
output_model=ExampleOutput,
stream=False,
)

View File

@ -203,7 +203,7 @@ const Annotation: FC<Props> = (props) => {
</Filter>
{isLoading
? <Loading type="app" />
// eslint-disable-next-line sonarjs/no-nested-conditional
: total > 0
? (
<List

View File

@ -134,7 +134,6 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
},
] as const
// eslint-disable-next-line sonarjs/no-nested-template-literals, sonarjs/no-nested-conditional
const [instructionFromSessionStorage, setInstruction] = useSessionStorageState<string>(`improve-instruction-${flowId}${isBasicMode ? '' : `-${nodeId}${editorId ? `-${editorId}` : ''}`}`)
const instruction = instructionFromSessionStorage || ''
const [ideaOutput, setIdeaOutput] = useState<string>('')

View File

@ -175,7 +175,7 @@ describe('SettingsModal', () => {
renderSettingsModal()
fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry'))
const privacyInput = screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')
// eslint-disable-next-line sonarjs/no-clear-text-protocols
fireEvent.change(privacyInput, { target: { value: 'ftp://invalid-url' } })
fireEvent.click(screen.getByText('common.operation.save'))

View File

@ -0,0 +1,26 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "12",
"height": "12",
"viewBox": "0 0 12 12",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M5.14286 5.14286V3.42857L8 5.71429L5.14286 8V6.28571H0V5.14286H5.14286ZM0.83303 7.42857H2.04658C2.72474 9.10389 4.36721 10.28571 6.28571 10.28571C8.81049 10.28571 10.85717 8.23903 10.85717 5.71429C10.85717 3.18956 8.81049 1.14285 6.28571 1.14285C4.36721 1.14285 2.72474 2.32467 2.04658 4H0.83303C1.56118 1.68165 3.72706 0 6.28571 0C9.4416 0 12 2.55837 12 5.71429C12 8.87014 9.4416 11.42854 6.28571 11.42854C3.72706 11.42854 1.56118 9.74691 0.83303 7.42857Z",
"fill": "currentColor"
},
"children": []
}
]
},
"name": "AssembleVariablesAlt"
}

View File

@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './AssembleVariablesAlt.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'AssembleVariablesAlt'
export default Icon

View File

@ -1,4 +1,5 @@
export { default as AssembleVariables } from './AssembleVariables'
export { default as AssembleVariablesAlt } from './AssembleVariablesAlt'
export { default as AtSign } from './AtSign'
export { default as Bookmark } from './Bookmark'
export { default as Check } from './Check'

View File

@ -205,7 +205,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
}
catch {
try {
// eslint-disable-next-line no-new-func, sonarjs/code-eval
// eslint-disable-next-line no-new-func
const result = new Function(`return ${trimmedContent}`)()
if (typeof result === 'object' && result !== null) {
setFinalChartOption(result)
@ -250,7 +250,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
}
catch {
try {
// eslint-disable-next-line no-new-func, sonarjs/code-eval
// eslint-disable-next-line no-new-func
const result = new Function(`return ${trimmedContent}`)()
if (typeof result === 'object' && result !== null) {
setFinalChartOption(result)

View File

@ -155,14 +155,13 @@ export type TriggerFn = (
text: string,
editor: LexicalEditor,
) => MenuTextMatch | null
export const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'
export function useBasicTypeaheadTriggerMatch(
trigger: string,
{ minLength = 1, maxLength = 75 }: { minLength?: number, maxLength?: number },
): TriggerFn {
return useCallback(
(text: string) => {
const validChars = `[${PUNCTUATION}\\s]`
const validChars = '[^\\n]'
const TypeaheadTriggerRegex = new RegExp(
'(.*)('
+ `[${trigger}]`

View File

@ -32,6 +32,8 @@ import { PickerBlockMenuOption } from './menu'
import { PromptMenuItem } from './prompt-option'
import { VariableMenuItem } from './variable-option'
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
export const usePromptOptions = (
contextBlock?: ContextBlockType,
queryBlock?: QueryBlockType,
@ -154,7 +156,7 @@ export const useVariableOptions = (
if (!queryString)
return baseOptions
const regex = new RegExp(queryString, 'i')
const regex = new RegExp(escapeRegExp(queryString), 'i')
return baseOptions.filter(option => regex.test(option.key))
}, [editor, queryString, variableBlock])
@ -232,7 +234,7 @@ export const useExternalToolOptions = (
if (!queryString)
return baseToolOptions
const regex = new RegExp(queryString, 'i')
const regex = new RegExp(escapeRegExp(queryString), 'i')
return baseToolOptions.filter(option => regex.test(option.key))
}, [editor, queryString, externalToolBlockType])

View File

@ -25,7 +25,9 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
import { LexicalTypeaheadMenuPlugin } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import {
$getRoot,
$getSelection,
$insertNodes,
$isRangeSelection,
KEY_ESCAPE_COMMAND,
} from 'lexical'
import {
@ -91,11 +93,26 @@ const ComponentPicker = ({
],
})
const [editor] = useLexicalComposerContext()
const useExternalSearch = triggerString === '/' || triggerString === '@'
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, {
minLength: 0,
maxLength: 0,
maxLength: useExternalSearch ? 75 : 0,
})
const getMatchFromSelection = useCallback(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection) || !selection.isCollapsed())
return null
const anchor = selection.anchor
if (anchor.type !== 'text')
return null
const anchorNode = anchor.getNode()
if (!anchorNode.isSimpleText())
return null
const text = anchorNode.getTextContent().slice(0, anchor.offset)
return checkForTriggerMatch(text, editor)
}, [checkForTriggerMatch, editor])
const [queryString, setQueryString] = useState<string | null>(null)
eventEmitter?.useSubscription((v: any) => {
@ -116,6 +133,7 @@ const ComponentPicker = ({
currentBlock,
errorMessageBlock,
lastRunBlock,
useExternalSearch ? (queryString ?? undefined) : undefined,
)
const onSelectOption = useCallback(
@ -137,7 +155,10 @@ const ComponentPicker = ({
const handleSelectWorkflowVariable = useCallback((variables: string[]) => {
editor.update(() => {
const needRemove = $splitNodeContainingQuery(checkForTriggerMatch(triggerString, editor)!)
const match = getMatchFromSelection()
if (!match)
return
const needRemove = $splitNodeContainingQuery(match)
if (needRemove)
needRemove.remove()
})
@ -157,7 +178,7 @@ const ComponentPicker = ({
else {
editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, variables)
}
}, [editor, currentBlock?.generatorType, checkForTriggerMatch, triggerString])
}, [editor, currentBlock?.generatorType, getMatchFromSelection])
const handleClose = useCallback(() => {
const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' })
@ -166,7 +187,7 @@ const ComponentPicker = ({
const handleSelectAssembleVariables = useCallback((): ValueSelector | null => {
editor.update(() => {
const match = checkForTriggerMatch(triggerString, editor)
const match = getMatchFromSelection()
if (!match)
return
const needRemove = $splitNodeContainingQuery(match)
@ -178,11 +199,14 @@ const ComponentPicker = ({
editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, assembleVariables)
handleClose()
return assembleVariables ?? null
}, [editor, checkForTriggerMatch, triggerString, workflowVariableBlock, handleClose])
}, [editor, getMatchFromSelection, workflowVariableBlock, handleClose])
const handleSelectAgent = useCallback((agent: { id: string, title: string }) => {
editor.update(() => {
const needRemove = $splitNodeContainingQuery(checkForTriggerMatch(triggerString, editor)!)
const match = getMatchFromSelection()
if (!match)
return
const needRemove = $splitNodeContainingQuery(match)
if (needRemove)
needRemove.remove()
@ -198,7 +222,7 @@ const ComponentPicker = ({
})
agentBlock?.onSelect?.(agent)
handleClose()
}, [editor, checkForTriggerMatch, triggerString, agentBlock, handleClose])
}, [editor, getMatchFromSelection, agentBlock, handleClose])
const isAgentTrigger = triggerString === '@' && agentBlock?.show
const showAssembleVariables = triggerString === '/'
@ -247,6 +271,9 @@ const ComponentPicker = ({
onBlur={handleClose}
maxHeightClass="max-h-[34vh]"
autoFocus={false}
hideSearch={useExternalSearch}
externalSearchText={useExternalSearch ? (queryString ?? '') : undefined}
enableKeyboardNavigation={useExternalSearch}
/>
)
: (
@ -270,6 +297,9 @@ const ComponentPicker = ({
onAssembleVariables={showAssembleVariables ? handleSelectAssembleVariables : undefined}
autoFocus={false}
isInCodeGeneratorInstructionEditor={currentBlock?.generatorType === GeneratorType.code}
hideSearch={useExternalSearch}
externalSearchText={useExternalSearch ? (queryString ?? '') : undefined}
enableKeyboardNavigation={useExternalSearch}
/>
</div>
)
@ -311,7 +341,7 @@ const ComponentPicker = ({
}
</>
)
}, [isAgentTrigger, agentNodes, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, handleSelectAgent, handleClose, workflowVariableOptions, isSupportFileVar, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField, showAssembleVariables, handleSelectAssembleVariables])
}, [isAgentTrigger, agentNodes, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, handleSelectAgent, handleClose, workflowVariableOptions, isSupportFileVar, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField, showAssembleVariables, handleSelectAssembleVariables, useExternalSearch])
return (
<LexicalTypeaheadMenuPlugin

View File

@ -28,10 +28,10 @@ const RuleDetail = ({
case 'mode':
value = !sourceData?.mode
? value
// eslint-disable-next-line sonarjs/no-nested-conditional
: sourceData.mode === ProcessMode.general
? (t('embedding.custom', { ns: 'datasetDocuments' }) as string)
// eslint-disable-next-line sonarjs/no-nested-conditional
: `${t('embedding.hierarchical', { ns: 'datasetDocuments' })} · ${sourceData?.rules?.parent_mode === 'paragraph'
? t('parentMode.paragraph', { ns: 'dataset' })
: t('parentMode.fullDoc', { ns: 'dataset' })}`
@ -70,7 +70,7 @@ const RuleDetail = ({
src={
retrievalMethod === RETRIEVE_METHOD.fullText
? retrievalIcon.fullText
// eslint-disable-next-line sonarjs/no-nested-conditional
: retrievalMethod === RETRIEVE_METHOD.hybrid
? retrievalIcon.hybrid
: retrievalIcon.vector

View File

@ -403,7 +403,7 @@ const Form = () => {
</div>
</>
)
// eslint-disable-next-line sonarjs/no-nested-conditional
: indexMethod
? (
<>

View File

@ -7,22 +7,28 @@ import { useShallow } from 'zustand/react/shallow'
import { useIsChatMode, useWorkflowVariables } from '@/app/components/workflow/hooks'
import Panel from '@/app/components/workflow/panel'
import { useStore } from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import ConfigPanel from './config-panel'
type SubGraphChildrenProps = {
agentName: string
extractorNodeId: string
mentionConfig: MentionConfig
onMentionConfigChange: (config: MentionConfig) => void
}
type SubGraphChildrenProps
= | {
variant: 'agent'
title: string
extractorNodeId: string
mentionConfig: MentionConfig
onMentionConfigChange: (config: MentionConfig) => void
}
| {
variant: 'assemble'
title: string
extractorNodeId: string
}
const SubGraphChildren: FC<SubGraphChildrenProps> = ({
agentName,
extractorNodeId,
mentionConfig,
onMentionConfigChange,
}) => {
const SubGraphChildren: FC<SubGraphChildrenProps> = (props) => {
const {
variant,
title,
extractorNodeId,
} = props
const { getNodeAvailableVars } = useWorkflowVariables()
const isChatMode = useIsChatMode()
const nodePanelWidth = useStore(s => s.nodePanelWidth)
@ -32,7 +38,7 @@ const SubGraphChildren: FC<SubGraphChildrenProps> = ({
}))
const extractorNode = useReactFlowStore(useShallow((s) => {
return s.getNodes().find(node => node.data.type === BlockEnum.LLM)
return s.getNodes().find(node => node.id === extractorNodeId)
}))
const availableNodes = useMemo(() => {
@ -51,8 +57,10 @@ const SubGraphChildren: FC<SubGraphChildrenProps> = ({
return vars.filter(item => item.nodeId === extractorNode.id)
}, [extractorNode, getNodeAvailableVars, isChatMode])
const agentProps = variant === 'agent' ? props : null
const panelRight = useMemo(() => {
if (selectedNode)
if (!agentProps || selectedNode)
return null
return (
@ -62,17 +70,25 @@ const SubGraphChildren: FC<SubGraphChildrenProps> = ({
style={{ width: `${nodePanelWidth}px` }}
>
<ConfigPanel
agentName={agentName}
agentName={title}
extractorNodeId={extractorNodeId}
mentionConfig={mentionConfig}
mentionConfig={agentProps.mentionConfig}
availableNodes={availableNodes}
availableVars={availableVars}
onMentionConfigChange={onMentionConfigChange}
onMentionConfigChange={agentProps.onMentionConfigChange}
/>
</div>
</div>
)
}, [agentName, availableNodes, availableVars, extractorNodeId, mentionConfig, nodePanelWidth, onMentionConfigChange, selectedNode])
}, [agentProps, availableNodes, availableVars, extractorNodeId, nodePanelWidth, selectedNode, title])
if (variant === 'assemble') {
return (
<Panel
withHeader={false}
/>
)
}
return (
<Panel

View File

@ -9,35 +9,46 @@ import { useStoreApi } from 'reactflow'
import { WorkflowWithInnerContext } from '@/app/components/workflow'
import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars'
import { useInspectVarsCrudCommon } from '@/app/components/workflow/hooks/use-inspect-vars-crud-common'
import { BlockEnum } from '@/app/components/workflow/types'
import { FlowType } from '@/types/common'
import { useAvailableNodesMetaData } from '../hooks'
import SubGraphChildren from './sub-graph-children'
type SubGraphMainProps = {
type SubGraphMainBaseProps = {
nodes: Node[]
edges: Edge[]
viewport: Viewport
agentName: string
title: string
extractorNodeId: string
configsMap?: HooksStoreShape['configsMap']
mentionConfig: MentionConfig
onMentionConfigChange: (config: MentionConfig) => void
selectableNodeTypes?: BlockEnum[]
onSave?: (nodes: Node[], edges: Edge[]) => void
onSyncWorkflowDraft?: SyncWorkflowDraft
}
const SubGraphMain: FC<SubGraphMainProps> = ({
nodes,
edges,
viewport,
agentName,
extractorNodeId,
configsMap,
mentionConfig,
onMentionConfigChange,
onSave,
onSyncWorkflowDraft,
}) => {
type SubGraphMainProps
= | (SubGraphMainBaseProps & {
variant: 'agent'
mentionConfig: MentionConfig
onMentionConfigChange: (config: MentionConfig) => void
})
| (SubGraphMainBaseProps & {
variant: 'assemble'
})
const SubGraphMain: FC<SubGraphMainProps> = (props) => {
const {
nodes,
edges,
viewport,
variant,
title,
extractorNodeId,
configsMap,
selectableNodeTypes,
onSave,
onSyncWorkflowDraft,
} = props
const reactFlowStore = useStoreApi()
const availableNodesMetaData = useAvailableNodesMetaData()
const flowType = configsMap?.flowType ?? FlowType.appFlow
@ -76,32 +87,53 @@ const SubGraphMain: FC<SubGraphMainProps> = ({
}
}, [handleSyncSubGraphDraft, onSyncWorkflowDraft])
const resolvedSelectableTypes = useMemo(() => {
if (selectableNodeTypes && selectableNodeTypes.length > 0)
return selectableNodeTypes
return variant === 'agent' ? [BlockEnum.LLM] : [BlockEnum.Code]
}, [selectableNodeTypes, variant])
const hooksStore = useMemo(() => ({
interactionMode: 'subgraph',
subGraphSelectableNodeTypes: resolvedSelectableTypes,
availableNodesMetaData,
configsMap,
fetchInspectVars,
...inspectVarsCrud,
doSyncWorkflowDraft: handleSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose: handleSyncSubGraphDraft,
}), [availableNodesMetaData, configsMap, fetchInspectVars, handleSyncSubGraphDraft, handleSyncWorkflowDraft, inspectVarsCrud])
}), [availableNodesMetaData, configsMap, fetchInspectVars, handleSyncSubGraphDraft, handleSyncWorkflowDraft, inspectVarsCrud, resolvedSelectableTypes])
const subGraphChildren = variant === 'agent'
? (
<SubGraphChildren
variant="agent"
title={title}
extractorNodeId={extractorNodeId}
mentionConfig={props.mentionConfig}
onMentionConfigChange={props.onMentionConfigChange}
/>
)
: (
<SubGraphChildren
variant="assemble"
title={title}
extractorNodeId={extractorNodeId}
/>
)
return (
<WorkflowWithInnerContext
nodes={nodes}
edges={edges}
viewport={viewport}
// eslint-disable-next-line ts/no-explicit-any -- TODO: remove after typing boundary
hooksStore={hooksStore as any}
allowSelectionWhenReadOnly
canvasReadOnly
interactionMode="subgraph"
>
<SubGraphChildren
agentName={agentName}
extractorNodeId={extractorNodeId}
mentionConfig={mentionConfig}
onMentionConfigChange={onMentionConfigChange}
/>
{subGraphChildren}
</WorkflowWithInnerContext>
)
}

View File

@ -7,6 +7,7 @@ import { memo, useEffect, useMemo } from 'react'
import WorkflowWithDefaultContext from '@/app/components/workflow'
import { NODE_WIDTH_X_OFFSET, START_INITIAL_POSITION } from '@/app/components/workflow/constants'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import { CUSTOM_SUB_GRAPH_START_NODE } from '@/app/components/workflow/nodes/sub-graph-start/constants'
import { useStore } from '@/app/components/workflow/store'
import { BlockEnum, EditionType, isPromptMessageContext, PromptRole } from '@/app/components/workflow/types'
import SubGraphMain from './components/sub-graph-main'
@ -18,7 +19,7 @@ const SUB_GRAPH_ENTRY_POSITION = {
x: START_INITIAL_POSITION.x,
y: 150,
}
const SUB_GRAPH_LLM_POSITION = {
const SUB_GRAPH_EXTRACTOR_POSITION = {
x: SUB_GRAPH_ENTRY_POSITION.x + NODE_WIDTH_X_OFFSET - SUB_GRAPH_EDGE_GAP,
y: SUB_GRAPH_ENTRY_POSITION.y,
}
@ -33,19 +34,19 @@ const SubGraphContent: FC<SubGraphProps> = (props) => {
const {
toolNodeId,
paramKey,
agentName,
agentNodeId,
mentionConfig,
onMentionConfigChange,
extractorNode,
toolParamValue,
parentAvailableNodes,
parentAvailableVars,
configsMap,
selectableNodeTypes,
onSave,
onSyncWorkflowDraft,
} = props
const isAgentVariant = props.variant === 'agent'
const sourceTitle = isAgentVariant ? (props.agentName || '') : (props.title || '')
const resolvedAgentNodeId = isAgentVariant ? props.agentNodeId : ''
const setParentAvailableVars = useStore(state => state.setParentAvailableVars)
const setParentAvailableNodes = useStore(state => state.setParentAvailableNodes)
@ -55,28 +56,47 @@ const SubGraphContent: FC<SubGraphProps> = (props) => {
}, [parentAvailableNodes, parentAvailableVars, setParentAvailableNodes, setParentAvailableVars])
const promptText = useMemo(() => {
if (!toolParamValue)
if (!isAgentVariant || !toolParamValue)
return ''
// Reason: escape agent id before building a regex pattern.
const escapedAgentId = agentNodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const escapedAgentId = resolvedAgentNodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const leadingPattern = new RegExp(`^\\{\\{[@#]${escapedAgentId}\\.context[@#]\\}\\}`)
return toolParamValue.replace(leadingPattern, '')
}, [agentNodeId, toolParamValue])
}, [isAgentVariant, resolvedAgentNodeId, toolParamValue])
const startNode = useMemo(() => {
if (!isAgentVariant) {
return {
id: 'subgraph-source',
type: CUSTOM_SUB_GRAPH_START_NODE,
position: SUB_GRAPH_ENTRY_POSITION,
data: {
type: BlockEnum.Start,
title: sourceTitle,
desc: '',
selected: false,
iconType: 'assemble',
variables: [],
},
selected: false,
selectable: false,
draggable: false,
connectable: false,
focusable: false,
deletable: false,
}
}
return {
id: 'subgraph-source',
type: 'custom',
type: CUSTOM_SUB_GRAPH_START_NODE,
position: SUB_GRAPH_ENTRY_POSITION,
data: {
type: BlockEnum.Start,
title: agentName,
title: sourceTitle,
desc: '',
_connectedSourceHandleIds: ['source'],
_connectedTargetHandleIds: [],
_subGraphEntry: true,
_iconTypeOverride: BlockEnum.Agent,
selected: false,
iconType: 'agent',
variables: [],
},
selected: false,
@ -86,65 +106,83 @@ const SubGraphContent: FC<SubGraphProps> = (props) => {
focusable: false,
deletable: false,
}
}, [agentName])
}, [isAgentVariant, sourceTitle])
const extractorDisplayNode = useMemo(() => {
if (!extractorNode)
return null
if (isAgentVariant) {
const extractorNode = props.extractorNode
if (!extractorNode)
return null
const applyPromptText = (item: PromptItem) => {
if (item.edition_type === EditionType.jinja2) {
return {
...item,
text: promptText,
jinja2_text: promptText,
}
}
return { ...item, text: promptText }
}
const nextPromptTemplate = (() => {
const template = extractorNode.data.prompt_template
if (!Array.isArray(template))
return applyPromptText(template as PromptItem)
const userIndex = template.findIndex(
item => !isPromptMessageContext(item) && (item as PromptItem).role === PromptRole.user,
)
if (userIndex >= 0) {
return template.map((item, index) => {
if (index !== userIndex)
return item
return applyPromptText(item as PromptItem)
}) as PromptTemplateItem[]
}
const useJinja = template.some(
item => !isPromptMessageContext(item) && (item as PromptItem).edition_type === EditionType.jinja2,
)
const defaultUserPrompt: PromptItem = useJinja
? {
role: PromptRole.user,
const applyPromptText = (item: PromptItem) => {
if (item.edition_type === EditionType.jinja2) {
return {
...item,
text: promptText,
jinja2_text: promptText,
edition_type: EditionType.jinja2,
}
: { role: PromptRole.user, text: promptText }
return [...template, defaultUserPrompt] as PromptTemplateItem[]
})()
}
return { ...item, text: promptText }
}
const nextPromptTemplate = (() => {
const template = extractorNode.data.prompt_template
if (!Array.isArray(template))
return applyPromptText(template as PromptItem)
const userIndex = template.findIndex(
item => !isPromptMessageContext(item) && (item as PromptItem).role === PromptRole.user,
)
if (userIndex >= 0) {
return template.map((item, index) => {
if (index !== userIndex)
return item
return applyPromptText(item as PromptItem)
}) as PromptTemplateItem[]
}
const useJinja = template.some(
item => !isPromptMessageContext(item) && (item as PromptItem).edition_type === EditionType.jinja2,
)
const defaultUserPrompt: PromptItem = useJinja
? {
role: PromptRole.user,
text: promptText,
jinja2_text: promptText,
edition_type: EditionType.jinja2,
}
: { role: PromptRole.user, text: promptText }
return [...template, defaultUserPrompt] as PromptTemplateItem[]
})()
return {
...extractorNode,
hidden: false,
selected: false,
position: SUB_GRAPH_EXTRACTOR_POSITION,
data: {
...extractorNode.data,
selected: false,
prompt_template: nextPromptTemplate,
},
}
}
const extractorNode = props.extractorNode
if (!extractorNode)
return null
return {
...extractorNode,
hidden: false,
selected: false,
position: SUB_GRAPH_LLM_POSITION,
position: SUB_GRAPH_EXTRACTOR_POSITION,
data: {
...extractorNode.data,
selected: false,
prompt_template: nextPromptTemplate,
},
}
}, [extractorNode, promptText])
}, [isAgentVariant, promptText, props.extractorNode])
const nodesSource = useMemo(() => {
if (!extractorDisplayNode)
@ -168,30 +206,54 @@ const SubGraphContent: FC<SubGraphProps> = (props) => {
selectable: false,
data: {
sourceType: BlockEnum.Start,
targetType: BlockEnum.LLM,
targetType: isAgentVariant ? BlockEnum.LLM : BlockEnum.Code,
_isTemp: true,
_isSubGraphTemp: true,
},
},
]
}, [extractorDisplayNode, startNode])
}, [extractorDisplayNode, isAgentVariant, startNode])
const { nodes, edges } = useSubGraphNodes(nodesSource, edgesSource)
if (isAgentVariant) {
return (
<WorkflowWithDefaultContext
nodes={nodes}
edges={edges}
>
<SubGraphMain
variant="agent"
nodes={nodes}
edges={edges}
viewport={defaultViewport}
title={sourceTitle}
extractorNodeId={`${toolNodeId}_ext_${paramKey}`}
configsMap={configsMap}
mentionConfig={props.mentionConfig}
onMentionConfigChange={props.onMentionConfigChange}
selectableNodeTypes={selectableNodeTypes}
onSave={onSave}
onSyncWorkflowDraft={onSyncWorkflowDraft}
/>
</WorkflowWithDefaultContext>
)
}
return (
<WorkflowWithDefaultContext
nodes={nodes}
edges={edges}
>
<SubGraphMain
variant="assemble"
nodes={nodes}
edges={edges}
viewport={defaultViewport}
agentName={agentName}
title={sourceTitle}
extractorNodeId={`${toolNodeId}_ext_${paramKey}`}
configsMap={configsMap}
mentionConfig={mentionConfig}
onMentionConfigChange={onMentionConfigChange}
selectableNodeTypes={selectableNodeTypes}
onSave={onSave}
onSyncWorkflowDraft={onSyncWorkflowDraft}
/>

View File

@ -1,8 +1,9 @@
import type { StateCreator } from 'zustand'
import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store'
import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types'
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { Edge, Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types'
import type { BlockEnum, Edge, Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types'
export type SyncWorkflowDraftCallback = {
onSuccess?: () => void
@ -15,23 +16,38 @@ export type SyncWorkflowDraft = (
callback?: SyncWorkflowDraftCallback,
) => Promise<void>
export type SubGraphProps = {
export type SubGraphVariant = 'agent' | 'assemble'
type BaseSubGraphProps = {
toolNodeId: string
paramKey: string
sourceVariable: ValueSelector
agentNodeId: string
agentName: string
configsMap?: HooksStoreShape['configsMap']
mentionConfig: MentionConfig
onMentionConfigChange: (config: MentionConfig) => void
extractorNode?: Node<LLMNodeType>
toolParamValue?: string
parentAvailableNodes?: Node[]
parentAvailableVars?: NodeOutPutVar[]
selectableNodeTypes?: BlockEnum[]
onSave?: (nodes: Node[], edges: Edge[]) => void
onSyncWorkflowDraft?: SyncWorkflowDraft
}
export type AgentSubGraphProps = BaseSubGraphProps & {
variant: 'agent'
sourceVariable: ValueSelector
agentNodeId: string
agentName: string
mentionConfig: MentionConfig
onMentionConfigChange: (config: MentionConfig) => void
extractorNode?: Node<LLMNodeType>
}
export type AssembleSubGraphProps = BaseSubGraphProps & {
variant: 'assemble'
title: string
extractorNode?: Node<CodeNodeType>
}
export type SubGraphProps = AgentSubGraphProps | AssembleSubGraphProps
export type SubGraphSliceShape = {
parentAvailableVars: NodeOutPutVar[]
parentAvailableNodes: Node[]

View File

@ -23,7 +23,7 @@ describe('GetSchema', () => {
it('shows an error when the URL is not http', () => {
fireEvent.click(screen.getByText('tools.createTool.importFromUrl'))
const input = screen.getByPlaceholderText('tools.createTool.importFromUrlPlaceHolder')
// eslint-disable-next-line sonarjs/no-clear-text-protocols
fireEvent.change(input, { target: { value: 'ftp://invalid' } })
fireEvent.click(screen.getByText('common.operation.ok'))

View File

@ -46,6 +46,7 @@ export type CommonHooksFnMap = {
handleWorkflowTriggerWebhookRunInWorkflow: (params: { nodeId: string }) => void
handleWorkflowTriggerPluginRunInWorkflow: (nodeId?: string) => void
handleWorkflowRunAllTriggersInWorkflow: (nodeIds: string[]) => void
subGraphSelectableNodeTypes?: BlockEnum[]
availableNodesMetaData?: AvailableNodesMetaData
getWorkflowRunAndTraceUrl: (runId?: string) => { runUrl: string, traceUrl: string }
exportCheck?: () => Promise<void>
@ -93,6 +94,7 @@ export const createHooksStore = ({
handleWorkflowTriggerWebhookRunInWorkflow = noop,
handleWorkflowTriggerPluginRunInWorkflow = noop,
handleWorkflowRunAllTriggersInWorkflow = noop,
subGraphSelectableNodeTypes,
availableNodesMetaData = {
nodes: [],
},
@ -136,6 +138,7 @@ export const createHooksStore = ({
handleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow,
subGraphSelectableNodeTypes,
availableNodesMetaData,
getWorkflowRunAndTraceUrl,
exportCheck,

View File

@ -89,6 +89,8 @@ import CustomIterationStartNode from './nodes/iteration-start'
import { CUSTOM_ITERATION_START_NODE } from './nodes/iteration-start/constants'
import CustomLoopStartNode from './nodes/loop-start'
import { CUSTOM_LOOP_START_NODE } from './nodes/loop-start/constants'
import CustomSubGraphStartNode from './nodes/sub-graph-start'
import { CUSTOM_SUB_GRAPH_START_NODE } from './nodes/sub-graph-start/constants'
import CustomNoteNode from './note-node'
import { CUSTOM_NOTE_NODE } from './note-node/constants'
import Operator from './operator'
@ -119,6 +121,7 @@ const nodeTypes = {
[CUSTOM_NODE]: CustomNode,
[CUSTOM_NOTE_NODE]: CustomNoteNode,
[CUSTOM_SIMPLE_NODE]: CustomSimpleNode,
[CUSTOM_SUB_GRAPH_START_NODE]: CustomSubGraphStartNode,
[CUSTOM_ITERATION_START_NODE]: CustomIterationStartNode,
[CUSTOM_LOOP_START_NODE]: CustomLoopStartNode,
[CUSTOM_DATA_SOURCE_EMPTY_NODE]: CustomDataSourceEmptyNode,
@ -355,6 +358,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
const dataSourceList = useStore(s => s.dataSourceList)
// buildInTools, customTools, workflowTools, mcpTools, dataSourceList
const configsMap = useHooksStore(s => s.configsMap)
const subGraphSelectableNodeTypes = useHooksStore(s => s.subGraphSelectableNodeTypes)
const [isLoadedVars, setIsLoadedVars] = useState(false)
const [vars, setVars] = useState<VarInInspect[]>([])
useEffect(() => {
@ -393,12 +397,17 @@ export const Workflow: FC<WorkflowProps> = memo(({
const handleNodeClickInMode = useCallback<NodeMouseHandler>(
(event, node) => {
if (isSubGraph && node.data.type !== BlockEnum.LLM)
return
if (isSubGraph) {
const allowTypes = subGraphSelectableNodeTypes?.length
? subGraphSelectableNodeTypes
: [BlockEnum.LLM]
if (!allowTypes.includes(node.data.type))
return
}
handleNodeClick(event, node)
},
[handleNodeClick, isSubGraph],
[handleNodeClick, isSubGraph, subGraphSelectableNodeTypes],
)
return (

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import type { BlockEnum } from '@/app/components/workflow/types'
import * as React from 'react'
import { useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import BlockIcon from '@/app/components/workflow/block-icon'
@ -17,9 +17,12 @@ export type AgentNode = {
type ItemProps = {
node: AgentNode
onSelect: (node: AgentNode) => void
isHighlighted?: boolean
onSetHighlight?: () => void
registerRef?: (element: HTMLButtonElement | null) => void
}
const Item: FC<ItemProps> = ({ node, onSelect }) => {
const Item: FC<ItemProps> = ({ node, onSelect, isHighlighted, onSetHighlight, registerRef }) => {
const [isHovering, setIsHovering] = useState(false)
return (
@ -27,10 +30,15 @@ const Item: FC<ItemProps> = ({ node, onSelect }) => {
type="button"
className={cn(
'relative flex h-6 w-full cursor-pointer items-center rounded-md border-none bg-transparent px-3 text-left',
isHovering && 'bg-state-base-hover',
(isHovering || isHighlighted) && 'bg-state-base-hover',
)}
onMouseEnter={() => setIsHovering(true)}
ref={registerRef}
onMouseEnter={() => {
setIsHovering(true)
onSetHighlight?.()
}}
onMouseLeave={() => setIsHovering(false)}
onFocus={onSetHighlight}
onClick={() => onSelect(node)}
onMouseDown={e => e.preventDefault()}
>
@ -58,6 +66,8 @@ type Props = {
searchBoxClassName?: string
maxHeightClass?: string
autoFocus?: boolean
externalSearchText?: string
enableKeyboardNavigation?: boolean
}
const AgentNodeList: FC<Props> = ({
@ -69,9 +79,15 @@ const AgentNodeList: FC<Props> = ({
searchBoxClassName,
maxHeightClass,
autoFocus = true,
externalSearchText,
enableKeyboardNavigation = false,
}) => {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
const normalizedSearchText = externalSearchText === undefined ? searchText : externalSearchText
const normalizedSearchTextTrimmed = normalizedSearchText.trim()
const normalizedSearchTextLower = normalizedSearchTextTrimmed.toLowerCase()
const shouldShowSearchInput = !hideSearch && externalSearchText === undefined
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
@ -80,15 +96,107 @@ const AgentNodeList: FC<Props> = ({
}
}
const filteredNodes = nodes.filter((node) => {
if (!searchText)
const filteredNodes = useMemo(() => nodes.filter((node) => {
if (!normalizedSearchTextTrimmed)
return true
return node.title.toLowerCase().includes(searchText.toLowerCase())
})
return node.title.toLowerCase().includes(normalizedSearchTextLower)
}), [nodes, normalizedSearchTextLower, normalizedSearchTextTrimmed])
const [activeIndex, setActiveIndex] = useState(-1)
const itemRefs = useRef<Array<HTMLButtonElement | null>>([])
const lastInteractionRef = useRef<'keyboard' | 'mouse' | 'filter' | null>(null)
const filteredNodesRef = useRef(filteredNodes)
const activeIndexRef = useRef(activeIndex)
const onCloseRef = useRef(onClose)
const resolvedActiveIndex = useMemo(() => {
if (!enableKeyboardNavigation || filteredNodes.length === 0)
return -1
if (activeIndex < 0 || activeIndex >= filteredNodes.length)
return 0
return activeIndex
}, [activeIndex, enableKeyboardNavigation, filteredNodes.length])
useEffect(() => {
itemRefs.current = []
}, [filteredNodes.length])
useEffect(() => {
filteredNodesRef.current = filteredNodes
}, [filteredNodes])
useEffect(() => {
activeIndexRef.current = resolvedActiveIndex
}, [resolvedActiveIndex])
useEffect(() => {
onCloseRef.current = onClose
}, [onClose])
const handleHighlightIndex = useCallback((index: number, source: 'keyboard' | 'mouse' | 'filter') => {
lastInteractionRef.current = source
setActiveIndex(index)
}, [])
useEffect(() => {
if (!enableKeyboardNavigation || filteredNodes.length === 0) {
lastInteractionRef.current = 'filter'
return
}
if (activeIndex < 0 || activeIndex >= filteredNodes.length)
lastInteractionRef.current = 'filter'
}, [activeIndex, enableKeyboardNavigation, filteredNodes.length])
useEffect(() => {
if (!enableKeyboardNavigation || resolvedActiveIndex < 0)
return
if (lastInteractionRef.current !== 'keyboard')
return
const target = itemRefs.current[resolvedActiveIndex]
if (target)
target.scrollIntoView({ block: 'nearest' })
lastInteractionRef.current = null
}, [enableKeyboardNavigation, filteredNodes.length, resolvedActiveIndex])
const handleSelectItem = useCallback((node: AgentNode) => {
onSelect(node)
}, [onSelect])
useEffect(() => {
if (!enableKeyboardNavigation)
return
const handleKeyDown = (event: KeyboardEvent) => {
const nodes = filteredNodesRef.current
if (nodes.length === 0)
return
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key))
return
event.preventDefault()
event.stopPropagation()
if (event.key === 'Escape') {
onCloseRef.current?.()
return
}
if (event.key === 'Enter') {
const index = activeIndexRef.current
if (index < 0 || index >= nodes.length)
return
handleSelectItem(nodes[index])
return
}
const delta = event.key === 'ArrowDown' ? 1 : -1
const baseIndex = activeIndexRef.current < 0 ? 0 : activeIndexRef.current
const nextIndex = Math.min(Math.max(baseIndex + delta, 0), nodes.length - 1)
handleHighlightIndex(nextIndex, 'keyboard')
}
document.addEventListener('keydown', handleKeyDown, true)
return () => {
document.removeEventListener('keydown', handleKeyDown, true)
}
}, [enableKeyboardNavigation, handleHighlightIndex, handleSelectItem])
return (
<>
{!hideSearch && (
{shouldShowSearchInput && (
<>
<div className={cn('mx-2 mb-2 mt-2', searchBoxClassName)}>
<Input
@ -114,11 +222,18 @@ const AgentNodeList: FC<Props> = ({
{filteredNodes.length > 0
? (
<div className={cn('max-h-[85vh] overflow-y-auto py-1', maxHeightClass)}>
{filteredNodes.map(node => (
{filteredNodes.map((node, index) => (
<Item
key={node.id}
node={node}
onSelect={onSelect}
isHighlighted={enableKeyboardNavigation && index === resolvedActiveIndex}
onSetHighlight={enableKeyboardNavigation ? () => handleHighlightIndex(index, 'mouse') : undefined}
registerRef={enableKeyboardNavigation
? (element) => {
itemRefs.current[index] = element
}
: undefined}
/>
))}
</div>

View File

@ -4,16 +4,13 @@ import type { CodeLanguage } from '../../code/types'
import type { GenRes } from '@/service/debug'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useCallback } 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
@ -31,39 +28,12 @@ const CodeGenerateBtn: FC<Props> = ({
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 (
<div className={cn(className)}>
<ActionButton
@ -73,28 +43,16 @@ const CodeGenerateBtn: FC<Props> = ({
<Generator className="h-4 w-4 text-primary-600" />
</ActionButton>
{showAutomatic && (
contextGenerateConfig
? (
<ContextGenerateModal
isShow={showAutomatic}
onClose={showAutomaticFalse}
toolNodeId={contextGenerateConfig.toolNodeId}
paramKey={contextGenerateConfig.paramKey}
codeNodeId={contextGenerateConfig.codeNodeId}
/>
)
: (
<GetCodeGeneratorResModal
mode={AppModeEnum.CHAT}
isShow={showAutomatic}
codeLanguages={codeLanguages}
onClose={showAutomaticFalse}
onFinished={handleAutomaticRes}
flowId={configsMap?.flowId || ''}
nodeId={nodeId}
currentCode={currentCode}
/>
)
<GetCodeGeneratorResModal
mode={AppModeEnum.CHAT}
isShow={showAutomatic}
codeLanguages={codeLanguages}
onClose={showAutomaticFalse}
onFinished={handleAutomaticRes}
flowId={configsMap?.flowId || ''}
nodeId={nodeId}
currentCode={currentCode}
/>
)}
</div>
)

View File

@ -474,7 +474,6 @@ const formatItem = (
break
}
// eslint-disable-next-line sonarjs/no-duplicated-branches
case BlockEnum.VariableAggregator: {
const { output_type, advanced_settings }
= data as VariableAssignerNodeType
@ -1875,7 +1874,7 @@ export const updateNodeVars = (
}
break
}
// eslint-disable-next-line sonarjs/no-duplicated-branches
case BlockEnum.VariableAggregator: {
const payload = data as VariableAssignerNodeType
if (payload.variables) {

View File

@ -6,7 +6,7 @@ import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflo
import { useHover } from 'ahooks'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
import { AssembleVariables, CodeAssistant, MagicEdit } from '@/app/components/base/icons/src/vender/line/general'
@ -43,6 +43,31 @@ type ItemProps = {
zIndex?: number
className?: string
preferSchemaType?: boolean
isHighlighted?: boolean
onSetHighlight?: () => void
registerRef?: (element: HTMLDivElement | null) => void
}
const buildValueSelector = ({
nodeId,
objPath,
itemData,
isFlat,
}: {
nodeId: string
objPath: string[]
itemData: Var
isFlat?: boolean
}): ValueSelector => {
if (isFlat)
return [itemData.variable]
const isSys = itemData.variable.startsWith('sys.')
const isEnv = itemData.variable.startsWith('env.')
const isChatVar = itemData.variable.startsWith('conversation.')
const isRagVariable = itemData.isRagVariable
if (isSys || isEnv || isChatVar || isRagVariable)
return [...objPath, ...itemData.variable.split('.')]
return [nodeId, ...objPath, itemData.variable]
}
const Item: FC<ItemProps> = ({
@ -60,6 +85,9 @@ const Item: FC<ItemProps> = ({
zIndex,
className,
preferSchemaType,
isHighlighted,
onSetHighlight,
registerRef,
}) => {
const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties
const isFile = itemData.type === VarType.file && !isStructureOutput
@ -123,6 +151,10 @@ const Item: FC<ItemProps> = ({
})()
const itemRef = useRef<HTMLDivElement>(null)
const setItemRef = useCallback((element: HTMLDivElement | null) => {
itemRef.current = element
registerRef?.(element)
}, [registerRef])
const [isItemHovering, setIsItemHovering] = useState(false)
useHover(itemRef, {
onChange: (hovering) => {
@ -152,15 +184,12 @@ const Item: FC<ItemProps> = ({
if (!isSupportFileVar && isFile)
return
if (isFlat) {
onChange([itemData.variable], itemData)
}
else if (isSys || isEnv || isChatVar || isRagVariable) { // system variable | environment variable | conversation variable
onChange([...objPath, ...itemData.variable.split('.')], itemData)
}
else {
onChange([nodeId, ...objPath, itemData.variable], itemData)
}
onChange(buildValueSelector({
nodeId,
objPath,
itemData,
isFlat,
}), itemData)
}
const variableCategory = useMemo(() => {
if (isEnv)
@ -181,14 +210,15 @@ const Item: FC<ItemProps> = ({
>
<PortalToFollowElemTrigger className="w-full">
<div
ref={itemRef}
ref={setItemRef}
className={cn(
(isObj || isStructureOutput) ? ' pr-1' : 'pr-[18px]',
isHovering && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
(isHovering || isHighlighted) && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3',
className,
)}
onClick={handleChosen}
onMouseEnter={onSetHighlight}
onMouseDown={e => e.preventDefault()}
>
<div className="flex w-0 grow items-center">
@ -259,6 +289,8 @@ type Props = {
onAssembleVariables?: () => ValueSelector | null
autoFocus?: boolean
preferSchemaType?: boolean
externalSearchText?: string
enableKeyboardNavigation?: boolean
}
const VarReferenceVars: FC<Props> = ({
hideSearch,
@ -278,9 +310,15 @@ const VarReferenceVars: FC<Props> = ({
onAssembleVariables,
autoFocus = true,
preferSchemaType,
externalSearchText,
enableKeyboardNavigation = false,
}) => {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
const normalizedSearchText = externalSearchText === undefined ? searchText : externalSearchText
const normalizedSearchTextTrimmed = normalizedSearchText.trim()
const normalizedSearchTextLower = normalizedSearchTextTrimmed.toLowerCase()
const shouldShowSearchInput = !hideSearch && externalSearchText === undefined
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
@ -296,35 +334,159 @@ const VarReferenceVars: FC<Props> = ({
onClose?.()
}
const filteredVars = vars.filter((v) => {
const children = v.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0]))
return children.length > 0
}).filter((node) => {
if (!searchText)
return node
const children = node.vars.filter((v) => {
const searchTextLower = searchText.toLowerCase()
return v.variable.toLowerCase().includes(searchTextLower) || node.title.toLowerCase().includes(searchTextLower)
const validatedVars = useMemo(() => {
const res: NodeOutPutVar[] = []
vars.forEach((node) => {
const nodeVars = node.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0]))
if (nodeVars.length === 0)
return
res.push({
...node,
vars: nodeVars,
})
})
return children.length > 0
}).map((node) => {
let vars = node.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0]))
if (searchText) {
const searchTextLower = searchText.toLowerCase()
if (!node.title.toLowerCase().includes(searchTextLower))
vars = vars.filter(v => v.variable.toLowerCase().includes(searchText.toLowerCase()))
}
return res
}, [vars])
return {
...node,
vars,
const filteredVars = useMemo(() => {
if (!normalizedSearchTextTrimmed)
return validatedVars
const res: NodeOutPutVar[] = []
validatedVars.forEach((node) => {
const titleLower = node.title.toLowerCase()
const matchedByTitle = titleLower.includes(normalizedSearchTextLower)
const nodeVars = matchedByTitle
? node.vars
: node.vars.filter(v => v.variable.toLowerCase().includes(normalizedSearchTextLower))
if (nodeVars.length === 0)
return
res.push({
...node,
vars: nodeVars,
})
})
return res
}, [normalizedSearchTextLower, normalizedSearchTextTrimmed, validatedVars])
const flatItems = useMemo(() => {
const items: Array<{ node: NodeOutPutVar, itemData: Var }> = []
filteredVars.forEach((node) => {
node.vars.forEach((itemData) => {
items.push({ node, itemData })
})
})
return items
}, [filteredVars])
const [activeIndex, setActiveIndex] = useState(-1)
const itemRefs = useRef<Array<HTMLDivElement | null>>([])
const lastInteractionRef = useRef<'keyboard' | 'mouse' | 'filter' | null>(null)
const flatItemsRef = useRef(flatItems)
const activeIndexRef = useRef(activeIndex)
const onCloseRef = useRef(onClose)
const resolvedActiveIndex = useMemo(() => {
if (!enableKeyboardNavigation || flatItems.length === 0)
return -1
if (activeIndex < 0 || activeIndex >= flatItems.length)
return 0
return activeIndex
}, [activeIndex, enableKeyboardNavigation, flatItems.length])
useEffect(() => {
itemRefs.current = []
}, [flatItems.length])
useEffect(() => {
flatItemsRef.current = flatItems
}, [flatItems])
useEffect(() => {
activeIndexRef.current = resolvedActiveIndex
}, [resolvedActiveIndex])
useEffect(() => {
onCloseRef.current = onClose
}, [onClose])
const handleHighlightIndex = useCallback((index: number, source: 'keyboard' | 'mouse' | 'filter') => {
lastInteractionRef.current = source
setActiveIndex(index)
}, [])
useEffect(() => {
if (!enableKeyboardNavigation || flatItems.length === 0) {
lastInteractionRef.current = 'filter'
return
}
})
if (activeIndex < 0 || activeIndex >= flatItems.length)
lastInteractionRef.current = 'filter'
}, [activeIndex, enableKeyboardNavigation, flatItems.length])
useEffect(() => {
if (!enableKeyboardNavigation || resolvedActiveIndex < 0)
return
if (lastInteractionRef.current !== 'keyboard')
return
const target = itemRefs.current[resolvedActiveIndex]
if (target)
target.scrollIntoView({ block: 'nearest' })
lastInteractionRef.current = null
}, [enableKeyboardNavigation, flatItems.length, resolvedActiveIndex])
const handleSelectItem = useCallback((item: { node: NodeOutPutVar, itemData: Var }) => {
const isStructureOutput = item.itemData.type === VarType.object
&& (item.itemData.children as StructuredOutput | undefined)?.schema?.properties
const isFile = item.itemData.type === VarType.file && !isStructureOutput
if (!isSupportFileVar && isFile)
return
const valueSelector = buildValueSelector({
nodeId: item.node.nodeId,
objPath: [],
itemData: item.itemData,
isFlat: item.node.isFlat,
})
onChange(valueSelector, item.itemData)
onClose?.()
}, [onChange, onClose, isSupportFileVar])
useEffect(() => {
if (!enableKeyboardNavigation)
return
const handleKeyDown = (event: KeyboardEvent) => {
const items = flatItemsRef.current
if (items.length === 0)
return
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key))
return
event.preventDefault()
event.stopPropagation()
if (event.key === 'Escape') {
onCloseRef.current?.()
return
}
if (event.key === 'Enter') {
const index = activeIndexRef.current
if (index < 0 || index >= items.length)
return
handleSelectItem(items[index])
return
}
const delta = event.key === 'ArrowDown' ? 1 : -1
const baseIndex = activeIndexRef.current < 0 ? 0 : activeIndexRef.current
const nextIndex = Math.min(Math.max(baseIndex + delta, 0), items.length - 1)
handleHighlightIndex(nextIndex, 'keyboard')
}
document.addEventListener('keydown', handleKeyDown, true)
return () => {
document.removeEventListener('keydown', handleKeyDown, true)
}
}, [enableKeyboardNavigation, handleHighlightIndex, handleSelectItem])
let runningIndex = -1
return (
<>
{
!hideSearch && (
shouldShowSearchInput && (
<>
<div className={cn('var-search-input-wrapper mx-2 mb-2 mt-2', searchBoxClassName)} onClick={e => e.stopPropagation()}>
<Input
@ -351,6 +513,25 @@ const VarReferenceVars: FC<Props> = ({
)
}
{
showAssembleVariables && (
<div className="flex items-center border-t border-divider-subtle pt-1">
<button
type="button"
className="flex h-6 w-full items-center rounded-md pl-3 pr-[18px] text-text-secondary hover:bg-state-base-hover"
onClick={handleAssembleVariables}
onMouseDown={e => e.preventDefault()}
>
<span className="mr-1 flex h-4 w-4 items-center justify-center rounded bg-util-colors-blue-blue-500">
<AssembleVariables className="h-3 w-3 text-text-primary-on-surface" />
</span>
<span className="system-xs-medium truncate" title={t('nodes.tool.assembleVariables', { ns: 'workflow' })}>
{t('nodes.tool.assembleVariables', { ns: 'workflow' })}
</span>
</button>
</div>
)
}
{filteredVars.length > 0
? (
<div className={cn('max-h-[85vh] overflow-y-auto', maxHeightClass)}>
@ -366,24 +547,35 @@ const VarReferenceVars: FC<Props> = ({
{item.title}
</div>
)}
{item.vars.map((v, j) => (
<Item
key={j}
title={item.title}
nodeId={item.nodeId}
objPath={[]}
itemData={v}
onChange={onChange}
itemWidth={itemWidth}
isSupportFileVar={isSupportFileVar}
isException={v.isException}
isLoopVar={item.isLoop}
isFlat={item.isFlat}
isInCodeGeneratorInstructionEditor={isInCodeGeneratorInstructionEditor}
zIndex={zIndex}
preferSchemaType={preferSchemaType}
/>
))}
{item.vars.map((v, j) => {
runningIndex += 1
const itemIndex = runningIndex
return (
<Item
key={j}
title={item.title}
nodeId={item.nodeId}
objPath={[]}
itemData={v}
onChange={onChange}
itemWidth={itemWidth}
isSupportFileVar={isSupportFileVar}
isException={v.isException}
isLoopVar={item.isLoop}
isFlat={item.isFlat}
isInCodeGeneratorInstructionEditor={isInCodeGeneratorInstructionEditor}
zIndex={zIndex}
preferSchemaType={preferSchemaType}
isHighlighted={enableKeyboardNavigation && itemIndex === resolvedActiveIndex}
onSetHighlight={enableKeyboardNavigation ? () => handleHighlightIndex(itemIndex, 'mouse') : undefined}
registerRef={enableKeyboardNavigation
? (element) => {
itemRefs.current[itemIndex] = element
}
: undefined}
/>
)
})}
{item.isFlat && !filteredVars[i + 1]?.isFlat && !!filteredVars.find(item => !item.isFlat) && (
<div className="relative mt-[14px] flex items-center space-x-1">
<div className="h-0 w-3 shrink-0 border border-divider-subtle"></div>
@ -404,25 +596,6 @@ const VarReferenceVars: FC<Props> = ({
/>
)
}
{
showAssembleVariables && (
<div className="flex items-center border-t border-divider-subtle pt-1">
<button
type="button"
className="flex h-6 w-full items-center rounded-md pl-3 pr-[18px] text-text-secondary hover:bg-state-base-hover"
onClick={handleAssembleVariables}
onMouseDown={e => e.preventDefault()}
>
<span className="mr-1 flex h-4 w-4 items-center justify-center rounded bg-util-colors-blue-blue-500">
<AssembleVariables className="h-3 w-3 text-text-primary-on-surface" />
</span>
<span className="system-xs-medium truncate" title={t('nodes.tool.assembleVariables', { ns: 'workflow' })}>
{t('nodes.tool.assembleVariables', { ns: 'workflow' })}
</span>
</button>
</div>
)
}
</>
)
}

View File

@ -625,7 +625,7 @@ const BasePanel: FC<BasePanelProps> = ({
</div>
<Split />
{
allowGraphActions && hasRetryNode(data.type) && (
hasRetryNode(data.type) && (
<RetryOnPanel
id={id}
data={data}
@ -633,7 +633,7 @@ const BasePanel: FC<BasePanelProps> = ({
)
}
{
allowGraphActions && hasErrorHandleNode(data.type) && (
hasErrorHandleNode(data.type) && (
<ErrorHandleOnPanel
id={id}
data={data}

View File

@ -56,17 +56,21 @@ const useConfig = (id: string, payload: CodeNodeType) => {
setInputs,
})
const [outputKeyOrders, setOutputKeyOrders] = useState<string[]>([])
const [outputKeyOrders, setOutputKeyOrders] = useState<string[]>(() => Object.keys(payload.outputs || {}))
const syncOutputKeyOrders = useCallback((outputs: OutputVar) => {
setOutputKeyOrders(Object.keys(outputs))
}, [])
useEffect(() => {
if (inputs.code) {
if (inputs.outputs && Object.keys(inputs.outputs).length > 0)
syncOutputKeyOrders(inputs.outputs)
const outputKeys = inputs.outputs ? Object.keys(inputs.outputs) : []
if (outputKeys.length > 0 && outputKeyOrders.length === 0)
syncOutputKeyOrders(inputs.outputs)
const hasExistingConfig = Boolean(inputs.code)
|| (inputs.variables?.length ?? 0) > 0
|| outputKeys.length > 0
if (hasExistingConfig)
return
}
const isReady = defaultConfig && Object.keys(defaultConfig).length > 0
if (isReady) {
@ -76,7 +80,7 @@ const useConfig = (id: string, payload: CodeNodeType) => {
})
syncOutputKeyOrders(defaultConfig.outputs)
}
}, [defaultConfig])
}, [defaultConfig, inputs.code, inputs.outputs, inputs.variables, outputKeyOrders.length, setInputs, syncOutputKeyOrders])
const handleCodeChange = useCallback((code: string) => {
const newInputs = produce(inputs, (draft) => {

View File

@ -139,20 +139,22 @@ const Panel: FC<NodePanelProps<DataSourceNodeType>> = ({ id, data }) => {
return (
<div key={outputItem.name}>
{outputItem.value?.type === 'object' ? (
<StructureOutputItem
rootClassName="code-sm-semibold text-text-secondary"
payload={wrapStructuredVarItem(outputItem, schemaType)}
/>
) : (
<VarItem
name={outputItem.name}
// eslint-disable-next-line sonarjs/no-nested-template-literals
type={`${outputItem.type.toLocaleLowerCase()}${schemaType ? ` (${schemaType})` : ''}`}
description={outputItem.description}
isIndent={hasObjectOutput}
/>
)}
{outputItem.value?.type === 'object'
? (
<StructureOutputItem
rootClassName="code-sm-semibold text-text-secondary"
payload={wrapStructuredVarItem(outputItem, schemaType)}
/>
)
: (
<VarItem
name={outputItem.name}
type={`${outputItem.type.toLocaleLowerCase()}${schemaType ? ` (${schemaType})` : ''}`}
description={outputItem.description}
isIndent={hasObjectOutput}
/>
)}
</div>
)
})}

View File

@ -100,7 +100,7 @@ const RetrievalConfig: FC<Props> = ({
score_threshold: configs.score_threshold_enabled ? (configs.score_threshold ?? DATASET_DEFAULT.score_threshold) : null,
reranking_model: retrieval_mode === RETRIEVE_TYPE.oneWay
? undefined
// eslint-disable-next-line sonarjs/no-nested-conditional
: (!configs.reranking_model?.reranking_provider_name
? undefined
: {

View File

@ -135,7 +135,7 @@ export const getMultipleRetrievalConfig = (
vector_setting: {
vector_weight: allHighQualityVectorSearch
? DEFAULT_WEIGHTED_SCORE.allHighQualityVectorSearch.semantic
// eslint-disable-next-line sonarjs/no-nested-conditional
: allHighQualityFullTextSearch
? DEFAULT_WEIGHTED_SCORE.allHighQualityFullTextSearch.semantic
: DEFAULT_WEIGHTED_SCORE.other.semantic,
@ -145,7 +145,7 @@ export const getMultipleRetrievalConfig = (
keyword_setting: {
keyword_weight: allHighQualityVectorSearch
? DEFAULT_WEIGHTED_SCORE.allHighQualityVectorSearch.keyword
// eslint-disable-next-line sonarjs/no-nested-conditional
: allHighQualityFullTextSearch
? DEFAULT_WEIGHTED_SCORE.allHighQualityFullTextSearch.keyword
: DEFAULT_WEIGHTED_SCORE.other.keyword,
@ -232,7 +232,6 @@ export const getMultipleRetrievalConfig = (
result.reranking_mode = RerankingModeEnum.RerankingModel
result.reranking_enable = true
// eslint-disable-next-line sonarjs/nested-control-flow
if ((!result.reranking_model?.provider || !result.reranking_model?.model) && isFallbackRerankModelValid) {
result.reranking_model = {
provider: fallbackRerankModel.provider || '',

View File

@ -0,0 +1 @@
export const CUSTOM_SUB_GRAPH_START_NODE = 'custom-sub-graph-start'

View File

@ -0,0 +1,60 @@
import type { NodeProps } from 'reactflow'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { AssembleVariablesAlt } from '@/app/components/base/icons/src/vender/line/general'
import { Agent } from '@/app/components/base/icons/src/vender/workflow'
import Tooltip from '@/app/components/base/tooltip'
import { NodeSourceHandle } from '@/app/components/workflow/nodes/_base/components/node-handle'
import { cn } from '@/utils/classnames'
type SubGraphStartNodeData = CommonNodeType<{
tooltip?: string
iconType?: string
}>
type IconComponent = typeof Agent
const iconMap: Record<string, IconComponent> = {
agent: Agent,
assemble: AssembleVariablesAlt,
}
const SubGraphStartNode = ({ id, data }: NodeProps<SubGraphStartNodeData>) => {
const { t } = useTranslation()
const iconType = data?.iconType || 'agent'
const Icon = iconMap[iconType] || Agent
const rawTitle = data?.title?.trim() || ''
const showTitle = iconType === 'agent' && !!rawTitle
const displayTitle = showTitle && (rawTitle.startsWith('@') ? rawTitle : `@${rawTitle}`)
const tooltip = data?.tooltip
|| (iconType === 'assemble' ? t('blocks.start', { ns: 'workflow' }) : (data?.title || t('blocks.start', { ns: 'workflow' })))
return (
<div
className={cn(
'nodrag group mt-1 flex h-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg shadow-xs',
showTitle ? 'gap-1.5 px-2' : 'w-11',
)}
>
<Tooltip popupContent={tooltip} asChild={false}>
<div className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500">
<Icon className="h-3 w-3 text-text-primary-on-surface" />
</div>
</Tooltip>
{showTitle && (
<span className="system-xs-medium max-w-[160px] truncate text-text-secondary">
{displayTitle}
</span>
)}
<NodeSourceHandle
id={id}
data={data}
handleClassName="!top-1/2 !-right-[9px] !-translate-y-1/2"
handleId="source"
/>
</div>
)
}
export default memo(SubGraphStartNode)

View File

@ -1,538 +0,0 @@
'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<string, { type: string }>) => {
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<string, { type: string }> = {}
Object.entries(outputs || {}).forEach(([key, value]) => {
next[key] = { type: value.type }
})
return next
}
const ContextGenerateModal: FC<Props> = ({
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<ContextGenerateResponse | null>(() => {
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<ContextGenerateMessage[]>(
`${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<Model>(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<HTMLDivElement>(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<HTMLDivElement>(null)
const [codePanelHeight, setCodePanelHeight] = useState(360)
const draggingRef = useRef(false)
const dragStartRef = useRef({ startY: 0, startHeight: 0 })
const handleResizeStart = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
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 (
<Modal
isShow={isShow}
onClose={onClose}
className="min-w-[1140px] !p-0"
>
<div className="relative flex h-[680px] flex-wrap">
<div className="flex h-full w-[420px] shrink-0 flex-col border-r border-divider-regular p-6">
<div className="mb-4 text-lg font-bold leading-[28px] text-text-primary">
{t('nodes.tool.contextGenerate.title', { ns: 'workflow' })}
</div>
<div className="mb-4">
<ModelParameterModal
popupClassName="!w-[520px]"
portalToFollowElemContentClassName="z-[1000]"
isAdvancedMode={true}
provider={model.provider}
completionParams={model.completion_params}
modelId={model.name}
setModel={handleModelChange}
onCompletionParamsChange={handleCompletionParamsChange}
hideDebugWithMultipleModel
/>
</div>
<div
ref={chatListRef}
className="flex-1 space-y-2 overflow-y-auto pr-1"
>
{(promptMessages || []).map((message, index) => {
const isUser = message.role === 'user'
return (
<div
key={`${message.role}-${index}`}
className={cn('flex', isUser ? 'justify-end' : 'justify-start')}
>
<div
className={cn(
'max-w-[320px] whitespace-pre-wrap rounded-2xl px-4 py-3 text-sm',
isUser
? 'bg-background-gradient-bg-fill-chat-bubble-bg-3 text-text-primary'
: 'bg-chat-bubble-bg text-text-primary',
)}
>
{message.content}
</div>
</div>
)
})}
{isGenerating && (
<div className="flex justify-start">
<div className="flex items-center gap-2 rounded-2xl bg-chat-bubble-bg px-4 py-3 text-sm text-text-primary">
<LoadingAnim type="text" />
<span>{t('nodes.tool.contextGenerate.generating', { ns: 'workflow' })}</span>
</div>
</div>
)}
</div>
<div className="mt-4 flex items-center gap-2">
<Input
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter')
handleGenerate()
}}
placeholder={t('nodes.tool.contextGenerate.inputPlaceholder', { ns: 'workflow' }) as string}
disabled={isGenerating}
/>
<Button
variant="primary"
className="shrink-0 px-3"
disabled={!inputValue.trim() || isGenerating}
onClick={handleGenerate}
>
<RiSendPlaneLine className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex h-full w-0 grow flex-col bg-background-default-subtle p-6 pb-0">
<div className="mb-3 flex shrink-0 items-center justify-between">
<div>
<div className="text-base font-semibold leading-[160%] text-text-secondary">
{t('nodes.tool.contextGenerate.codeBlock', { ns: 'workflow' })}
</div>
{versions.length > 0 && (
<VersionSelector
versionLen={versions.length}
value={currentVersionIndex || 0}
onChange={setCurrentVersionIndex}
/>
)}
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleRun}
disabled={!canRun || isGenerating || isRunning}
>
{t('nodes.tool.contextGenerate.run', { ns: 'workflow' })}
</Button>
<Button
variant="primary"
onClick={() => applyToNode(true)}
disabled={!current || isGenerating}
>
{t('nodes.tool.contextGenerate.apply', { ns: 'workflow' })}
</Button>
</div>
</div>
<div ref={rightContainerRef} className="flex h-full flex-col overflow-hidden">
{isGenerating && !displayVersion && (
<div className="flex h-full flex-col items-center justify-center space-y-3">
<Loading />
<div className="text-[13px] text-text-tertiary">
{t('nodes.tool.contextGenerate.generating', { ns: 'workflow' })}
</div>
</div>
)}
{!isGenerating && !displayVersion && (
<ResPlaceholder />
)}
{displayVersion && (
<div className="flex h-full flex-col overflow-hidden">
<div
className="flex min-h-[220px] flex-col overflow-hidden rounded-lg border border-components-panel-border bg-components-panel-bg"
style={{ height: codePanelHeight }}
>
<div className="px-3 pb-1 pt-2 text-xs font-semibold uppercase text-text-tertiary">
{t('nodes.tool.contextGenerate.code', { ns: 'workflow' })}
</div>
<div className="flex-1 overflow-hidden px-3 pb-3">
<CodeEditor
noWrapper
isExpand
readOnly
language={displayCodeLanguage}
value={displayVersion.code || ''}
className="h-full"
/>
</div>
</div>
<div
className="flex h-[6px] cursor-row-resize items-center justify-center"
onMouseDown={handleResizeStart}
>
<div className="h-1 w-8 rounded-full bg-divider-subtle" />
</div>
<div className="flex min-h-[160px] flex-1 flex-col overflow-hidden rounded-lg border border-components-panel-border bg-components-panel-bg">
<div className="px-3 pb-1 pt-2 text-xs font-semibold uppercase text-text-tertiary">
{t('nodes.tool.contextGenerate.output', { ns: 'workflow' })}
</div>
<div className="flex-1 overflow-hidden px-3 pb-3">
<CodeEditor
noWrapper
isExpand
readOnly
isJSONStringifyBeauty
language={CodeLanguage.json}
value={displayOutputData}
className="h-full"
/>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</Modal>
)
}
export default React.memo(ContextGenerateModal)

View File

@ -1,38 +0,0 @@
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<ContextGenerateResponse[]>(`${keyPrefix}${storageKey}-versions`, {
defaultValue: [],
})
const [currentVersionIndex, setCurrentVersionIndex] = useSessionStorageState<number>(`${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

View File

@ -2,6 +2,7 @@ import type { FC } from 'react'
import { RiCloseLine, RiEqualizer2Line } from '@remixicon/react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { AssembleVariables } from '@/app/components/base/icons/src/vender/line/general'
import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle'
import { Agent } from '@/app/components/base/icons/src/vender/workflow'
import { cn } from '@/utils/classnames'
@ -34,8 +35,11 @@ const AgentHeaderBar: FC<AgentHeaderBarProps> = ({
: 'border-components-panel-border-subtle bg-components-badge-white-to-dark',
)}
>
<div className="flex h-4 w-4 items-center justify-center rounded bg-util-colors-indigo-indigo-500">
<Agent className="h-3 w-3 text-text-primary-on-surface" />
<div className={cn('flex h-4 w-4 items-center justify-center rounded', showAtPrefix
? 'bg-util-colors-indigo-indigo-500'
: 'bg-util-colors-blue-blue-500')}
>
{showAtPrefix ? <Agent className="h-3 w-3 text-text-primary-on-surface" /> : <AssembleVariables className="h-3 w-3 text-text-primary-on-surface" />}
</div>
<span className="system-xs-medium text-text-secondary">
{showAtPrefix && '@'}

View File

@ -26,20 +26,26 @@ import { VarKindType as VarKindTypeEnum } from '@/app/components/workflow/nodes/
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { useStore } from '@/app/components/workflow/store'
import { BlockEnum, EditionType, isPromptMessageContext, PromptRole, VarType } from '@/app/components/workflow/types'
import { generateNewNode, getNodeCustomTypeByNodeDataType } from '@/app/components/workflow/utils'
import { generateNewNode, getNodeCustomTypeByNodeDataType, mergeNodeDefaultData } from '@/app/components/workflow/utils'
import { useGetLanguage } from '@/context/i18n'
import { useStrategyProviders } from '@/service/use-strategy'
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@}}
* Example: {{@agent-123.context@}} -> captures "agent-123"
* Example: {{@agent-123.context@}}
*/
const AGENT_CONTEXT_VAR_PATTERN = /\{\{@([^.@#]+)\.context@\}\}/g
const AGENT_CONTEXT_VAR_PATTERN = /\{\{@[^.@#]+\.context@\}\}/g
const AGENT_CONTEXT_VAR_PREFIX = '{{@'
const AGENT_CONTEXT_VAR_SUFFIX = '.context@}}'
const getAgentNodeIdFromContextVar = (placeholder: string) => {
if (!placeholder.startsWith(AGENT_CONTEXT_VAR_PREFIX) || !placeholder.endsWith(AGENT_CONTEXT_VAR_SUFFIX))
return ''
return placeholder.slice(AGENT_CONTEXT_VAR_PREFIX.length, -AGENT_CONTEXT_VAR_SUFFIX.length)
}
const buildAssemblePlaceholder = (toolNodeId?: string, paramKey?: string) => {
if (!toolNodeId || !paramKey)
@ -163,10 +169,10 @@ const MixedVariableTextInput = ({
const nodes = useNodes<CommonNodeType>()
const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey)
const setControlPromptEditorRerenderKey = useStore(s => s.setControlPromptEditorRerenderKey)
const nodesDefaultConfigs = useStore(s => s.nodesDefaultConfigs)
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) => {
@ -214,8 +220,9 @@ const MixedVariableTextInput = ({
}) => {
if (!toolNodeId)
return null
const defaultValue = nodesMetaDataMap?.[payload.nodeType]?.defaultValue as Partial<LLMNodeType | CodeNodeType> | undefined
if (!defaultValue)
const metaDefault = nodesMetaDataMap?.[payload.nodeType]?.defaultValue as Partial<LLMNodeType | CodeNodeType> | undefined
const appDefault = nodesDefaultConfigs?.[payload.nodeType] as Partial<LLMNodeType | CodeNodeType> | undefined
if (!metaDefault && !appDefault)
return null
const { getNodes, setNodes } = reactFlowStore.getState()
@ -226,15 +233,22 @@ const MixedVariableTextInput = ({
const nextNodes = shouldReplace
? currentNodes.filter(node => node.id !== payload.extractorNodeId)
: currentNodes
const mergedData = mergeNodeDefaultData({
nodeType: payload.nodeType,
metaDefault,
appDefault,
overrideData: payload.data,
})
const resolvedTitle = mergedData.title ?? metaDefault?.title ?? appDefault?.title ?? ''
const resolvedDesc = mergedData.desc ?? metaDefault?.desc ?? appDefault?.desc ?? ''
const { newNode } = generateNewNode({
id: payload.extractorNodeId,
type: getNodeCustomTypeByNodeDataType(payload.nodeType),
data: {
...defaultValue,
...payload.data,
...mergedData,
type: payload.nodeType,
title: defaultValue?.title ?? '',
desc: defaultValue.desc || '',
title: resolvedTitle,
desc: resolvedDesc,
parent_node_id: toolNodeId,
},
position: {
@ -249,7 +263,7 @@ const MixedVariableTextInput = ({
}
return existingNode
}, [handleSyncWorkflowDraft, nodesMetaDataMap, reactFlowStore, toolNodeId])
}, [handleSyncWorkflowDraft, nodesDefaultConfigs, nodesMetaDataMap, reactFlowStore, toolNodeId])
const ensureAssembleExtractorNode = useCallback(() => {
if (!assembleExtractorNodeId)
@ -311,8 +325,9 @@ const MixedVariableTextInput = ({
const matches = text.matchAll(AGENT_CONTEXT_VAR_PATTERN)
for (const match of matches) {
const variablePath = match[1]
const nodeId = variablePath.split('.')[0]
const nodeId = getAgentNodeIdFromContextVar(match[0])
if (!nodeId)
continue
const node = nodesByIdMap[nodeId]
if (node && contextNodeIds.has(nodeId)) {
return {
@ -328,6 +343,14 @@ const MixedVariableTextInput = ({
return detectAgentFromText(value)
}, [detectAgentFromText, value])
// Check if value only contains agent context variable without other user input
const isOnlyAgentContext = useMemo(() => {
if (!detectedAgentFromValue || !value)
return false
const valueWithoutAgentContext = value.replace(AGENT_CONTEXT_VAR_PATTERN, '').trim()
return valueWithoutAgentContext === ''
}, [detectedAgentFromValue, value])
const agentNodes = useMemo(() => {
if (!contextNodeIds.size)
return []
@ -463,8 +486,8 @@ const MixedVariableTextInput = ({
if (!agentNodeId || !onChange)
return
const valueWithoutAgentVars = value.replace(AGENT_CONTEXT_VAR_PATTERN, (match, variablePath) => {
const nodeId = variablePath.split('.')[0]
const valueWithoutAgentVars = value.replace(AGENT_CONTEXT_VAR_PATTERN, (match) => {
const nodeId = getAgentNodeIdFromContextVar(match)
return nodeId === agentNodeId ? '' : match
})
@ -477,7 +500,8 @@ const MixedVariableTextInput = ({
if (!onChange)
return
const valueWithoutTrigger = value.replace(/@$/, '')
// compute words after the latest '@' and delete them
const valueWithoutTrigger = value.replace(/@[^@\n]*$/, '')
const newValue = `{{@${agent.id}.context@}}${valueWithoutTrigger}`
if (toolNodeId && paramKey) {
@ -519,7 +543,6 @@ const MixedVariableTextInput = ({
ensureAssembleExtractorNode()
onChange?.(assemblePlaceholder, VarKindTypeEnum.mixed, null)
setControlPromptEditorRerenderKey(Date.now())
setIsContextGenerateModalOpen(true)
return [extractorNodeId, 'result']
}, [assembleExtractorNodeId, assemblePlaceholder, ensureAssembleExtractorNode, onChange, paramKey, setControlPromptEditorRerenderKey, toolNodeId])
@ -540,10 +563,6 @@ const MixedVariableTextInput = ({
setIsSubGraphModalOpen(false)
}, [])
const handleCloseContextGenerateModal = useCallback(() => {
setIsContextGenerateModalOpen(false)
}, [])
const sourceVariable: ValueSelector | undefined = detectedAgentFromValue
? [detectedAgentFromValue.nodeId, 'context']
: undefined
@ -559,6 +578,7 @@ const MixedVariableTextInput = ({
<AgentHeaderBar
agentName={t('nodes.tool.assembleVariables', { ns: 'workflow' })}
onRemove={handleAssembleRemove}
onViewInternals={handleOpenSubGraphModal}
hasWarning={hasAssembleWarning}
showAtPrefix={false}
/>
@ -572,44 +592,64 @@ const MixedVariableTextInput = ({
/>
)}
{!isAssembleValue && (
<PromptEditor
key={controlPromptEditorRerenderKey}
wrapperClassName="min-h-8 px-2 py-1"
className="caret:text-text-accent"
editable={!readOnly}
value={value}
workflowVariableBlock={{
show: !disableVariableInsertion,
variables: nodesOutputVars || [],
workflowNodesMap,
showManageInputField,
onManageInputField,
showAssembleVariables: !disableVariableInsertion && !!toolNodeId && !!paramKey,
onAssembleVariables: handleAssembleSelect,
}}
agentBlock={{
show: agentNodes.length > 0 && !detectedAgentFromValue,
agentNodes,
onSelect: handleAgentSelect,
}}
placeholder={<Placeholder disableVariableInsertion={disableVariableInsertion} hasSelectedAgent={!!detectedAgentFromValue} />}
onChange={(text) => {
const hasPlaceholder = new RegExp(AGENT_CONTEXT_VAR_PATTERN.source).test(text)
if (hasPlaceholder)
syncExtractorPromptFromText(text)
if (detectedAgentFromValue && !hasPlaceholder) {
removeExtractorNode()
onChange?.(text, VarKindTypeEnum.mixed, null)
return
}
onChange?.(text)
}}
/>
<div className="relative">
<PromptEditor
key={controlPromptEditorRerenderKey}
wrapperClassName="min-h-8 px-2 py-1"
className="caret:text-text-accent"
editable={!readOnly}
value={value}
workflowVariableBlock={{
show: !disableVariableInsertion,
variables: nodesOutputVars || [],
workflowNodesMap,
showManageInputField,
onManageInputField,
showAssembleVariables: !disableVariableInsertion && !!toolNodeId && !!paramKey,
onAssembleVariables: handleAssembleSelect,
}}
agentBlock={{
show: agentNodes.length > 0 && !detectedAgentFromValue,
agentNodes,
onSelect: handleAgentSelect,
}}
placeholder={<Placeholder disableVariableInsertion={disableVariableInsertion} hasSelectedAgent={!!detectedAgentFromValue} />}
onChange={(text) => {
const hasPlaceholder = new RegExp(AGENT_CONTEXT_VAR_PATTERN.source).test(text)
if (hasPlaceholder)
syncExtractorPromptFromText(text)
if (detectedAgentFromValue && !hasPlaceholder) {
removeExtractorNode()
onChange?.(text, VarKindTypeEnum.mixed, null)
return
}
onChange?.(text)
}}
/>
{isOnlyAgentContext && paramKey && (
<div className="pointer-events-none absolute left-0 top-0 flex h-full w-full items-center px-2 py-1">
<span className="system-sm-regular text-components-input-text-placeholder">
{t('nodes.tool.agentPlaceholder', { ns: 'workflow', paramKey })}
</span>
</div>
)}
</div>
)}
{toolNodeId && detectedAgentFromValue && sourceVariable && (
{toolNodeId && paramKey && isAssembleValue && (
<SubGraphModal
isOpen={isSubGraphModalOpen}
onClose={handleCloseSubGraphModal}
variant="assemble"
toolNodeId={toolNodeId}
paramKey={paramKey}
title={t('nodes.tool.assembleVariables', { ns: 'workflow' })}
/>
)}
{toolNodeId && paramKey && !isAssembleValue && detectedAgentFromValue && sourceVariable && (
<SubGraphModal
isOpen={isSubGraphModalOpen}
onClose={handleCloseSubGraphModal}
variant="agent"
toolNodeId={toolNodeId}
paramKey={paramKey}
sourceVariable={sourceVariable}
@ -617,15 +657,6 @@ const MixedVariableTextInput = ({
agentNodeId={detectedAgentFromValue.nodeId}
/>
)}
{toolNodeId && paramKey && (
<ContextGenerateModal
isShow={isContextGenerateModalOpen}
onClose={handleCloseContextGenerateModal}
toolNodeId={toolNodeId}
paramKey={paramKey}
codeNodeId={assembleExtractorNodeId || `${toolNodeId}_ext_${paramKey}`}
/>
)}
</div>
)
}

View File

@ -2,6 +2,7 @@
import type { FC } from 'react'
import type { SubGraphModalProps } from './types'
import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types'
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types'
import type { Node, PromptItem, PromptTemplateItem } from '@/app/components/workflow/types'
@ -11,24 +12,29 @@ import { noop } from 'es-toolkit/function'
import { Fragment, memo, useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
import { AssembleVariablesAlt } from '@/app/components/base/icons/src/vender/line/general'
import { Agent } from '@/app/components/base/icons/src/vender/workflow'
import { useIsChatMode, useNodesSyncDraft, useWorkflow, useWorkflowVariables } from '@/app/components/workflow/hooks'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import { VarKindType } from '@/app/components/workflow/nodes/_base/types'
import { useStore as useWorkflowStore } from '@/app/components/workflow/store'
import { BlockEnum, EditionType, isPromptMessageContext, PromptRole } from '@/app/components/workflow/types'
import { BlockEnum, EditionType, isPromptMessageContext, PromptRole, VarType } from '@/app/components/workflow/types'
import SubGraphCanvas from './sub-graph-canvas'
const SubGraphModal: FC<SubGraphModalProps> = ({
isOpen,
onClose,
toolNodeId,
paramKey,
sourceVariable,
agentName,
agentNodeId,
}) => {
const SubGraphModal: FC<SubGraphModalProps> = (props) => {
const { t } = useTranslation()
const { isOpen, onClose, variant, toolNodeId, paramKey } = props
const isAgentVariant = variant === 'agent'
const resolvedAgentNodeId = isAgentVariant ? props.agentNodeId : ''
const agentName = isAgentVariant ? props.agentName : ''
const assembleTitle = !isAgentVariant ? props.title : ''
const modalTitle = useMemo(() => {
const baseTitle = isAgentVariant
? agentName
: (assembleTitle || t('nodes.tool.assembleVariables', { ns: 'workflow' }))
const prefix = isAgentVariant && baseTitle ? '@' : ''
return `${prefix}${baseTitle} ${t('subGraphModal.title', { ns: 'workflow' })}`.trim()
}, [agentName, assembleTitle, isAgentVariant, t])
const reactflowStore = useStoreApi()
const workflowNodes = useWorkflowStore(state => state.nodes)
const workflowEdges = useReactFlowStore(state => state.edges)
@ -41,13 +47,16 @@ const SubGraphModal: FC<SubGraphModalProps> = ({
const extractorNodeId = `${toolNodeId}_ext_${paramKey}`
const extractorNode = useMemo(() => {
return workflowNodes.find(node => node.id === extractorNodeId) as Node<LLMNodeType> | undefined
return workflowNodes.find(node => node.id === extractorNodeId) as Node<LLMNodeType | CodeNodeType> | undefined
}, [extractorNodeId, workflowNodes])
const toolNode = useMemo(() => {
return workflowNodes.find(node => node.id === toolNodeId)
}, [toolNodeId, workflowNodes])
const toolParam = (toolNode?.data as ToolNodeType | undefined)?.tool_parameters?.[paramKey]
const toolParamValue = toolParam?.value as string | undefined
const assemblePlaceholder = useMemo(() => {
return `{{#${toolNodeId}_ext_${paramKey}.result#}}`
}, [paramKey, toolNodeId])
const parentBeforeNodes = useMemo(() => {
if (!isOpen)
@ -56,25 +65,28 @@ const SubGraphModal: FC<SubGraphModalProps> = ({
}, [getBeforeNodesInSameBranch, isOpen, toolNodeId, workflowEdges, workflowNodes])
const parentContextNodes = useMemo(() => {
if (!parentBeforeNodes.length)
if (!parentBeforeNodes.length || !isAgentVariant)
return []
return parentBeforeNodes.filter(node => node.data.type === BlockEnum.Agent || node.data.type === BlockEnum.LLM)
}, [parentBeforeNodes])
}, [isAgentVariant, parentBeforeNodes])
const parentContextNodeIds = useMemo(() => {
return parentContextNodes.map(node => node.id)
}, [parentContextNodes])
const parentAvailableNodes = useMemo(() => {
if (!isOpen)
return []
return isAgentVariant ? parentContextNodes : parentBeforeNodes
}, [isAgentVariant, isOpen, parentBeforeNodes, parentContextNodes])
const parentAvailableVars = useMemo(() => {
if (!parentContextNodeIds.length)
if (!parentAvailableNodes.length)
return []
const vars = getNodeAvailableVars({
beforeNodes: parentContextNodes,
beforeNodes: parentAvailableNodes,
isChatMode,
filterVar: () => true,
})
return vars.filter(nodeVar => parentContextNodeIds.includes(nodeVar.nodeId))
}, [getNodeAvailableVars, isChatMode, parentContextNodeIds, parentContextNodes])
const availableNodeIds = new Set(parentAvailableNodes.map(node => node.id))
return vars.filter(nodeVar => availableNodeIds.has(nodeVar.nodeId))
}, [getNodeAvailableVars, isChatMode, parentAvailableNodes])
const mentionConfig = useMemo<MentionConfig>(() => {
const current = toolParam?.mention_config
@ -91,6 +103,9 @@ const SubGraphModal: FC<SubGraphModalProps> = ({
}, [extractorNodeId, paramKey, toolParam?.mention_config])
const handleMentionConfigChange = useCallback((config: MentionConfig) => {
if (!isAgentVariant)
return
const { getNodes, setNodes } = reactflowStore.getState()
const nextNodes = getNodes().map((node) => {
if (node.id !== toolNodeId)
@ -118,10 +133,10 @@ const SubGraphModal: FC<SubGraphModalProps> = ({
})
setNodes(nextNodes)
handleSyncWorkflowDraft()
}, [handleSyncWorkflowDraft, paramKey, reactflowStore, toolNodeId])
}, [handleSyncWorkflowDraft, isAgentVariant, paramKey, reactflowStore, toolNodeId])
useEffect(() => {
if (!toolParam || (toolParam.type && toolParam.type !== VarKindType.mention))
if (!isAgentVariant || !toolParam || (toolParam.type && toolParam.type !== VarKindType.mention))
return
const current = toolParam.mention_config
@ -132,7 +147,7 @@ const SubGraphModal: FC<SubGraphModalProps> = ({
if (needsExtractor || needsNullStrategy || needsOutputSelector || needsDefaultValue)
handleMentionConfigChange(mentionConfig)
}, [handleMentionConfigChange, mentionConfig, toolParam])
}, [handleMentionConfigChange, isAgentVariant, mentionConfig, toolParam])
const getUserPromptText = useCallback((promptTemplate?: PromptTemplateItem[] | PromptItem) => {
if (!promptTemplate)
@ -156,23 +171,46 @@ const SubGraphModal: FC<SubGraphModalProps> = ({
// TODO: handle external workflow updates while sub-graph modal is open.
const handleSave = useCallback((subGraphNodes: Node[]) => {
const extractorNodeData = subGraphNodes.find(node => node.id === extractorNodeId) as Node<LLMNodeType> | undefined
const extractorNodeData = subGraphNodes.find(node => node.id === extractorNodeId) as Node<LLMNodeType | CodeNodeType> | undefined
if (!extractorNodeData)
return
const userPromptText = getUserPromptText(extractorNodeData.data?.prompt_template)
const placeholder = `{{@${agentNodeId}.context@}}`
const nextValue = `${placeholder}${userPromptText}`
const ensureAssembleOutputs = (payload: CodeNodeType) => {
const outputs = payload.outputs || {}
if (outputs.result)
return payload
return {
...payload,
outputs: {
...outputs,
result: {
type: VarType.string,
children: null,
},
},
}
}
const userPromptText = isAgentVariant
? getUserPromptText((extractorNodeData.data as LLMNodeType).prompt_template)
: ''
const placeholder = isAgentVariant && resolvedAgentNodeId ? `{{@${resolvedAgentNodeId}.context@}}` : ''
const nextValue = isAgentVariant
? `${placeholder}${userPromptText}`
: assemblePlaceholder
const { getNodes, setNodes } = reactflowStore.getState()
const nextNodes = getNodes().map((node) => {
if (node.id === extractorNodeId) {
const nextData = isAgentVariant
? extractorNodeData.data
: ensureAssembleOutputs(extractorNodeData.data as CodeNodeType)
return {
...node,
hidden: true,
data: {
...node.data,
...extractorNodeData.data,
...nextData,
parent_node_id: toolNodeId,
},
}
@ -200,7 +238,7 @@ const SubGraphModal: FC<SubGraphModalProps> = ({
})
setNodes(nextNodes)
setControlPromptEditorRerenderKey(Date.now())
}, [agentNodeId, extractorNodeId, getUserPromptText, paramKey, reactflowStore, setControlPromptEditorRerenderKey, toolNodeId])
}, [assemblePlaceholder, extractorNodeId, getUserPromptText, isAgentVariant, paramKey, reactflowStore, resolvedAgentNodeId, setControlPromptEditorRerenderKey, toolNodeId])
return (
<Transition appear show={isOpen} as={Fragment}>
@ -215,13 +253,12 @@ const SubGraphModal: FC<SubGraphModalProps> = ({
<div className="flex h-14 shrink-0 items-center justify-between border-b border-divider-subtle px-4">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded bg-util-colors-indigo-indigo-500">
<Agent className="h-4 w-4 text-text-primary-on-surface" />
{isAgentVariant
? <Agent className="h-4 w-4 text-text-primary-on-surface" />
: <AssembleVariablesAlt className="h-4 w-4 text-text-primary-on-surface" />}
</div>
<span className="system-md-semibold text-text-primary">
@
{agentName}
{' '}
{t('subGraphModal.title', { ns: 'workflow' })}
{modalTitle}
</span>
</div>
<button
@ -234,22 +271,41 @@ const SubGraphModal: FC<SubGraphModalProps> = ({
</div>
<div className="bg-workflow-canvas-wrapper relative flex-1 overflow-hidden">
<SubGraphCanvas
toolNodeId={toolNodeId}
paramKey={paramKey}
sourceVariable={sourceVariable}
agentNodeId={agentNodeId}
agentName={agentName}
configsMap={configsMap}
mentionConfig={mentionConfig}
onMentionConfigChange={handleMentionConfigChange}
extractorNode={extractorNode}
toolParamValue={toolParamValue}
parentAvailableNodes={parentContextNodes}
parentAvailableVars={parentAvailableVars}
onSave={handleSave}
onSyncWorkflowDraft={doSyncWorkflowDraft}
/>
{variant === 'agent'
? (
<SubGraphCanvas
variant="agent"
toolNodeId={toolNodeId}
paramKey={paramKey}
sourceVariable={props.sourceVariable}
agentNodeId={props.agentNodeId}
agentName={props.agentName}
configsMap={configsMap}
mentionConfig={mentionConfig}
onMentionConfigChange={handleMentionConfigChange}
extractorNode={extractorNode as Node<LLMNodeType> | undefined}
toolParamValue={toolParamValue}
parentAvailableNodes={parentAvailableNodes}
parentAvailableVars={parentAvailableVars}
onSave={handleSave}
onSyncWorkflowDraft={doSyncWorkflowDraft}
/>
)
: (
<SubGraphCanvas
variant="assemble"
toolNodeId={toolNodeId}
paramKey={paramKey}
title={props.title}
configsMap={configsMap}
extractorNode={extractorNode as Node<CodeNodeType> | undefined}
toolParamValue={toolParamValue}
parentAvailableNodes={parentAvailableNodes}
parentAvailableVars={parentAvailableVars}
onSave={handleSave}
onSyncWorkflowDraft={doSyncWorkflowDraft}
/>
)}
</div>
</DialogPanel>
</TransitionChild>

View File

@ -4,40 +4,10 @@ import type { SubGraphCanvasProps } from './types'
import { memo } from 'react'
import SubGraph from '@/app/components/sub-graph'
const SubGraphCanvas: FC<SubGraphCanvasProps> = ({
toolNodeId,
paramKey,
sourceVariable,
agentNodeId,
agentName,
configsMap,
mentionConfig,
onMentionConfigChange,
extractorNode,
toolParamValue,
parentAvailableNodes,
parentAvailableVars,
onSave,
onSyncWorkflowDraft,
}) => {
const SubGraphCanvas: FC<SubGraphCanvasProps> = (props) => {
return (
<div className="h-full w-full">
<SubGraph
toolNodeId={toolNodeId}
paramKey={paramKey}
sourceVariable={sourceVariable}
agentNodeId={agentNodeId}
agentName={agentName}
configsMap={configsMap}
mentionConfig={mentionConfig}
onMentionConfigChange={onMentionConfigChange}
extractorNode={extractorNode}
toolParamValue={toolParamValue}
parentAvailableNodes={parentAvailableNodes}
parentAvailableVars={parentAvailableVars}
onSave={onSave}
onSyncWorkflowDraft={onSyncWorkflowDraft}
/>
<SubGraph {...props} />
</div>
)
}

View File

@ -1,34 +1,25 @@
import type { SyncWorkflowDraft } from '@/app/components/sub-graph/types'
import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store'
import type { MentionConfig } from '@/app/components/workflow/nodes/_base/types'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { NodeOutPutVar, Edge as WorkflowEdge, Node as WorkflowNode } from '@/app/components/workflow/types'
import type { SubGraphProps } from '@/app/components/sub-graph/types'
import type { ValueSelector } from '@/app/components/workflow/types'
type WorkflowValueSelector = string[]
export type SubGraphModalProps = {
type BaseSubGraphModalProps = {
isOpen: boolean
onClose: () => void
toolNodeId: string
paramKey: string
sourceVariable: WorkflowValueSelector
}
type AgentSubGraphModalProps = BaseSubGraphModalProps & {
variant: 'agent'
sourceVariable: ValueSelector
agentName: string
agentNodeId: string
}
export type SubGraphCanvasProps = {
toolNodeId: string
paramKey: string
sourceVariable: WorkflowValueSelector
agentNodeId: string
agentName: string
configsMap?: HooksStoreShape['configsMap']
mentionConfig: MentionConfig
onMentionConfigChange: (config: MentionConfig) => void
extractorNode?: WorkflowNode<LLMNodeType>
toolParamValue?: string
parentAvailableNodes?: WorkflowNode[]
parentAvailableVars?: NodeOutPutVar[]
onSave?: (nodes: WorkflowNode[], edges: WorkflowEdge[]) => void
onSyncWorkflowDraft?: SyncWorkflowDraft
type AssembleSubGraphModalProps = BaseSubGraphModalProps & {
variant: 'assemble'
title: string
}
export type SubGraphModalProps = AgentSubGraphModalProps | AssembleSubGraphModalProps
export type SubGraphCanvasProps = SubGraphProps

View File

@ -20,7 +20,14 @@ import { useStrategyProviders } from '@/service/use-strategy'
import { cn } from '@/utils/classnames'
import { VarType } from './types'
const AGENT_CONTEXT_VAR_PATTERN = /\{\{@([^.@#]+)\.context@\}\}/g
const AGENT_CONTEXT_VAR_PATTERN = /\{\{@[^.@#]+\.context@\}\}/g
const AGENT_CONTEXT_VAR_PREFIX = '{{@'
const AGENT_CONTEXT_VAR_SUFFIX = '.context@}}'
const getAgentNodeIdFromContextVar = (placeholder: string) => {
if (!placeholder.startsWith(AGENT_CONTEXT_VAR_PREFIX) || !placeholder.endsWith(AGENT_CONTEXT_VAR_SUFFIX))
return ''
return placeholder.slice(AGENT_CONTEXT_VAR_PREFIX.length, -AGENT_CONTEXT_VAR_SUFFIX.length)
}
type AgentCheckValidContext = {
provider?: StrategyPluginDetail
strategy?: StrategyDetail
@ -80,7 +87,7 @@ const Node: FC<NodeProps<ToolNodeType>> = ({
return
const matches = value.matchAll(AGENT_CONTEXT_VAR_PATTERN)
for (const match of matches) {
const agentNodeId = match[1]
const agentNodeId = getAgentNodeIdFromContextVar(match[0])
if (!agentNodeId)
continue
const entryKey = `${paramKey}:${agentNodeId}`

View File

@ -124,20 +124,22 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({
// TODO empty object type always match `qa_structured` schema type
return (
<div key={outputItem.name}>
{outputItem.value?.type === 'object' ? (
<StructureOutputItem
rootClassName="code-sm-semibold text-text-secondary"
payload={wrapStructuredVarItem(outputItem, schemaType)}
/>
) : (
<VarItem
name={outputItem.name}
// eslint-disable-next-line sonarjs/no-nested-template-literals
type={`${outputItem.type.toLocaleLowerCase()}${schemaType ? ` (${schemaType})` : ''}`}
description={outputItem.description}
isIndent={hasObjectOutput}
/>
)}
{outputItem.value?.type === 'object'
? (
<StructureOutputItem
rootClassName="code-sm-semibold text-text-secondary"
payload={wrapStructuredVarItem(outputItem, schemaType)}
/>
)
: (
<VarItem
name={outputItem.name}
type={`${outputItem.type.toLocaleLowerCase()}${schemaType ? ` (${schemaType})` : ''}`}
description={outputItem.description}
isIndent={hasObjectOutput}
/>
)}
</div>
)
})}

View File

@ -18,5 +18,4 @@ export function getSelectedNode(
return $isAtNodeEnd(anchor) ? anchorNode : focusNode
}
// eslint-disable-next-line sonarjs/empty-string-repetition
export const urlRegExp = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-]*)?\??[-+=&;%@.\w]*#?\w*)?)/

View File

@ -13,6 +13,7 @@ import {
} from '@/app/components/workflow/constants'
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
import { CUSTOM_SUB_GRAPH_START_NODE } from '@/app/components/workflow/nodes/sub-graph-start/constants'
import {
BlockEnum,
} from '@/app/components/workflow/types'
@ -442,6 +443,7 @@ const normaliseChildLayout = (
const startNode = nodes.find(node =>
node.type === CUSTOM_ITERATION_START_NODE
|| node.type === CUSTOM_LOOP_START_NODE
|| node.type === CUSTOM_SUB_GRAPH_START_NODE
|| node.data?.type === BlockEnum.LoopStart
|| node.data?.type === BlockEnum.IterationStart,
)

View File

@ -1,6 +1,8 @@
import type { CodeNodeType, OutputVar } from '../nodes/code/types'
import type { IterationNodeType } from '../nodes/iteration/types'
import type { LoopNodeType } from '../nodes/loop/types'
import type {
CommonNodeType,
Node,
} from '../types'
import {
@ -20,6 +22,60 @@ import {
BlockEnum,
} from '../types'
type MergeNodeDefaultDataParams<T extends CommonNodeType<Record<string, unknown>>> = {
nodeType: BlockEnum
metaDefault?: Partial<T>
appDefault?: Partial<T>
baseData?: Partial<T>
overrideData?: Partial<T>
}
const pickNonEmptyArray = <T>(value?: T[]) => {
return Array.isArray(value) && value.length > 0 ? value : undefined
}
export const mergeNodeDefaultData = <T extends CommonNodeType<Record<string, unknown>>>({
nodeType,
metaDefault,
appDefault,
baseData,
overrideData,
}: MergeNodeDefaultDataParams<T>) => {
const merged = {
...(metaDefault || {}),
...(appDefault || {}),
...(baseData || {}),
...(overrideData || {}),
} as Partial<T>
if (nodeType === BlockEnum.Code) {
const codeMetaDefault = (metaDefault || {}) as Partial<CodeNodeType>
const codeAppDefault = (appDefault || {}) as Partial<CodeNodeType>
const codeBase = (baseData || {}) as Partial<CodeNodeType>
const codeOverride = (overrideData || {}) as Partial<CodeNodeType>
const codeDefaults = {
...codeMetaDefault,
...codeAppDefault,
}
const outputs: OutputVar = {
...(codeDefaults.outputs || {}),
...(codeBase.outputs || {}),
...(codeOverride.outputs || {}),
}
if (Object.keys(outputs).length > 0)
(merged as Partial<CodeNodeType>).outputs = outputs
const resolvedVariables = pickNonEmptyArray(codeBase.variables)
?? pickNonEmptyArray(codeOverride.variables)
?? pickNonEmptyArray(codeDefaults.variables)
if (resolvedVariables)
(merged as Partial<CodeNodeType>).variables = resolvedVariables
}
return merged
}
export function generateNewNode<T = {}>({ data, position, id, zIndex, type, ...rest }: Omit<Node<T>, 'id'> & { id?: string }): {
newNode: Node
newIterationStartNode?: Node

View File

@ -0,0 +1,60 @@
import type { NodeProps } from 'reactflow'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { AssembleVariablesAlt } from '@/app/components/base/icons/src/vender/line/general'
import { Agent } from '@/app/components/base/icons/src/vender/workflow'
import Tooltip from '@/app/components/base/tooltip'
import { cn } from '@/utils/classnames'
import { NodeSourceHandle } from '../../node-handle'
type SubGraphStartNodeData = CommonNodeType<{
tooltip?: string
iconType?: string
}>
type IconComponent = typeof Agent
const iconMap: Record<string, IconComponent> = {
agent: Agent,
assemble: AssembleVariablesAlt,
}
const SubGraphStartNode = ({ id, data }: NodeProps<SubGraphStartNodeData>) => {
const { t } = useTranslation()
const iconType = data?.iconType || 'agent'
const Icon = iconMap[iconType] || Agent
const rawTitle = data?.title?.trim() || ''
const showTitle = iconType === 'agent' && !!rawTitle
const displayTitle = showTitle && (rawTitle.startsWith('@') ? rawTitle : `@${rawTitle}`)
const tooltip = data?.tooltip
|| (iconType === 'assemble' ? t('blocks.start', { ns: 'workflow' }) : (data?.title || t('blocks.start', { ns: 'workflow' })))
return (
<div
className={cn(
'nodrag group mt-1 flex h-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg shadow-xs',
showTitle ? 'gap-1.5 px-2' : 'w-11',
)}
>
<Tooltip popupContent={tooltip} asChild={false}>
<div className="flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500">
<Icon className="h-3 w-3 text-text-primary-on-surface" />
</div>
</Tooltip>
{showTitle && (
<span className="system-xs-medium max-w-[160px] truncate text-text-secondary">
{displayTitle}
</span>
)}
<NodeSourceHandle
id={id}
data={data}
handleClassName="!top-1/2 !-right-[9px] !-translate-y-1/2"
handleId="source"
/>
</div>
)
}
export default memo(SubGraphStartNode)

View File

@ -29,6 +29,7 @@ import {
import CustomConnectionLine from '@/app/components/workflow/custom-connection-line'
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
import { CUSTOM_SUB_GRAPH_START_NODE } from '@/app/components/workflow/nodes/sub-graph-start/constants'
import { CUSTOM_NOTE_NODE } from '@/app/components/workflow/note-node/constants'
import { CUSTOM_SIMPLE_NODE } from '@/app/components/workflow/simple-node/constants'
import {
@ -40,6 +41,7 @@ import CustomEdge from './components/custom-edge'
import CustomNode from './components/nodes'
import IterationStartNode from './components/nodes/iteration-start'
import LoopStartNode from './components/nodes/loop-start'
import SubGraphStartNode from './components/nodes/sub-graph-start'
import CustomNoteNode from './components/note-node'
import ZoomInOut from './components/zoom-in-out'
import 'reactflow/dist/style.css'
@ -49,6 +51,7 @@ const nodeTypes = {
[CUSTOM_NODE]: CustomNode,
[CUSTOM_NOTE_NODE]: CustomNoteNode,
[CUSTOM_SIMPLE_NODE]: CustomNode,
[CUSTOM_SUB_GRAPH_START_NODE]: SubGraphStartNode,
[CUSTOM_ITERATION_START_NODE]: IterationStartNode,
[CUSTOM_LOOP_START_NODE]: LoopStartNode,
}

View File

@ -3280,9 +3280,6 @@
}
},
"app/components/workflow/nodes/data-source/panel.tsx": {
"style/multiline-ternary": {
"count": 2
},
"ts/no-explicit-any": {
"count": 3
}
@ -3634,11 +3631,6 @@
"count": 7
}
},
"app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -3665,9 +3657,6 @@
}
},
"app/components/workflow/nodes/tool/panel.tsx": {
"style/multiline-ternary": {
"count": 2
},
"ts/no-explicit-any": {
"count": 2
}
@ -4549,4 +4538,4 @@
"count": 2
}
}
}
}

View File

@ -766,8 +766,9 @@
"nodes.templateTransform.codeSupportTip": "Only supports Jinja2",
"nodes.templateTransform.inputVars": "Input Variables",
"nodes.templateTransform.outputVars.output": "Transformed content",
"nodes.tool.authorize": "Authorize",
"nodes.tool.agentPlaceholder": "Tell me the {{paramKey}}...",
"nodes.tool.assembleVariables": "Assemble variables",
"nodes.tool.authorize": "Authorize",
"nodes.tool.inputVars": "Input Variables",
"nodes.tool.insertPlaceholder1": "Type or press",
"nodes.tool.insertPlaceholder2": "insert variable",

View File

@ -769,23 +769,11 @@ 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: {

View File

@ -764,8 +764,9 @@
"nodes.templateTransform.codeSupportTip": "Jinja2 のみをサポートしています",
"nodes.templateTransform.inputVars": "入力変数",
"nodes.templateTransform.outputVars.output": "変換されたコンテンツ",
"nodes.tool.authorize": "認証する",
"nodes.tool.agentPlaceholder": "{{paramKey}} を教えてください...",
"nodes.tool.assembleVariables": "変数を組み立てる",
"nodes.tool.authorize": "認証する",
"nodes.tool.inputVars": "入力変数",
"nodes.tool.insertPlaceholder1": "タイプするか押してください",
"nodes.tool.insertPlaceholder2": "変数を挿入する",

View File

@ -764,8 +764,9 @@
"nodes.templateTransform.codeSupportTip": "只支持 Jinja2",
"nodes.templateTransform.inputVars": "输入变量",
"nodes.templateTransform.outputVars.output": "转换后内容",
"nodes.tool.authorize": "授权",
"nodes.tool.agentPlaceholder": "告诉我 {{paramKey}}...",
"nodes.tool.assembleVariables": "组装变量",
"nodes.tool.authorize": "授权",
"nodes.tool.inputVars": "输入变量",
"nodes.tool.insertPlaceholder1": "键入",
"nodes.tool.insertPlaceholder2": "插入变量",

View File

@ -769,23 +769,11 @@ const translation = {
'assignedVarsDescription': '赋值变量必须是可写入的变量,例如会话变量。',
},
tool: {
assembleVariables: '组合变量',
authorize: '授权',
inputVars: '输入变量',
settings: '设置',
insertPlaceholder1: '键入',
insertPlaceholder2: '插入变量',
contextGenerate: {
title: '组合变量',
codeBlock: '代码块',
code: '代码',
output: '输出',
run: '运行',
apply: '应用',
generating: '生成中...',
inputPlaceholder: '输入修改需求...',
defaultAssistantMessage: '已完成,请检查。',
},
outputVars: {
text: '工具生成的内容',
files: {

View File

@ -764,8 +764,9 @@
"nodes.templateTransform.codeSupportTip": "只支持 Jinja2",
"nodes.templateTransform.inputVars": "輸入變數",
"nodes.templateTransform.outputVars.output": "轉換後內容",
"nodes.tool.authorize": "授權",
"nodes.tool.agentPlaceholder": "告訴我 {{paramKey}}...",
"nodes.tool.assembleVariables": "組裝變數",
"nodes.tool.authorize": "授權",
"nodes.tool.inputVars": "輸入變數",
"nodes.tool.insertPlaceholder1": "輸入或按壓",
"nodes.tool.insertPlaceholder2": "插入變數",

View File

@ -747,7 +747,6 @@ const translation = {
'varNotSet': '未設置變數',
},
tool: {
assembleVariables: '組合變數',
authorize: '授權',
inputVars: '輸入變數',
outputVars: {
@ -764,17 +763,6 @@ const translation = {
insertPlaceholder2: '插入變數',
insertPlaceholder1: '輸入或按壓',
settings: '設定',
contextGenerate: {
title: '組合變數',
codeBlock: '程式碼區塊',
code: '程式碼',
output: '輸出',
run: '執行',
apply: '套用',
generating: '生成中...',
inputPlaceholder: '輸入修改需求...',
defaultAssistantMessage: '已完成,請檢查。',
},
},
questionClassifiers: {
model: '模型',

View File

@ -106,7 +106,7 @@ function parseTsContent(content: string): NestedTranslation {
// Use Function constructor to safely evaluate the object literal
// This handles JS object syntax like unquoted keys, template literals, etc.
try {
// eslint-disable-next-line no-new-func, sonarjs/code-eval
// eslint-disable-next-line no-new-func
const fn = new Function(`return (${cleaned})`)
return fn() as NestedTranslation
}
@ -123,7 +123,7 @@ function parseTsContent(content: string): NestedTranslation {
function getMainBranchFile(filePath: string): string | null {
try {
const relativePath = `./i18n/${LOCALE}/${filePath}`
// eslint-disable-next-line sonarjs/os-command
return execSync(`git show main:${relativePath}`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
@ -148,12 +148,12 @@ function getTranslationFiles(): string[] {
function getMainBranchNamespaces(): string[] {
try {
const relativePath = `./i18n/${LOCALE}`
// eslint-disable-next-line sonarjs/os-command
const output = execSync(`git ls-tree --name-only main ${relativePath}/`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
})
// eslint-disable-next-line sonarjs/os-command
return output
.trim()
.split('\n')

View File

@ -25,38 +25,6 @@ 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<string, any>
}
}
export type ContextGenerateVariable = {
variable: string
value_selector: string[]
}
export type ContextGenerateResponse = {
variables: ContextGenerateVariable[]
code_language: string
code: string
outputs: Record<string, { type: string }>
message: string
error: string
}
export const sendChatMessage = async (appId: string, body: Record<string, any>, { onData, onCompleted, onThought, onFile, onError, getAbortController, onMessageEnd, onMessageReplace }: {
onData: IOnData
onCompleted: IOnCompleted
@ -125,12 +93,6 @@ export const generateRule = (body: Record<string, any>) => {
})
}
export const generateContext = (body: ContextGenerateRequest) => {
return post<ContextGenerateResponse>('/context-generate', {
body,
})
}
export const fetchModelParams = (providerName: string, modelId: string) => {
return get(`workspaces/current/model-providers/${providerName}/models/parameter-rules`, {
params: {