Merge branch 'hotfix/v0.2.10' into develop
This commit is contained in:
@@ -23,6 +23,7 @@ from app.models.user_model import User
|
||||
from app.schemas import chunk_schema
|
||||
from app.schemas.response_schema import ApiResponse
|
||||
from app.services import knowledge_service, document_service, file_service, knowledgeshare_service
|
||||
from app.services.model_service import ModelApiKeyService
|
||||
|
||||
# Obtain a dedicated API logger
|
||||
api_logger = get_api_logger()
|
||||
@@ -460,18 +461,20 @@ async def retrieve_chunks(
|
||||
if retrieve_data.retrieve_type == chunk_schema.RetrieveType.Graph:
|
||||
kb_ids = [str(kb_id) for kb_id in private_kb_ids]
|
||||
workspace_ids = [str(workspace_id) for workspace_id in private_workspace_ids]
|
||||
llm_key = ModelApiKeyService.get_available_api_key(db, db_knowledge.llm_id)
|
||||
emb_key = ModelApiKeyService.get_available_api_key(db, db_knowledge.embedding_id)
|
||||
# Prepare to configure chat_mdl、embedding_model、vision_model information
|
||||
chat_model = Base(
|
||||
key=db_knowledge.llm.api_keys[0].api_key,
|
||||
model_name=db_knowledge.llm.api_keys[0].model_name,
|
||||
base_url=db_knowledge.llm.api_keys[0].api_base
|
||||
key=llm_key.api_key,
|
||||
model_name=llm_key.model_name,
|
||||
base_url=llm_key.api_base
|
||||
)
|
||||
embedding_model = OpenAIEmbed(
|
||||
key=db_knowledge.embedding.api_keys[0].api_key,
|
||||
model_name=db_knowledge.embedding.api_keys[0].model_name,
|
||||
base_url=db_knowledge.embedding.api_keys[0].api_base
|
||||
key=emb_key.api_key,
|
||||
model_name=emb_key.model_name,
|
||||
base_url=emb_key.api_base
|
||||
)
|
||||
doc = kg_retriever.retrieval(question=retrieve_data.query, workspace_ids=workspace_ids, kb_ids= kb_ids, emb_mdl=embedding_model, llm=chat_model)
|
||||
doc = kg_retriever.retrieval(question=retrieve_data.query, workspace_ids=workspace_ids, kb_ids=kb_ids, emb_mdl=embedding_model, llm=chat_model)
|
||||
if doc:
|
||||
rs.insert(0, doc)
|
||||
return success(data=jsonable_encoder(rs), msg="retrieval successful")
|
||||
@@ -292,9 +292,10 @@ class MinerUParser(RAGPdfParser):
|
||||
self.page_from = page_from
|
||||
self.page_to = page_to
|
||||
try:
|
||||
with pdfplumber.open(fnm) if isinstance(fnm, (str, PathLike)) else pdfplumber.open(BytesIO(fnm)) as pdf:
|
||||
self.pdf = pdf
|
||||
self.page_images = [p.to_image(resolution=72 * zoomin, antialias=True).original for _, p in enumerate(self.pdf.pages[page_from:page_to])]
|
||||
with sys.modules[LOCK_KEY_pdfplumber]: # ← 加这一行,获取全局锁
|
||||
with pdfplumber.open(fnm) if isinstance(fnm, (str, PathLike)) else pdfplumber.open(BytesIO(fnm)) as pdf:
|
||||
self.pdf = pdf
|
||||
self.page_images = [p.to_image(resolution=72 * zoomin, antialias=True).original for _, p in enumerate(self.pdf.pages[page_from:page_to])]
|
||||
except Exception as e:
|
||||
self.page_images = None
|
||||
self.total_page = 0
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.core.rag.common.float_utils import get_float
|
||||
from app.core.rag.common.constants import PAGERANK_FLD, TAG_FLD
|
||||
from app.core.rag.llm.chat_model import Base
|
||||
from app.core.rag.llm.embedding_model import OpenAIEmbed
|
||||
from app.services.model_service import ModelApiKeyService
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -114,9 +115,8 @@ def knowledge_retrieval(
|
||||
# Use the specified reranker for re-ranking
|
||||
if reranker_id:
|
||||
try:
|
||||
return rerank(db=db, reranker_id=reranker_id, query=query, docs=all_results, top_k=reranker_top_k)
|
||||
all_results = rerank(db=db, reranker_id=reranker_id, query=query, docs=all_results, top_k=reranker_top_k)
|
||||
except Exception as rerank_error:
|
||||
# If reranker fails, log warning and continue with original results
|
||||
logger.warning(
|
||||
"Reranker failed, falling back to original results",
|
||||
extra={
|
||||
@@ -132,7 +132,10 @@ def knowledge_retrieval(
|
||||
from app.core.rag.common.settings import kg_retriever
|
||||
doc = kg_retriever.retrieval(question=query, workspace_ids=workspace_ids, kb_ids=kb_ids, emb_mdl=embedding_model, llm=chat_model)
|
||||
if doc:
|
||||
all_results.insert(0, doc)
|
||||
all_results.insert(0, DocumentChunk(
|
||||
page_content=doc.get("page_content", ""),
|
||||
metadata=doc.get("metadata", {})
|
||||
))
|
||||
except Exception as graph_error:
|
||||
print(f"Failed to retrieve from knowledge graph: {str(graph_error)}")
|
||||
|
||||
@@ -198,16 +201,18 @@ def _retrieve_for_knowledge(
|
||||
workspace_ids.append(str(db_knowledge.workspace_id))
|
||||
|
||||
if not chat_model:
|
||||
llm_key = ModelApiKeyService.get_available_api_key(db, db_knowledge.llm_id)
|
||||
chat_model = Base(
|
||||
key=db_knowledge.llm.api_keys[0].api_key,
|
||||
model_name=db_knowledge.llm.api_keys[0].model_name,
|
||||
base_url=db_knowledge.llm.api_keys[0].api_base,
|
||||
key=llm_key.api_key,
|
||||
model_name=llm_key.model_name,
|
||||
base_url=llm_key.api_base,
|
||||
)
|
||||
if not embedding_model:
|
||||
emb_key = ModelApiKeyService.get_available_api_key(db, db_knowledge.embedding_id)
|
||||
embedding_model = OpenAIEmbed(
|
||||
key=db_knowledge.embedding.api_keys[0].api_key,
|
||||
model_name=db_knowledge.embedding.api_keys[0].model_name,
|
||||
base_url=db_knowledge.embedding.api_keys[0].api_base,
|
||||
key=emb_key.api_key,
|
||||
model_name=emb_key.model_name,
|
||||
base_url=emb_key.api_base,
|
||||
)
|
||||
|
||||
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
|
||||
@@ -248,6 +253,29 @@ def _retrieve_for_knowledge(
|
||||
seen_ids.add(doc.metadata["doc_id"])
|
||||
unique_rs.append(doc)
|
||||
rs = unique_rs
|
||||
if unique_rs:
|
||||
rs = vector_service.rerank(
|
||||
query=kb_config["query"],
|
||||
docs=unique_rs,
|
||||
top_k=kb_config["top_k"]
|
||||
)
|
||||
if kb_config["retrieve_type"] == "graph":
|
||||
try:
|
||||
from app.core.rag.common.settings import kg_retriever
|
||||
graph_doc = kg_retriever.retrieval(
|
||||
question=kb_config["query"],
|
||||
workspace_ids=[str(db_knowledge.workspace_id)],
|
||||
kb_ids=[str(db_knowledge.id)],
|
||||
emb_mdl=embedding_model,
|
||||
llm=chat_model,
|
||||
)
|
||||
if graph_doc:
|
||||
rs.insert(0, DocumentChunk(
|
||||
page_content=graph_doc.get("page_content", ""),
|
||||
metadata=graph_doc.get("metadata", {})
|
||||
))
|
||||
except Exception as graph_error:
|
||||
logger.warning(f"Graph retrieval failed for kb {db_knowledge.id}: {graph_error}")
|
||||
|
||||
results.extend(rs)
|
||||
return results, chat_model, embedding_model
|
||||
|
||||
@@ -230,7 +230,7 @@ class DateTimeTool(BuiltinTool):
|
||||
@staticmethod
|
||||
def _datetime_to_timestamp(kwargs) -> dict:
|
||||
"""日期时间转时间戳"""
|
||||
input_value = kwargs.get("input_value")
|
||||
input_value = kwargs.get("input_value").strip()
|
||||
input_format = kwargs.get("input_format", "%Y-%m-%d %H:%M:%S")
|
||||
timezone_str = kwargs.get("from_timezone", "Asia/Shanghai")
|
||||
|
||||
@@ -253,9 +253,9 @@ class DateTimeTool(BuiltinTool):
|
||||
return {
|
||||
"datetime": input_value,
|
||||
"timezone": timezone_str,
|
||||
"timestamp": int(dt.timestamp()),
|
||||
"timestamp": int(dt.timestamp()) * 1000,
|
||||
"iso_format": dt.isoformat(),
|
||||
"result_data": int(dt.timestamp())
|
||||
"result_data": int(dt.timestamp()) * 1000
|
||||
}
|
||||
|
||||
def _calculate_datetime(self, kwargs) -> dict:
|
||||
|
||||
@@ -138,6 +138,29 @@ class OperationTool(BaseTool):
|
||||
default="Asia/Shanghai"
|
||||
)
|
||||
]
|
||||
elif self.operation == "datetime_to_timestamp":
|
||||
return [
|
||||
ToolParameter(
|
||||
name="input_value",
|
||||
type=ParameterType.STRING,
|
||||
description="输入值(时间字符串,如:2026-04-07 10:30:25)",
|
||||
required=True
|
||||
),
|
||||
ToolParameter(
|
||||
name="input_format",
|
||||
type=ParameterType.STRING,
|
||||
description="输入时间格式(如:%Y-%m-%d %H:%M:%S)",
|
||||
required=False,
|
||||
default="%Y-%m-%d %H:%M:%S"
|
||||
),
|
||||
ToolParameter(
|
||||
name="from_timezone",
|
||||
type=ParameterType.STRING,
|
||||
description="源时区(如:UTC, Asia/Shanghai)",
|
||||
required=False,
|
||||
default="Asia/Shanghai"
|
||||
)
|
||||
]
|
||||
else:
|
||||
return []
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ from langchain_core.documents import Document
|
||||
from app.core.error_codes import BizCode
|
||||
from app.core.exceptions import BusinessException
|
||||
from app.core.models import RedBearRerank, RedBearModelConfig
|
||||
from app.core.rag.llm.chat_model import Base
|
||||
from app.core.rag.llm.embedding_model import OpenAIEmbed
|
||||
from app.core.rag.models.chunk import DocumentChunk
|
||||
from app.core.rag.vdb.elasticsearch.elasticsearch_vector import ElasticSearchVectorFactory
|
||||
from app.core.workflow.engine.state_manager import WorkflowState
|
||||
@@ -39,8 +41,9 @@ class KnowledgeRetrievalNode(BaseNode):
|
||||
if isinstance(business_result, dict) and "chunks" in business_result:
|
||||
return business_result["chunks"]
|
||||
return business_result
|
||||
|
||||
def _extract_citations(self, business_result: Any) -> list:
|
||||
|
||||
@staticmethod
|
||||
def _extract_citations(business_result: Any) -> list:
|
||||
if isinstance(business_result, dict):
|
||||
return business_result.get("citations", [])
|
||||
return []
|
||||
@@ -230,23 +233,23 @@ class KnowledgeRetrievalNode(BaseNode):
|
||||
}
|
||||
)
|
||||
)
|
||||
case RetrieveType.HYBRID:
|
||||
case retrieve_type if retrieve_type in (RetrieveType.HYBRID, RetrieveType.Graph):
|
||||
rs1_task = asyncio.to_thread(
|
||||
vector_service.search_by_vector, **{
|
||||
"query": query,
|
||||
"top_k": kb_config.top_k,
|
||||
"indices": indices,
|
||||
"score_threshold": kb_config.vector_similarity_weight
|
||||
}
|
||||
)
|
||||
vector_service.search_by_vector, **{
|
||||
"query": query,
|
||||
"top_k": kb_config.top_k,
|
||||
"indices": indices,
|
||||
"score_threshold": kb_config.vector_similarity_weight
|
||||
}
|
||||
)
|
||||
rs2_task = asyncio.to_thread(
|
||||
vector_service.search_by_full_text, **{
|
||||
"query": query,
|
||||
"top_k": kb_config.top_k,
|
||||
"indices": indices,
|
||||
"score_threshold": kb_config.similarity_threshold
|
||||
}
|
||||
)
|
||||
vector_service.search_by_full_text, **{
|
||||
"query": query,
|
||||
"top_k": kb_config.top_k,
|
||||
"indices": indices,
|
||||
"score_threshold": kb_config.similarity_threshold
|
||||
}
|
||||
)
|
||||
rs1, rs2 = await asyncio.gather(rs1_task, rs2_task)
|
||||
|
||||
# Deduplicate hybrid retrieval results
|
||||
@@ -266,6 +269,33 @@ class KnowledgeRetrievalNode(BaseNode):
|
||||
key=lambda d: d.metadata.get("score", 0),
|
||||
reverse=True
|
||||
)[:kb_config.top_k])
|
||||
if kb_config.retrieve_type == RetrieveType.Graph:
|
||||
from app.core.rag.common.settings import kg_retriever
|
||||
llm_key = self.model_balance(db_knowledge.llm)
|
||||
emb_key = self.model_balance(db_knowledge.embedding)
|
||||
chat_model = Base(
|
||||
key=llm_key.api_key,
|
||||
model_name=llm_key.model_name,
|
||||
base_url=llm_key.api_base
|
||||
)
|
||||
embedding_model = OpenAIEmbed(
|
||||
key=emb_key.api_key,
|
||||
model_name=emb_key.model_name,
|
||||
base_url=emb_key.api_base
|
||||
)
|
||||
doc = await asyncio.to_thread(
|
||||
kg_retriever.retrieval,
|
||||
question=query,
|
||||
workspace_ids=[str(db_knowledge.workspace_id)],
|
||||
kb_ids=[str(kb_config.kb_id)],
|
||||
emb_mdl=embedding_model,
|
||||
llm=chat_model
|
||||
)
|
||||
if doc:
|
||||
rs.insert(0, DocumentChunk(
|
||||
page_content=doc.get("page_content", ""),
|
||||
metadata=doc.get("metadata", {})
|
||||
))
|
||||
case _:
|
||||
raise RuntimeError("Unknown retrieval type")
|
||||
return rs
|
||||
|
||||
@@ -574,6 +574,29 @@ class ToolService:
|
||||
"default": "Asia/Shanghai"
|
||||
}
|
||||
]
|
||||
elif operation == "datetime_to_timestamp":
|
||||
return [
|
||||
{
|
||||
"name": "input_value",
|
||||
"type": "string",
|
||||
"description": "输入值(时间字符串,如:2026-04-07 10:30:25)",
|
||||
"required": True
|
||||
},
|
||||
{
|
||||
"name": "input_format",
|
||||
"type": "string",
|
||||
"description": "输入时间格式(如:%Y-%m-%d %H:%M:%S)",
|
||||
"required": False,
|
||||
"default": "%Y-%m-%d %H:%M:%S"
|
||||
},
|
||||
{
|
||||
"name": "from_timezone",
|
||||
"type": "string",
|
||||
"description": "源时区(如:UTC, Asia/Shanghai)",
|
||||
"required": False,
|
||||
"default": "Asia/Shanghai"
|
||||
}
|
||||
]
|
||||
else:
|
||||
# 默认返回所有参数(除了operation)
|
||||
return [
|
||||
|
||||
@@ -229,7 +229,11 @@ const Agent = forwardRef<AgentRef, { onFeaturesLoad?: (features: FeaturesConfigF
|
||||
...knowledgeRest,
|
||||
knowledge_bases: knowledge_bases.map(item => ({
|
||||
kb_id: item.kb_id || item.id,
|
||||
...(item.config || {})
|
||||
retrieve_type: item.retrieve_type,
|
||||
top_k: item.top_k,
|
||||
similarity_threshold: item.similarity_threshold,
|
||||
vector_similarity_weight: item.vector_similarity_weight,
|
||||
// ...(item.config || {})
|
||||
}))
|
||||
} as KnowledgeConfig : null,
|
||||
tools: tools.map(vo => {
|
||||
|
||||
@@ -117,6 +117,7 @@ const Knowledge: FC<{value?: KnowledgeConfig; onChange?: (config: KnowledgeConfi
|
||||
const list = [...knowledgeList]
|
||||
list[index] = {
|
||||
...list[index],
|
||||
...values,
|
||||
config: {...values as KnowledgeConfigForm}
|
||||
}
|
||||
setKnowledgeList([...list])
|
||||
|
||||
@@ -33,7 +33,7 @@ interface KnowledgeConfigModalProps {
|
||||
* Available retrieval types
|
||||
*/
|
||||
const retrieveTypes: RetrieveType[] = ['participle', 'semantic', 'hybrid',
|
||||
// 'graph'
|
||||
'graph'
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -88,6 +88,10 @@ const KnowledgeListModal = forwardRef<KnowledgeModalRef, KnowledgeModalProps>(({
|
||||
const handleSave = () => {
|
||||
refresh(selectedRows.map(item => ({
|
||||
...item,
|
||||
similarity_threshold: 0.7,
|
||||
retrieve_type: "hybrid",
|
||||
top_k: 3,
|
||||
weight: 1,
|
||||
config: {
|
||||
similarity_threshold: 0.7,
|
||||
retrieve_type: "hybrid",
|
||||
|
||||
@@ -155,12 +155,10 @@ const ModelConfigModal = forwardRef<ModelConfigModalRef, ModelConfigModalProps>(
|
||||
</FormItem>
|
||||
{['model', 'chat'].includes(source) && <>
|
||||
<FormItem name="capability" hidden />
|
||||
{(values?.deep_thinking || values?.capability?.includes('thinking')) && (
|
||||
<FormItem name="deep_thinking" valuePropName="checked">
|
||||
<Checkbox>{t('application.deep_thinking')}</Checkbox>
|
||||
</FormItem>
|
||||
)}
|
||||
</>}
|
||||
<FormItem name="deep_thinking" valuePropName="checked" hidden={!['model', 'chat'].includes(source) || !(values?.deep_thinking || values?.capability?.includes('thinking'))}>
|
||||
<Checkbox>{t('application.deep_thinking')}</Checkbox>
|
||||
</FormItem>
|
||||
{source === 'chat' && <FormItem name="label" hidden />}
|
||||
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ const CreateDataset = () => {
|
||||
const [processingMethod, setProcessingMethod] = useState<ProcessingMethod>('directBlock');
|
||||
const [parameterSettings, setParameterSettings] = useState<ParameterSettings>('defaultSettings');
|
||||
const [pdfEnhancementEnabled, setPdfEnhancementEnabled] = useState<boolean>(true);
|
||||
const [pdfEnhancementMethod, setPdfEnhancementMethod] = useState<string>('deepdoc');
|
||||
const [pdfEnhancementMethod, setPdfEnhancementMethod] = useState<string>('mineru');
|
||||
const fileType = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'md', 'htm', 'html', 'json', 'ppt', 'pptx', 'txt','png','jpg','mp3','mp4','mov','wav']
|
||||
const steps = useMemo(
|
||||
() => [
|
||||
|
||||
@@ -89,14 +89,6 @@ const WordCloud: FC = () => {
|
||||
|
||||
return {
|
||||
color: ['#155EEF'],
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: any) => {
|
||||
const dataIndex = params.dataIndex
|
||||
const item = radarData[dataIndex]
|
||||
return `${item.name}<br/>${item.percentage.toFixed(1)}%`
|
||||
}
|
||||
},
|
||||
radar: {
|
||||
indicator: radarData.map(item => ({
|
||||
name: t(`statementDetail.${item.name}`),
|
||||
|
||||
@@ -149,8 +149,8 @@ const Editor: FC<LexicalEditorProps> =({
|
||||
<HistoryPlugin />
|
||||
<CommandPlugin />
|
||||
<AutocompletePlugin options={options} />
|
||||
<CharacterCountPlugin setCount={setCount} onChange={onChange} />
|
||||
<InitialValuePlugin value={value} options={options} />
|
||||
<CharacterCountPlugin setCount={setCount} />
|
||||
<InitialValuePlugin value={value} options={options} onChange={onChange} />
|
||||
<BlurPlugin />
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
|
||||
@@ -1,41 +1,22 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { $getRoot, $isParagraphNode } from 'lexical';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
|
||||
import { $isVariableNode } from '../nodes/VariableNode';
|
||||
|
||||
const CharacterCountPlugin = ({ setCount, onChange }: { setCount: (count: number) => void; onChange?: (value: string) => void }) => {
|
||||
const CharacterCountPlugin = ({ setCount }: { setCount: (count: number) => void }) => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState, tags }) => {
|
||||
if (tags.has('programmatic')) return;
|
||||
editorState.read(() => {
|
||||
const root = $getRoot();
|
||||
let serializedContent = '';
|
||||
|
||||
// Traverse all nodes and serialize properly
|
||||
const paragraphs: string[] = [];
|
||||
root.getChildren().forEach(child => {
|
||||
if ($isParagraphNode(child)) {
|
||||
let paragraphContent = '';
|
||||
child.getChildren().forEach(node => {
|
||||
if ($isVariableNode(node)) {
|
||||
paragraphContent += node.getTextContent();
|
||||
} else {
|
||||
paragraphContent += node.getTextContent();
|
||||
}
|
||||
});
|
||||
paragraphs.push(paragraphContent);
|
||||
paragraphs.push(child.getChildren().map(n => n.getTextContent()).join(''));
|
||||
}
|
||||
});
|
||||
|
||||
serializedContent = paragraphs.join('\n');
|
||||
|
||||
setCount(serializedContent.length);
|
||||
onChangeRef.current?.(serializedContent);
|
||||
setCount(paragraphs.join('\n').length);
|
||||
});
|
||||
});
|
||||
}, [editor, setCount]);
|
||||
|
||||
@@ -50,7 +50,7 @@ const CommandPlugin = () => {
|
||||
|
||||
// Create and insert the variable node
|
||||
const tagNode = $createVariableNode(payload.data);
|
||||
const spaceNode = $createTextNode(' ');
|
||||
const spaceNode = $createTextNode('');
|
||||
|
||||
anchorNode.insertAfter(tagNode);
|
||||
tagNode.insertAfter(spaceNode);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical';
|
||||
import { $getRoot, $createParagraphNode, $createTextNode, $isParagraphNode } from 'lexical';
|
||||
|
||||
import { $createVariableNode } from '../nodes/VariableNode';
|
||||
import { type Suggestion } from '../plugin/AutocompletePlugin'
|
||||
@@ -14,24 +14,34 @@ import { type Suggestion } from '../plugin/AutocompletePlugin'
|
||||
interface InitialValuePluginProps {
|
||||
value: string;
|
||||
options?: Suggestion[];
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options = [] }) => {
|
||||
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options = [], onChange }) => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const prevValueRef = useRef<string>('');
|
||||
const isUserInputRef = useRef(false);
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState, tags }) => {
|
||||
if (tags.has('programmatic')) return;
|
||||
editorState.read(() => {
|
||||
const root = $getRoot();
|
||||
const textContent = root.getTextContent();
|
||||
if (textContent !== prevValueRef.current) {
|
||||
const paragraphs: string[] = [];
|
||||
root.getChildren().forEach(child => {
|
||||
if ($isParagraphNode(child)) {
|
||||
paragraphs.push(child.getChildren().map(n => n.getTextContent()).join(''));
|
||||
}
|
||||
});
|
||||
const text = paragraphs.join('\n');
|
||||
if (text !== prevValueRef.current) {
|
||||
isUserInputRef.current = true;
|
||||
prevValueRef.current = textContent;
|
||||
prevValueRef.current = text;
|
||||
onChangeRef.current?.(text);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,23 +49,24 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => {
|
||||
|
||||
const incomingEdges = graph.getIncomingEdges(node);
|
||||
const outgoingEdges = graph.getOutgoingEdges(node);
|
||||
|
||||
incomingEdges?.forEach(edge => {
|
||||
graph.addEdge({
|
||||
const addedEdges: any[] = [];
|
||||
|
||||
incomingEdges?.forEach((edge: any) => {
|
||||
addedEdges.push(graph.addEdge({
|
||||
source: { cell: edge.getSourceCellId(), port: edge.getSourcePortId() },
|
||||
target: { cell: newNode.id, port: newNode.getPorts().find((port: any) => port.group === 'left')?.id || 'left' },
|
||||
...edgeAttrs
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
outgoingEdges?.forEach(edge => {
|
||||
outgoingEdges?.forEach((edge: any) => {
|
||||
const targetCell = graph.getCellById(edge.getTargetCellId()) as any;
|
||||
const targetPortId = targetCell?.getPorts?.()?.find((port: any) => port.group === 'left')?.id || edge.getTargetPortId();
|
||||
graph.addEdge({
|
||||
addedEdges.push(graph.addEdge({
|
||||
source: { cell: newNode.id, port: newNode.getPorts().find((port: any) => port.group === 'right')?.id || 'right' },
|
||||
target: { cell: edge.getTargetCellId(), port: targetPortId },
|
||||
...edgeAttrs
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
// Remove all add-node type nodes
|
||||
@@ -75,6 +76,15 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => {
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
addedEdges.forEach(e => {
|
||||
const src = graph.getCellById(e.getSourceCellId());
|
||||
const tgt = graph.getCellById(e.getTargetCellId());
|
||||
if (src?.isNode()) src.toFront();
|
||||
if (tgt?.isNode()) tgt.toFront();
|
||||
});
|
||||
}, 50);
|
||||
|
||||
// Automatically adjust loop node size
|
||||
const loopNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
|
||||
if (loopNode) {
|
||||
|
||||
@@ -59,6 +59,9 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => {
|
||||
target: { cell: addNode.id, port: targetPort },
|
||||
...edgeAttrs,
|
||||
});
|
||||
|
||||
cycleStartNode.toFront()
|
||||
addNode.toFront()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +120,12 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => {
|
||||
...edgeAttrs
|
||||
}
|
||||
graph.addEdge(edgeConfig)
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
cycleStartNode.toFront()
|
||||
addNode.toFront()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -34,9 +34,12 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
||||
};
|
||||
|
||||
window.addEventListener('port:click', handlePortClick as EventListener);
|
||||
const handleBlankClick = () => handlePopoverClose();
|
||||
window.addEventListener('blank:click', handleBlankClick);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('port:click', handlePortClick as EventListener);
|
||||
window.removeEventListener('blank:click', handleBlankClick);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -188,38 +191,39 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
||||
setTimeout(() => {
|
||||
const newPorts = newNode.getPorts();
|
||||
|
||||
const addedEdges: any[] = [];
|
||||
if (edgeInsertion) {
|
||||
// Edge insertion: create source→new and new→target edges
|
||||
const { targetCell, targetPort: origTargetPort } = edgeInsertion;
|
||||
const newLeftPort = newPorts.find((p: any) => p.group === 'left')?.id || 'left';
|
||||
const newRightPort = newPorts.find((p: any) => p.group === 'right')?.id || 'right';
|
||||
graph.addEdge({
|
||||
addedEdges.push(graph.addEdge({
|
||||
source: { cell: sourceNode.id, port: sourcePort },
|
||||
target: { cell: newNode.id, port: newLeftPort },
|
||||
...edgeAttrs
|
||||
});
|
||||
graph.addEdge({
|
||||
}));
|
||||
addedEdges.push(graph.addEdge({
|
||||
source: { cell: newNode.id, port: newRightPort },
|
||||
target: { cell: targetCell.id, port: origTargetPort },
|
||||
...edgeAttrs
|
||||
});
|
||||
}));
|
||||
setEdgeInsertion(null);
|
||||
} else if (sourcePortGroup === 'left') {
|
||||
// Connect from left port to new node's right side
|
||||
const targetPort = newPorts.find((port: any) => port.group === 'right')?.id || 'right';
|
||||
graph.addEdge({
|
||||
addedEdges.push(graph.addEdge({
|
||||
source: { cell: newNode.id, port: targetPort },
|
||||
target: { cell: sourceNode.id, port: sourcePort },
|
||||
...edgeAttrs
|
||||
});
|
||||
}));
|
||||
} else {
|
||||
// Connect from right port to new node's left side
|
||||
const targetPort = newPorts.find((port: any) => port.group === 'left')?.id || 'left';
|
||||
graph.addEdge({
|
||||
addedEdges.push(graph.addEdge({
|
||||
source: { cell: sourceNode.id, port: sourcePort },
|
||||
target: { cell: newNode.id, port: targetPort },
|
||||
...edgeAttrs
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// Adjust loop node size when child node is added via port within loop node
|
||||
@@ -266,6 +270,44 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isCycleContainer = (type: string) => type === 'loop' || type === 'iteration';
|
||||
const newNodeType = selectedNodeType.type;
|
||||
|
||||
// Helper: bring all child nodes and their edges of a cycle container to front
|
||||
const bringCycleChildrenToFront = (cycleContainerId: string) => {
|
||||
|
||||
graph.getEdges().forEach((e: any) => {
|
||||
const src = graph.getCellById(e.getSourceCellId());
|
||||
const tgt = graph.getCellById(e.getTargetCellId());
|
||||
if (src?.getData()?.cycle === cycleContainerId || tgt?.getData()?.cycle === cycleContainerId) e.toFront();
|
||||
});
|
||||
graph.getNodes().forEach((n: any) => {
|
||||
if (n.getData()?.cycle === cycleContainerId) n.toFront();
|
||||
});
|
||||
};
|
||||
|
||||
if (isCycleContainer(sourceNodeType)) {
|
||||
console.log('isCycleContainer(sourceNodeType)')
|
||||
// Case 4: source is a loop/iteration node — bring new node to front, then its children
|
||||
newNode.toFront();
|
||||
sourceNode.toFront();
|
||||
bringCycleChildrenToFront(sourceNodeData.id);
|
||||
} else if (isCycleContainer(newNodeType)) {
|
||||
console.log('isCycleContainer(newNodeType)')
|
||||
// Case 3: adding a loop/iteration node from a normal node — bring new node to front, then its children
|
||||
newNode.toFront();
|
||||
sourceNode.toFront()
|
||||
bringCycleChildrenToFront(id);
|
||||
} else {
|
||||
// Case 2: normal node → normal node
|
||||
addedEdges.forEach(e => {
|
||||
const src = graph.getCellById(e.getSourceCellId());
|
||||
const tgt = graph.getCellById(e.getTargetCellId());
|
||||
if (src?.isNode()) src.toFront();
|
||||
if (tgt?.isNode()) tgt.toFront();
|
||||
});
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Clean up temporary element
|
||||
|
||||
@@ -61,6 +61,20 @@ const CaseList: FC<CaseListProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const form = Form.useFormInstance();
|
||||
|
||||
const bringLoopChildrenToFront = (cell: any) => {
|
||||
const type = cell?.getData()?.type;
|
||||
if ((type !== 'loop' && type !== 'iteration') || !graphRef?.current) return;
|
||||
const cycleId = cell.getData().id;
|
||||
graphRef.current.getEdges().forEach((edge: any) => {
|
||||
const src = graphRef.current?.getCellById(edge.getSourceCellId());
|
||||
const tgt = graphRef.current?.getCellById(edge.getTargetCellId());
|
||||
if (src?.getData()?.cycle === cycleId || tgt?.getData()?.cycle === cycleId) edge.toFront();
|
||||
});
|
||||
graphRef.current.getNodes().forEach((n: any) => {
|
||||
if (n.getData()?.cycle === cycleId) n.toFront();
|
||||
});
|
||||
};
|
||||
|
||||
// Recalculate node height and port Y positions without rebuilding ports
|
||||
const updateNodeLayout = (cases: any[]) => {
|
||||
if (!selectedNode || !graphRef?.current) return;
|
||||
@@ -139,6 +153,10 @@ const CaseList: FC<CaseListProps> = ({
|
||||
...edgeAttrs,
|
||||
});
|
||||
}
|
||||
sourceCell.toFront()
|
||||
selectedNode.toFront()
|
||||
bringLoopChildrenToFront(sourceCell)
|
||||
bringLoopChildrenToFront(selectedNode)
|
||||
graphRef.current?.removeCell(edge);
|
||||
return;
|
||||
}
|
||||
@@ -183,6 +201,10 @@ const CaseList: FC<CaseListProps> = ({
|
||||
target: { cell: targetCellId, port: targetPortId },
|
||||
...edgeAttrs
|
||||
});
|
||||
selectedNode.toFront()
|
||||
bringLoopChildrenToFront(selectedNode)
|
||||
targetCell.toFront()
|
||||
bringLoopChildrenToFront(targetCell)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,20 @@ const CategoryList: FC<CategoryListProps> = ({ parentName, selectedNode, graphRe
|
||||
const form = Form.useFormInstance();
|
||||
const formValues = Form.useWatch([parentName], form);
|
||||
|
||||
const bringLoopChildrenToFront = (cell: any) => {
|
||||
const type = cell?.getData()?.type;
|
||||
if ((type !== 'loop' && type !== 'iteration') || !graphRef?.current) return;
|
||||
const cycleId = cell.getData().id;
|
||||
graphRef.current.getEdges().forEach((edge: any) => {
|
||||
const src = graphRef.current?.getCellById(edge.getSourceCellId());
|
||||
const tgt = graphRef.current?.getCellById(edge.getTargetCellId());
|
||||
if (src?.getData()?.cycle === cycleId || tgt?.getData()?.cycle === cycleId) edge.toFront();
|
||||
});
|
||||
graphRef.current.getNodes().forEach((n: any) => {
|
||||
if (n.getData()?.cycle === cycleId) n.toFront();
|
||||
});
|
||||
};
|
||||
|
||||
// Update node ports based on category count changes (add/remove categories)
|
||||
const updateNodePorts = (caseCount: number, removedCaseIndex?: number) => {
|
||||
if (!selectedNode || !graphRef?.current) return;
|
||||
@@ -88,6 +102,10 @@ const CategoryList: FC<CategoryListProps> = ({ parentName, selectedNode, graphRe
|
||||
target: { cell: selectedNode.id, port: targetPortId },
|
||||
...edgeAttrs
|
||||
});
|
||||
sourceCell.toFront()
|
||||
bringLoopChildrenToFront(sourceCell)
|
||||
selectedNode.toFront()
|
||||
bringLoopChildrenToFront(selectedNode)
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -119,6 +137,10 @@ const CategoryList: FC<CategoryListProps> = ({ parentName, selectedNode, graphRe
|
||||
target: { cell: targetCellId, port: targetPortId },
|
||||
...edgeAttrs
|
||||
});
|
||||
selectedNode.toFront()
|
||||
bringLoopChildrenToFront(selectedNode)
|
||||
targetCell.toFront()
|
||||
bringLoopChildrenToFront(targetCell)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ interface KnowledgeConfigModalProps {
|
||||
refresh: (values: KnowledgeConfigForm, type: 'knowledgeConfig') => void;
|
||||
}
|
||||
const retrieveTypes: RetrieveType[] = ['participle', 'semantic', 'hybrid',
|
||||
// 'graph'
|
||||
'graph'
|
||||
]
|
||||
|
||||
const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfigModalProps>(({
|
||||
|
||||
@@ -757,7 +757,7 @@ const defaultPortGroup = {
|
||||
stroke: port_color,
|
||||
strokeWidth: edge_width,
|
||||
fill: port_color,
|
||||
opacity: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
label: {
|
||||
text: '+',
|
||||
@@ -768,7 +768,7 @@ const defaultPortGroup = {
|
||||
textVerticalAnchor: 'middle',
|
||||
pointerEvents: 'none',
|
||||
y: '0.15em',
|
||||
opacity: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,20 +4,20 @@
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-07 23:17:50
|
||||
*/
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { App } from 'antd'
|
||||
import { Graph, Node, MiniMap, Snapline, Clipboard, Keyboard, type Edge } from '@antv/x6';
|
||||
import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, type Edge } from '@antv/x6';
|
||||
import { register } from '@antv/x6-react-shape';
|
||||
import type { PortMetadata } from '@antv/x6/lib/model/port';
|
||||
import { App } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edgeHoverTool, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode, defaultPortItems, portItemArgsY, edge_width, conditionNodePortItemArgsY, conditionNodeItemHeight, conditionNodeHeight, notesConfig } from '../constant';
|
||||
import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types';
|
||||
import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'
|
||||
import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application';
|
||||
import { useUser } from '@/store/user';
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
|
||||
import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils'
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
|
||||
import { conditionNodeHeight, conditionNodeItemHeight, conditionNodePortItemArgsY, defaultAbsolutePortGroups, defaultPortItems, edgeAttrs, edgeHoverTool, edge_color, edge_selected_color, edge_width, graphNodeLibrary, nodeLibrary, nodeRegisterLibrary, nodeWidth, notesConfig, portAttrs, portItemArgsY, portMarkup, portTextAttrs, unknownNode } from '../constant';
|
||||
import type { ChatVariable, NodeProperties, WorkflowConfig } from '../types';
|
||||
import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils';
|
||||
|
||||
/**
|
||||
* Props for useWorkflowGraph hook
|
||||
@@ -94,10 +94,10 @@ export const useWorkflowGraph = ({
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation()
|
||||
const { user } = useUser();
|
||||
|
||||
|
||||
// Refs
|
||||
const graphRef = useRef<Graph>();
|
||||
|
||||
|
||||
// State
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
@@ -144,7 +144,7 @@ export const useWorkflowGraph = ({
|
||||
useEffect(() => {
|
||||
initWorkflow()
|
||||
}, [config, graphRef.current])
|
||||
|
||||
|
||||
/**
|
||||
* Initialize workflow graph with nodes and edges from configuration
|
||||
*/
|
||||
@@ -231,11 +231,11 @@ export const useWorkflowGraph = ({
|
||||
if (w) nodeConfig.width = w as number;
|
||||
if (h) nodeConfig.height = h as number;
|
||||
}
|
||||
|
||||
|
||||
// Generate ports dynamically for if-else node based on cases
|
||||
if (type === 'if-else' && config.cases && Array.isArray(config.cases)) {
|
||||
const totalPorts = config.cases.length + 1; // IF/ELIF + ELSE
|
||||
|
||||
|
||||
const portItems: PortMetadata[] = [
|
||||
defaultPortItems[0],
|
||||
];
|
||||
@@ -250,24 +250,24 @@ export const useWorkflowGraph = ({
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
nodeConfig.ports = {
|
||||
groups: defaultAbsolutePortGroups,
|
||||
items: portItems
|
||||
};
|
||||
|
||||
|
||||
nodeConfig.height = calcConditionNodeTotalHeight(config.cases);
|
||||
}
|
||||
|
||||
|
||||
// Generate ports dynamically for question-classifier node based on categories
|
||||
if (type === 'question-classifier' && config.categories && Array.isArray(config.categories)) {
|
||||
const categoryCount = config.categories.length;
|
||||
const newHeight = conditionNodeHeight + (categoryCount - 2) * conditionNodeItemHeight;
|
||||
|
||||
|
||||
const portItems: PortMetadata[] = [
|
||||
defaultPortItems[0]
|
||||
];
|
||||
|
||||
|
||||
// Add category ports
|
||||
config.categories.forEach((_category: any, index: number) => {
|
||||
portItems.push({
|
||||
@@ -279,15 +279,15 @@ export const useWorkflowGraph = ({
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
nodeConfig.ports = {
|
||||
groups: defaultAbsolutePortGroups,
|
||||
items: portItems
|
||||
};
|
||||
|
||||
|
||||
nodeConfig.height = newHeight;
|
||||
}
|
||||
|
||||
|
||||
// Check error_handle.method config for http-request node
|
||||
if (type === 'http-request' && (nodeConfig as any).error_handle?.method === 'branch') {
|
||||
nodeConfig.ports = {
|
||||
@@ -304,21 +304,22 @@ export const useWorkflowGraph = ({
|
||||
x: nodeWidth,
|
||||
y: portItemArgsY + portItemArgsY,
|
||||
},
|
||||
id: 'ERROR', attrs: { text: { text: t('workflow.config.http-request.errorBranch'), ...portTextAttrs }}}
|
||||
id: 'ERROR', attrs: { text: { text: t('workflow.config.http-request.errorBranch'), ...portTextAttrs } }
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return nodeConfig
|
||||
})
|
||||
|
||||
|
||||
// Separate parent nodes and child nodes
|
||||
const parentNodes = nodeList.filter(node => !node.data.cycle)
|
||||
const childNodes = nodeList.filter(node => node.data.cycle)
|
||||
|
||||
|
||||
// Add parent nodes first
|
||||
graphRef.current?.addNodes(parentNodes)
|
||||
|
||||
|
||||
// Then process child nodes, use addChild to add to corresponding parent node
|
||||
childNodes.forEach(childNode => {
|
||||
const cycleId = childNode.data.cycle
|
||||
@@ -332,14 +333,14 @@ export const useWorkflowGraph = ({
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// Adjust parent node size to fit child nodes
|
||||
setTimeout(() => {
|
||||
const parentNodesWithChildren = parentNodes.filter(parentNode => {
|
||||
const parentId = parentNode.data.id
|
||||
return childNodes.some(child => child.data.cycle === parentId)
|
||||
})
|
||||
|
||||
|
||||
parentNodesWithChildren.forEach(parentNodeConfig => {
|
||||
const parentNode = graphRef.current?.getCellById(parentNodeConfig.data.id)
|
||||
if (parentNode) {
|
||||
@@ -350,18 +351,18 @@ export const useWorkflowGraph = ({
|
||||
const minY = Math.min(...childBounds.map(b => b.y))
|
||||
const maxX = Math.max(...childBounds.map(b => b.x + b.width))
|
||||
const maxY = Math.max(...childBounds.map(b => b.y + b.height))
|
||||
|
||||
|
||||
const padding = 24
|
||||
const headerHeight = 50
|
||||
const parentBBox = parentNode.getBBox()
|
||||
|
||||
|
||||
const newWidth = Math.max(parentBBox.width, maxX - minX + padding * 2)
|
||||
const newHeight = Math.max(parentBBox.height, maxY - minY + padding * 2 + headerHeight)
|
||||
|
||||
console.log('newWidth', newHeight, newWidth)
|
||||
|
||||
|
||||
parentNode.prop('size', { width: newWidth, height: newHeight })
|
||||
|
||||
|
||||
// Update x position of right group ports
|
||||
const ports = (parentNode as Node).getPorts()
|
||||
ports.forEach(port => {
|
||||
@@ -381,7 +382,7 @@ export const useWorkflowGraph = ({
|
||||
const sourceCell = graphRef.current?.getCellById(e.source);
|
||||
const sourceType = sourceCell?.getData()?.type;
|
||||
const isMultiPortNode = sourceType === 'question-classifier' || sourceType === 'if-else';
|
||||
|
||||
|
||||
if (isMultiPortNode) {
|
||||
// Multi-port nodes need to compare source, target and label
|
||||
return e.source === edge.source && e.target === edge.target && e.label === edge.label;
|
||||
@@ -391,18 +392,18 @@ export const useWorkflowGraph = ({
|
||||
}
|
||||
}) === index;
|
||||
});
|
||||
|
||||
|
||||
const edgeList = uniqueEdges.map(edge => {
|
||||
const { source, target, label } = edge
|
||||
const sourceCell = graphRef.current?.getCellById(source)
|
||||
const targetCell = graphRef.current?.getCellById(target)
|
||||
|
||||
|
||||
if (sourceCell && targetCell) {
|
||||
const sourcePorts = (sourceCell as Node).getPorts()
|
||||
const targetPorts = (targetCell as Node).getPorts()
|
||||
|
||||
|
||||
let sourcePort = sourcePorts.find((port: any) => port.group === 'right')?.id || 'right';
|
||||
|
||||
|
||||
// If if-else node has label, match corresponding port by label
|
||||
if (sourceCell.getData()?.type === 'if-else' && label) {
|
||||
// Find matching port ID
|
||||
@@ -411,7 +412,7 @@ export const useWorkflowGraph = ({
|
||||
sourcePort = label;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If question-classifier node has label, match corresponding port by label
|
||||
if (sourceCell.getData()?.type === 'question-classifier' && label) {
|
||||
const matchingPort = sourcePorts.find((port: any) => port.id === label);
|
||||
@@ -419,7 +420,7 @@ export const useWorkflowGraph = ({
|
||||
sourcePort = label;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If http-request node has label, match corresponding port by label
|
||||
if (sourceCell.getData()?.type === 'http-request' && label) {
|
||||
const matchingPort = sourcePorts.find((port: any) => port.id === label);
|
||||
@@ -427,7 +428,7 @@ export const useWorkflowGraph = ({
|
||||
sourcePort = label;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const edgeConfig = {
|
||||
source: {
|
||||
cell: sourceCell.id,
|
||||
@@ -448,15 +449,23 @@ export const useWorkflowGraph = ({
|
||||
})
|
||||
graphRef.current.addEdges(edgeList.filter(vo => vo !== null))
|
||||
}
|
||||
|
||||
|
||||
// Initialize after completion, display nodes in visible area
|
||||
if (nodes.length > 0 || edges.length > 0) {
|
||||
setTimeout(() => {
|
||||
if (graphRef.current) {
|
||||
graphRef.current.centerContent()
|
||||
// graphRef.current.getNodes().forEach(node => node.toFront());
|
||||
graphRef.current.getNodes().forEach(node => {
|
||||
if (!node.getData()?.cycle) node.toFront();
|
||||
});
|
||||
// Bring edges to front first, then child nodes above edges; parent nodes stay behind
|
||||
graphRef.current.getEdges().forEach(edge => edge.toFront());
|
||||
graphRef.current.getEdges().forEach(edge => {
|
||||
const sourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
|
||||
const targetCell = graphRef.current?.getCellById(edge.getTargetCellId());
|
||||
if (sourceCell?.getData()?.cycle || targetCell?.getData()?.cycle) {
|
||||
edge.toFront();
|
||||
}
|
||||
});
|
||||
graphRef.current.getNodes().forEach(node => {
|
||||
if (node.getData()?.cycle) node.toFront();
|
||||
});
|
||||
@@ -585,6 +594,7 @@ export const useWorkflowGraph = ({
|
||||
clearEdgeSelect();
|
||||
graphRef.current?.cleanSelection();
|
||||
setSelectedNode(null);
|
||||
window.dispatchEvent(new CustomEvent('blank:click'));
|
||||
};
|
||||
/**
|
||||
* Handle canvas scale/zoom event
|
||||
@@ -605,26 +615,26 @@ export const useWorkflowGraph = ({
|
||||
// Get parent node and child node bounding boxes
|
||||
const parentBBox = parentNode.getBBox();
|
||||
const childBBox = node.getBBox();
|
||||
|
||||
|
||||
// Calculate parent node padding
|
||||
const padding = 24;
|
||||
const headerHeight = 50;
|
||||
|
||||
|
||||
// Calculate minimum and maximum positions allowed for child node
|
||||
const minX = parentBBox.x + padding;
|
||||
const minY = parentBBox.y + padding + headerHeight;
|
||||
const maxX = parentBBox.x + parentBBox.width - padding - childBBox.width;
|
||||
const maxY = parentBBox.y + parentBBox.height - padding - childBBox.height;
|
||||
|
||||
|
||||
// Restrict child node movement within parent node
|
||||
let newX = childBBox.x;
|
||||
let newY = childBBox.y;
|
||||
|
||||
|
||||
if (newX < minX) newX = minX;
|
||||
if (newY < minY) newY = minY;
|
||||
if (newX > maxX) newX = maxX;
|
||||
if (newY > maxY) newY = maxY;
|
||||
|
||||
|
||||
// If child node position is restricted, update its position
|
||||
if (newX !== childBBox.x || newY !== childBBox.y) {
|
||||
node.setPosition(newX, newY);
|
||||
@@ -716,7 +726,7 @@ export const useWorkflowGraph = ({
|
||||
}
|
||||
// Add child node to deletion list
|
||||
cells.push(nodeToDelete);
|
||||
}
|
||||
}
|
||||
// Check if it's LoopNode, IterationNode or SubGraphNode
|
||||
else if (nodeToDelete.shape === 'loop-node' || nodeToDelete.shape === 'iteration-node' || nodeToDelete.shape === 'subgraph-node') {
|
||||
// Find all child nodes with cycle equal to current node id
|
||||
@@ -728,7 +738,7 @@ export const useWorkflowGraph = ({
|
||||
});
|
||||
// Add parent node to deletion list
|
||||
cells.push(nodeToDelete);
|
||||
}
|
||||
}
|
||||
// Normal node
|
||||
else {
|
||||
cells.push(nodeToDelete);
|
||||
@@ -736,7 +746,7 @@ export const useWorkflowGraph = ({
|
||||
});
|
||||
blankClick();
|
||||
}
|
||||
|
||||
|
||||
// Delete all collected nodes and edges
|
||||
if (cells.length > 0) {
|
||||
graphRef.current?.removeCells(cells);
|
||||
@@ -883,19 +893,19 @@ export const useWorkflowGraph = ({
|
||||
const targetGroup = targetMagnet ? getPortGroup(targetMagnet) : null;
|
||||
|
||||
if (sourceGroup === 'left' || targetGroup === 'right') return false;
|
||||
|
||||
|
||||
// Node cannot connect to itself
|
||||
if (sourceCell?.id === targetCell?.id) return false;
|
||||
|
||||
const targetType = targetCell?.getData()?.type;
|
||||
|
||||
|
||||
// Start node cannot be connection target
|
||||
if (targetType === 'start') return false;
|
||||
|
||||
|
||||
// Get source node and target node parent IDs
|
||||
const sourceParentId = sourceCell?.getData()?.cycle;
|
||||
const targetParentId = targetCell?.getData()?.cycle;
|
||||
|
||||
|
||||
// Validate parent-child relationship:
|
||||
// 1. If both nodes have parent IDs, they must be same to connect
|
||||
// 2. If both have no parent ID, can connect normally
|
||||
@@ -979,25 +989,6 @@ export const useWorkflowGraph = ({
|
||||
graphRef.current.on('edge:click', edgeClick);
|
||||
// Listen to port click event
|
||||
graphRef.current.on('node:port:click', nodePortClickEvent);
|
||||
// Port hover: show circle style on right ports
|
||||
graphRef.current.on('node:port:mouseenter', ({ node, port }) => {
|
||||
console.log('node:port:mouseenter', port)
|
||||
if (!port) return;
|
||||
const portData = node.getPort(port);
|
||||
if (portData?.group !== 'right') return;
|
||||
node.toFront();
|
||||
node.setPortProp(port, 'attrs/body/opacity', 0);
|
||||
node.setPortProp(port, 'attrs/hoverBody/opacity', 1);
|
||||
node.setPortProp(port, 'attrs/label/opacity', 1);
|
||||
});
|
||||
graphRef.current.on('node:port:mouseleave', ({ node, port }) => {
|
||||
if (!port) return;
|
||||
const portData = node.getPort(port);
|
||||
if (portData?.group !== 'right') return;
|
||||
node.setPortProp(port, 'attrs/body/opacity', 1);
|
||||
node.setPortProp(port, 'attrs/hoverBody/opacity', 0);
|
||||
node.setPortProp(port, 'attrs/label/opacity', 0);
|
||||
});
|
||||
// Listen to canvas click event, cancel selection
|
||||
graphRef.current.on('blank:click', blankClick);
|
||||
// Node hover: highlight connected edges
|
||||
@@ -1015,11 +1006,6 @@ export const useWorkflowGraph = ({
|
||||
edge.setData({ ...edge.getData(), isNodeHover: true });
|
||||
}
|
||||
});
|
||||
node.getPorts().filter(p => p.group === 'right').forEach(p => {
|
||||
node.setPortProp(p.id!, 'attrs/body/opacity', 0);
|
||||
node.setPortProp(p.id!, 'attrs/hoverBody/opacity', 1);
|
||||
node.setPortProp(p.id!, 'attrs/label/opacity', 1);
|
||||
});
|
||||
});
|
||||
graphRef.current.on('node:mouseleave', ({ node }) => {
|
||||
graphRef.current?.getConnectedEdges(node).forEach(edge => {
|
||||
@@ -1028,48 +1014,31 @@ export const useWorkflowGraph = ({
|
||||
edge.setData({ ...edge.getData(), isNodeHover: false });
|
||||
}
|
||||
});
|
||||
node.getPorts().filter(p => p.group === 'right').forEach(p => {
|
||||
node.setPortProp(p.id!, 'attrs/body/opacity', 1);
|
||||
node.setPortProp(p.id!, 'attrs/hoverBody/opacity', 0);
|
||||
node.setPortProp(p.id!, 'attrs/label/opacity', 0);
|
||||
});
|
||||
});
|
||||
// Listen to zoom event
|
||||
graphRef.current.on('scale', scaleEvent);
|
||||
// Listen to node move event
|
||||
graphRef.current.on('node:moved', nodeMoved);
|
||||
// When parent (isGroup) node position changes, move children with it
|
||||
graphRef.current.on('node:change:position', ({ node, current, previous }: { node: Node; current: { x: number; y: number }; previous: { x: number; y: number } }) => {
|
||||
|
||||
if (!(node.getData()?.type === 'iteration' && node.getData()?.type === 'loop') || !current || !previous) return;
|
||||
|
||||
const dx = current.x - previous.x;
|
||||
const dy = current.y - previous.y;
|
||||
const parentId = node.getData()?.id || node.id;
|
||||
graphRef.current?.getNodes().forEach(child => {
|
||||
if (child.getData()?.cycle === parentId) {
|
||||
const cp = child.getPosition();
|
||||
child.setPosition(cp.x + dx, cp.y + dy, { silent: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
graphRef.current.on('node:removed', blankClick)
|
||||
// When edge connected, bring connected nodes' ports to front
|
||||
graphRef.current.on('edge:connected', ({ isNew, edge }) => {
|
||||
graphRef.current.on('edge:connected', ({ isNew }) => {
|
||||
// Bring edge to front first, then bring child nodes above edges
|
||||
// Parent (loop/iteration) nodes stay behind to avoid covering edges
|
||||
edge.toFront();
|
||||
graphRef.current?.getNodes().forEach(node => {
|
||||
if (node.getData()?.cycle) node.toFront();
|
||||
});
|
||||
// Reset any port hover state left from dragging
|
||||
if (isNew) {
|
||||
graphRef.current?.getNodes().forEach(node => {
|
||||
node.getPorts().filter(p => p.group === 'right').forEach(p => {
|
||||
node.setPortProp(p.id!, 'attrs/body/opacity', 1);
|
||||
node.setPortProp(p.id!, 'attrs/hoverBody/opacity', 0);
|
||||
node.setPortProp(p.id!, 'attrs/label/opacity', 0);
|
||||
});
|
||||
if (!node.getData()?.cycle) node.toFront();
|
||||
});
|
||||
graphRef.current?.getEdges().forEach(edge => {
|
||||
const sourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
|
||||
const targetCell = graphRef.current?.getCellById(edge.getTargetCellId());
|
||||
if (sourceCell?.getData()?.cycle || targetCell?.getData()?.cycle) {
|
||||
edge.toFront();
|
||||
}
|
||||
});
|
||||
graphRef.current?.getNodes().forEach(node => {
|
||||
if (node.getData()?.cycle) node.toFront();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1099,34 +1068,9 @@ export const useWorkflowGraph = ({
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
if (found?.node.id !== lastHoveredPort?.node.id || found?.portId !== lastHoveredPort?.portId) {
|
||||
// Leave previous
|
||||
if (lastHoveredPort) {
|
||||
const { node, portId } = lastHoveredPort;
|
||||
node.setPortProp(portId, 'attrs/body/opacity', 1);
|
||||
node.setPortProp(portId, 'attrs/hoverBody/opacity', 0);
|
||||
node.setPortProp(portId, 'attrs/label/opacity', 0);
|
||||
}
|
||||
// Enter new
|
||||
if (found) {
|
||||
const { node, portId } = found;
|
||||
node.toFront();
|
||||
node.setPortProp(portId, 'attrs/body/opacity', 0);
|
||||
node.setPortProp(portId, 'attrs/hoverBody/opacity', 1);
|
||||
node.setPortProp(portId, 'attrs/label/opacity', 1);
|
||||
}
|
||||
lastHoveredPort = found;
|
||||
}
|
||||
});
|
||||
graphRef.current.on('edge:mouseup', () => {
|
||||
if (lastHoveredPort) {
|
||||
const { node, portId } = lastHoveredPort;
|
||||
node.setPortProp(portId, 'attrs/body/opacity', 1);
|
||||
node.setPortProp(portId, 'attrs/hoverBody/opacity', 0);
|
||||
node.setPortProp(portId, 'attrs/label/opacity', 0);
|
||||
lastHoveredPort = null;
|
||||
}
|
||||
lastHoveredPort = found;
|
||||
});
|
||||
graphRef.current.on('edge:mouseup', () => { lastHoveredPort = null; });
|
||||
// Listen to copy keyboard event
|
||||
graphRef.current.bindKey(['ctrl+c', 'cmd+c'], copyEvent);
|
||||
// Listen to paste keyboard event
|
||||
@@ -1183,7 +1127,7 @@ export const useWorkflowGraph = ({
|
||||
if (!graph) return;
|
||||
|
||||
const point = graphRef.current.clientToLocal(event.clientX, event.clientY);
|
||||
|
||||
|
||||
// Get original config from node library to avoid config data chaining
|
||||
let nodeLibraryConfig = [...nodeLibrary]
|
||||
.flatMap(category => category.nodes)
|
||||
@@ -1336,11 +1280,11 @@ export const useWorkflowGraph = ({
|
||||
const sourcePortId = edge.getSourcePortId();
|
||||
|
||||
// Filter invalid edges: source or target node doesn't exist, or is add-node type
|
||||
if (!sourceCell?.getData()?.id || !targetCell?.getData()?.id ||
|
||||
sourceCell?.getData()?.type === 'add-node' || targetCell?.getData()?.type === 'add-node') {
|
||||
if (!sourceCell?.getData()?.id || !targetCell?.getData()?.id ||
|
||||
sourceCell?.getData()?.type === 'add-node' || targetCell?.getData()?.type === 'add-node') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// If if-else node right port connection, add label
|
||||
if (sourceCell?.getData()?.type === 'if-else' && sourcePortId?.startsWith('CASE')) {
|
||||
return {
|
||||
@@ -1349,7 +1293,7 @@ export const useWorkflowGraph = ({
|
||||
label: sourcePortId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// If question-classifier node right port connection, add label
|
||||
if (sourceCell?.getData()?.type === 'question-classifier' && sourcePortId?.startsWith('CASE')) {
|
||||
return {
|
||||
@@ -1358,7 +1302,7 @@ export const useWorkflowGraph = ({
|
||||
label: sourcePortId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// If http-request node right port connection, add label
|
||||
if (sourceCell?.getData()?.type === 'http-request') {
|
||||
if (sourcePortId === 'ERROR') {
|
||||
@@ -1375,40 +1319,40 @@ export const useWorkflowGraph = ({
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
source: sourceCell?.getData().id,
|
||||
target: targetCell?.getData().id,
|
||||
};
|
||||
})
|
||||
.filter(edge => edge !== null)
|
||||
.filter((edge, index, arr) => {
|
||||
// Deduplication: For if-else and question-classifier nodes, different ports can connect to same node
|
||||
return arr.findIndex(e => {
|
||||
if (!e || !edge) return false;
|
||||
const sourceCell = graphRef.current?.getCellById(e.source);
|
||||
const sourceType = sourceCell?.getData()?.type;
|
||||
const isMultiPortNode = sourceType === 'question-classifier' || sourceType === 'if-else';
|
||||
|
||||
if (isMultiPortNode) {
|
||||
// Multi-port nodes need to compare source, target and label
|
||||
return e.source === edge.source && e.target === edge.target && e.label === edge.label;
|
||||
} else {
|
||||
// Other nodes only compare source and target
|
||||
return e.source === edge.source && e.target === edge.target;
|
||||
}
|
||||
}) === index;
|
||||
}),
|
||||
.filter(edge => edge !== null)
|
||||
.filter((edge, index, arr) => {
|
||||
// Deduplication: For if-else and question-classifier nodes, different ports can connect to same node
|
||||
return arr.findIndex(e => {
|
||||
if (!e || !edge) return false;
|
||||
const sourceCell = graphRef.current?.getCellById(e.source);
|
||||
const sourceType = sourceCell?.getData()?.type;
|
||||
const isMultiPortNode = sourceType === 'question-classifier' || sourceType === 'if-else';
|
||||
|
||||
if (isMultiPortNode) {
|
||||
// Multi-port nodes need to compare source, target and label
|
||||
return e.source === edge.source && e.target === edge.target && e.label === edge.label;
|
||||
} else {
|
||||
// Other nodes only compare source and target
|
||||
return e.source === edge.source && e.target === edge.target;
|
||||
}
|
||||
}) === index;
|
||||
}),
|
||||
}
|
||||
saveWorkflowConfig(config.app_id, params as WorkflowConfig)
|
||||
.then((res) => {
|
||||
if (flag) {
|
||||
message.success({ content: t('common.saveSuccess'), duration: 1 })
|
||||
}
|
||||
resolve(res)
|
||||
}).catch(error => {
|
||||
reject(error)
|
||||
})
|
||||
.then((res) => {
|
||||
if (flag) {
|
||||
message.success({ content: t('common.saveSuccess'), duration: 1 })
|
||||
}
|
||||
resolve(res)
|
||||
}).catch(error => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user