Compare commits

...
52 changed files with 1426 additions and 528 deletions
+26 -75
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}"}
+34
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
@@ -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,
+11 -8
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 {
@@ -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,
)
+1 -1
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
@@ -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>('')
@@ -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'))
@@ -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"
}
@@ -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
@@ -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'
@@ -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)
@@ -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}]`
@@ -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])
@@ -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
@@ -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
@@ -403,7 +403,7 @@ const Form = () => {
</div>
</>
)
// eslint-disable-next-line sonarjs/no-nested-conditional
: indexMethod
? (
<>
@@ -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
@@ -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>
)
}
+125 -63
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}
/>
+24 -8
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[]
@@ -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'))
@@ -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,
+12 -3
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 (
@@ -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>
@@ -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) {
@@ -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>
)
}
</>
)
}
@@ -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}
@@ -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) => {
@@ -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>
)
})}
@@ -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
: {
@@ -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 || '',
@@ -0,0 +1 @@
export const CUSTOM_SUB_GRAPH_START_NODE = 'custom-sub-graph-start'
@@ -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)
@@ -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 && '@'}
@@ -26,7 +26,7 @@ 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'
@@ -36,9 +36,16 @@ import Placeholder from './placeholder'
/**
* 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)
@@ -162,6 +169,7 @@ 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)
@@ -212,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()
@@ -224,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: {
@@ -247,7 +263,7 @@ const MixedVariableTextInput = ({
}
return existingNode
}, [handleSyncWorkflowDraft, nodesMetaDataMap, reactFlowStore, toolNodeId])
}, [handleSyncWorkflowDraft, nodesDefaultConfigs, nodesMetaDataMap, reactFlowStore, toolNodeId])
const ensureAssembleExtractorNode = useCallback(() => {
if (!assembleExtractorNodeId)
@@ -309,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 {
@@ -326,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 []
@@ -461,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
})
@@ -475,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) {
@@ -552,6 +578,7 @@ const MixedVariableTextInput = ({
<AgentHeaderBar
agentName={t('nodes.tool.assembleVariables', { ns: 'workflow' })}
onRemove={handleAssembleRemove}
onViewInternals={handleOpenSubGraphModal}
hasWarning={hasAssembleWarning}
showAtPrefix={false}
/>
@@ -565,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}
@@ -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>
@@ -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>
)
}
@@ -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
@@ -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}`
@@ -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>
)
})}
@@ -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*)?)/
@@ -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,
)
+56
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
@@ -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)
@@ -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,
}
+1 -12
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
}
}
}
}
+2 -1
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",
+2 -1
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": "変数を挿入する",
+2 -1
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": "插入变量",
+2 -1
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": "插入變數",
+4 -4
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')