Compare commits

..

53 Commits

Author SHA1 Message Date
Mark
aac904007f Merge branch 'feature/rag2' into develop
* feature/rag2:
  [fix] system prompt fit error
  [modify] QA pair
2026-05-07 19:49:24 +08:00
Mark
f8d1ed51a7 [fix] system prompt fit error 2026-05-07 19:37:34 +08:00
山程漫悟
24d2fe726a Merge pull request #1051 from SuanmoSuanyangTechnology/feature/agent-tool_xjn
feat(workflow)
2026-05-07 19:12:39 +08:00
yingzhao
4c59e41b95 Merge pull request #1053 from SuanmoSuanyangTechnology/feature/knowledgeBase_zy
feat(web): add csv template
2026-05-07 19:12:06 +08:00
zhaoying
6a43623aa3 feat(web): add csv template 2026-05-07 19:11:24 +08:00
Mark
9fa83ed01e [modify] QA pair 2026-05-07 19:04:19 +08:00
yingzhao
f659bc7de2 Merge pull request #1052 from SuanmoSuanyangTechnology/feature/single_node_run_zy
feat(web): single node run
2026-05-07 18:54:06 +08:00
zhaoying
2234024aee feat(web): single node run 2026-05-07 18:52:46 +08:00
Mark
194026a97e Merge branch 'feature/rag2' into develop
* feature/rag2:
  [add] batch add chunk for v1
  [fix] index_not_found_exception
  [fix] delete chunk refresh index
  [fix] es vector
  [fix] file upload
  no message
  [add] import qa chunks
  [add] task log
  [fix] qa cache
  [add] batch chunk.  qa_prompt set
  [modify] rag qa chunk
2026-05-07 18:47:42 +08:00
Mark
e222490bce [add] batch add chunk for v1 2026-05-07 18:45:36 +08:00
zhaoying
7b43e59172 feat(web): single node run 2026-05-07 18:40:41 +08:00
Timebomb2018
0dc8d8cbeb feat(workflow): support doc_id in citation metadata and unify document_id handling 2026-05-07 18:34:16 +08:00
山程漫悟
8967b00303 Merge pull request #1049 from SuanmoSuanyangTechnology/feat/wxy-dev
feat(LLM node): integrate exception handling and enable branch routing
2026-05-07 17:34:31 +08:00
山程漫悟
2edfaa3863 Merge pull request #1050 from SuanmoSuanyangTechnology/feature/agent-tool_xjn
feat(workflow)
2026-05-07 17:32:55 +08:00
Timebomb2018
8d3da2fd0e feat(workflow): support single-node execution and MCP Streamable HTTP protocol
- Add `run_single_node` method in workflow service for isolated node execution
- Refactor MCP client to support Streamable HTTP protocol (2025-03-26) with session ID handling, SSE/JSON response parsing, and proper initialized notification
- Update iteration node to conditionally initialize stream writer based on stream flag
- Improve cycle graph node invocation with checkpoint config passing
2026-05-07 17:18:21 +08:00
wxy
cef33fce0d fix(workflow): sanitize condition expression building and cache assigner node inputs
- Sanitize condition expression construction in graph_builder.py using json.dumps to prevent potential injection vulnerabilities.
- Cache input data prior to assigner node execution to ensure variable values are correctly captured before processing.
2026-05-07 16:26:47 +08:00
Timebomb2018
595c3517e3 Merge branch 'refs/heads/develop' into feature/agent-tool_xjn 2026-05-07 12:23:42 +08:00
wxy
d9f08860bc feat(LLM node): integrate exception handling and enable branch routing
- Integrate exception handling configuration into LLM nodes, supporting three strategies: throw exception, return default value, or trigger exception branch.
- Modify execution logic to return a result structure containing a branch signal, enabling routing to designated branches upon failure.
- Update graph_builder to support LLM node branch routing logic using the branch_signal field for conditional judgment.
- Implement backward compatibility to support both legacy and new result formats.
2026-05-07 11:43:24 +08:00
yingzhao
7f9dcaebfb Merge pull request #1048 from SuanmoSuanyangTechnology/feature/knowledgeBase_zy
feat(web): qa not support rechunking
2026-05-06 18:58:54 +08:00
zhaoying
df556aa396 feat(web): qa not support rechunking 2026-05-06 18:56:08 +08:00
Mark
ad2e885f72 [fix] index_not_found_exception 2026-05-06 18:34:07 +08:00
yingzhao
aa2a3d67d6 Merge pull request #1047 from SuanmoSuanyangTechnology/feature/memory_zy
feat(web): memory validation change api params
2026-05-06 18:07:30 +08:00
yingzhao
e6f47da02f Merge pull request #1046 from SuanmoSuanyangTechnology/feature/knowledgeBase_zy
feat(web): deleteDocumentChunk add force_refresh
2026-05-06 18:06:41 +08:00
zhaoying
0adc022f4e feat(web): memory validation change api params 2026-05-06 18:02:52 +08:00
zhaoying
0361bba33f feat(web): deleteDocumentChunk add force_refresh 2026-05-06 18:00:01 +08:00
Mark
70c6d161c8 [fix] delete chunk refresh index 2026-05-06 15:19:46 +08:00
yingzhao
5118e343d6 Merge pull request #1044 from SuanmoSuanyangTechnology/feature/app_zy
fix(web): left port not support add node
2026-05-06 14:35:34 +08:00
yingzhao
c684aa55d5 Merge branch 'develop' into feature/app_zy 2026-05-06 14:34:01 +08:00
zhaoying
577f443459 fix(web): left port not support add node 2026-05-06 14:32:55 +08:00
yingzhao
b3e1fdcf90 Merge pull request #1043 from SuanmoSuanyangTechnology/feature/KnowledgeBase_zy
Feature/knowledge base zy
2026-05-06 14:16:23 +08:00
yingzhao
b2f366b031 Merge branch 'develop' into feature/KnowledgeBase_zy 2026-05-06 14:16:08 +08:00
zhaoying
a947d6d095 feat(web): knowledge base 2026-05-06 14:13:13 +08:00
yingzhao
03d9600c49 Merge pull request #1042 from SuanmoSuanyangTechnology/feature/app_zy
Feature/app zy
2026-05-06 12:23:24 +08:00
yingzhao
ce6ecef35e Merge pull request #1041 from SuanmoSuanyangTechnology/feature/safari_fit_zy
feat(web): workflow Safari browser compatibility
2026-05-06 12:22:43 +08:00
zhaoying
f47c256863 feat(web): workflow Safari browser compatibility 2026-05-06 11:56:30 +08:00
Ke Sun
14eb64f7c6 Merge pull request #1039 from SuanmoSuanyangTechnology/feat/update-readme
Feat/update readme
2026-05-06 11:41:25 +08:00
yingzhao
6b68ee9fc8 Merge pull request #1038 from SuanmoSuanyangTechnology/fix/history_zy
fix(web): history undo/redo
2026-05-06 10:41:42 +08:00
zhaoying
e53be0765a fix(web): history undo/redo 2026-05-06 10:36:02 +08:00
zhaoying
f47873aaea fix(web): knowledge reranker config 2026-04-29 17:24:01 +08:00
zhaoying
4003d7b019 fix(web): llm json_output init 2026-04-29 17:16:37 +08:00
Mark
f85c0594c9 [fix] es vector 2026-04-29 15:24:25 +08:00
Mark
5fceba54b4 [fix] file upload 2026-04-29 13:41:14 +08:00
zhaoying
b0a4f9fa18 fix(web): knowledge config 2026-04-29 12:27:04 +08:00
Mark
6e89302cb2 no message 2026-04-29 11:44:03 +08:00
zhaoying
6197d698a2 fix(web): workflow knowledge save 2026-04-29 11:43:30 +08:00
zhaoying
4d7f9c4dae feat(web): show ids 2026-04-29 11:28:13 +08:00
Mark
90aa4cef21 [add] import qa chunks 2026-04-28 16:38:14 +08:00
Mark
6c47bb77ab [add] task log 2026-04-28 16:13:26 +08:00
Mark
f667936664 [fix] qa cache 2026-04-28 15:53:07 +08:00
Mark
64e640d882 [add] batch chunk. qa_prompt set 2026-04-28 15:33:44 +08:00
Mark
140311048a [modify] rag qa chunk 2026-04-28 14:04:36 +08:00
Timebomb2018
26b843a605 Merge branch 'refs/heads/develop' into feature/agent-tool_xjn 2026-04-28 12:07:50 +08:00
Timebomb2018
15b352d16b Merge branch 'refs/heads/develop' into feature/agent-tool_xjn 2026-04-24 19:41:23 +08:00
76 changed files with 3190 additions and 1049 deletions

View File

@@ -1,5 +1,6 @@
import uuid import uuid
import io import io
import json
from typing import Optional, Annotated from typing import Optional, Annotated
import yaml import yaml
@@ -1068,6 +1069,62 @@ async def draft_run_compare(
return success(data=app_schema.DraftRunCompareResponse(**result)) return success(data=app_schema.DraftRunCompareResponse(**result))
@router.post("/{app_id}/workflow/nodes/{node_id}/run", summary="单节点试运行")
@cur_workspace_access_guard()
async def run_single_workflow_node(
app_id: uuid.UUID,
node_id: str,
payload: app_schema.NodeRunRequest,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
workflow_service: Annotated[WorkflowService, Depends(get_workflow_service)] = None,
):
"""单独执行工作流中的某个节点
inputs 支持以下 key 格式:
- 节点变量: "node_id.var_name"
- 系统变量: "sys.message""sys.files"
"""
workspace_id = current_user.current_workspace_id
config = workflow_service.check_config(app_id)
raw_inputs = payload.inputs or {}
input_data = {
"message": raw_inputs.pop("sys.message", ""),
"files": raw_inputs.pop("sys.files", []),
"user_id": raw_inputs.pop("sys.user_id", str(current_user.id)),
"inputs": raw_inputs,
"conversation_id": "",
"conv_messages": [],
}
if payload.stream:
async def event_generator():
async for event in workflow_service.run_single_node_stream(
app_id=app_id,
node_id=node_id,
config=config,
workspace_id=workspace_id,
input_data=input_data,
):
yield f"event: {event['event']}\ndata: {json.dumps(event['data'], ensure_ascii=False)}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}
)
result = await workflow_service.run_single_node(
app_id=app_id,
node_id=node_id,
config=config,
workspace_id=workspace_id,
input_data=input_data,
)
return success(data=result)
@router.get("/{app_id}/workflow") @router.get("/{app_id}/workflow")
@cur_workspace_access_guard() @cur_workspace_access_guard()
async def get_workflow_config( async def get_workflow_config(

View File

@@ -1,8 +1,10 @@
import os import os
import csv
import io
from typing import Any, Optional from typing import Any, Optional
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -23,6 +25,7 @@ from app.models.user_model import User
from app.schemas import chunk_schema from app.schemas import chunk_schema
from app.schemas.response_schema import ApiResponse from app.schemas.response_schema import ApiResponse
from app.services import knowledge_service, document_service, file_service, knowledgeshare_service from app.services import knowledge_service, document_service, file_service, knowledgeshare_service
from app.services.file_storage_service import FileStorageService, get_file_storage_service, generate_kb_file_key
from app.services.model_service import ModelApiKeyService from app.services.model_service import ModelApiKeyService
# Obtain a dedicated API logger # Obtain a dedicated API logger
@@ -271,6 +274,9 @@ async def create_chunk(
"sort_id": sort_id, "sort_id": sort_id,
"status": 1, "status": 1,
} }
# QA chunk: 注入 chunk_type/question/answer 到 metadata
if create_data.is_qa:
metadata.update(create_data.qa_metadata)
chunk = DocumentChunk(page_content=content, metadata=metadata) chunk = DocumentChunk(page_content=content, metadata=metadata)
# 3. Segmented vector storage # 3. Segmented vector storage
vector_service.add_chunks([chunk]) vector_service.add_chunks([chunk])
@@ -282,6 +288,187 @@ async def create_chunk(
return success(data=jsonable_encoder(chunk), msg="Document chunk creation successful") return success(data=jsonable_encoder(chunk), msg="Document chunk creation successful")
@router.post("/{kb_id}/{document_id}/chunk/batch", response_model=ApiResponse)
async def create_chunks_batch(
kb_id: uuid.UUID,
document_id: uuid.UUID,
batch_data: chunk_schema.ChunkBatchCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Batch create chunks (max 8)
"""
api_logger.info(f"Batch create chunks: kb_id={kb_id}, document_id={document_id}, count={len(batch_data.items)}, username: {current_user.username}")
if len(batch_data.items) > settings.MAX_CHUNK_BATCH_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Batch size exceeds limit: max {settings.MAX_CHUNK_BATCH_SIZE}, got {len(batch_data.items)}"
)
db_knowledge = knowledge_service.get_knowledge_by_id(db, knowledge_id=kb_id, current_user=current_user)
if not db_knowledge:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="The knowledge base does not exist or access is denied")
db_document = db.query(Document).filter(Document.id == document_id).first()
if not db_document:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="The document does not exist or you do not have permission to access it")
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
# Get current max sort_id
sort_id = 0
total, items = vector_service.search_by_segment(document_id=str(document_id), pagesize=1, page=1, asc=False)
if items:
sort_id = items[0].metadata["sort_id"]
chunks = []
for create_data in batch_data.items:
sort_id += 1
doc_id = uuid.uuid4().hex
metadata = {
"doc_id": doc_id,
"file_id": str(db_document.file_id),
"file_name": db_document.file_name,
"file_created_at": int(db_document.created_at.timestamp() * 1000),
"document_id": str(document_id),
"knowledge_id": str(kb_id),
"sort_id": sort_id,
"status": 1,
}
if create_data.is_qa:
metadata.update(create_data.qa_metadata)
chunks.append(DocumentChunk(page_content=create_data.chunk_content, metadata=metadata))
vector_service.add_chunks(chunks)
db_document.chunk_num += len(chunks)
db.commit()
return success(data=jsonable_encoder(chunks), msg=f"Batch created {len(chunks)} chunks successfully")
@router.post("/{kb_id}/import_qa", response_model=ApiResponse)
async def import_qa_new_doc(
kb_id: uuid.UUID,
file: UploadFile = File(..., description="CSV 或 Excel 文件(第一行标题跳过,第一列问题,第二列答案)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
storage_service: FileStorageService = Depends(get_file_storage_service),
):
"""
导入 QA 问答对并新建文档CSV/Excel异步处理
"""
from app.schemas import file_schema, document_schema
api_logger.info(f"Import QA (new doc): kb_id={kb_id}, file={file.filename}, username: {current_user.username}")
# 1. 校验文件格式
filename = file.filename or ""
if not (filename.endswith(".csv") or filename.endswith(".xlsx") or filename.endswith(".xls")):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 CSV (.csv) 或 Excel (.xlsx) 格式")
# 2. 校验知识库
db_knowledge = knowledge_service.get_knowledge_by_id(db, knowledge_id=kb_id, current_user=current_user)
if not db_knowledge:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库不存在或无权访问")
# 3. 读取文件
contents = await file.read()
file_size = len(contents)
if file_size == 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件为空")
_, file_extension = os.path.splitext(filename)
file_ext = file_extension.lower()
# 4. 创建 File 记录
file_data = file_schema.FileCreate(
kb_id=kb_id, created_by=current_user.id,
parent_id=uuid.UUID("00000000-0000-0000-0000-000000000000"),
file_name=filename, file_ext=file_ext, file_size=file_size,
)
db_file = file_service.create_file(db=db, file=file_data, current_user=current_user)
# 5. 上传文件到存储后端
file_key = generate_kb_file_key(kb_id=kb_id, file_id=db_file.id, file_ext=file_ext)
try:
await storage_service.storage.upload(file_key=file_key, content=contents, content_type=file.content_type)
except Exception as e:
api_logger.error(f"Storage upload failed: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"文件存储失败: {str(e)}")
db_file.file_key = file_key
db.commit()
db.refresh(db_file)
# 6. 创建 Document 记录(标记为 QA 类型)
doc_data = document_schema.DocumentCreate(
kb_id=kb_id, created_by=current_user.id, file_id=db_file.id,
file_name=filename, file_ext=file_ext, file_size=file_size,
file_meta={}, parser_id="qa",
parser_config={"doc_type": "qa", "auto_questions": 0}
)
db_document = document_service.create_document(db=db, document=doc_data, current_user=current_user)
api_logger.info(f"Created doc for QA import: file_id={db_file.id}, document_id={db_document.id}, file_key={file_key}")
# 7. 派发异步任务
from app.celery_app import celery_app
task = celery_app.send_task(
"app.core.rag.tasks.import_qa_chunks",
args=[str(kb_id), str(db_document.id), filename, contents],
queue="qa_import"
)
return success(data={
"task_id": task.id,
"document_id": str(db_document.id),
"file_id": str(db_file.id),
}, msg="QA 导入任务已提交,后台处理中")
@router.post("/{kb_id}/{document_id}/import_qa", response_model=ApiResponse)
async def import_qa_chunks(
kb_id: uuid.UUID,
document_id: uuid.UUID,
file: UploadFile = File(..., description="CSV 或 Excel 文件(第一行标题跳过,第一列问题,第二列答案)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
导入 QA 问答对CSV/Excel异步处理
"""
api_logger.info(f"Import QA chunks: kb_id={kb_id}, document_id={document_id}, file={file.filename}, username: {current_user.username}")
# 1. 校验文件格式
filename = file.filename or ""
if not (filename.endswith(".csv") or filename.endswith(".xlsx") or filename.endswith(".xls")):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 CSV (.csv) 或 Excel (.xlsx) 格式")
# 2. 校验知识库和文档
db_knowledge = knowledge_service.get_knowledge_by_id(db, knowledge_id=kb_id, current_user=current_user)
if not db_knowledge:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库不存在或无权访问")
db_document = db.query(Document).filter(Document.id == document_id).first()
if not db_document:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在或无权访问")
# 3. 读取文件内容,派发异步任务
contents = await file.read()
from app.celery_app import celery_app
task = celery_app.send_task(
"app.core.rag.tasks.import_qa_chunks",
args=[str(kb_id), str(document_id), filename, contents],
queue="qa_import"
)
return success(data={"task_id": task.id}, msg="QA 导入任务已提交,后台处理中")
@router.get("/{kb_id}/{document_id}/{doc_id}", response_model=ApiResponse) @router.get("/{kb_id}/{document_id}/{doc_id}", response_model=ApiResponse)
async def get_chunk( async def get_chunk(
kb_id: uuid.UUID, kb_id: uuid.UUID,
@@ -342,6 +529,9 @@ async def update_chunk(
if total: if total:
chunk = items[0] chunk = items[0]
chunk.page_content = content chunk.page_content = content
# QA chunk: 更新 metadata 中的 question/answer
if update_data.is_qa:
chunk.metadata.update(update_data.qa_metadata)
vector_service.update_by_segment(chunk) vector_service.update_by_segment(chunk)
return success(data=jsonable_encoder(chunk), msg="The document chunk has been successfully updated") return success(data=jsonable_encoder(chunk), msg="The document chunk has been successfully updated")
else: else:
@@ -356,6 +546,7 @@ async def delete_chunk(
kb_id: uuid.UUID, kb_id: uuid.UUID,
document_id: uuid.UUID, document_id: uuid.UUID,
doc_id: str, doc_id: str,
force_refresh: bool = Query(False, description="Force Elasticsearch refresh after deletion"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@@ -373,7 +564,7 @@ async def delete_chunk(
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge) vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
if vector_service.text_exists(doc_id): if vector_service.text_exists(doc_id):
vector_service.delete_by_ids([doc_id]) vector_service.delete_by_ids([doc_id], refresh=force_refresh)
# 更新 chunk_num # 更新 chunk_num
db_document = db.query(Document).filter(Document.id == document_id).first() db_document = db.query(Document).filter(Document.id == document_id).first()
db_document.chunk_num -= 1 db_document.chunk_num -= 1

View File

@@ -113,6 +113,33 @@ async def create_chunk(
current_user=current_user) current_user=current_user)
@router.post("/{kb_id}/{document_id}/chunk/batch", response_model=ApiResponse)
@require_api_key(scopes=["rag"])
async def create_chunks_batch(
kb_id: uuid.UUID,
document_id: uuid.UUID,
request: Request,
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
items: list = Body(..., description="chunk items list"),
):
"""
Batch create chunks (max 8)
"""
body = await request.json()
batch_data = chunk_schema.ChunkBatchCreate(**body)
# 0. Obtain the creator of the api key
api_key = api_key_service.ApiKeyService.get_api_key(db, api_key_auth.api_key_id, api_key_auth.workspace_id)
current_user = api_key.creator
current_user.current_workspace_id = api_key_auth.workspace_id
return await chunk_controller.create_chunks_batch(kb_id=kb_id,
document_id=document_id,
batch_data=batch_data,
db=db,
current_user=current_user)
@router.get("/{kb_id}/{document_id}/{doc_id}", response_model=ApiResponse) @router.get("/{kb_id}/{document_id}/{doc_id}", response_model=ApiResponse)
@require_api_key(scopes=["rag"]) @require_api_key(scopes=["rag"])
async def get_chunk( async def get_chunk(
@@ -176,6 +203,7 @@ async def delete_chunk(
request: Request, request: Request,
api_key_auth: ApiKeyAuth = None, api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
force_refresh: bool = Query(False, description="Force Elasticsearch refresh after deletion"),
): ):
""" """
delete document chunk delete document chunk
@@ -188,6 +216,7 @@ async def delete_chunk(
return await chunk_controller.delete_chunk(kb_id=kb_id, return await chunk_controller.delete_chunk(kb_id=kb_id,
document_id=document_id, document_id=document_id,
doc_id=doc_id, doc_id=doc_id,
force_refresh=force_refresh,
db=db, db=db,
current_user=current_user) current_user=current_user)

View File

@@ -98,6 +98,7 @@ class Settings:
# File Upload # File Upload
MAX_FILE_SIZE: int = int(os.getenv("MAX_FILE_SIZE", "52428800")) MAX_FILE_SIZE: int = int(os.getenv("MAX_FILE_SIZE", "52428800"))
MAX_FILE_COUNT: int = int(os.getenv("MAX_FILE_COUNT", "20")) MAX_FILE_COUNT: int = int(os.getenv("MAX_FILE_COUNT", "20"))
MAX_CHUNK_BATCH_SIZE: int = int(os.getenv("MAX_CHUNK_BATCH_SIZE", "8"))
FILE_PATH: str = os.getenv("FILE_PATH", "/files") FILE_PATH: str = os.getenv("FILE_PATH", "/files")
FILE_URL_EXPIRES: int = int(os.getenv("FILE_URL_EXPIRES", "3600")) FILE_URL_EXPIRES: int = int(os.getenv("FILE_URL_EXPIRES", "3600"))

View File

@@ -46,7 +46,10 @@ async def run_graphrag(
start = trio.current_time() start = trio.current_time()
workspace_id, kb_id, document_id = row["workspace_id"], str(row["kb_id"]), row["document_id"] workspace_id, kb_id, document_id = row["workspace_id"], str(row["kb_id"]), row["document_id"]
chunks = [] chunks = []
for d in settings.retriever.chunk_list(document_id, workspace_id, [kb_id], fields=["page_content", "document_id"], sort_by_position=True): for d in settings.retriever.chunk_list(document_id, workspace_id, [kb_id], fields=["page_content", "document_id", "chunk_type"], sort_by_position=True):
# 跳过 QA chunks只用原文 chunks 构建图谱
if d.get("chunk_type") == "qa":
continue
chunks.append(d["page_content"]) chunks.append(d["page_content"])
with trio.fail_after(max(120, len(chunks) * 60 * 10) if enable_timeout_assertion else 10000000000): with trio.fail_after(max(120, len(chunks) * 60 * 10) if enable_timeout_assertion else 10000000000):
@@ -150,6 +153,9 @@ async def run_graphrag_for_kb(
total, items = vector_service.search_by_segment(document_id=str(document_id), query=None, pagesize=9999, page=1, asc=True) total, items = vector_service.search_by_segment(document_id=str(document_id), query=None, pagesize=9999, page=1, asc=True)
for doc in items: for doc in items:
# 跳过 QA chunks只用原文 chunks 构建图谱
if (doc.metadata or {}).get("chunk_type") == "qa":
continue
content = doc.page_content content = doc.page_content
if num_tokens_from_string(current_chunk + content) < 1024: if num_tokens_from_string(current_chunk + content) < 1024:
current_chunk += content current_chunk += content

View File

@@ -131,18 +131,52 @@ def keyword_extraction(chat_mdl, content, topn=3):
def question_proposal(chat_mdl, content, topn=3): def question_proposal(chat_mdl, content, topn=3):
template = PROMPT_JINJA_ENV.from_string(QUESTION_PROMPT_TEMPLATE) """生成问题(向后兼容,返回纯文本问题列表)"""
rendered_prompt = template.render(content=content, topn=topn) pairs = qa_proposal(chat_mdl, content, topn)
if not pairs:
msg = [{"role": "system", "content": rendered_prompt}, {"role": "user", "content": "Output: "}]
_, msg = message_fit_in(msg, getattr(chat_mdl, 'max_length', 8096))
kwd = chat_mdl.chat(rendered_prompt, msg[1:], {"temperature": 0.2})
if isinstance(kwd, tuple):
kwd = kwd[0]
kwd = re.sub(r"^.*</think>", "", kwd, flags=re.DOTALL)
if kwd.find("**ERROR**") >= 0:
return "" return ""
return kwd return "\n".join([p["question"] for p in pairs])
def qa_proposal(chat_mdl, content, topn=3, custom_prompt=None):
"""生成 QA 对,返回 [{"question": ..., "answer": ...}, ...]
Args:
chat_mdl: LLM 模型
content: 文本内容
topn: 生成 QA 对数量
custom_prompt: 自定义 prompt 模板(支持 Jinja2可用变量: content, topn
"""
if custom_prompt:
template = PROMPT_JINJA_ENV.from_string(custom_prompt)
sys_prompt = template.render(topn=topn)
else:
sys_prompt = QUESTION_PROMPT_TEMPLATE
msg = [{"role": "system", "content": sys_prompt}, {"role": "user", "content": content}]
_, msg = message_fit_in(msg, getattr(chat_mdl, 'max_length', 8096))
raw = chat_mdl.chat(sys_prompt, msg[1:], {"temperature": 0.2})
if isinstance(raw, tuple):
raw = raw[0]
raw = re.sub(r"^.*</think>", "", raw, flags=re.DOTALL)
if raw.find("**ERROR**") >= 0:
return []
return parse_qa_pairs(raw)
def parse_qa_pairs(text: str) -> list:
"""解析 LLM 返回的 QA 对文本,格式: Q: xxx A: xxx"""
pairs = []
for line in text.strip().split("\n"):
line = line.strip()
if not line:
continue
# 匹配 Q: ... A: ... 格式
match = re.match(r'^Q:\s*(.+?)\s+A:\s*(.+)$', line, re.IGNORECASE)
if match:
q, a = match.group(1).strip(), match.group(2).strip()
if q and a:
pairs.append({"question": q, "answer": a})
return pairs
def graph_entity_types(chat_mdl, scenario): def graph_entity_types(chat_mdl, scenario):

View File

@@ -1,19 +1,20 @@
## Role ## Role
You are a text analyzer. You are a text analyzer and knowledge extraction expert.
## Task ## Task
Propose {{ topn }} questions about a given piece of text content. Generate question-answer pairs from the given text content.
## Requirements ## Requirements
- Understand and summarize the text content, and propose the top {{ topn }} important questions. - Understand and summarize the text content, then generate up to {{ topn }} important question-answer pairs.
- Each question-answer pair MUST be on a single line, formatted as: Q: <question> A: <answer>
- The questions SHOULD NOT have overlapping meanings. - The questions SHOULD NOT have overlapping meanings.
- The questions SHOULD cover the main content of the text as much as possible. - The questions SHOULD cover the main content of the text as much as possible.
- The questions MUST be in the same language as the given piece of text content. - The answers MUST be concise, accurate, and directly derived from the text content.
- One question per line. - The answers SHOULD be self-contained and understandable without additional context.
- Output questions ONLY. - Both questions and answers MUST be in the same language as the given text content.
- If the text is too short or lacks substantive content, generate fewer pairs rather than padding.
--- - Output question-answer pairs ONLY, no extra explanation or commentary.
## Text Content
{{ content }}
## Example Output
Q: What is the capital of France? A: The capital of France is Paris.
Q: When was the Eiffel Tower built? A: The Eiffel Tower was built in 1889.

View File

@@ -5,7 +5,7 @@ from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
import requests import requests
from elasticsearch import Elasticsearch, helpers from elasticsearch import Elasticsearch, helpers, NotFoundError
from elasticsearch.helpers import BulkIndexError from elasticsearch.helpers import BulkIndexError
from packaging.version import parse as parse_version from packaging.version import parse as parse_version
# langchain-community # langchain-community
@@ -53,13 +53,30 @@ class ElasticSearchVector(BaseVector):
return "elasticsearch" return "elasticsearch"
def add_chunks(self, chunks: list[DocumentChunk], **kwargs): def add_chunks(self, chunks: list[DocumentChunk], **kwargs):
# 实现 Elasticsearch 保存向量 # QA chunks: embedding 只对 question 字段做source chunks: 不做 embedding
texts = [chunk.page_content for chunk in chunks] texts_for_embedding = []
if self.is_multimodal_embedding: for chunk in chunks:
# 火山引擎多模态 Embedding chunk_type = (chunk.metadata or {}).get("chunk_type", "chunk")
embeddings = self.embeddings.embed_batch(texts) if chunk_type == "source":
# source chunk 不需要向量索引
texts_for_embedding.append("")
elif chunk_type == "qa":
# QA chunk: 用 question 字段做 embedding
texts_for_embedding.append((chunk.metadata or {}).get("question", chunk.page_content))
else: else:
embeddings = self.embeddings.embed_documents(list(texts)) # 普通 chunk: 用 page_content 做 embedding
texts_for_embedding.append(chunk.page_content)
if self.is_multimodal_embedding:
embeddings = self.embeddings.embed_batch(texts_for_embedding)
else:
embeddings = self.embeddings.embed_documents(texts_for_embedding)
# source chunk 的向量置空
for i, chunk in enumerate(chunks):
if (chunk.metadata or {}).get("chunk_type") == "source":
embeddings[i] = None
self.create(chunks, embeddings, **kwargs) self.create(chunks, embeddings, **kwargs)
def create(self, chunks: list[DocumentChunk], embeddings: list[list[float]], **kwargs): def create(self, chunks: list[DocumentChunk], embeddings: list[list[float]], **kwargs):
@@ -72,13 +89,25 @@ class ElasticSearchVector(BaseVector):
uuids = self._get_uuids(chunks) uuids = self._get_uuids(chunks)
actions = [] actions = []
for i, chunk in enumerate(chunks): for i, chunk in enumerate(chunks):
action = { source = {
"_index": self._collection_name,
"_source": {
Field.CONTENT_KEY.value: chunk.page_content, Field.CONTENT_KEY.value: chunk.page_content,
Field.METADATA_KEY.value: chunk.metadata or {}, Field.METADATA_KEY.value: chunk.metadata or {},
Field.VECTOR.value: embeddings[i] or None Field.VECTOR.value: embeddings[i] or None
} }
# 写入 QA 相关字段
meta = chunk.metadata or {}
if meta.get("chunk_type"):
source[Field.CHUNK_TYPE.value] = meta["chunk_type"]
if meta.get("question"):
source[Field.QUESTION.value] = meta["question"]
if meta.get("answer"):
source[Field.ANSWER.value] = meta["answer"]
if meta.get("source_chunk_id"):
source[Field.SOURCE_CHUNK_ID.value] = meta["source_chunk_id"]
action = {
"_index": self._collection_name,
"_source": source
} }
actions.append(action) actions.append(action)
# using bulk mode # using bulk mode
@@ -113,7 +142,7 @@ class ElasticSearchVector(BaseVector):
return True return True
def delete_by_ids(self, ids: list[str]): def delete_by_ids(self, ids: list[str], *, refresh: bool = False):
if not ids: if not ids:
return return
if not self._client.indices.exists(index=self._collection_name): if not self._client.indices.exists(index=self._collection_name):
@@ -134,6 +163,8 @@ class ElasticSearchVector(BaseVector):
actions = [{"_op_type": "delete", "_index": self._collection_name, "_id": es_id} for es_id in actual_ids] actions = [{"_op_type": "delete", "_index": self._collection_name, "_id": es_id} for es_id in actual_ids]
try: try:
helpers.bulk(self._client, actions) helpers.bulk(self._client, actions)
if refresh:
self._client.indices.refresh(index=self._collection_name)
except BulkIndexError as e: except BulkIndexError as e:
for error in e.errors: for error in e.errors:
delete_error = error.get('delete', {}) delete_error = error.get('delete', {})
@@ -153,7 +184,7 @@ class ElasticSearchVector(BaseVector):
else: else:
return None return None
def delete_by_metadata_field(self, key: str, value: str): def delete_by_metadata_field(self, key: str, value: str, *, refresh: bool = False):
if not self._client.indices.exists(index=self._collection_name): if not self._client.indices.exists(index=self._collection_name):
return False return False
actual_ids = self.get_ids_by_metadata_field(key, value) actual_ids = self.get_ids_by_metadata_field(key, value)
@@ -162,6 +193,8 @@ class ElasticSearchVector(BaseVector):
actions = [{"_op_type": "delete", "_index": self._collection_name, "_id": es_id} for es_id in actual_ids] actions = [{"_op_type": "delete", "_index": self._collection_name, "_id": es_id} for es_id in actual_ids]
try: try:
helpers.bulk(self._client, actions) helpers.bulk(self._client, actions)
if refresh:
self._client.indices.refresh(index=self._collection_name)
except BulkIndexError as e: except BulkIndexError as e:
for error in e.errors: for error in e.errors:
delete_error = error.get('delete', {}) delete_error = error.get('delete', {})
@@ -192,6 +225,8 @@ class ElasticSearchVector(BaseVector):
List of DocumentChunk objects that match the query. List of DocumentChunk objects that match the query.
""" """
indices = kwargs.get("indices", self._collection_name) # Default single index, multiple indexes are also supported, such as "index1, index2, index3" indices = kwargs.get("indices", self._collection_name) # Default single index, multiple indexes are also supported, such as "index1, index2, index3"
if not self._client.indices.exists(index=indices):
return 0, []
# Calculate the start position for the current page # Calculate the start position for the current page
from_ = pagesize * (page-1) from_ = pagesize * (page-1)
@@ -226,12 +261,15 @@ class ElasticSearchVector(BaseVector):
}) })
# For simplicity, we use from/size here which has a limit (usually up to 10,000). # For simplicity, we use from/size here which has a limit (usually up to 10,000).
try:
result = self._client.search( result = self._client.search(
index=indices, index=indices,
from_=from_, # Only use from_ for the first page (simplified) from_=from_, # Only use from_ for the first page (simplified)
size=pagesize, size=pagesize,
body=query_str, body=query_str,
) )
except NotFoundError:
return 0, []
if "errors" in result: if "errors" in result:
raise ValueError(f"Error during query: {result['errors']}") raise ValueError(f"Error during query: {result['errors']}")
@@ -241,10 +279,19 @@ class ElasticSearchVector(BaseVector):
for res in result["hits"]["hits"]: for res in result["hits"]["hits"]:
source = res["_source"] source = res["_source"]
page_content = source.get(Field.CONTENT_KEY.value) page_content = source.get(Field.CONTENT_KEY.value)
# vector = source.get(Field.VECTOR.value)
vector = None vector = None
metadata = source.get(Field.METADATA_KEY.value, {}) metadata = source.get(Field.METADATA_KEY.value, {})
chunk_type = source.get(Field.CHUNK_TYPE.value)
score = res["_score"] score = res["_score"]
# 将 QA 字段注入 metadata 供前端展示
if chunk_type:
metadata["chunk_type"] = chunk_type
if chunk_type == "qa":
metadata["question"] = source.get(Field.QUESTION.value, "")
metadata["answer"] = source.get(Field.ANSWER.value, "")
page_content = f"Q: {metadata['question']}\nA: {metadata['answer']}"
docs_and_scores.append((DocumentChunk(page_content=page_content, vector=vector, metadata=metadata), score)) docs_and_scores.append((DocumentChunk(page_content=page_content, vector=vector, metadata=metadata), score))
docs = [] docs = []
@@ -267,13 +314,18 @@ class ElasticSearchVector(BaseVector):
List of DocumentChunk objects that match the query. List of DocumentChunk objects that match the query.
""" """
indices = kwargs.get("indices", self._collection_name) # Default single index, multi-index availableetc "index1,index2,index3" indices = kwargs.get("indices", self._collection_name) # Default single index, multi-index availableetc "index1,index2,index3"
if not self._client.indices.exists(index=indices):
return 0, []
query_str = {"query": {"term": {f"{Field.DOC_ID.value}": doc_id}}} query_str = {"query": {"term": {f"{Field.DOC_ID.value}": doc_id}}}
try:
result = self._client.search( result = self._client.search(
index=indices, index=indices,
from_=0, # Only use from_ for the first page (simplified) from_=0, # Only use from_ for the first page (simplified)
size=1, size=1,
body=query_str, body=query_str,
) )
except NotFoundError:
return 0, []
# print(result) # print(result)
if "errors" in result: if "errors" in result:
raise ValueError(f"Error during query: {result['errors']}") raise ValueError(f"Error during query: {result['errors']}")
@@ -308,27 +360,43 @@ class ElasticSearchVector(BaseVector):
Returns: Returns:
updated count. updated count.
""" """
indices = kwargs.get("indices", self._collection_name) # Default single index, multi-index availableetc "index1,index2,index3" indices = kwargs.get("indices", self._collection_name)
if self.is_multimodal_embedding: chunk_type = (chunk.metadata or {}).get("chunk_type")
# 火山引擎多模态 Embedding
chunk.vector = self.embeddings.embed_text(chunk.page_content) # QA chunk: embedding 基于 questionsource chunk: 不更新向量
if chunk_type == "source":
embed_text = ""
elif chunk_type == "qa":
embed_text = (chunk.metadata or {}).get("question", chunk.page_content)
else: else:
chunk.vector = self.embeddings.embed_query(chunk.page_content) embed_text = chunk.page_content
if chunk_type != "source":
if self.is_multimodal_embedding:
chunk.vector = self.embeddings.embed_text(embed_text)
else:
chunk.vector = self.embeddings.embed_query(embed_text)
script_source = "ctx._source.page_content = params.new_content; ctx._source.vector = params.new_vector;"
params = {
"new_content": chunk.page_content,
"new_vector": chunk.vector if chunk_type != "source" else None
}
# QA chunk: 同时更新 question/answer 字段
if chunk_type == "qa":
script_source += " ctx._source.question = params.new_question; ctx._source.answer = params.new_answer;"
params["new_question"] = (chunk.metadata or {}).get("question", "")
params["new_answer"] = (chunk.metadata or {}).get("answer", "")
body = { body = {
"script": { "script": {
"source": """ "source": script_source,
ctx._source.page_content = params.new_content; "params": params
ctx._source.vector = params.new_vector;
""",
"params": {
"new_content": chunk.page_content,
"new_vector": chunk.vector
}
}, },
"query": { "query": {
"term": { "term": {
Field.DOC_ID.value: chunk.metadata["doc_id"] # exact match doc_id Field.DOC_ID.value: chunk.metadata["doc_id"]
} }
} }
} }
@@ -336,9 +404,6 @@ class ElasticSearchVector(BaseVector):
index=indices, index=indices,
body=body, body=body,
) )
# Remove debug printing and use logging instead
# print(result)
# print(f"Update successful, number of affected documents: {result['updated']}")
return result['updated'] return result['updated']
def change_status_by_document_id(self, document_id: str, status: int, **kwargs) -> str: def change_status_by_document_id(self, document_id: str, status: int, **kwargs) -> str:
@@ -397,11 +462,11 @@ class ElasticSearchVector(BaseVector):
} }
} }
}, },
"filter": { # Add the filter condition of status=1 "filter": [
"term": { {"term": {"metadata.status": 1}},
"metadata.status": 1 # 排除 source chunk仅供 GraphRAG 使用,不参与检索)
} {"bool": {"must_not": {"term": {Field.CHUNK_TYPE.value: "source"}}}}
} ]
} }
} }
# If file_names_filter is passed in, merge the filtering conditions # If file_names_filter is passed in, merge the filtering conditions
@@ -415,22 +480,14 @@ class ElasticSearchVector(BaseVector):
}, },
"script": { "script": {
"source": f"cosineSimilarity(params.query_vector, '{Field.VECTOR.value}') + 1.0", "source": f"cosineSimilarity(params.query_vector, '{Field.VECTOR.value}') + 1.0",
# The script_score query calculates the cosine similarity between the embedding field of each document and the query vector. The addition of +1.0 is to ensure that the scores returned by the script are non-negative, as the range of cosine similarity is [-1, 1]
"params": {"query_vector": query_vector} "params": {"query_vector": query_vector}
} }
} }
}, },
"filter": [ "filter": [
{ {"term": {"metadata.status": 1}},
"term": { {"terms": {"metadata.file_name": file_names_filter}},
"metadata.status": 1 {"bool": {"must_not": {"term": {Field.CHUNK_TYPE.value: "source"}}}}
}
},
{
"terms": {
"metadata.file_name": file_names_filter # Additional file_name filtering
}
}
], ],
} }
} }
@@ -451,8 +508,19 @@ class ElasticSearchVector(BaseVector):
source = res["_source"] source = res["_source"]
page_content = source.get(Field.CONTENT_KEY.value) page_content = source.get(Field.CONTENT_KEY.value)
metadata = source.get(Field.METADATA_KEY.value, {}) metadata = source.get(Field.METADATA_KEY.value, {})
chunk_type = source.get(Field.CHUNK_TYPE.value)
score = res["_score"] score = res["_score"]
score = score / 2 # Normalized [0-1] score = score / 2 # Normalized [0-1]
# QA chunk: 返回 Q+A 拼接作为上下文
if chunk_type == "qa":
question = source.get(Field.QUESTION.value, "")
answer = source.get(Field.ANSWER.value, "")
page_content = f"Q: {question}\nA: {answer}"
metadata["chunk_type"] = "qa"
metadata["question"] = question
metadata["answer"] = answer
docs_and_scores.append((DocumentChunk(page_content=page_content, metadata=metadata), score)) docs_and_scores.append((DocumentChunk(page_content=page_content, metadata=metadata), score))
docs = [] docs = []
@@ -491,11 +559,10 @@ class ElasticSearchVector(BaseVector):
} }
} }
}, },
"filter": { # Add the filter condition of status=1 "filter": [
"term": { {"term": {"metadata.status": 1}},
"metadata.status": 1 {"bool": {"must_not": {"term": {Field.CHUNK_TYPE.value: "source"}}}}
} ]
}
} }
} }
@@ -512,16 +579,9 @@ class ElasticSearchVector(BaseVector):
} }
}, },
"filter": [ "filter": [
{ {"term": {"metadata.status": 1}},
"term": { {"terms": {"metadata.file_name": file_names_filter}},
"metadata.status": 1 {"bool": {"must_not": {"term": {Field.CHUNK_TYPE.value: "source"}}}}
}
},
{
"terms": {
"metadata.file_name": file_names_filter # Additional file_name filtering
}
}
], ],
} }
} }
@@ -543,6 +603,17 @@ class ElasticSearchVector(BaseVector):
source = res["_source"] source = res["_source"]
page_content = source.get(Field.CONTENT_KEY.value) page_content = source.get(Field.CONTENT_KEY.value)
metadata = source.get(Field.METADATA_KEY.value, {}) metadata = source.get(Field.METADATA_KEY.value, {})
chunk_type = source.get(Field.CHUNK_TYPE.value)
# QA chunk: 返回 Q+A 拼接作为上下文
if chunk_type == "qa":
question = source.get(Field.QUESTION.value, "")
answer = source.get(Field.ANSWER.value, "")
page_content = f"Q: {question}\nA: {answer}"
metadata["chunk_type"] = "qa"
metadata["question"] = question
metadata["answer"] = answer
# Normalize the score to the [0,1] interval # Normalize the score to the [0,1] interval
normalized_score = res["_score"] / max_score normalized_score = res["_score"] / max_score
docs_and_scores.append((DocumentChunk(page_content=page_content, metadata=metadata), normalized_score)) docs_and_scores.append((DocumentChunk(page_content=page_content, metadata=metadata), normalized_score))
@@ -652,7 +723,7 @@ class ElasticSearchVector(BaseVector):
}, },
Field.VECTOR.value: { Field.VECTOR.value: {
"type": "dense_vector", "type": "dense_vector",
"dims": len(embeddings[0]), # Make sure the dimension is correct here,The dimension size of the vector. When index is true, it cannot exceed 1024; when index is false or not specified, it cannot exceed 2048, which can improve retrieval efficiency "dims": len(next((e for e in embeddings if e is not None), [0]*768)), # 跳过 None 获取向量维度fallback 768
"index": True, "index": True,
"similarity": "cosine" "similarity": "cosine"
} }

View File

@@ -14,3 +14,8 @@ class Field(StrEnum):
DOCUMENT_ID = "metadata.document_id" DOCUMENT_ID = "metadata.document_id"
KNOWLEDGE_ID = "metadata.knowledge_id" KNOWLEDGE_ID = "metadata.knowledge_id"
SORT_ID = "metadata.sort_id" SORT_ID = "metadata.sort_id"
# QA fields
CHUNK_TYPE = "chunk_type" # "chunk" | "source" | "qa"
QUESTION = "question"
ANSWER = "answer"
SOURCE_CHUNK_ID = "source_chunk_id"

View File

@@ -27,14 +27,14 @@ class BaseVector(ABC):
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
def delete_by_ids(self, ids: list[str]): def delete_by_ids(self, ids: list[str], *, refresh: bool = False):
raise NotImplementedError raise NotImplementedError
def get_ids_by_metadata_field(self, key: str, value: str): def get_ids_by_metadata_field(self, key: str, value: str):
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
def delete_by_metadata_field(self, key: str, value: str): def delete_by_metadata_field(self, key: str, value: str, *, refresh: bool = False):
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod

View File

@@ -90,8 +90,8 @@ class SimpleMCPClient:
if self.is_sse: if self.is_sse:
await self._initialize_sse_session() await self._initialize_sse_session()
elif "modelscope.net" in self.server_url: else:
await self._initialize_modelscope_session() await self._initialize_streamable_session()
async def _initialize_sse_session(self): async def _initialize_sse_session(self):
"""初始化 SSE MCP 会话 - 参考 Dify 实现""" """初始化 SSE MCP 会话 - 参考 Dify 实现"""
@@ -208,14 +208,14 @@ class SimpleMCPClient:
if not (200 <= response.status < 300): if not (200 <= response.status < 300):
logger.warning(f"通知发送失败: {response.status}") logger.warning(f"通知发送失败: {response.status}")
async def _initialize_modelscope_session(self): async def _initialize_streamable_session(self):
"""初始化 ModelScope MCP 会话""" """初始化 Streamable HTTP MCP 会话MCP 2025-03-26 规范)"""
init_request = { init_request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": self._get_request_id(), "id": self._get_request_id(),
"method": "initialize", "method": "initialize",
"params": { "params": {
"protocolVersion": "2024-11-05", "protocolVersion": "2025-03-26",
"capabilities": {"tools": {}}, "capabilities": {"tools": {}},
"clientInfo": {"name": "MemoryBear", "version": "1.0.0"} "clientInfo": {"name": "MemoryBear", "version": "1.0.0"}
} }
@@ -227,20 +227,20 @@ class SimpleMCPClient:
error_text = await response.text() error_text = await response.text()
raise MCPConnectionError(f"初始化失败 {response.status}: {error_text}") raise MCPConnectionError(f"初始化失败 {response.status}: {error_text}")
init_response = await response.json() # 提取 session idStreamable HTTP 规范要求后续请求携带)
if "error" in init_response:
raise MCPConnectionError(f"初始化失败: {init_response['error']}")
session_id = response.headers.get("Mcp-Session-Id") or response.headers.get("mcp-session-id") session_id = response.headers.get("Mcp-Session-Id") or response.headers.get("mcp-session-id")
if session_id: if session_id:
self._session.headers.update({"Mcp-Session-Id": session_id}) self._session.headers.update({"Mcp-Session-Id": session_id})
initialized_notification = { init_response = await self._parse_streamable_response(response)
"jsonrpc": "2.0", if "error" in init_response:
"method": "notifications/initialized" raise MCPConnectionError(f"初始化失败: {init_response['error']}")
}
async with self._session.post(self.server_url, json=initialized_notification): self._server_capabilities = init_response.get("result", {}).get("capabilities", {})
# 发送 initialized 通知
notification = {"jsonrpc": "2.0", "method": "notifications/initialized"}
async with self._session.post(self.server_url, json=notification):
pass pass
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
@@ -310,6 +310,21 @@ class SimpleMCPClient:
"method": "notifications/initialized" "method": "notifications/initialized"
})) }))
async def _parse_streamable_response(self, response) -> Dict[str, Any]:
"""解析 Streamable HTTP 响应(支持 JSON 和 SSE 两种格式)"""
content_type = response.headers.get("Content-Type", "")
if "text/event-stream" in content_type:
# 服务端返回 SSE 流,读取第一条 data 消息
async for line in response.content:
line = line.decode("utf-8").strip()
if line.startswith("data:"):
data = line[5:].strip()
if data and data != "[DONE]":
return json.loads(data)
raise MCPConnectionError("SSE 流中未收到有效响应")
else:
return await response.json()
async def list_tools(self) -> List[Dict[str, Any]]: async def list_tools(self) -> List[Dict[str, Any]]:
"""获取工具列表""" """获取工具列表"""
request = { request = {
@@ -326,7 +341,7 @@ class SimpleMCPClient:
response_data = await self._send_sse_request(request) response_data = await self._send_sse_request(request)
else: else:
async with self._session.post(self.server_url, json=request) as response: async with self._session.post(self.server_url, json=request) as response:
response_data = await response.json() response_data = await self._parse_streamable_response(response)
if "error" in response_data: if "error" in response_data:
raise MCPConnectionError(f"获取工具列表失败: {response_data['error']}") raise MCPConnectionError(f"获取工具列表失败: {response_data['error']}")
@@ -351,7 +366,7 @@ class SimpleMCPClient:
response_data = await self._send_sse_request(request) response_data = await self._send_sse_request(request)
else: else:
async with self._session.post(self.server_url, json=request) as response: async with self._session.post(self.server_url, json=request) as response:
response_data = await response.json() response_data = await self._parse_streamable_response(response)
if "error" in response_data: if "error" in response_data:
error = response_data["error"] error = response_data["error"]

View File

@@ -2,6 +2,7 @@
# Author: Eternity # Author: Eternity
# @Email: 1533512157@qq.com # @Email: 1533512157@qq.com
# @Time : 2026/2/10 13:33 # @Time : 2026/2/10 13:33
import json
import logging import logging
import re import re
import uuid import uuid
@@ -141,6 +142,7 @@ class GraphBuilder:
for node_info in source_nodes: for node_info in source_nodes:
if self.get_node_type(node_info["id"]) in BRANCH_NODES: if self.get_node_type(node_info["id"]) in BRANCH_NODES:
if node_info.get("branch") is not None:
branch_nodes.append( branch_nodes.append(
(node_info["id"], node_info["branch"]) (node_info["id"], node_info["branch"])
) )
@@ -314,9 +316,12 @@ class GraphBuilder:
for idx in range(len(related_edge)): for idx in range(len(related_edge)):
# Generate a condition expression for each edge # Generate a condition expression for each edge
# Used later to determine which branch to take based on the node's output # Used later to determine which branch to take based on the node's output
# Assumes node output `node.<node_id>.output` matches the edge's label # For LLM nodes, use branch_signal field for routing (output is dynamic text)
# For example, if node.123.output == 'CASE1', take the branch labeled 'CASE1' # For other branch nodes (e.g. HTTP), use output field
related_edge[idx]['condition'] = f"node['{node_id}']['output'] == '{related_edge[idx]['label']}'" route_field = "branch_signal" if node_type == NodeType.LLM else "output"
related_edge[idx]['condition'] = (
f"node[{json.dumps(node_id)}][{json.dumps(route_field)}] == {json.dumps(related_edge[idx]['label'])}"
)
if node_instance: if node_instance:
# Wrap node's run method to avoid closure issues # Wrap node's run method to avoid closure issues

View File

@@ -18,10 +18,17 @@ class AssignerNode(BaseNode):
super().__init__(node_config, workflow_config, down_stream_nodes) super().__init__(node_config, workflow_config, down_stream_nodes)
self.variable_updater = True self.variable_updater = True
self.typed_config: AssignerNodeConfig | None = None self.typed_config: AssignerNodeConfig | None = None
self._input_data: dict[str, Any] | None = None
def _output_types(self) -> dict[str, VariableType]: def _output_types(self) -> dict[str, VariableType]:
return {} return {}
def _extract_input(self, state: WorkflowState, variable_pool: VariablePool) -> dict[str, Any]:
"""提取节点输入,如果有缓存的执行前数据则使用缓存"""
if self._input_data is not None:
return self._input_data
return {"config": self._resolve_config(self.config, variable_pool)}
async def execute(self, state: WorkflowState, variable_pool: VariablePool) -> Any: async def execute(self, state: WorkflowState, variable_pool: VariablePool) -> Any:
""" """
Execute the assignment operation defined by this node. Execute the assignment operation defined by this node.
@@ -34,6 +41,9 @@ class AssignerNode(BaseNode):
Returns: Returns:
None or the result of the assignment operation. None or the result of the assignment operation.
""" """
# 在执行前提取并缓存输入数据(捕获执行前的变量值)
self._input_data = {"config": self._resolve_config(self.config, variable_pool)}
# Initialize a variable pool for accessing conversation, node, and system variables # Initialize a variable pool for accessing conversation, node, and system variables
self.typed_config = AssignerNodeConfig(**self.config) self.typed_config = AssignerNodeConfig(**self.config)
logger.info(f"节点 {self.node_id} 开始执行") logger.info(f"节点 {self.node_id} 开始执行")

View File

@@ -70,7 +70,7 @@ class IterationRuntime:
self.variable_pool = variable_pool self.variable_pool = variable_pool
self.cycle_nodes = cycle_nodes self.cycle_nodes = cycle_nodes
self.cycle_edges = cycle_edges self.cycle_edges = cycle_edges
self.event_write = get_stream_writer() self.event_write = get_stream_writer() if self.stream else (lambda x: None)
self.output_value = None self.output_value = None
self.result: list = [] self.result: list = []
@@ -196,7 +196,7 @@ class IterationRuntime:
}) })
result = graph.get_state(config=checkpoint).values result = graph.get_state(config=checkpoint).values
else: else:
result = await graph.ainvoke(init_state) result = await graph.ainvoke(init_state, config=checkpoint)
output = child_pool.get_value(self.output_value) output = child_pool.get_value(self.output_value)
stopped = result["looping"] == 2 stopped = result["looping"] == 2

View File

@@ -57,7 +57,7 @@ class LoopRuntime:
self.looping = True self.looping = True
self.variable_pool = variable_pool self.variable_pool = variable_pool
self.child_variable_pool = child_variable_pool self.child_variable_pool = child_variable_pool
self.event_write = get_stream_writer() self.event_write = get_stream_writer() if self.stream else (lambda x: None)
self.checkpoint = RunnableConfig( self.checkpoint = RunnableConfig(
configurable={ configurable={
@@ -223,7 +223,7 @@ class LoopRuntime:
}) })
return self.graph.get_state(config=self.checkpoint).values return self.graph.get_state(config=self.checkpoint).values
else: else:
return await self.graph.ainvoke(loopstate) return await self.graph.ainvoke(loopstate, config=self.checkpoint)
async def run(self): async def run(self):
""" """

View File

@@ -31,7 +31,7 @@ class NodeType(StrEnum):
NOTES = "notes" NOTES = "notes"
BRANCH_NODES = frozenset({NodeType.IF_ELSE, NodeType.HTTP_REQUEST, NodeType.QUESTION_CLASSIFIER}) BRANCH_NODES = frozenset({NodeType.IF_ELSE, NodeType.HTTP_REQUEST, NodeType.QUESTION_CLASSIFIER, NodeType.LLM})
class ComparisonOperator(StrEnum): class ComparisonOperator(StrEnum):

View File

@@ -385,6 +385,7 @@ class HttpRequestNode(BaseNode):
logger.info(f"Node {self.node_id}: HTTP request succeeded") logger.info(f"Node {self.node_id}: HTTP request succeeded")
response = HttpResponse(resp) response = HttpResponse(resp)
# Build raw request summary for process_data # Build raw request summary for process_data
await resp.request.aread()
raw_request = ( raw_request = (
f"{self.typed_config.method.upper()} {resp.request.url} HTTP/1.1\r\n" f"{self.typed_config.method.upper()} {resp.request.url} HTTP/1.1\r\n"
+ "".join(f"{k}: {v}\r\n" for k, v in resp.request.headers.items()) + "".join(f"{k}: {v}\r\n" for k, v in resp.request.headers.items())

View File

@@ -363,11 +363,12 @@ class KnowledgeRetrievalNode(BaseNode):
seen_doc_ids = set() seen_doc_ids = set()
for chunk in final_rs: for chunk in final_rs:
meta = chunk.metadata or {} meta = chunk.metadata or {}
doc_id = meta.get("document_id") or meta.get("doc_id") document_id = meta.get("document_id")
if doc_id and doc_id not in seen_doc_ids: if document_id and document_id not in seen_doc_ids:
seen_doc_ids.add(doc_id) seen_doc_ids.add(document_id)
citations.append({ citations.append({
"document_id": str(doc_id), "document_id": str(document_id),
"doc_id": meta.get("doc_id", ""),
"file_name": meta.get("file_name", ""), "file_name": meta.get("file_name", ""),
"knowledge_id": str(meta.get("knowledge_id", kb_config.kb_id)), "knowledge_id": str(meta.get("knowledge_id", kb_config.kb_id)),
"score": meta.get("score", 0.0), "score": meta.get("score", 0.0),

View File

@@ -6,6 +6,7 @@ import uuid
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from app.core.workflow.nodes.base_config import BaseNodeConfig, VariableDefinition from app.core.workflow.nodes.base_config import BaseNodeConfig, VariableDefinition
from app.core.workflow.nodes.enums import HttpErrorHandle
from app.core.workflow.variable.base_variable import VariableType from app.core.workflow.variable.base_variable import VariableType
@@ -49,6 +50,20 @@ class MemoryWindowSetting(BaseModel):
) )
class LLMErrorHandleConfig(BaseModel):
"""LLM 异常处理配置"""
method: HttpErrorHandle = Field(
default=HttpErrorHandle.NONE,
description="异常处理策略:'none' 抛出异常, 'default' 返回默认值, 'branch' 走异常分支",
)
output: str = Field(
default="",
description="LLM 异常时返回的默认输出文本method=default 时生效)",
)
class LLMNodeConfig(BaseNodeConfig): class LLMNodeConfig(BaseNodeConfig):
"""LLM 节点配置 """LLM 节点配置
@@ -152,6 +167,11 @@ class LLMNodeConfig(BaseNodeConfig):
description="输出变量定义(自动生成,通常不需要修改)" description="输出变量定义(自动生成,通常不需要修改)"
) )
error_handle: LLMErrorHandleConfig = Field(
default_factory=LLMErrorHandleConfig,
description="LLM 异常处理配置",
)
@field_validator("messages", "prompt") @field_validator("messages", "prompt")
@classmethod @classmethod
def validate_input_mode(cls, v): def validate_input_mode(cls, v):

View File

@@ -15,6 +15,7 @@ from app.core.models import RedBearLLM, RedBearModelConfig
from app.core.workflow.engine.state_manager import WorkflowState from app.core.workflow.engine.state_manager import WorkflowState
from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.engine.variable_pool import VariablePool
from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.base_node import BaseNode
from app.core.workflow.nodes.enums import HttpErrorHandle
from app.core.workflow.nodes.llm.config import LLMNodeConfig from app.core.workflow.nodes.llm.config import LLMNodeConfig
from app.core.workflow.variable.base_variable import VariableType from app.core.workflow.variable.base_variable import VariableType
from app.db import get_db_context from app.db import get_db_context
@@ -76,7 +77,7 @@ class LLMNode(BaseNode):
self.messages = [] self.messages = []
def _output_types(self) -> dict[str, VariableType]: def _output_types(self) -> dict[str, VariableType]:
return {"output": VariableType.STRING} return {"output": VariableType.STRING, "branch_signal": VariableType.STRING}
def _render_context(self, message: str, variable_pool: VariablePool): def _render_context(self, message: str, variable_pool: VariablePool):
context = f"<context>{self._render_template(self.typed_config.context, variable_pool)}</context>" context = f"<context>{self._render_template(self.typed_config.context, variable_pool)}</context>"
@@ -239,7 +240,7 @@ class LLMNode(BaseNode):
return llm return llm
async def execute(self, state: WorkflowState, variable_pool: VariablePool) -> AIMessage: async def execute(self, state: WorkflowState, variable_pool: VariablePool):
"""非流式执行 LLM 调用 """非流式执行 LLM 调用
Args: Args:
@@ -247,8 +248,10 @@ class LLMNode(BaseNode):
variable_pool: 变量池 variable_pool: 变量池
Returns: Returns:
LLM 响应消息 dict: {"llm_result": AIMessage, "branch_signal": "SUCCESS"} on success,
{"llm_result": None, "branch_signal": "ERROR"} on branch error
""" """
try:
# self.typed_config = LLMNodeConfig(**self.config) # self.typed_config = LLMNodeConfig(**self.config)
llm = await self._prepare_llm(state, variable_pool, False) llm = await self._prepare_llm(state, variable_pool, False)
@@ -265,10 +268,16 @@ class LLMNode(BaseNode):
logger.info(f"节点 {self.node_id} LLM 调用完成,输出长度: {len(content)}") logger.info(f"节点 {self.node_id} LLM 调用完成,输出长度: {len(content)}")
# 返回 AIMessage包含响应元数据 # 返回 AIMessage包含响应元数据
return AIMessage(content=content, response_metadata={ return {
"llm_result": AIMessage(content=content, response_metadata={
**response.response_metadata, **response.response_metadata,
"token_usage": getattr(response, 'usage_metadata', None) or response.response_metadata.get('token_usage') "token_usage": getattr(response, 'usage_metadata', None) or response.response_metadata.get('token_usage')
}) }),
"branch_signal": "SUCCESS",
}
except Exception as e:
logger.error(f"节点 {self.node_id} LLM 调用失败: {e}")
return self._handle_llm_error(e)
def _extract_input(self, state: WorkflowState, variable_pool: VariablePool) -> dict[str, Any]: def _extract_input(self, state: WorkflowState, variable_pool: VariablePool) -> dict[str, Any]:
"""提取输入数据(用于记录)""" """提取输入数据(用于记录)"""
@@ -286,16 +295,36 @@ class LLMNode(BaseNode):
} }
} }
def _extract_output(self, business_result: Any) -> str: def _extract_output(self, business_result: Any) -> dict:
""" AIMessage 中提取文本内容""" """业务结果中提取输出变量
支持新旧两种格式:
- 新格式:{"llm_result": AIMessage, "branch_signal": "SUCCESS"}
- 旧格式AIMessage向后兼容
"""
if isinstance(business_result, dict) and "branch_signal" in business_result:
llm_result = business_result.get("llm_result")
if isinstance(llm_result, AIMessage):
return {
"output": llm_result.content,
"branch_signal": business_result["branch_signal"],
}
return {
"output": str(llm_result) if llm_result else "",
"branch_signal": business_result["branch_signal"],
}
# 旧格式向后兼容
if isinstance(business_result, AIMessage): if isinstance(business_result, AIMessage):
return business_result.content return {"output": business_result.content, "branch_signal": "SUCCESS"}
return str(business_result) return {"output": str(business_result), "branch_signal": "SUCCESS"}
def _extract_token_usage(self, business_result: Any) -> dict[str, int] | None: def _extract_token_usage(self, business_result: Any) -> dict[str, int] | None:
""" AIMessage 中提取 token 使用情况""" """业务结果中提取 token 使用情况"""
if isinstance(business_result, AIMessage) and hasattr(business_result, 'response_metadata'): llm_result = business_result
usage = business_result.response_metadata.get('token_usage') if isinstance(business_result, dict):
llm_result = business_result.get("llm_result", business_result)
if isinstance(llm_result, AIMessage) and hasattr(llm_result, 'response_metadata'):
usage = llm_result.response_metadata.get('token_usage')
if usage: if usage:
return { return {
"prompt_tokens": usage.get('input_tokens', 0), "prompt_tokens": usage.get('input_tokens', 0),
@@ -304,6 +333,44 @@ class LLMNode(BaseNode):
} }
return None return None
def _handle_llm_error(self, error: Exception) -> dict:
"""处理 LLM 调用异常,根据 error_handle 配置决定行为
Args:
error: LLM 调用中捕获的异常
Returns:
dict: {"llm_result": None, "branch_signal": "ERROR"} for branch mode,
or default output for default mode
Raises:
原异常(当 error_handle.method 为 NONE 时)
"""
if self.typed_config is None:
raise error
match self.typed_config.error_handle.method:
case HttpErrorHandle.NONE:
raise error
case HttpErrorHandle.DEFAULT:
logger.warning(
f"节点 {self.node_id}: LLM 调用失败,返回默认输出"
)
default_output = self.typed_config.error_handle.output or ""
return {
"llm_result": AIMessage(content=default_output, response_metadata={}),
"branch_signal": "SUCCESS",
}
case HttpErrorHandle.BRANCH:
logger.warning(
f"节点 {self.node_id}: LLM 调用失败,切换到异常处理分支"
)
return {
"llm_result": None,
"branch_signal": "ERROR",
}
raise error
async def execute_stream(self, state: WorkflowState, variable_pool: VariablePool): async def execute_stream(self, state: WorkflowState, variable_pool: VariablePool):
"""流式执行 LLM 调用 """流式执行 LLM 调用
@@ -316,10 +383,10 @@ class LLMNode(BaseNode):
""" """
self.typed_config = LLMNodeConfig(**self.config) self.typed_config = LLMNodeConfig(**self.config)
try:
llm = await self._prepare_llm(state, variable_pool, True) llm = await self._prepare_llm(state, variable_pool, True)
logger.info(f"节点 {self.node_id} 开始执行 LLM 调用(流式)") logger.info(f"节点 {self.node_id} 开始执行 LLM 调用(流式)")
# logger.debug(f"LLM 配置: streaming={getattr(llm._model, 'streaming', 'unknown')}")
# 累积完整响应 # 累积完整响应
full_response = "" full_response = ""
@@ -366,4 +433,8 @@ class LLMNode(BaseNode):
) )
# yield 完成标记 # yield 完成标记
yield {"__final__": True, "result": final_message} yield {"__final__": True, "result": {"llm_result": final_message, "branch_signal": "SUCCESS"}}
except Exception as e:
logger.error(f"节点 {self.node_id} LLM 流式调用失败: {e}")
error_result = self._handle_llm_error(e)
yield {"__final__": True, "result": error_result}

View File

@@ -205,6 +205,7 @@ class CitationConfig(BaseModel):
class Citation(BaseModel): class Citation(BaseModel):
document_id: str document_id: str
doc_id: str
file_name: str file_name: str
knowledge_id: str knowledge_id: str
score: float score: float
@@ -703,6 +704,24 @@ class ModelCompareItem(BaseModel):
) )
class NodeRunRequest(BaseModel):
"""单节点试运行请求"""
# 扁平格式,支持:
# 节点变量: {"node_id.var_name": value}
# 系统变量: {"sys.message": "hello", "sys.files": [...]}
inputs: Dict[str, Any] = Field(
default_factory=dict,
description="节点输入变量,格式: {'node_id.var_name': value} 或 {'sys.message': 'hello'}",
examples=[{
"sys.message": "帮我写一首诗",
"sys.user_id": "user-123",
"sys.files": [],
"llm_node_abc.output": "上游输出内容",
}]
)
stream: bool = Field(default=False, description="是否流式返回")
class DraftRunCompareRequest(BaseModel): class DraftRunCompareRequest(BaseModel):
"""多模型对比试运行请求""" """多模型对比试运行请求"""
message: str = Field(..., description="用户消息") message: str = Field(..., description="用户消息")

View File

@@ -20,13 +20,26 @@ class ChunkCreate(BaseModel):
@property @property
def chunk_content(self) -> str: def chunk_content(self) -> str:
""" """Get the actual content string regardless of input type"""
Get the actual content string regardless of input type
"""
if isinstance(self.content, QAChunk): if isinstance(self.content, QAChunk):
return f"question: {self.content.question} answer: {self.content.answer}" return self.content.question # QA 模式下 page_content 存 question
return self.content return self.content
@property
def is_qa(self) -> bool:
return isinstance(self.content, QAChunk)
@property
def qa_metadata(self) -> dict:
"""返回 QA 相关的 metadata 字段"""
if isinstance(self.content, QAChunk):
return {
"chunk_type": "qa",
"question": self.content.question,
"answer": self.content.answer,
}
return {}
class ChunkUpdate(BaseModel): class ChunkUpdate(BaseModel):
content: Union[str, QAChunk] = Field( content: Union[str, QAChunk] = Field(
@@ -35,13 +48,26 @@ class ChunkUpdate(BaseModel):
@property @property
def chunk_content(self) -> str: def chunk_content(self) -> str:
""" """Get the actual content string regardless of input type"""
Get the actual content string regardless of input type
"""
if isinstance(self.content, QAChunk): if isinstance(self.content, QAChunk):
return f"question: {self.content.question} answer: {self.content.answer}" return self.content.question # QA 模式下 page_content 存 question
return self.content return self.content
@property
def is_qa(self) -> bool:
return isinstance(self.content, QAChunk)
@property
def qa_metadata(self) -> dict:
"""返回 QA 相关的 metadata 字段"""
if isinstance(self.content, QAChunk):
return {
"chunk_type": "qa",
"question": self.content.question,
"answer": self.content.answer,
}
return {}
class ChunkRetrieve(BaseModel): class ChunkRetrieve(BaseModel):
query: str query: str
@@ -51,3 +77,8 @@ class ChunkRetrieve(BaseModel):
vector_similarity_weight: float | None = Field(None) vector_similarity_weight: float | None = Field(None)
top_k: int | None = Field(None) top_k: int | None = Field(None)
retrieve_type: RetrieveType | None = Field(None) retrieve_type: RetrieveType | None = Field(None)
class ChunkBatchCreate(BaseModel):
"""批量创建 chunk"""
items: list[ChunkCreate] = Field(..., min_length=1, description="chunk 列表")

View File

@@ -242,11 +242,12 @@ def create_knowledge_retrieval_tool(kb_config, kb_ids, user_id, citations_collec
seen_doc_ids = {c.get("document_id") for c in citations_collector} seen_doc_ids = {c.get("document_id") for c in citations_collector}
for chunk in retrieve_chunks_result: for chunk in retrieve_chunks_result:
meta = chunk.metadata or {} meta = chunk.metadata or {}
doc_id = meta.get("document_id") or meta.get("doc_id") document_id = meta.get("document_id")
if doc_id and doc_id not in seen_doc_ids: if document_id and document_id not in seen_doc_ids:
seen_doc_ids.add(doc_id) seen_doc_ids.add(document_id)
citations_collector.append(Citation( citations_collector.append(Citation(
document_id=doc_id, document_id=str(document_id),
doc_id=meta.get("doc_id", ""),
file_name=meta.get("file_name", ""), file_name=meta.get("file_name", ""),
knowledge_id=str(meta.get("knowledge_id", "")), knowledge_id=str(meta.get("knowledge_id", "")),
score=meta.get("score", 0) score=meta.get("score", 0)

View File

@@ -2,6 +2,7 @@
工作流服务层 工作流服务层
""" """
import datetime import datetime
import time
import logging import logging
import uuid import uuid
from typing import Any, Annotated, Optional from typing import Any, Annotated, Optional
@@ -17,7 +18,6 @@ from app.core.workflow.executor import execute_workflow, execute_workflow_stream
from app.core.workflow.nodes.enums import NodeType from app.core.workflow.nodes.enums import NodeType
from app.core.workflow.validator import validate_workflow_config from app.core.workflow.validator import validate_workflow_config
from app.db import get_db from app.db import get_db
from sqlalchemy import select
from app.models import App from app.models import App
from app.models.workflow_model import WorkflowConfig, WorkflowExecution, WorkflowNodeExecution from app.models.workflow_model import WorkflowConfig, WorkflowExecution, WorkflowNodeExecution
from app.repositories import knowledge_repository from app.repositories import knowledge_repository
@@ -1070,6 +1070,189 @@ class WorkflowService:
} }
} }
async def _build_node_context(
self,
app_id: uuid.UUID,
node_id: str,
config: WorkflowConfig,
workspace_id: uuid.UUID,
input_data: dict[str, Any],
):
"""构建单节点执行所需的上下文node_config, node, state, variable_pool"""
from app.core.workflow.engine.runtime_schema import ExecutionContext
from app.core.workflow.engine.variable_pool import VariablePool, VariablePoolInitializer
from app.core.workflow.engine.state_manager import WorkflowState
from app.core.workflow.nodes.node_factory import NodeFactory
from app.core.workflow.variable.base_variable import VariableType
if not config:
config = self.get_workflow_config(app_id)
if not config:
raise BusinessException(code=BizCode.CONFIG_MISSING, message="工作流配置不存在")
node_config = next((n for n in config.nodes if n.get("id") == node_id), None)
if not node_config:
raise BusinessException(code=BizCode.NOT_FOUND, message=f"节点不存在: node_id={node_id}")
workflow_config_dict = {
"nodes": config.nodes,
"edges": config.edges,
"variables": config.variables or [],
"execution_config": config.execution_config or {},
"features": config.features or {},
}
storage_type, user_rag_memory_id = self._get_memory_store_info(workspace_id)
execution_id = f"node_{uuid.uuid4().hex[:16]}"
execution_context = ExecutionContext.create(
execution_id=execution_id,
workspace_id=str(workspace_id),
user_id=input_data.get("user_id", ""),
conversation_id=input_data.get("conversation_id", ""),
memory_storage_type=storage_type,
user_rag_memory_id=user_rag_memory_id,
)
# sys.files 转换为 FileObject 格式
raw_files = input_data.get("files") or []
if raw_files:
from app.schemas.app_schema import FileInput
file_inputs = [
FileInput(**f) if isinstance(f, dict) else f
for f in raw_files
]
input_data["files"] = await self._handle_file_input(file_inputs)
variable_pool = VariablePool()
await VariablePoolInitializer(workflow_config_dict).initialize(variable_pool, input_data, execution_context)
# 注入节点输入变量,支持扁平格式 {"node_id.var": value}
for key, value in (input_data.get("inputs") or {}).items():
if "." in key:
ref_node_id, var_name = key.split(".", 1)
var_type = VariableType.type_map(value)
await variable_pool.new(ref_node_id, var_name, value, var_type, mut=False)
state = WorkflowState(
messages=input_data.get("conv_messages", []),
node_outputs={},
execution_id=execution_id,
workspace_id=str(workspace_id),
user_id=input_data.get("user_id", ""),
error=None,
error_node=None,
cycle_nodes=[],
looping=0,
activate={node_id: True},
memory_storage_type=storage_type,
user_rag_memory_id=user_rag_memory_id,
)
node = NodeFactory.create_node(node_config, workflow_config_dict, [])
return node_config, node, state, variable_pool
async def run_single_node(
self,
app_id: uuid.UUID,
node_id: str,
config: WorkflowConfig,
workspace_id: uuid.UUID,
input_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""单节点执行(非流式)"""
input_data = input_data or {}
node_config, node, state, variable_pool = await self._build_node_context(
app_id, node_id, config, workspace_id, input_data
)
start_time = time.time()
try:
result = await node.execute(state, variable_pool)
elapsed = (time.time() - start_time) * 1000
return {
"status": "completed",
"node_id": node_id,
"node_type": node_config.get("type"),
"inputs": node._extract_input(state, variable_pool),
"outputs": node._extract_output(result),
"token_usage": node._extract_token_usage(result),
"elapsed_time": elapsed,
"error": None,
}
except Exception as e:
elapsed = (time.time() - start_time) * 1000
logger.error(f"单节点执行失败: node_id={node_id}, error={e}", exc_info=True)
return {
"status": "failed",
"node_id": node_id,
"node_type": node_config.get("type"),
"inputs": node._extract_input(state, variable_pool),
"outputs": None,
"token_usage": None,
"elapsed_time": elapsed,
"error": str(e),
}
async def run_single_node_stream(
self,
app_id: uuid.UUID,
node_id: str,
config: WorkflowConfig,
workspace_id: uuid.UUID,
input_data: dict[str, Any] | None = None,
):
"""单节点执行(流式)
Yields:
node_start -> node_chunkLLM 等流式节点)-> node_end / node_error
"""
input_data = input_data or {}
node_config, node, state, variable_pool = await self._build_node_context(
app_id, node_id, config, workspace_id, input_data
)
node_type = node_config.get("type")
start_time = time.time()
yield {"event": "node_start", "data": {"node_id": node_id, "node_type": node_type}}
final_result = None
try:
async for item in node.execute_stream(state, variable_pool):
if item.get("__final__"):
final_result = item["result"]
else:
chunk = item.get("chunk", "")
if chunk:
yield {"event": "node_chunk", "data": {"node_id": node_id, "chunk": chunk}}
elapsed = (time.time() - start_time) * 1000
yield {
"event": "node_end",
"data": {
"node_id": node_id,
"node_type": node_type,
"status": "succeeded",
"inputs": node._extract_input(state, variable_pool),
"outputs": node._extract_output(final_result),
"token_usage": node._extract_token_usage(final_result),
"elapsed_time": elapsed,
"error": None,
}
}
except Exception as e:
elapsed = (time.time() - start_time) * 1000
logger.error(f"单节点流式执行失败: node_id={node_id}, error={e}", exc_info=True)
yield {
"event": "node_error",
"data": {
"node_id": node_id,
"node_type": node_type,
"inputs": node._extract_input(state, variable_pool),
"elapsed_time": elapsed,
"error": str(e),
}
}
@staticmethod @staticmethod
def get_start_node_variables(config: dict) -> list: def get_start_node_variables(config: dict) -> list:
nodes = config.get("nodes", []) nodes = config.get("nodes", [])

View File

@@ -30,7 +30,7 @@ from app.core.rag.llm.cv_model import QWenCV
from app.core.rag.llm.embedding_model import OpenAIEmbed from app.core.rag.llm.embedding_model import OpenAIEmbed
from app.core.rag.llm.sequence2txt_model import QWenSeq2txt from app.core.rag.llm.sequence2txt_model import QWenSeq2txt
from app.core.rag.models.chunk import DocumentChunk from app.core.rag.models.chunk import DocumentChunk
from app.core.rag.prompts.generator import question_proposal from app.core.rag.prompts.generator import question_proposal, qa_proposal
from app.core.rag.vdb.elasticsearch.elasticsearch_vector import ( from app.core.rag.vdb.elasticsearch.elasticsearch_vector import (
ElasticSearchVectorFactory, ElasticSearchVectorFactory,
) )
@@ -311,6 +311,7 @@ def parse_document(file_key: str, document_id: uuid.UUID, file_name: str = ""):
vector_service.delete_by_metadata_field(key="document_id", value=str(document_id)) vector_service.delete_by_metadata_field(key="document_id", value=str(document_id))
# 2.2 Vectorize and import batch documents # 2.2 Vectorize and import batch documents
auto_questions_topn = db_document.parser_config.get("auto_questions", 0) auto_questions_topn = db_document.parser_config.get("auto_questions", 0)
qa_prompt = db_document.parser_config.get("qa_prompt", None)
chat_model = None chat_model = None
if auto_questions_topn: if auto_questions_topn:
chat_model = Base( chat_model = Base(
@@ -318,48 +319,80 @@ def parse_document(file_key: str, document_id: uuid.UUID, file_name: str = ""):
model_name=db_knowledge.llm.api_keys[0].model_name, model_name=db_knowledge.llm.api_keys[0].model_name,
base_url=db_knowledge.llm.api_keys[0].api_base, base_url=db_knowledge.llm.api_keys[0].api_base,
) )
logger.info(f"[QA] LLM model: {db_knowledge.llm.api_keys[0].model_name}, base_url: {db_knowledge.llm.api_keys[0].api_base}")
if qa_prompt:
logger.info(f"[QA] Using custom prompt ({len(qa_prompt)} chars)")
# 预先构建所有 batch 的 chunks保证 sort_id 全局有序 # 预先构建所有 batch 的 chunks保证 sort_id 全局有序
all_batch_chunks: list[list[DocumentChunk]] = [] all_batch_chunks: list[list[DocumentChunk]] = []
if auto_questions_topn: if auto_questions_topn:
# auto_questions 开启:先并发生成所有 chunk 的问题,再按 batch 分组 # QA 模式FastGPT 方案):
# 构建 (global_idx, item) 列表 # 1. 原 chunk 标记为 source保留供 GraphRAG 使用,不参与检索)
# 2. LLM 生成 QA 对,每个 QA 对独立存储为 qa chunk
indexed_items = list(enumerate(res)) indexed_items = list(enumerate(res))
def _generate_question(idx_item: tuple[int, dict]) -> tuple[int, str]: def _generate_qa(idx_item: tuple[int, dict]) -> tuple[int, list]:
"""为单个 chunk 生成问题(带缓存),返回 (global_idx, question_text)""" """为单个 chunk 生成 QA 对(带缓存),返回 (global_idx, qa_pairs)"""
global_idx, item = idx_item global_idx, item = idx_item
content = item["content_with_weight"] content = item["content_with_weight"]
cached = get_llm_cache(chat_model.model_name, content, "question", cache_params = {"topn": auto_questions_topn}
{"topn": auto_questions_topn}) if qa_prompt:
import hashlib
cache_params["prompt_hash"] = hashlib.md5(qa_prompt.encode()).hexdigest()[:8]
cached = get_llm_cache(chat_model.model_name, content, "qa", cache_params)
if not cached: if not cached:
cached = question_proposal(chat_model, content, auto_questions_topn) logger.info(f"[QA] Cache miss for chunk {global_idx}, calling LLM. cache_params={cache_params}")
set_llm_cache(chat_model.model_name, content, cached, "question", try:
{"topn": auto_questions_topn}) pairs = qa_proposal(chat_model, content, auto_questions_topn, custom_prompt=qa_prompt)
return global_idx, cached except Exception as e:
logger.error(f"[QA] LLM call failed: model={chat_model.model_name}, base_url={getattr(chat_model, 'base_url', 'N/A')}, error={e}")
return global_idx, []
logger.info(f"[QA] Chunk {global_idx} generated {len(pairs)} QA pairs")
# 缓存存 JSON 字符串
set_llm_cache(chat_model.model_name, content, json.dumps(pairs, ensure_ascii=False), "qa",
cache_params)
return global_idx, pairs
logger.info(f"[QA] Cache hit for chunk {global_idx}, cache_params={cache_params}, cached_type={type(cached).__name__}")
# 从缓存读取:可能是 JSON 字符串或旧格式纯文本
if isinstance(cached, str):
try:
parsed = json.loads(cached)
if isinstance(parsed, list):
logger.info(f"[QA] Chunk {global_idx} loaded {len(parsed)} QA pairs from cache")
return global_idx, parsed
except (json.JSONDecodeError, TypeError):
pass
# 旧缓存格式(纯文本问题),尝试解析
from app.core.rag.prompts.generator import parse_qa_pairs
return global_idx, parse_qa_pairs(cached) if cached else []
return global_idx, cached if isinstance(cached, list) else []
# 并发调用 LLM 生成问题 # 并发调用 LLM 生成 QA 对
question_map: dict[int, str] = {} qa_map: dict[int, list] = {}
with ThreadPoolExecutor(max_workers=AUTO_QUESTIONS_MAX_WORKERS) as q_executor: with ThreadPoolExecutor(max_workers=AUTO_QUESTIONS_MAX_WORKERS) as q_executor:
futures = {q_executor.submit(_generate_question, item): item[0] futures = {q_executor.submit(_generate_qa, item): item[0]
for item in indexed_items} for item in indexed_items}
for future in futures: for future in futures:
global_idx, cached = future.result() global_idx, pairs = future.result()
question_map[global_idx] = cached qa_map[global_idx] = pairs
progress_lines.append( progress_lines.append(
f"{datetime.now().strftime('%H:%M:%S')} Auto questions generated for {total_chunks} chunks " f"{datetime.now().strftime('%H:%M:%S')} QA pairs generated for {total_chunks} chunks "
f"(workers={AUTO_QUESTIONS_MAX_WORKERS}).") f"(workers={AUTO_QUESTIONS_MAX_WORKERS}).")
# 按 batch 分组组装 DocumentChunk # 组装 chunkssource chunks + qa chunks
for batch_start in range(0, total_chunks, EMBEDDING_BATCH_SIZE): source_chunks = []
batch_end = min(batch_start + EMBEDDING_BATCH_SIZE, total_chunks) qa_chunks = []
chunks = [] qa_sort_id = 0
for global_idx in range(batch_start, batch_end):
for global_idx in range(total_chunks):
item = res[global_idx] item = res[global_idx]
metadata = { source_chunk_id = uuid.uuid4().hex
"doc_id": uuid.uuid4().hex,
# source chunk保留原文供 GraphRAG 使用,不参与向量检索
source_meta = {
"doc_id": source_chunk_id,
"file_id": str(db_document.file_id), "file_id": str(db_document.file_id),
"file_name": db_document.file_name, "file_name": db_document.file_name,
"file_created_at": int(db_document.created_at.timestamp() * 1000), "file_created_at": int(db_document.created_at.timestamp() * 1000),
@@ -367,13 +400,42 @@ def parse_document(file_key: str, document_id: uuid.UUID, file_name: str = ""):
"knowledge_id": str(db_document.kb_id), "knowledge_id": str(db_document.kb_id),
"sort_id": global_idx, "sort_id": global_idx,
"status": 1, "status": 1,
"chunk_type": "source",
} }
cached = question_map[global_idx] source_chunks.append(
chunks.append( DocumentChunk(page_content=item["content_with_weight"], metadata=source_meta))
DocumentChunk(
page_content=f"question: {cached} answer: {item['content_with_weight']}", # qa chunks每个 QA 对独立存储
metadata=metadata)) pairs = qa_map.get(global_idx, [])
all_batch_chunks.append(chunks) for pair in pairs:
qa_meta = {
"doc_id": uuid.uuid4().hex,
"file_id": str(db_document.file_id),
"file_name": db_document.file_name,
"file_created_at": int(db_document.created_at.timestamp() * 1000),
"document_id": str(db_document.id),
"knowledge_id": str(db_document.kb_id),
"sort_id": qa_sort_id,
"status": 1,
"chunk_type": "qa",
"question": pair["question"],
"answer": pair["answer"],
"source_chunk_id": source_chunk_id,
}
# page_content 存 question用于向量索引
qa_chunks.append(
DocumentChunk(page_content=pair["question"], metadata=qa_meta))
qa_sort_id += 1
# 按 batch 分组source + qa 一起)
all_chunks = source_chunks + qa_chunks
for batch_start in range(0, len(all_chunks), EMBEDDING_BATCH_SIZE):
batch_end = min(batch_start + EMBEDDING_BATCH_SIZE, len(all_chunks))
all_batch_chunks.append(all_chunks[batch_start:batch_end])
progress_lines.append(
f"{datetime.now().strftime('%H:%M:%S')} QA mode: {len(source_chunks)} source chunks + "
f"{len(qa_chunks)} QA chunks prepared.")
else: else:
# 无 auto_questions直接构建 chunks # 无 auto_questions直接构建 chunks
for batch_start in range(0, total_chunks, EMBEDDING_BATCH_SIZE): for batch_start in range(0, total_chunks, EMBEDDING_BATCH_SIZE):
@@ -635,6 +697,136 @@ def build_graphrag_for_document(document_id: str, knowledge_id: str):
return f"build_graphrag_for_document '{document_id}' failed: {e}" return f"build_graphrag_for_document '{document_id}' failed: {e}"
@celery_app.task(name="app.core.rag.tasks.import_qa_chunks", queue="qa_import")
def import_qa_chunks(kb_id: str, document_id: str, filename: str, contents: bytes):
"""
异步导入 QA 问答对CSV/Excel
文件格式:第一行标题(跳过),第一列问题,第二列答案
"""
import csv as csv_module
import io
db = None
try:
from app.db import get_db_context
with get_db_context() as db:
db_document = db.query(Document).filter(Document.id == uuid.UUID(document_id)).first()
db_knowledge = db.query(Knowledge).filter(Knowledge.id == uuid.UUID(kb_id)).first()
if not db_document or not db_knowledge:
logger.error(f"[ImportQA] document={document_id} or knowledge={kb_id} not found")
return {"error": "document or knowledge not found", "imported": 0}
# 1. 解析文件
qa_pairs = []
failed_rows = []
if filename.endswith(".csv"):
try:
text = contents.decode("utf-8-sig")
except UnicodeDecodeError:
text = contents.decode("gbk", errors="ignore")
sniffer = csv_module.Sniffer()
try:
dialect = sniffer.sniff(text[:2048])
delimiter = dialect.delimiter
except csv_module.Error:
delimiter = "," if "," in text[:500] else "\t"
reader = csv_module.reader(io.StringIO(text), delimiter=delimiter)
for i, row in enumerate(reader):
if i == 0:
continue
if len(row) >= 2 and row[0].strip() and row[1].strip():
qa_pairs.append({"question": row[0].strip(), "answer": row[1].strip()})
elif len(row) >= 1 and row[0].strip():
failed_rows.append(i + 1)
elif filename.endswith(".xlsx") or filename.endswith(".xls"):
try:
import openpyxl
wb = openpyxl.load_workbook(io.BytesIO(contents), read_only=True)
for sheet in wb.worksheets:
for i, row in enumerate(sheet.iter_rows(values_only=True)):
if i == 0:
continue
if len(row) >= 2 and row[0] and row[1]:
q = str(row[0]).strip()
a = str(row[1]).strip()
if q and a:
qa_pairs.append({"question": q, "answer": a})
elif len(row) >= 1 and row[0]:
failed_rows.append(i + 1)
wb.close()
except Exception as e:
logger.error(f"[ImportQA] Excel parse failed: {e}")
return {"error": f"Excel parse failed: {e}", "imported": 0}
if not qa_pairs:
logger.warning(f"[ImportQA] No valid QA pairs found in {filename}")
return {"error": "No valid QA pairs found", "imported": 0}
logger.info(f"[ImportQA] Parsed {len(qa_pairs)} QA pairs from {filename}, failed_rows={failed_rows}")
# 2. 写入 ES
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
sort_id = 0
total, items = vector_service.search_by_segment(document_id=document_id, pagesize=1, page=1, asc=False)
if items:
sort_id = items[0].metadata["sort_id"]
chunks = []
for pair in qa_pairs:
sort_id += 1
doc_id = uuid.uuid4().hex
metadata = {
"doc_id": doc_id,
"file_id": str(db_document.file_id),
"file_name": db_document.file_name,
"file_created_at": int(db_document.created_at.timestamp() * 1000),
"document_id": document_id,
"knowledge_id": kb_id,
"sort_id": sort_id,
"status": 1,
"chunk_type": "qa",
"question": pair["question"],
"answer": pair["answer"],
}
chunks.append(DocumentChunk(page_content=pair["question"], metadata=metadata))
batch_size = 50
for i in range(0, len(chunks), batch_size):
batch = chunks[i:i + batch_size]
vector_service.add_chunks(batch)
# 3. 更新 chunk_num 和 progress
db_document.chunk_num += len(chunks)
db_document.progress = 1.0
db_document.progress_msg = f"QA 导入完成: {len(chunks)}"
db.commit()
result = {"imported": len(chunks), "failed_rows": failed_rows}
logger.info(f"[ImportQA] Done: imported={len(chunks)}, failed={len(failed_rows)}")
return result
except Exception as e:
logger.error(f"[ImportQA] Failed: {e}", exc_info=True)
# 尝试更新文档状态为失败
try:
from app.db import get_db_context
with get_db_context() as err_db:
doc = err_db.query(Document).filter(Document.id == uuid.UUID(document_id)).first()
if doc:
doc.progress = -1.0
doc.progress_msg = f"QA 导入失败: {str(e)[:200]}"
err_db.commit()
except Exception:
pass
return {"error": str(e), "imported": 0}
@celery_app.task(name="app.core.rag.tasks.sync_knowledge_for_kb") @celery_app.task(name="app.core.rag.tasks.sync_knowledge_for_kb")
def sync_knowledge_for_kb(kb_id: uuid.UUID): def sync_knowledge_for_kb(kb_id: uuid.UUID):
""" """

View File

@@ -19,5 +19,8 @@ export default defineConfig([
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
rules: {
'@typescript-eslint/no-explicit-any': false
}
}, },
]) ])

View File

@@ -62,6 +62,7 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"x6-html-shape": "^0.4.9",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 13:59:45 * @Date: 2026-02-03 13:59:45
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-24 15:48:30 * @Last Modified time: 2026-05-06 15:09:49
*/ */
import { request } from '@/utils/request' import { request } from '@/utils/request'
import type { ApplicationModalData } from '@/views/ApplicationManagement/types' import type { ApplicationModalData } from '@/views/ApplicationManagement/types'
@@ -179,3 +179,7 @@ export const getAppLogDetail = (app_id: string, conversation_id: string) => {
export const resetAppModelConfig = (app_id: string) => { export const resetAppModelConfig = (app_id: string) => {
return request.get(`/apps/${app_id}/model/parameters/default`) return request.get(`/apps/${app_id}/model/parameters/default`)
} }
// Single node test run
export const nodeRun = (app_id: string, node_id: string, values: Record<string, unknown>) => {
return request.post(`/apps/${app_id}/workflow/nodes/${node_id}/run`, values)
}

View File

@@ -154,6 +154,19 @@ export const uploadFile = async (data: FormData, options?: UploadFileOptions) =>
}); });
return response as UploadFileResponse; return response as UploadFileResponse;
}; };
// 上传 QA 文件
export const uploadQaFile = async (data: FormData, options?: UploadFileOptions) => {
const { kb_id, parent_id, onUploadProgress, signal } = options || {};
const params: Record<string, string> = {};
if (kb_id) params.kb_id = kb_id;
if (parent_id) params.parent_id = parent_id;
const response = await request.uploadFile(`/chunks/${kb_id}/import_qa`, data, {
params,
onUploadProgress,
signal,
});
return response as UploadFileResponse;
};
// 下载文件 // 下载文件
export const downloadFile = async (fileId: string, fileName?: string) => { export const downloadFile = async (fileId: string, fileName?: string) => {
@@ -293,7 +306,10 @@ export const updateDocumentChunk = async (kb_id:string, document_id:string, doc_
const response = await request.put(`${apiPrefix}/chunks/${kb_id}/${document_id}/${doc_id}`, data); const response = await request.put(`${apiPrefix}/chunks/${kb_id}/${document_id}/${doc_id}`, data);
return response as any; return response as any;
}; };
export const deleteDocumentChunk = async (kb_id: string, document_id: string, doc_id: string) => {
const response = await request.delete(`${apiPrefix}/chunks/${kb_id}/${document_id}/${doc_id}?force_refresh=true`);
return response as any;
};
// 文档块儿创建 // 文档块儿创建
export const createDocumentChunk = async (kb_id:string, document_id:string, data: any) => { export const createDocumentChunk = async (kb_id:string, document_id:string, data: any) => {
const response = await request.post(`${apiPrefix}/chunks/${kb_id}/${document_id}/chunk`, data); const response = await request.post(`${apiPrefix}/chunks/${kb_id}/${document_id}/chunk`, data);

View File

@@ -0,0 +1 @@
Q A
1 Q A

View File

@@ -2,7 +2,7 @@
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>编组 31</title> <title>编组 31</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linejoin="round"> <g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linejoin="round">
<g id="应用管理-工作流-配置-开始" transform="translate(-1325, -24)" stroke="#171719" stroke-width="1.2"> <g id="应用管理-工作流-配置-开始" transform="translate(-1325, -24)" stroke="#5B6167" stroke-width="1.2">
<g id="运行" transform="translate(1318, 17)"> <g id="运行" transform="translate(1318, 17)">
<g id="编组-31" transform="translate(7, 7)"> <g id="编组-31" transform="translate(7, 7)">
<path d="M4.5,3.55424764 L4.5,12.4457524 C4.5,12.9980371 4.94771525,13.4457524 5.5,13.4457524 C5.68741972,13.4457524 5.87106734,13.3930829 6.02999894,13.2937507 L13.1432027,8.8479983 C13.6115392,8.55528797 13.7539124,7.93833759 13.4612021,7.47000106 C13.3807214,7.34123193 13.2719718,7.2324824 13.1432027,7.1520017 L6.02999894,2.70624934 C5.56166241,2.41353901 4.94471203,2.55591217 4.6520017,3.0242487 C4.55266944,3.1831803 4.5,3.36682792 4.5,3.55424764 Z" id="路径-46"></path> <path d="M4.5,3.55424764 L4.5,12.4457524 C4.5,12.9980371 4.94771525,13.4457524 5.5,13.4457524 C5.68741972,13.4457524 5.87106734,13.3930829 6.02999894,13.2937507 L13.1432027,8.8479983 C13.6115392,8.55528797 13.7539124,7.93833759 13.4612021,7.47000106 C13.3807214,7.34123193 13.2719718,7.2324824 13.1432027,7.1520017 L6.02999894,2.70624934 C5.56166241,2.41353901 4.94471203,2.55591217 4.6520017,3.0242487 C4.55266944,3.1831803 4.5,3.36682792 4.5,3.55424764 Z" id="路径-46"></path>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -54,10 +54,14 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
useEffect(() => { useEffect(() => {
if (values?.retrieve_type) { if (values?.retrieve_type) {
const resetValues: KnowledgeConfigForm = {}
const fieldsToReset = Object.keys(values).filter(key => const fieldsToReset = Object.keys(values).filter(key =>
key !== 'kb_id' && key !== 'retrieve_type' && key !== 'top_k' key !== 'kb_id' && key !== 'retrieve_type' && key !== 'top_k'
) as (keyof KnowledgeConfigForm)[]; ) as (keyof KnowledgeConfigForm)[];
form.resetFields(fieldsToReset); fieldsToReset.forEach(key => {
resetValues[key] = undefined
})
form.setFieldsValue(resetValues);
} }
}, [values?.retrieve_type]) }, [values?.retrieve_type])

View File

@@ -40,7 +40,8 @@ const KnowledgeGlobalConfigModal = forwardRef<KnowledgeGlobalConfigModalRef, Kno
useEffect(() => { useEffect(() => {
if (values?.rerank_model) { if (values?.rerank_model) {
form.setFieldsValue({ ...data }) const { rerank_model, ...rest } = data;
form.setFieldsValue({ ...rest })
} else { } else {
form.setFieldsValue({ reranker_id: undefined, reranker_top_k: undefined }) form.setFieldsValue({ reranker_id: undefined, reranker_top_k: undefined })
} }

View File

@@ -24,6 +24,7 @@ export interface TagProps {
/** Additional CSS classes */ /** Additional CSS classes */
className?: string; className?: string;
variant?: 'outline' | 'borderless' variant?: 'outline' | 'borderless'
onClick?: () => void;
} }
/** Color theme mappings with text, border, and background colors */ /** Color theme mappings with text, border, and background colors */
@@ -38,9 +39,9 @@ const colors = {
} }
/** Custom tag component with color themes */ /** Custom tag component with color themes */
const Tag: FC<TagProps> = ({ color = 'processing', children, className, variant = 'outline' }) => { const Tag: FC<TagProps> = ({ color = 'processing', children, className, variant = 'outline', onClick }) => {
return ( return (
<span className={`rb:inline-block rb:px-1 rb:py-0.5 rb:rounded-sm rb:text-[12px] rb:font-regular! rb:leading-4 rb:border ${colors[color]} ${className || ''} ${variant === 'borderless' ? 'rb:border-none!' : ''}`}> <span onClick={onClick} className={`rb:inline-block rb:px-1 rb:py-0.5 rb:rounded-sm rb:text-[12px] rb:font-regular! rb:leading-4 rb:border ${colors[color]} ${className || ''} ${variant === 'borderless' ? 'rb:border-none!' : ''}`}>
{children} {children}
</span> </span>
) )

View File

@@ -709,6 +709,8 @@ export const en = {
localFile: 'Local File', localFile: 'Local File',
uploadFileTypes: 'Upload PDF, TXT, DOCX, IMAGE, MEDIA and other format files', uploadFileTypes: 'Upload PDF, TXT, DOCX, IMAGE, MEDIA and other format files',
webLink: 'Web Link', webLink: 'Web Link',
csvFile: 'Tabular Dataset',
csvUploadFileTypes: 'Upload files in CSV format',
webLinkPlaceholder:'Please enter', webLinkPlaceholder:'Please enter',
webLinkDesc: 'Only static links are supported. If the uploaded data shows as empty, the link may not be readable. One per line, with a maximum of {{count}} links at a time', webLinkDesc: 'Only static links are supported. If the uploaded data shows as empty, the link may not be readable. One per line, with a maximum of {{count}} links at a time',
selectorTutorial: 'Selector Usage Tutorial', selectorTutorial: 'Selector Usage Tutorial',
@@ -949,7 +951,8 @@ export const en = {
feishuFolderToken: 'Folder Token', feishuFolderToken: 'Folder Token',
feishuFolderTokenRequired: 'Please enter Folder Token', feishuFolderTokenRequired: 'Please enter Folder Token',
feishuFolderTokenPlaceholder: 'Enter your Feishu Folder Token', feishuFolderTokenPlaceholder: 'Enter your Feishu Folder Token',
} },
csvTemplate: 'Click to download CSV template',
}, },
api: { api: {
pageTitle: 'Memory library IAP document', pageTitle: 'Memory library IAP document',
@@ -1281,13 +1284,13 @@ export const en = {
hybrid: 'Hybrid Retrieval', hybrid: 'Hybrid Retrieval',
graph: 'Graph Retrieval', graph: 'Graph Retrieval',
similarity_threshold: 'Semantic similarity threshold', vector_similarity_weight: 'Semantic similarity threshold',
similarity_threshold_desc: 'Only return results with semantic similarity higher than this threshold', vector_similarity_weight_desc: 'Only return results with semantic similarity higher than this threshold',
similarity_threshold_desc1: 'The minimum similarity threshold for semantic retrieval', vector_similarity_weight_desc1: 'The minimum similarity threshold for semantic retrieval',
vector_similarity_weight: 'Vector Similarity Weight', similarity_threshold: 'Vector Similarity Weight',
vector_similarity_weight_desc: 'Only return results with BM25 scores above this threshold', similarity_threshold_desc: 'Only return results with BM25 scores above this threshold',
vector_similarity_weight_desc1: 'The minimum BM25 score threshold for word segmentation retrieval', similarity_threshold_desc1: 'The minimum BM25 score threshold for word segmentation retrieval',
description: 'Description', description: 'Description',
shareVersion: 'Share Version', shareVersion: 'Share Version',
@@ -2534,6 +2537,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
input_result: 'Input', input_result: 'Input',
output_result: 'Output', output_result: 'Output',
process_result: 'Data Processing', process_result: 'Data Processing',
inputs_result: 'Input',
outputs_result: 'Output',
error: 'Error Message', error: 'Error Message',
loopNum: ' loops', loopNum: ' loops',
iterationNum: ' iterations', iterationNum: ' iterations',
@@ -2544,6 +2549,13 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
output_cycle_vars: 'Final Loop Variables', output_cycle_vars: 'Final Loop Variables',
}, },
sureReplace: 'Confirm Replace', sureReplace: 'Confirm Replace',
testRun: 'Test Run',
variables: 'Variables',
startRun: 'Start Run',
reStartRun: 'Restart Run',
status: 'Status',
elapsedTime: 'Elapsed Time',
totalTokens: 'Total Tokens',
checkList: 'Check List', checkList: 'Check List',
checkListDesc: 'Ensure all issues are resolved before publishing', checkListDesc: 'Ensure all issues are resolved before publishing',
checkListEmpty: 'No issues found', checkListEmpty: 'No issues found',

View File

@@ -194,6 +194,8 @@ export const zh = {
localFile: '本地文件', localFile: '本地文件',
uploadFileTypes: '上传 PDF、 TXT、 DOCX、 IMAGE、 MEDIA 等格式的文件', uploadFileTypes: '上传 PDF、 TXT、 DOCX、 IMAGE、 MEDIA 等格式的文件',
webLink: '网页链接', webLink: '网页链接',
csvFile: '表格数据集',
csvUploadFileTypes: '上传 CSV 格式的文件',
webLinkPlaceholder: '请输入', webLinkPlaceholder: '请输入',
webLinkDesc: '仅支持静态链接。如果上传的数据显示为空,则该链接可能无法读取。每行一个,一次最多{{count}}个链接', webLinkDesc: '仅支持静态链接。如果上传的数据显示为空,则该链接可能无法读取。每行一个,一次最多{{count}}个链接',
selectorTutorial: '选择器使用教程', selectorTutorial: '选择器使用教程',
@@ -283,6 +285,7 @@ export const zh = {
qaExtract: '问答对提取', qaExtract: '问答对提取',
default: '默认', default: '默认',
customize: '自定义', customize: '自定义',
qaPrompt: 'QA 拆分引导词',
defaultSettings: '使用系统默认的参数和规则', defaultSettings: '使用系统默认的参数和规则',
customSettings: '自定义设置数据处理规则', customSettings: '自定义设置数据处理规则',
fileName: '文件名称', fileName: '文件名称',
@@ -435,7 +438,8 @@ export const zh = {
feishuFolderToken: '文件夹 Token', feishuFolderToken: '文件夹 Token',
feishuFolderTokenRequired: '请输入文件夹 Token', feishuFolderTokenRequired: '请输入文件夹 Token',
feishuFolderTokenPlaceholder: '请输入您的飞书文件夹 Token', feishuFolderTokenPlaceholder: '请输入您的飞书文件夹 Token',
} },
csvTemplate: '点击下载 CSV 模板',
}, },
application: { application: {
searchPlaceholder: '搜索应用', searchPlaceholder: '搜索应用',
@@ -663,13 +667,13 @@ export const zh = {
hybrid: '混合检索', hybrid: '混合检索',
graph: '图谱检索', graph: '图谱检索',
similarity_threshold: '语义相似度阈值', similarity_threshold: '向量相似度权重',
similarity_threshold_desc: '仅返回语义相似度高于此阈值的结果', similarity_threshold_desc: '仅返回BM25分数高于此阈值的结果',
similarity_threshold_desc1: '语义检索的最小相似度阈值', similarity_threshold_desc1: '分词检索的最小BM25分数阈值',
vector_similarity_weight: '向量相似度权重', vector_similarity_weight: '语义相似度阈值',
vector_similarity_weight_desc: '仅返回BM25分数高于此阈值的结果', vector_similarity_weight_desc: '仅返回语义相似度高于此阈值的结果',
vector_similarity_weight_desc1: '分词检索的最小BM25分数阈值', vector_similarity_weight_desc1: '语义检索的最小相似度阈值',
description: '描述', description: '描述',
shareVersion: '分享版本', shareVersion: '分享版本',
@@ -2498,6 +2502,8 @@ export const zh = {
input_result: '输入', input_result: '输入',
output_result: '输出', output_result: '输出',
process_result: '数据处理', process_result: '数据处理',
inputs_result: '输入',
outputs_result: '输出',
error: '错误信息', error: '错误信息',
loopNum: '个循环', loopNum: '个循环',
iterationNum: '个迭代', iterationNum: '个迭代',
@@ -2508,6 +2514,13 @@ export const zh = {
output_cycle_vars: '最终循环变量', output_cycle_vars: '最终循环变量',
}, },
sureReplace: '确认替换', sureReplace: '确认替换',
testRun: '测试运行',
variables: '变量',
startRun: '开始运行',
reStartRun: '重新运行',
status: '状态',
elapsedTime: '运行时间',
totalTokens: '总 TOKEN 数',
checkList: '检查清单', checkList: '检查清单',
checkListDesc: '发布前确保所有问题均已解决', checkListDesc: '发布前确保所有问题均已解决',
checkListEmpty: '没有发现问题', checkListEmpty: '没有发现问题',
@@ -2552,6 +2565,7 @@ export const zh = {
variableSelect: { variableSelect: {
empty: '暂无变量', empty: '暂无变量',
}, },
singleRun: '运行此节点',
}, },
emotionEngine: { emotionEngine: {
emotionEngineConfig: '情感引擎配置', emotionEngineConfig: '情感引擎配置',

182
web/src/vendor/x6-html-shape/index.js vendored Normal file
View File

@@ -0,0 +1,182 @@
/*
* @Author: ZhaoYing
* @Date: 2026-05-06 11:54:23
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-06 11:54:23
*/
// Patched x6-html-shape: replaces View.createElement (removed in X6 3.x) with document.createElement
import { Node as p, NodeView as l, Graph as C, Dom as s } from "@antv/x6";
import { getConfig as w, clickable as x, isInputElement as y, forwardEvent as S } from "./utils.js";
const u = "html-shape", h = "html-shape-view", T = p.define(w(h)), m = {};
export function register(i) {
const { shape: e, render: n, inherit: t = u, ...o } = i;
if (!e) throw new Error("should specify shape in config");
m[e] = n;
C.registerNode(e, { inherit: t, ...o }, true);
}
const a = "html";
// Determine which HTML layer a node belongs to.
// Parent (loop/iteration) nodes go behind the SVG layer so edges render above them.
// All other nodes go in front of the SVG layer so they render above edges.
function isBackNode(cell) {
const type = cell.getData?.()?.type;
return type === 'loop' || type === 'iteration';
}
// Ensure the two HTML container layers exist and are correctly positioned.
function ensureHtmlLayers(graph) {
if (!graph._htmlBack) {
const back = graph._htmlBack = document.createElement('div');
s.css(back, {
position: 'absolute', width: '100%', height: '100%',
'touch-action': 'none', 'user-select': 'none', 'pointer-events': 'none',
'z-index': 0, 'transform-origin': 'left top',
});
back.classList.add('x6-html-shape-container', 'x6-html-shape-back');
const svg = graph.container.querySelector('svg');
// back layer: before SVG → visually behind edges
graph.container.insertBefore(back, svg || null);
}
if (!graph._htmlFront) {
const front = graph._htmlFront = document.createElement('div');
s.css(front, {
position: 'absolute', width: '100%', height: '100%',
'touch-action': 'none', 'user-select': 'none', 'pointer-events': 'none',
'z-index': 0, 'transform-origin': 'left top',
});
front.classList.add('x6-html-shape-container', 'x6-html-shape-front');
// front layer: after SVG → visually above edges
graph.container.append(front);
}
// Keep legacy alias so updateHtmlContainerSize can iterate both
graph.htmlContainers = [graph._htmlBack, graph._htmlFront];
}
class BaseHTMLShapeView extends l {
confirmUpdate(e) {
const n = super.confirmUpdate(e);
return this.handleAction(n, a, () => {
if (!this.mounted) {
const t = m[this.cell.shape], o = this.ensureComponentContainer();
t && o && (this.mounted = t(this.cell, this.graph, o) || true,
this.onMounted(),
o.addEventListener("mousedown", this.prevEvent, true),
o.addEventListener("mouseup", this.prevEvent, true));
}
});
}
prevEvent(e) {
(x(e.target) || y(e.target)) && (e.preventDefault(), e.stopPropagation());
}
ensureComponentContainer() {}
onMounted() {}
onUnMount() {
if (this.onZIndexChange) {
this.cell.off("change:zIndex", this.onZIndexChange);
}
if (this.onNodeMoving) {
this.graph.off("node:moving", this.onNodeMoving);
}
}
unmount() {
typeof this.mounted == "function" && this.mounted();
this.componentContainer && this.componentContainer.remove();
this.onUnMount();
return super.unmount(), this;
}
}
BaseHTMLShapeView.config({ bootstrap: [a], actions: { component: a } });
class HTMLShapeView extends BaseHTMLShapeView {
constructor(...e) {
super(...e);
this.cell.on("change:visible", ({ cell: n }) => {
if (n.view === h) {
const t = this.graph.findViewByCell(n.id);
t && Promise.resolve().then(() => {
t.componentContainer.style.display = t.container.style.display;
});
}
});
}
onMounted() {
const listeners = this.graph.listeners;
// Always register per-cell zIndex listener regardless of shared transform events
this.onZIndexChange = () => this.updateContainerStyle();
this.cell.on("change:zIndex", this.onZIndexChange);
if (listeners?.hasTransformEvent?.length) return;
this.onTranslate = this.updateHtmlContainerSize.bind(this);
this.graph.on("translate", this.onTranslate);
this.graph.on("scale", this.onTranslate);
this.graph.on("node:change:position", this.onTranslate);
this.graph.on("hasTransformEvent", this.onTranslate);
// While dragging, lift this node's componentContainer to the top of its
// layer so its ports are never obscured by a sibling node underneath.
this.onNodeMoving = ({ node }) => {
if (node === this.cell && this.componentContainer) {
const layer = isBackNode(this.cell) ? this.graph._htmlBack : this.graph._htmlFront;
layer.append(this.componentContainer);
}
};
this.graph.on("node:moving", this.onNodeMoving);
this.updateHtmlContainerSize();
}
ensureComponentContainer() {
ensureHtmlLayers(this.graph);
const layer = isBackNode(this.cell) ? this.graph._htmlBack : this.graph._htmlFront;
if (!this.componentContainer) {
const e = this.componentContainer = document.createElement("div");
s.css(e, {
"pointer-events": "auto", "touch-action": "none", "user-select": "none",
"transform-origin": "center", position: "absolute"
});
e.classList.add("x6-html-shape-node");
"click,dblclick,contextmenu,mousedown,mousemove,mouseup,mouseover,mouseout,mouseenter,mouseleave"
.split(",").forEach(t => S(t, e, this.container));
layer.append(e);
}
return this.componentContainer;
}
resize() { super.resize(); this.updateContainerStyle(); }
updateTransform() { super.updateTransform(); this.updateContainerStyle(); }
updateContainerStyle() {
const e = this.ensureComponentContainer();
const { x: n, y: t } = this.cell.getBBox();
const { width: o, height: r } = this.cell.getSize();
const g = getComputedStyle(this.container).cursor;
const f = this.cell.getZIndex() ?? 0;
// Shrink the interactive width by the port hover radius (6px) so the right
// port circle is fully outside the componentContainer and never blocked by it.
// overflow:visible keeps the visual rendering intact.
const PORT_RADIUS = 6;
s.css(e, {
cursor: g, height: r + "px", width: (o - PORT_RADIUS) + "px",
overflow: "visible",
"z-index": f,
transform: `translate(${n}px, ${t}px) rotate(${this.cell.getAngle()}deg)`
});
}
updateHtmlContainerSize() {
const { graph: e } = this;
const t = e.transform.getMatrix();
const { offsetHeight: o, offsetWidth: r } = e.container;
const n = e.transform.getZoom();
const style = {
transform: `matrix(${t.a}, ${t.b}, ${t.c}, ${t.d}, ${t.e}, ${t.f})`,
width: r / n + "px",
height: o / n + "px",
};
// Update both layers
(e.htmlContainers || [e._htmlBack, e._htmlFront].filter(Boolean)).forEach(c => s.css(c, style));
}
}
l.registry.register(h, HTMLShapeView, true);
p.registry.register(u, T, true);
export { BaseHTMLShapeView, T as HTMLShape, u as HTMLShapeName, HTMLShapeView, h as HTMLView, a as action };

7
web/src/vendor/x6-html-shape/react.js vendored Normal file
View File

@@ -0,0 +1,7 @@
/*
* @Author: ZhaoYing
* @Date: 2026-05-06 11:54:26
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-06 11:54:26
*/
export { default } from "x6-html-shape/dist/react.js";

104
web/src/vendor/x6-html-shape/utils.js vendored Normal file
View File

@@ -0,0 +1,104 @@
/*
* @Author: ZhaoYing
* @Date: 2026-05-06 11:54:29
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-06 11:54:29
*/
import { Dom as u, ObjectExt as l, Markup as c } from "@antv/x6";
const o = "fo-shape-view";
function p(t, e, r) {
e.addEventListener(t, function(n) {
r.dispatchEvent(new n.constructor(n.type, n)), n.preventDefault(), n.stopPropagation();
});
}
function s(t, e = 3) {
return !t || !u.isHTMLElement(t) || e <= 0 ? !1 : ["a", "button"].includes(u.tagName(t)) || t.getAttribute("role") === "button" || t.getAttribute("type") === "button" ? !0 : s(t.parentNode, e - 1);
}
function g(t) {
if (u.tagName(t) === "input") {
const r = t.getAttribute("type");
if (r == null || ["text", "password", "number", "email", "search", "tel", "url"].includes(
r
))
return !0;
}
return !1;
}
function f(t = "rect", e = !0) {
return [
{
tagName: t,
selector: "body"
},
e ? c.getForeignObjectMarkup() : null,
{
tagName: "text",
selector: "label"
}
].filter((r) => r);
}
function b(t) {
return {
view: t,
markup: f("rect", t === o),
attrs: {
body: {
// fill: "none",
// 这里很奇怪none的时候不能触发节点移动改成transparent可以触发
fill: "transparent",
stroke: "none",
refWidth: "100%",
refHeight: "100%"
},
label: {
fontSize: 14,
fill: "#333",
refX: "50%",
refY: "50%",
textAnchor: "middle",
textVerticalAnchor: "middle"
},
fo: {
refWidth: "100%",
refHeight: "100%"
}
},
propHooks(e) {
if (e.markup == null) {
const { primer: r, view: n } = e;
if (r && r !== "rect") {
e.markup = f(r, n === o);
let i = {};
r === "circle" ? i = {
refCx: "50%",
refCy: "50%",
refR: "50%"
} : r === "ellipse" && (i = {
refCx: "50%",
refCy: "50%",
refRx: "50%",
refRy: "50%"
}), e.attrs = l.merge(
{},
{
body: {
refWidth: null,
refHeight: null,
...i
}
},
e.attrs || {}
);
}
}
return e;
}
};
}
export {
o as FOView,
s as clickable,
p as forwardEvent,
b as getConfig,
g as isInputElement
};

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:25:37 * @Date: 2026-02-03 16:25:37
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-07 22:35:08 * @Last Modified time: 2026-04-29 17:21:46
*/ */
/** /**
* Knowledge Configuration Modal * Knowledge Configuration Modal
@@ -91,10 +91,14 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
useEffect(() => { useEffect(() => {
if (values?.retrieve_type) { if (values?.retrieve_type) {
const resetValues: KnowledgeConfigForm = {}
const fieldsToReset = Object.keys(values).filter(key => const fieldsToReset = Object.keys(values).filter(key =>
key !== 'kb_id' && key !== 'retrieve_type' && key !== 'top_k' key !== 'kb_id' && key !== 'retrieve_type' && key !== 'top_k'
) as (keyof KnowledgeConfigForm)[]; ) as (keyof KnowledgeConfigForm)[];
form.resetFields(fieldsToReset); fieldsToReset.forEach(key => {
resetValues[key] = undefined
})
form.setFieldsValue(resetValues);
} }
}, [values?.retrieve_type]) }, [values?.retrieve_type])
@@ -150,23 +154,8 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
onChange={(value) => form.setFieldValue('top_k', value)} onChange={(value) => form.setFieldValue('top_k', value)}
/> />
</FormItem> </FormItem>
{/* Semantic similarity threshold */} {/* Vector similarity weight */}
{values?.retrieve_type === 'semantic' && ( {values?.retrieve_type === 'semantic' && (
<FormItem
name="similarity_threshold"
label={t('application.similarity_threshold')}
extra={t('application.similarity_threshold_desc')}
initialValue={0.5}
>
<RbSlider
max={1.0}
step={0.1}
min={0.0}
/>
</FormItem>
)}
{/* Word segmentation matching threshold */}
{values?.retrieve_type === 'participle' && (
<FormItem <FormItem
name="vector_similarity_weight" name="vector_similarity_weight"
label={t('application.vector_similarity_weight')} label={t('application.vector_similarity_weight')}
@@ -177,6 +166,23 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
max={1.0} max={1.0}
step={0.1} step={0.1}
min={0.0} min={0.0}
isInput={true}
/>
</FormItem>
)}
{/* Semantic similarity threshold */}
{values?.retrieve_type === 'participle' && (
<FormItem
name="similarity_threshold"
label={t('application.similarity_threshold')}
extra={t('application.similarity_threshold_desc')}
initialValue={0.5}
>
<RbSlider
max={1.0}
step={0.1}
min={0.0}
isInput={true}
/> />
</FormItem> </FormItem>
)} )}
@@ -193,6 +199,7 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
max={1.0} max={1.0}
step={0.1} step={0.1}
min={0.0} min={0.0}
isInput={true}
/> />
</FormItem> </FormItem>
<FormItem <FormItem
@@ -205,6 +212,7 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
max={1.0} max={1.0}
step={0.1} step={0.1}
min={0.0} min={0.0}
isInput={true}
/> />
</FormItem> </FormItem>
</> </>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:25:42 * @Date: 2026-02-03 16:25:42
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-07 17:03:22 * @Last Modified time: 2026-04-29 17:21:05
*/ */
/** /**
* Knowledge Global Configuration Modal * Knowledge Global Configuration Modal
@@ -67,7 +67,8 @@ const KnowledgeGlobalConfigModal = forwardRef<KnowledgeGlobalConfigModalRef, Kno
useEffect(() => { useEffect(() => {
if (values?.rerank_model) { if (values?.rerank_model) {
form.setFieldsValue({ ...data }) const { rerank_model, ...rest } = data;
form.setFieldsValue({ ...rest })
} else { } else {
form.setFieldsValue({ reranker_id: undefined, reranker_top_k: undefined }) form.setFieldsValue({ reranker_id: undefined, reranker_top_k: undefined })
} }

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-06 21:09:42 * @Date: 2026-02-06 21:09:42
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 18:29:48 * @Last Modified time: 2026-04-21 10:22:41
*/ */
/** /**
* File Upload Component * File Upload Component
@@ -56,7 +56,7 @@ interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
/** Custom file removal callback */ /** Custom file removal callback */
onRemove?: (file: UploadFile) => boolean | void | Promise<boolean | void>; onRemove?: (file: UploadFile) => boolean | void | Promise<boolean | void>;
featureConfig: FeaturesConfigForm['file_upload']; featureConfig?: FeaturesConfigForm['file_upload'];
textType?: 'button' | 'text'; textType?: 'button' | 'text';
block?: boolean; block?: boolean;
} }
@@ -184,7 +184,7 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
audio: 'audio_max_size_mb', audio: 'audio_max_size_mb',
} }
const maxSizeKey = categoryMap[mimePrefix] ?? 'document_max_size_mb' const maxSizeKey = categoryMap[mimePrefix] ?? 'document_max_size_mb'
const maxSize = (featureConfig[maxSizeKey] as number) ?? fileSize const maxSize = (featureConfig?.[maxSizeKey] as number) ?? fileSize
const fileSizeMB = file.size / 1024 / 1024 const fileSizeMB = file.size / 1024 / 1024
const isLtMaxSize = fileSizeMB < maxSize; const isLtMaxSize = fileSizeMB < maxSize;

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-06 21:09:47 * @Date: 2026-02-06 21:09:47
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-23 17:49:42 * @Last Modified time: 2026-04-21 10:22:45
*/ */
/** /**
* Upload File List Modal Component * Upload File List Modal Component
@@ -31,7 +31,7 @@ const FormItem = Form.Item;
interface UploadFileListModalProps { interface UploadFileListModalProps {
/** Callback to refresh parent component with new file list */ /** Callback to refresh parent component with new file list */
refresh: (fileList?: any[]) => void; refresh: (fileList?: any[]) => void;
featureConfig: FeaturesConfigForm['file_upload'] featureConfig?: FeaturesConfigForm['file_upload']
} }
/** /**

View File

@@ -10,7 +10,7 @@ import type { ColumnsType } from 'antd/es/table';
import type { UploadFile } from 'antd'; import type { UploadFile } from 'antd';
import UploadFiles from '@/components/Upload/UploadFiles'; import UploadFiles from '@/components/Upload/UploadFiles';
import type { UploadRequestOption } from 'rc-upload/lib/interface'; import type { UploadRequestOption } from 'rc-upload/lib/interface';
import { uploadFile, getDocumentList, parseDocument, updateDocument, deleteDocument, createDocumentAndUpload } from '@/api/knowledgeBase'; import { uploadFile, uploadQaFile, getDocumentList, parseDocument, updateDocument, deleteDocument, createDocumentAndUpload } from '@/api/knowledgeBase';
import exitIcon from '@/assets/images/knowledgeBase/exit.png'; import exitIcon from '@/assets/images/knowledgeBase/exit.png';
import SliderInput from '@/components/SliderInput'; import SliderInput from '@/components/SliderInput';
@@ -38,7 +38,7 @@ const { TextArea } = Input;
}); });
type SourceType = 'local' | 'link' | 'text'; type SourceType = 'local' | 'link' | 'text' | 'csv';
type ProcessingMethod = 'directBlock' | 'qaExtract'; type ProcessingMethod = 'directBlock' | 'qaExtract';
type ParameterSettings = 'defaultSettings' | 'customSettings'; type ParameterSettings = 'defaultSettings' | 'customSettings';
const stepKeys = ['selectFile', 'parameterSettings', 'dataPreview', 'confirmUpload'] as const; const stepKeys = ['selectFile', 'parameterSettings', 'dataPreview', 'confirmUpload'] as const;
@@ -63,6 +63,8 @@ interface ContentFormData {
title: string; title: string;
content: string; content: string;
} }
const fileType = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'md', 'htm', 'html', 'json', 'ppt', 'pptx', 'txt', 'png', 'jpg', 'mp3', 'mp4', 'mov', 'wav']
const csvFileType = ['csv']
const CreateDataset = () => { const CreateDataset = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -91,11 +93,12 @@ const CreateDataset = () => {
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [delimiter, setDelimiter] = useState<string | undefined>(undefined); const [delimiter, setDelimiter] = useState<string | undefined>(undefined);
const [blockSize, setBlockSize] = useState<number>(130); const [blockSize, setBlockSize] = useState<number>(130);
const [qaPrompt, setQaPrompt] = useState<string | undefined>()
console.log('qaPrompt', qaPrompt)
const [processingMethod, setProcessingMethod] = useState<ProcessingMethod>('directBlock'); const [processingMethod, setProcessingMethod] = useState<ProcessingMethod>('directBlock');
const [parameterSettings, setParameterSettings] = useState<ParameterSettings>('defaultSettings'); const [parameterSettings, setParameterSettings] = useState<ParameterSettings>('defaultSettings');
const [pdfEnhancementEnabled, setPdfEnhancementEnabled] = useState<boolean>(true); const [pdfEnhancementEnabled, setPdfEnhancementEnabled] = useState<boolean>(true);
const [pdfEnhancementMethod, setPdfEnhancementMethod] = useState<string>('mineru'); 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( const steps = useMemo(
() => [ () => [
{ title: t('knowledgeBase.selectFile') }, { title: t('knowledgeBase.selectFile') },
@@ -112,8 +115,11 @@ const CreateDataset = () => {
const handleNext = async () => { const handleNext = async () => {
// Temporarily hide step 3: adjust step index (0->1->2 corresponds to select file->parameter settings->confirm upload) // Temporarily hide step 3: adjust step index (0->1->2 corresponds to select file->parameter settings->confirm upload)
let nextStep = current + 1; let nextStep = current + 1;
if (current === 0 && source === 'csv') {
return
}
if(nextStep === 1 && source === 'local') { if((nextStep === 1 && source === 'local') || (nextStep === 2 && source === 'csv')) {
// Check if files have been uploaded // Check if files have been uploaded
if (rechunkFileIds.length === 0) { if (rechunkFileIds.length === 0) {
// If no files, prompt user to upload first // If no files, prompt user to upload first
@@ -159,6 +165,7 @@ const CreateDataset = () => {
delimiter: delimiter, delimiter: delimiter,
chunk_token_num: blockSize, chunk_token_num: blockSize,
auto_questions: processingMethod === 'directBlock' ? 0 : 1, auto_questions: processingMethod === 'directBlock' ? 0 : 1,
qa_prompt: qaPrompt
} }
} }
updateDocument(id, params) updateDocument(id, params)
@@ -378,6 +385,32 @@ const CreateDataset = () => {
formData.append('parent_id', parentId); formData.append('parent_id', parentId);
} }
if (source === 'csv') {
uploadQaFile(formData, {
kb_id: knowledgeBaseId,
parent_id: parentId,
signal: abortController.signal,
})
.then((res: UploadFileResponse) => {
// Upload successful, remove AbortController
abortControllersRef.current.delete(fileUid);
onSuccess?.(res, new XMLHttpRequest());
messageApi.success(t('knowledgeBase.uploadSuccess'))
handleBack()
})
.catch((error) => {
// Remove AbortController
abortControllersRef.current.delete(fileUid);
// If user actively cancelled, don't show error message
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
console.log('Upload cancelled:', (file as File).name);
return;
}
onError?.(error as Error);
});
} else {
uploadFile(formData, { uploadFile(formData, {
kb_id: knowledgeBaseId, kb_id: knowledgeBaseId,
parent_id: parentId, parent_id: parentId,
@@ -412,6 +445,7 @@ const CreateDataset = () => {
} }
onError?.(error as Error); onError?.(error as Error);
}); });
}
}; };
@@ -557,21 +591,21 @@ const CreateDataset = () => {
<img src={exitIcon} alt='exit' className='rb:w-4 rb:h-4' /> <img src={exitIcon} alt='exit' className='rb:w-4 rb:h-4' />
<span className='rb:text-gray-500 rb:text-sm'>{t('common.exit')}</span> <span className='rb:text-gray-500 rb:text-sm'>{t('common.exit')}</span>
</div> </div>
<div className='rb:px-24 rb:py-5 rb:bg-white rb:rounded-xl'> {source !== 'csv' && <div className='rb:px-24 rb:py-5 rb:bg-white rb:rounded-xl'>
<Steps current={current} items={steps} className="custom-steps" /> <Steps current={current} items={steps} className="custom-steps" />
</div> </div> }
<div className='rb:bg-white rb:rounded-xl rb:flex-1 rb:mt-3'> <div className='rb:bg-white rb:rounded-xl rb:flex-1 rb:mt-3'>
{current === 0 && ( {current === 0 && (<>
<div className='rb:flex rb:w-full rb:p-6'> <div className='rb:flex rb:w-full rb:p-6'>
{source && source === 'local' && ( {source && (source === 'local' || source === 'csv') && (
<UploadFiles <UploadFiles
ref={uploadRef} ref={uploadRef}
isCanDrag={true} isCanDrag={true}
fileSize={100} fileSize={100}
multiple={true} multiple={source !== 'csv'}
maxCount={99} maxCount={source === 'csv' ? 1 : 99}
fileType={fileType} fileType={source === 'csv' ? csvFileType : fileType}
customRequest={handleUpload} customRequest={handleUpload}
onChange={(fileList) => { onChange={(fileList) => {
console.log('File list changed:', fileList); console.log('File list changed:', fileList);
@@ -604,7 +638,8 @@ const CreateDataset = () => {
// Also allow removal in other cases (such as failed uploads) // Also allow removal in other cases (such as failed uploads)
return true; return true;
}} /> }}
/>
)} )}
{source && source === 'link' && ( {source && source === 'link' && (
<div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-40'> <div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-40'>
@@ -666,7 +701,16 @@ const CreateDataset = () => {
</div> </div>
)} )}
</div> </div>
)} {source === 'csv' &&
<a
href="@/assets/csv_template.csv"
download="csv_template.csv"
className='rb:mx-6 rb:text-sm rb:font-medium rb:text-gray-800 rb:-mt-6!'
>
{t('knowledgeBase.csvTemplate')}
</a>
}
</>)}
{current === 1 && ( {current === 1 && (
<div className='rb:flex rb:flex-col rb:mt-10 rb:px-40'> <div className='rb:flex rb:flex-col rb:mt-10 rb:px-40'>
@@ -765,7 +809,7 @@ const CreateDataset = () => {
</Flex> </Flex>
</Radio> </Radio>
</Radio.Group> </Radio.Group>
{parameterSettings === 'customSettings' && ( {parameterSettings === 'customSettings' && (<>
<div className='rb:grid rb:grid-cols-2 rb:mt-5 rb-border rb:rounded-xl rb:px-6 rb:py-4 rb:gap-10'> <div className='rb:grid rb:grid-cols-2 rb:mt-5 rb-border rb:rounded-xl rb:px-6 rb:py-4 rb:gap-10'>
<div> <div>
<div className='rb:w-full rb:text-[#5B6167] rb:leading-5 rb:mb-2'> <div className='rb:w-full rb:text-[#5B6167] rb:leading-5 rb:mb-2'>
@@ -775,8 +819,13 @@ const CreateDataset = () => {
</div> </div>
<SliderInput label={t('knowledgeBase.suggestedBlockSize')} max={1024} min={1} step={1} value={blockSize} onChange={handleChange} /> <SliderInput label={t('knowledgeBase.suggestedBlockSize')} max={1024} min={1} step={1} value={blockSize} onChange={handleChange} />
</div> </div>
<div>
)} <div className='rb:w-full rb:text-[#5B6167] rb:leading-5 rb:mb-2 rb:mt-4'>
{t('knowledgeBase.qaPrompt')}
</div>
<Input.TextArea value={qaPrompt} rows={6} onChange={(e) => setQaPrompt(e.target.value)} />
</div>
</>)}
</div> </div>
)} )}
@@ -853,7 +902,7 @@ const CreateDataset = () => {
{t('common.previous') || 'Prev'} {t('common.previous') || 'Prev'}
</Button> </Button>
)} )}
<Button {source !== 'csv' && <Button
type='primary' type='primary'
onClick={current === 2 ? handleStartUpload : handleNext} onClick={current === 2 ? handleStartUpload : handleNext}
disabled={ disabled={
@@ -863,7 +912,7 @@ const CreateDataset = () => {
} }
> >
{current === 2 ? t('knowledgeBase.startUploading') || 'Start Upload' : t('common.next') || 'Next'} {current === 2 ? t('knowledgeBase.startUploading') || 'Start Upload' : t('common.next') || 'Next'}
</Button> </Button>}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -10,8 +10,8 @@ import { useEffect, useState, useRef, type FC } from 'react';
import { useNavigate, useParams, useLocation, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useLocation, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useBreadcrumbManager, type BreadcrumbPath } from '@/hooks/useBreadcrumbManager'; import { useBreadcrumbManager, type BreadcrumbPath } from '@/hooks/useBreadcrumbManager';
import { Button, Spin, message, Switch } from 'antd'; import { Button, Spin, message, Switch, App } from 'antd';
import { getDocumentDetail, getDocumentChunkList, downloadFile, updateDocument, updateDocumentChunk, createDocumentChunk, getFileUrl } from '@/api/knowledgeBase'; import { getDocumentDetail, getDocumentChunkList, downloadFile, updateDocument, updateDocumentChunk, createDocumentChunk } from '@/api/knowledgeBase';
import type { KnowledgeBaseDocumentData, RecallTestData } from '@/views/KnowledgeBase/types'; import type { KnowledgeBaseDocumentData, RecallTestData } from '@/views/KnowledgeBase/types';
import { formatDateTime } from '@/utils/format'; import { formatDateTime } from '@/utils/format';
import InfoPanel, { type InfoItem } from '../components/InfoPanel'; import InfoPanel, { type InfoItem } from '../components/InfoPanel';
@@ -20,10 +20,11 @@ import SearchInput from '@/components/SearchInput';
import DocumentPreview from '@/components/DocumentPreview'; import DocumentPreview from '@/components/DocumentPreview';
import InsertModal, { type InsertModalRef } from '../components/InsertModal'; import InsertModal, { type InsertModalRef } from '../components/InsertModal';
import exitIcon from '@/assets/images/knowledgeBase/exit.png'; import exitIcon from '@/assets/images/knowledgeBase/exit.png';
const imagePath = 'https://devapi.mem.redbearai.com' import copy from 'copy-to-clipboard'
const DocumentDetails: FC = () => { const DocumentDetails: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { message: messageApi } = App.useApp()
const { knowledgeBaseId } = useParams<{ knowledgeBaseId: string }>(); const { knowledgeBaseId } = useParams<{ knowledgeBaseId: string }>();
const location = useLocation(); const location = useLocation();
const { updateBreadcrumbs } = useBreadcrumbManager({ const { updateBreadcrumbs } = useBreadcrumbManager({
@@ -100,9 +101,25 @@ const DocumentDetails: FC = () => {
}, [keywords]); }, [keywords]);
const handleCopy = (value?: string) => {
if (!value) return
copy(value)
messageApi.success(t('common.copySuccess'))
}
const formatDocumentInfo = (doc: KnowledgeBaseDocumentData): InfoItem[] => { const formatDocumentInfo = (doc: KnowledgeBaseDocumentData): InfoItem[] => {
return [ return [
{
key: 'file_id',
label: 'ID',
value: <span onClick={() => handleCopy(doc.file_id)}>
{doc.file_id}
<span
className="rb:cursor-pointer rb:-mb-0.5 rb:ml-1 rb:inline-block rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/common/copy_dark.svg')]"
></span>
</span>,
},
{ {
key: 'file_name', key: 'file_name',
label: t('knowledgeBase.fileName') || '文件名', label: t('knowledgeBase.fileName') || '文件名',
@@ -210,6 +227,11 @@ const DocumentDetails: FC = () => {
} }
}; };
const refreshChunks = () => {
let nextPage = 1;
setPage(nextPage);
ChunkList(nextPage);
}
const loadMoreChunks = () => { const loadMoreChunks = () => {
const nextPage = page + 1; const nextPage = page + 1;
setPage(nextPage); setPage(nextPage);
@@ -345,8 +367,8 @@ const DocumentDetails: FC = () => {
fileName={document?.file_name} fileName={document?.file_name}
fileExt={document?.file_ext} fileExt={document?.file_ext}
height="calc(100% - 40px)" height="calc(100% - 40px)"
mode="google" // mode="google"
showModeSwitch={true} // showModeSwitch={true}
/> />
</div> </div>
)} )}
@@ -387,7 +409,7 @@ const DocumentDetails: FC = () => {
<div className="rb:flex rb:h-full rb:flex-1 rb:overflow-hidden rb:bg-white rb:rounded-xl rb:border rb:border-[#DFE4ED]"> <div className="rb:flex rb:h-full rb:flex-1 rb:overflow-hidden rb:bg-white rb:rounded-xl rb:border rb:border-[#DFE4ED]">
{/* Left: Document info */} {/* Left: Document info */}
<div className='rb:w-80 rb:h-full rb:flex rb:flex-col rb:gap-4 rb:overflow-hidden'> <div className='rb:w-80 rb:h-full rb:flex rb:flex-col rb:gap-4 rb:overflow-hidden'>
<div className='rb:h-full rb:border-r rb:border-[#DFE4ED] rb:p-4'> <div className='rb:h-full rb:border-r rb:border-[#DFE4ED] rb:p-4 rb:overflow-y-auto'>
<InfoPanel <InfoPanel
title={t('knowledgeBase.documentInfo') || '文档信息'} title={t('knowledgeBase.documentInfo') || '文档信息'}
items={infoItems} items={infoItems}
@@ -407,7 +429,7 @@ const DocumentDetails: FC = () => {
{t('knowledgeBase.chunkList') || '分块列表'} {t('knowledgeBase.chunkList') || '分块列表'}
</h2> </h2>
<RecallTestResult <RecallTestResult
refresh={refreshChunks}
data={chunkList} data={chunkList}
showEmpty={false} showEmpty={false}
hasMore={hasMore} hasMore={hasMore}
@@ -417,6 +439,7 @@ const DocumentDetails: FC = () => {
editable={true} editable={true}
onItemClick={handleChunkClick} onItemClick={handleChunkClick}
parserMode={parserMode} parserMode={parserMode}
handleCopy={handleCopy}
/> />
</div> </div>
</div> </div>

View File

@@ -39,6 +39,8 @@ import { formatDateTime } from '@/utils/format';
import KnowledgeGraphCard from '../components/KnowledgeGraphCard'; import KnowledgeGraphCard from '../components/KnowledgeGraphCard';
import { useBreadcrumbManager, type BreadcrumbItem } from '@/hooks/useBreadcrumbManager'; import { useBreadcrumbManager, type BreadcrumbItem } from '@/hooks/useBreadcrumbManager';
import './Private.css' import './Private.css'
import Tag from '@/components/Tag'
import copy from 'copy-to-clipboard'
// Tree node data type // Tree node data type
const Private: FC = () => { const Private: FC = () => {
@@ -456,15 +458,8 @@ const Private: FC = () => {
} }
// Generate dropdown menu items (based on current row) // Generate dropdown menu items (based on current row)
const getOptMenuItems = (row: KnowledgeBaseListItem): MenuProps['items'] => [ const getOptMenuItems = (row: KnowledgeBaseListItem): MenuProps['items'] => {
{ const options = [{
key: '1',
label: t('knowledgeBase.rechunking'),
onClick: () => {
handleRechunking(row);
},
},
{
key: '2', key: '2',
label: t('knowledgeBase.download'), label: t('knowledgeBase.download'),
onClick: () => { onClick: () => {
@@ -477,8 +472,21 @@ const Private: FC = () => {
onClick: () => { onClick: () => {
handleDelete(row); handleDelete(row);
}, },
}]
if (row.parser_config?.doc_type === 'qa') {
return options
} }
]; return [
{
key: '1',
label: t('knowledgeBase.rechunking'),
onClick: () => {
handleRechunking(row);
},
},
...options
]
};
const handleRechunking = (item: KnowledgeBaseListItem) => { const handleRechunking = (item: KnowledgeBaseListItem) => {
if (!knowledgeBaseId) return; if (!knowledgeBaseId) return;
const document = item as unknown as KnowledgeBaseDocumentData; const document = item as unknown as KnowledgeBaseDocumentData;
@@ -570,7 +578,7 @@ const Private: FC = () => {
return ( return (
<span className="rb:text-xs rb:border rb:border-[#DFE4ED] rb:bg-[#FBFDFF] rb:rounded rb:items-center rb:text-[#212332] rb:py-1 rb:px-2"> <span className="rb:text-xs rb:border rb:border-[#DFE4ED] rb:bg-[#FBFDFF] rb:rounded rb:items-center rb:text-[#212332] rb:py-1 rb:px-2">
<span <span
className="rb:inline-block rb:w-[5px] rb:h-[5px] rb:mr-2 rb:rounded-full" className="rb:inline-block rb:w-1.25 rb:h-1.25 rb:mr-2 rb:rounded-full"
style={{ backgroundColor: value === 1 ? '#369F21' : value === 0 ? '#FF0000' : '#FF8A4C' }} style={{ backgroundColor: value === 1 ? '#369F21' : value === 0 ? '#FF0000' : '#FF8A4C' }}
></span> ></span>
<span>{value === 1 ? t('knowledgeBase.completed') : value === 0 ? t('knowledgeBase.pending') : t('knowledgeBase.processing')}</span> <span>{value === 1 ? t('knowledgeBase.completed') : value === 0 ? t('knowledgeBase.pending') : t('knowledgeBase.processing')}</span>
@@ -613,6 +621,7 @@ const Private: FC = () => {
title: t('knowledgeBase.processingMode'), title: t('knowledgeBase.processingMode'),
dataIndex: 'parser_id', dataIndex: 'parser_id',
key: 'parser_id', key: 'parser_id',
width: 100,
}, },
{ {
title: t('knowledgeBase.dataSize'), title: t('knowledgeBase.dataSize'),
@@ -629,6 +638,11 @@ const Private: FC = () => {
) )
} }
}, },
{
title: 'ID',
dataIndex: 'id',
key: 'id',
},
{ {
title: t('common.operation'), title: t('common.operation'),
@@ -762,11 +776,16 @@ const Private: FC = () => {
setIsSyncing(false); setIsSyncing(false);
}; };
const handleCopy = (value: string) => {
copy(value)
messageApi.success(t('common.copySuccess'))
}
return ( return (
<> <>
<div className="rb:flex rb:h-full rb:bg-white rb:rounded-xl"> <div className="rb:flex rb:h-full rb:bg-white rb:rounded-xl">
{folder && ( {folder && (
<div className="rb:w-64 rb:py-4 rb:flex-shrink-0 rb:h-[calc(100%+40px)] rb:border-r rb:border-[#EAECEE] rb:p-4 rb:bg-transparent"> <div className="rb:w-64 rb:py-4 rb:shrink-0 rb:h-[calc(100%+40px)] rb:border-r rb:border-[#EAECEE] rb:p-4 rb:bg-transparent">
<FolderTree <FolderTree
multiple multiple
className="customTree" className="customTree"
@@ -791,11 +810,15 @@ const Private: FC = () => {
<div className="rb:flex rb:items-center rb:border rb:border-[rgba(33, 35, 50, 0.17)] rb:text-gray-500 rb:cursor-pointer rb:px-1 rb:py-0.5 rb:rounded" <div className="rb:flex rb:items-center rb:border rb:border-[rgba(33, 35, 50, 0.17)] rb:text-gray-500 rb:cursor-pointer rb:px-1 rb:py-0.5 rb:rounded"
onClick={handleEditFolder} onClick={handleEditFolder}
> >
<img src={editIcon} alt="edit" className="rb:w-[14px] rb:h-[14px" /> <img src={editIcon} alt="edit" className="rb:w-3.5 rb:h-[14px" />
<span className='rb:text-[12px]'>{t('knowledgeBase.edit')} {t('knowledgeBase.name')}</span> <span className='rb:text-[12px]'>{t('knowledgeBase.edit')} {t('knowledgeBase.name')}</span>
</div> </div>
</div> </div>
<div className='rb:flex rb:items-center rb:gap-6 rb:text-gray-500 rb:mt-2 rb:text-xs'> <div className='rb:flex rb:items-center rb:gap-6 rb:text-gray-500 rb:mt-2 rb:text-xs'>
<Tag variant="borderless" color="default" className="rb:cursor-pointer" onClick={() => handleCopy(knowledgeBase.id)}>
ID: {knowledgeBase.id}
<span className="rb:-mb-0.5 rb:ml-1 rb:inline-block rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/copy_dark.svg')]"></span>
</Tag>
<span className='rb:text-[12px]'>{t('knowledgeBase.created')} {t('knowledgeBase.time')}: {formatDateTime(knowledgeBase.created_at) || '-'}</span> <span className='rb:text-[12px]'>{t('knowledgeBase.created')} {t('knowledgeBase.time')}: {formatDateTime(knowledgeBase.created_at) || '-'}</span>
<span className='rb:text-[12px]'>{t('knowledgeBase.updated')} {t('knowledgeBase.time')}: {formatDateTime(knowledgeBase.updated_at) || '-'}</span> <span className='rb:text-[12px]'>{t('knowledgeBase.updated')} {t('knowledgeBase.time')}: {formatDateTime(knowledgeBase.updated_at) || '-'}</span>

View File

@@ -55,6 +55,10 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
title: t('knowledgeBase.customText'), title: t('knowledgeBase.customText'),
description: t('knowledgeBase.manuallyInputText') description: t('knowledgeBase.manuallyInputText')
}, },
{
title: t('knowledgeBase.csvFile'),
description: t('knowledgeBase.csvUploadFileTypes')
},
] ]
// 封装取消方法,添加关闭弹窗逻辑 // 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => { const handleClose = () => {
@@ -86,7 +90,7 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
// description: selected.description, // description: selected.description,
// }); // });
// 跳转到创建数据集页面并携带来源参数 // 跳转到创建数据集页面并携带来源参数
const source = value === 0 ? 'local' : value === 1 ? 'link' : 'text'; const source = value === 3 ? 'csv' : value === 0 ? 'local' : value === 1 ? 'link' : 'text';
if (knowledgeBaseId) { if (knowledgeBaseId) {
navigate(`/knowledge-base/${knowledgeBaseId}/create-dataset`,{ navigate(`/knowledge-base/${knowledgeBaseId}/create-dataset`,{
state: { state: {
@@ -140,6 +144,12 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
<span className='rb:text-xs rb:text-gray-500'>{items[1].description}</span> <span className='rb:text-xs rb:text-gray-500'>{items[1].description}</span>
</Flex> </Flex>
</Radio> </Radio>
<Radio value={3} style={getActiveRadioStyle(value === 3)} className='rb:w-full'>
<Flex gap="small" align='start' justify='start' vertical>
<span className='rb:text-base rb:font-medium rb:text-gray-800'>{items[2].title}</span>
<span className='rb:text-xs rb:text-gray-500'>{items[2].description}</span>
</Flex>
</Radio>
</Radio.Group> </Radio.Group>
</div> </div>
</div> </div>

View File

@@ -7,11 +7,12 @@
* @LastEditTime: 2025-11-19 19:59:36 * @LastEditTime: 2025-11-19 19:59:36
*/ */
import { Divider } from 'antd'; import { Divider } from 'antd';
import type { ReactElement } from 'react';
export interface InfoItem { export interface InfoItem {
key: string; key: string;
label: string; label: string;
value: string | number | undefined; value: string | number | undefined | ReactElement;
icon?: string; icon?: string;
} }

View File

@@ -266,6 +266,8 @@ const KnowledgeGraph: FC<KnowledgeGraphProps> = ({ data, loading = false }) => {
} }
}, [nodes]) }, [nodes])
console.log('selectedNode', selectedNode)
return ( return (
<Col span={24}> <Col span={24}>
<RbCard <RbCard

View File

@@ -7,25 +7,28 @@
* @LastEditTime: 2025-12-22 13:47:53 * @LastEditTime: 2025-12-22 13:47:53
*/ */
import { FileOutlined, FieldTimeOutlined, EditOutlined } from '@ant-design/icons'; import { FileOutlined, FieldTimeOutlined, EditOutlined } from '@ant-design/icons';
import { Skeleton } from 'antd'; import { Skeleton, Flex, Space, App } from 'antd';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { RecallTestData } from '@/views/KnowledgeBase/types'; import type { RecallTestData } from '@/views/KnowledgeBase/types';
import { NoData } from './noData'; import { NoData } from './noData';
import { formatDateTime } from '@/utils/format'; import { formatDateTime } from '@/utils/format';
import InfiniteScroll from 'react-infinite-scroll-component'; import InfiniteScroll from 'react-infinite-scroll-component';
import RbMarkdown from '@/components/Markdown'; import RbMarkdown from '@/components/Markdown';
import { useMemo } from 'react'; import { useMemo, type MouseEvent } from 'react';
import { deleteDocumentChunk } from '@/api/knowledgeBase'
interface RecallTestResultProps { interface RecallTestResultProps {
data: RecallTestData[]; data: RecallTestData[];
showEmpty?: boolean; showEmpty?: boolean;
hasMore?: boolean; hasMore?: boolean;
loadMore?: () => void; loadMore?: () => void;
refresh?: () => void;
loading?: boolean; loading?: boolean;
scrollableTarget?: string; scrollableTarget?: string;
editable?: boolean; // Whether editable editable?: boolean; // Whether editable
onItemClick?: (item: RecallTestData, index: number) => void; // Click item callback onItemClick?: (item: RecallTestData, index: number) => void; // Click item callback
parserMode?: number; // Parser mode, 1 means QA format parserMode?: number; // Parser mode, 1 means QA format
handleCopy?: (text?: string) => void;
} }
const RecallTestResult = ({ const RecallTestResult = ({
@@ -33,13 +36,17 @@ const RecallTestResult = ({
showEmpty = true, showEmpty = true,
hasMore = false, hasMore = false,
loadMore, loadMore,
refresh,
loading = false, loading = false,
scrollableTarget, scrollableTarget,
editable = false, editable = false,
onItemClick, onItemClick,
parserMode = 0, parserMode = 0,
handleCopy,
}: RecallTestResultProps) => { }: RecallTestResultProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { modal, message } = App.useApp()
console.log('chunk data', data)
// Parse QA format content // Parse QA format content
const parseQAContent = (content: string) => { const parseQAContent = (content: string) => {
@@ -130,6 +137,24 @@ const RecallTestResult = ({
return 'rb:text-[#FF5D34]'; return 'rb:text-[#FF5D34]';
} }
}; };
const handleDelete = (e: MouseEvent, item: RecallTestData) => {
e.preventDefault();
e.stopPropagation();
modal.confirm({
title: t('common.confirmDeleteDesc', { name: `chunk_${item.metadata?.sort_id}` }),
okText: t('common.delete'),
cancelText: t('common.cancel'),
okType: 'danger',
onOk: () => {
deleteDocumentChunk(item.metadata.knowledge_id, item.metadata.document_id, item.metadata.doc_id)
.then(() => {
message.success(t('common.deleteSuccess'));
refresh?.()
})
}
})
console.log('RecallTestData', item)
}
// Show skeleton when initial loading // Show skeleton when initial loading
if (loading && data.length === 0) { if (loading && data.length === 0) {
@@ -183,17 +208,21 @@ const RecallTestResult = ({
{scorePercentage.toFixed(1)}% {t('knowledgeBase.similarity')} {scorePercentage.toFixed(1)}% {t('knowledgeBase.similarity')}
</span> </span>
)} )}
<div className={`rb:flex rb:mt-2 rb:flex rb:items-end rb:justify-end rb:gap-4 ${!showScore ? 'rb:w-full' : ''}`}> <div className={`rb:flex rb:mt-2 rb:items-end rb:justify-end rb:gap-4 ${!showScore ? 'rb:w-full' : ''}`}>
<span className='rb:text-gray-800'> <span className='rb:text-gray-800'>
<FileOutlined /> {item.metadata?.file_name || '-'} <FileOutlined /> {item.metadata?.file_name || '-'}
</span> </span>
<span className='rb:text-gray-500 rb:text-xs rb:bg-[#DFDFDF] rb:px-1 rb:py-[2px] rb:rounded'> <span className='rb:text-gray-500 rb:text-xs rb:bg-[#DFDFDF] rb:px-1 rb:py-0.5 rb:rounded'>
chunk_{item.metadata?.sort_id || index} chunk_{item.metadata?.sort_id || index}
</span> </span>
<div
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/common/delete.svg')] rb:hover:bg-[url('@/assets/images/common/delete_hover.svg')]"
onClick={(e) => handleDelete(e, item)}
></div>
</div> </div>
</div> </div>
<div className='rb:flex rb:text-left rb:px-4 rb:py-3 rb:bg-white rb:rounded-lg rb:mt-2'> <div className='rb:flex rb:text-left rb:px-4 rb:py-3 rb:bg-white rb:rounded-lg rb:mt-2'>
<div className='rb:text-gray-800 rb:text-sm rb:whitespace-pre-wrap rb:break-words rb:w-full'> <div className='rb:text-gray-800 rb:text-sm rb:whitespace-pre-wrap rb:wrap-break-word rb:w-full'>
{(() => { {(() => {
const qaContent = parseQAContent(item.page_content); const qaContent = parseQAContent(item.page_content);
if (qaContent) { if (qaContent) {
@@ -204,13 +233,21 @@ const RecallTestResult = ({
})()} })()}
</div> </div>
</div> </div>
<Flex align="center" justify={item.metadata?.file_created_at ? 'space-between' : 'end'} className="rb:mt-3!">
{item.metadata?.file_created_at && ( {item.metadata?.file_created_at && (
<div className='rb:flex rb:items-center rb:justify-start rb:mt-3'> <div className='rb:flex rb:items-center rb:justify-start'>
<span className='rb:text-gray-500 rb:text-xs'> <span className='rb:text-gray-500 rb:text-xs'>
<FieldTimeOutlined /> {formatDateTime(item.metadata.file_created_at)} <FieldTimeOutlined /> {formatDateTime(item.metadata.file_created_at)}
</span> </span>
</div> </div>
)} )}
<Space align="center" className='rb:text-gray-500 rb:text-xs' onClick={() => handleCopy?.(item.metadata?.doc_id)}>
ID: {item.metadata?.doc_id}
<span
className="rb:cursor-pointer rb:inline-block rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/common/copy_dark.svg')]"
></span>
</Space>
</Flex>
</div> </div>
); );
})} })}
@@ -228,7 +265,7 @@ const RecallTestResult = ({
<div className='rb:flex rb:h-full rb:flex-col'> <div className='rb:flex rb:h-full rb:flex-col'>
<div className='rb:flex rb:items-center rb:justify-start rb:gap-2'> <div className='rb:flex rb:items-center rb:justify-start rb:gap-2'>
<span className='rb:text-lg rb:font-medium'>{t('knowledgeBase.recallResult')}</span> <span className='rb:text-lg rb:font-medium'>{t('knowledgeBase.recallResult')}</span>
<span className='rb:text-gray-500 rb:text-xs rb:pt-[2px]'> <span className='rb:text-gray-500 rb:text-xs rb:pt-0.5'>
(<span className='rb:text-[#155EEF]'>{data.length}</span> results) (<span className='rb:text-[#155EEF]'>{data.length}</span> results)
</span> </span>
</div> </div>
@@ -245,12 +282,13 @@ const RecallTestResult = ({
); );
} }
// Otherwise use normal rendering // Otherwise use normal rendering
return ( return (
<div className='rb:flex rb:flex-col'> <div className='rb:flex rb:flex-col'>
<div className='rb:flex rb:items-center rb:justify-start rb:gap-2'> <div className='rb:flex rb:items-center rb:justify-start rb:gap-2'>
<span className='rb:text-lg rb:font-medium'>{t('knowledgeBase.recallResult')}</span> <span className='rb:text-lg rb:font-medium'>{t('knowledgeBase.recallResult')}</span>
<span className='rb:text-gray-500 rb:text-xs rb:pt-[2px]'> <span className='rb:text-gray-500 rb:text-xs rb:pt-0.5'>
(<span className='rb:text-[#155EEF]'>{data.length}</span> results) (<span className='rb:text-[#155EEF]'>{data.length}</span> results)
</span> </span>
</div> </div>

View File

@@ -16,6 +16,7 @@ import RbCard from '@/components/RbCard/Card'
import SearchInput from '@/components/SearchInput' import SearchInput from '@/components/SearchInput'
import Empty from '@/components/Empty' import Empty from '@/components/Empty'
import { getKnowledgeBaseList, getModelList, getModelTypeList, deleteKnowledgeBase, getKnowledgeBaseTypeList } from '@/api/knowledgeBase' import { getKnowledgeBaseList, getModelList, getModelTypeList, deleteKnowledgeBase, getKnowledgeBaseTypeList } from '@/api/knowledgeBase'
import copy from 'copy-to-clipboard'
import InfiniteScroll from 'react-infinite-scroll-component'; import InfiniteScroll from 'react-infinite-scroll-component';
@@ -527,6 +528,10 @@ const KnowledgeBaseManagement: FC = () => {
fetchData(1, false); fetchData(1, false);
} }
}, [modelTypes, query.parent_id, query.keywords, query.orderby, query.desc]) }, [modelTypes, query.parent_id, query.keywords, query.orderby, query.desc])
const handleCopy = (value: string) => {
copy(value)
messageApi.success(t('common.copySuccess'))
}
return ( return (
<> <>
@@ -574,6 +579,8 @@ const KnowledgeBaseManagement: FC = () => {
title={item.name} title={item.name}
headerType="borderless" headerType="borderless"
headerClassName="rb:py-3!" headerClassName="rb:py-3!"
className="rb:cursor-pointer"
onClick={() => handleToDetail(item)}
extra={ extra={
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<Dropdown <Dropdown
@@ -585,7 +592,7 @@ const KnowledgeBaseManagement: FC = () => {
</div> </div>
} }
> >
<div className='' onClick={() => handleToDetail(item)}> <div className=''>
<div className="rb:flex rb:text-[#5B6167] rb:h-5 rb:line-clamp-1 rb:text-sm rb:leading-5 rb:mb-3"> <div className="rb:flex rb:text-[#5B6167] rb:h-5 rb:line-clamp-1 rb:text-sm rb:leading-5 rb:mb-3">
{/* <div className="rb:font-medium rb:w-20">{t('knowledgeBase.description')} </div> */} {/* <div className="rb:font-medium rb:w-20">{t('knowledgeBase.description')} </div> */}
<Tooltip title={item.description}> <Tooltip title={item.description}>
@@ -593,6 +600,13 @@ const KnowledgeBaseManagement: FC = () => {
</Tooltip> </Tooltip>
</div> </div>
<Flex vertical gap={4} className='rb:min-h-15 rb:py-2.5! rb:px-3! rb:bg-[#F6F6F6] rb:rounded-lg rb:mb-3'> <Flex vertical gap={4} className='rb:min-h-15 rb:py-2.5! rb:px-3! rb:bg-[#F6F6F6] rb:rounded-lg rb:mb-3'>
<div className="rb:cursor-pointer rb:mb-3 rb:w-full" onClick={() => handleCopy(item.id)}>
<div className="rb:text-gray-800 rb:font-medium">ID:</div>
<Flex align="center" className="rb:text-[#5B6167]">
{item.id}
<span className="rb:ml-1 rb:inline-block rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/common/copy_dark.svg')]"></span>
</Flex>
</div>
{item.descriptionItems?.map((description: Record<string, unknown>) => ( {item.descriptionItems?.map((description: Record<string, unknown>) => (
<div <div
key={description.key as string} key={description.key as string}

View File

@@ -95,7 +95,7 @@ export interface ParserConfig {
auto_keywords?: number; // 自动关键词 auto_keywords?: number; // 自动关键词
auto_questions?: number; // 自动问题 auto_questions?: number; // 自动问题
html4excel?: boolean; // 是否为Excel文件 html4excel?: boolean; // 是否为Excel文件
graphrag: GraphragConfig; // 知识图谱生成 graphrag?: GraphragConfig; // 知识图谱生成
// Web 类型特有字段 // Web 类型特有字段
entry_url?: string; // 入口网址 entry_url?: string; // 入口网址
@@ -135,6 +135,7 @@ export interface KnowledgeBaseDocumentData { // 知识库文档数据
status?: number; // 状态 1 可检索 0 不可检索 status?: number; // 状态 1 可检索 0 不可检索
created_at?: string; // 创建时间 created_at?: string; // 创建时间
updated_at?: string; // 更新时间 updated_at?: string; // 更新时间
qa_prompt?: string; // 提示词
} }
export interface DocumentModalRef { export interface DocumentModalRef {
handleOpen: (file?: KnowledgeBaseDocumentData | null) => void; handleOpen: (file?: KnowledgeBaseDocumentData | null) => void;

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 17:09:03 * @Date: 2026-02-03 17:09:03
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-20 16:59:25 * @Last Modified time: 2026-05-06 18:01:59
*/ */
/** /**
* Memory Conversation Page * Memory Conversation Page
@@ -62,14 +62,13 @@ export interface TestParams {
message: string; message: string;
/** Search mode switch (0: deep thinking, 1: normal, 2: quick) */ /** Search mode switch (0: deep thinking, 1: normal, 2: quick) */
search_switch: string; search_switch: string;
/** Conversation history */
history: { role: string; content: string }[];
/** Enable web keyword */ /** Enable web keyword */
web_search?: boolean; web_search?: boolean;
/** Enable memory function */ /** Enable memory function */
memory?: boolean; memory?: boolean;
/** Conversation ID */ /** Conversation ID */
conversation_id?: string; conversation_id?: string;
session_id?: string;
} }
/** /**
* Data item in analysis logs * Data item in analysis logs
@@ -118,6 +117,7 @@ const MemoryConversation: FC = () => {
const [search_switch, setSearchSwitch] = useState('0') const [search_switch, setSearchSwitch] = useState('0')
const [msg, setMsg] = useState<string>('') const [msg, setMsg] = useState<string>('')
const [expandedLogs, setExpandedLogs] = useState<Record<number, boolean>>({}) const [expandedLogs, setExpandedLogs] = useState<Record<number, boolean>>({})
const [sessionId, setSessionId] = useState<string | undefined>(undefined)
/** Handle message send */ /** Handle message send */
const handleSend = () => { const handleSend = () => {
@@ -132,13 +132,14 @@ const MemoryConversation: FC = () => {
message: msg, message: msg,
end_user_id: userId, end_user_id: userId,
search_switch: search_switch, search_switch: search_switch,
history: [], session_id: sessionId
}) })
.then(res => { .then(res => {
const response = res as { answer: string; intermediate_outputs: LogItem[] } const response = res as { answer: string; intermediate_outputs: LogItem[]; session_id?: string; }
setChatData(prev => [...prev, { content: response.answer || '-', created_at: new Date().getTime(), role: 'assistant' }]) setChatData(prev => [...prev, { content: response.answer || '-', created_at: new Date().getTime(), role: 'assistant' }])
setLogs(response.intermediate_outputs) setLogs(response.intermediate_outputs)
setExpandedLogs(Object.fromEntries(response.intermediate_outputs.map((_, i) => [i, true]))) setExpandedLogs(Object.fromEntries(response.intermediate_outputs.map((_, i) => [i, true])))
setSessionId(response.session_id)
}) })
.finally(() => { .finally(() => {
setLoading(false) setLoading(false)
@@ -153,6 +154,12 @@ const MemoryConversation: FC = () => {
if (!file_path) return if (!file_path) return
window.open(file_path, '_blank') window.open(file_path, '_blank')
} }
const handleChangeUser = (opt: DefaultOptionType) => {
setUserId(opt?.value as string)
setSessionId(undefined)
setChatData([])
setLogs([])
}
return ( return (
<> <>
@@ -169,7 +176,7 @@ const MemoryConversation: FC = () => {
}))} }))}
placeholder={t('memoryConversation.searchPlaceholder')} placeholder={t('memoryConversation.searchPlaceholder')}
style={{ width: '100%', marginBottom: '16px' }} style={{ width: '100%', marginBottom: '16px' }}
onChange={(opt: DefaultOptionType) => setUserId(opt?.value as string)} onChange={handleChangeUser}
variant="borderless" variant="borderless"
className="rb:bg-white rb:rounded-lg" className="rb:bg-white rb:rounded-lg"
showSearch showSearch

View File

@@ -77,13 +77,11 @@ const AddChatVariable = forwardRef<AddChatVariableRef, AddChatVariableProps>(({
renderItem={(item, index) => ( renderItem={(item, index) => (
<List.Item> <List.Item>
<div key={index} className="rb:relative rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:cursor-pointer rb-border rb:rounded-lg"> <div key={index} className="rb:relative rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:cursor-pointer rb-border rb:rounded-lg">
<Flex align="center" justify="space-between"> <Flex align="center" justify="space-between" className="rb:leading-4 rb:max-w-[calc(100%-60px)]">
<div className="rb:leading-4"> <div className="rb:flex-1 rb:font-medium rb:whitespace-break-spaces rb:wrap-break-word rb:line-clamp-1">{item.name}</div>
<span className="rb:font-medium">{item.name}</span> <div className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular"> ({t(`workflow.config.parameter-extractor.${item.type}`)})</div>
<span className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular"> ({t(`workflow.config.parameter-extractor.${item.type}`)})</span>
</div>
</Flex> </Flex>
<div className="rb:mt-1 rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:wrap-break-word rb:line-clamp-1">{item.description}</div> <div className="rb:mt-1 rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:wrap-break-word rb:line-clamp-1 rb:max-w-[calc(100%-60px)]">{item.description}</div>
<Flex gap={12} className="rb:absolute rb:right-4 rb:top-[50%] rb:transform-[translateY(-50%)] rb:bg-white"> <Flex gap={12} className="rb:absolute rb:right-4 rb:top-[50%] rb:transform-[translateY(-50%)] rb:bg-white">
<div <div
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/editBorder.svg')] rb:hover:bg-[url('@/assets/images/editBg.svg')]" className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/editBorder.svg')] rb:hover:bg-[url('@/assets/images/editBg.svg')]"

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-04-09 18:58:21 * @Date: 2026-04-09 18:58:21
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-20 10:39:17 * @Last Modified time: 2026-05-07 18:35:54
*/ */
import { useState, useCallback, useEffect, useRef, type FC } from 'react' import { useState, useCallback, useEffect, useRef, type FC } from 'react'
import { Popover, Flex } from 'antd' import { Popover, Flex } from 'antd'
@@ -60,7 +60,7 @@ const specialValidators: Record<string, (val: any) => boolean> = {
return val.some(c => !c?.expressions?.length || c.expressions.some((expr: any) => !isExprSet(expr))) return val.some(c => !c?.expressions?.length || c.expressions.some((expr: any) => !isExprSet(expr)))
}, },
// question-classifier.categories: every category must have a value // question-classifier.categories: every category must have a value
'question-classifier.categories': (val: any[]) => !Array.isArray(val) || !val.some(c => c?.class_name && String(c.class_name).trim()), 'question-classifier.categories': (val: any[]) => !Array.isArray(val) || !val.every(c => c?.class_name && String(c.class_name).trim()),
// var-aggregator.group_variables: must be non-empty array // var-aggregator.group_variables: must be non-empty array
'var-aggregator.group_variables': (val: any[]) => !Array.isArray(val) || !val.length, 'var-aggregator.group_variables': (val: any[]) => !Array.isArray(val) || !val.length,
// assigner.assignments: every item needs variable_selector + operation; value required unless operation is 'clear' // assigner.assignments: every item needs variable_selector + operation; value required unless operation is 'clear'

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2025-12-23 16:22:51 * @Date: 2025-12-23 16:22:51
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-13 14:00:07 * @Last Modified time: 2026-05-06 15:06:03
*/ */
import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react'; import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
@@ -27,6 +27,7 @@ export interface Suggestion {
disabled?: boolean; // Flag for disabled state disabled?: boolean; // Flag for disabled state
children?: Suggestion[]; // Sub-variables (e.g. file fields) children?: Suggestion[]; // Sub-variables (e.g. file fields)
parentLabel?: string; // Parent variable label (for child display) parentLabel?: string; // Parent variable label (for child display)
default?: any;
} }
// Autocomplete plugin for variable suggestions triggered by '/' character // Autocomplete plugin for variable suggestions triggered by '/' character

View File

@@ -4,180 +4,22 @@
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-30 11:55:10 * @Last Modified time: 2026-03-30 11:55:10
*/ */
import { useState } from 'react'; import { Flex } from 'antd';
import { Popover, Flex } from 'antd';
import clsx from 'clsx';
import type { ReactShapeConfig } from '@antv/x6-react-shape'; import type { ReactShapeConfig } from '@antv/x6-react-shape';
import { nodeLibrary, graphNodeLibrary, edgeAttrs, nodeWidth } from '../../constant';
import { useTranslation } from 'react-i18next';
const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { const AddNode: ReactShapeConfig['component'] = ({ node }) => {
const data = node?.getData() || {}; const data = node?.getData() || {};
const { t } = useTranslation();
const [open, setOpen] = useState(false);
// Handle node selection from popover and create new node replacing the add-node placeholder
const handleNodeSelect = (selectedNodeType: any) => {
graph.startBatch('add-node');
const parentBBox = node.getBBox();
const cycleId = data.cycle;
const horizontalSpacing = 0;
const id = `${selectedNodeType.type.replace(/-/g, '_') }_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
const newNode = graph.addNode({
...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default),
x: parentBBox.x + horizontalSpacing,
y: parentBBox.y - 12,
id,
data: {
id,
type: selectedNodeType.type,
icon: selectedNodeType.icon,
name: t(`workflow.${selectedNodeType.type}`),
cycle: cycleId,
parentId: data.parentId,
config: selectedNodeType.config || {}
},
});
// Add new node as child of parent node
if (cycleId) {
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
if (parentNode) {
parentNode.addChild(newNode, { silent: true });
}
}
const incomingEdges = graph.getIncomingEdges(node);
const outgoingEdges = graph.getOutgoingEdges(node);
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: any) => {
const targetCell = graph.getCellById(edge.getTargetCellId()) as any;
const targetPortId = targetCell?.getPorts?.()?.find((port: any) => port.group === 'left')?.id || edge.getTargetPortId();
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
graph.getNodes().forEach((n: any) => {
if (n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId) {
n.remove();
}
});
// Automatically adjust loop node size
const loopNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
if (loopNode) {
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
if (childNodes.length > 0) {
const bounds = childNodes.reduce((acc, child) => {
const bbox = child.getBBox();
return {
minX: Math.min(acc.minX, bbox.x),
minY: Math.min(acc.minY, bbox.y),
maxX: Math.max(acc.maxX, bbox.x + bbox.width),
maxY: Math.max(acc.maxY, bbox.y + bbox.height)
};
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
const padding = 50;
const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2);
const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2);
loopNode.prop('size', { width: newWidth, height: newHeight });
loopNode.getPorts().forEach(port => {
if (port.group === 'right' && port.args) {
loopNode.portProp(port.id!, 'args/x', newWidth);
}
});
}
}
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();
});
graph.stopBatch('add-node');
setOpen(false);
};
const content = (
<div style={{ maxHeight: '300px', overflowY: 'auto', minWidth: `${nodeWidth}px'` }}>
{nodeLibrary.map((category, categoryIndex) => {
const filteredNodes = category.nodes.filter(nodeType =>
nodeType.type !== 'start' && nodeType.type !== 'end' && nodeType.type !== 'iteration' && nodeType.type !== 'loop' && nodeType.type !== 'cycle-start'
);
if (filteredNodes.length === 0) return null;
return ( return (
<div key={category.category}>
{categoryIndex > 0 && <div style={{ height: '1px', background: '#f0f0f0', margin: '4px 0' }} />}
<div style={{ padding: '4px 12px', fontSize: '12px', color: '#999', fontWeight: 'bold' }}>
{t(`workflow.${category.category}`)}
</div>
{filteredNodes.map((nodeType) => (
<div
key={nodeType.type}
style={{
padding: '8px 12px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onClick={() => handleNodeSelect(nodeType)}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f0f8ff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'white';
}}
>
<div className={`rb:size-4 rb:bg-cover ${nodeType.icon}`} />
<span style={{ fontSize: '14px' }}>{t(`workflow.${nodeType.type}`)}</span>
</div>
))}
</div>
);
})}
</div>
);
return (
<Popover
content={content}
trigger="click"
open={open}
onOpenChange={setOpen}
placement="bottomLeft"
>
<Flex <Flex
align="center" align="center"
justify="center" justify="center"
gap={4} gap={4}
className={clsx('rb:text-[#212332] rb:font-medium rb:text-[12px] rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:border rb:rounded-lg rb:bg-[#FCFCFD] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)] rb:border-[#FCFCFD] rb:flex rb:items-center rb:justify-center', { className="rb:text-[#212332] rb:font-medium rb:text-[12px] rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:border rb:rounded-lg rb:bg-[#FCFCFD] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)] rb:border-[#FCFCFD] rb:flex rb:items-center rb:justify-center"
'rb:border-orange-500 rb:border-[3px] rb:bg-[#FCFCFD] rb:text-[#475467]': data.isSelected,
'rb:border-[#d1d5db] rb:bg-[#FCFCFD] rb:text-[#374151]': !data.isSelected
})}
> >
<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/workflow/node_plus.png')]"></div> <div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/workflow/node_plus.png')]"></div>
{data.label} {data.label}
</Flex> </Flex>
</Popover>
); );
}; };

View File

@@ -20,9 +20,8 @@ const NodeTools: FC<{ node: Node }> = ({
} }
} }
return ( return (
<div className={clsx("rb:absolute rb:p-1 rb:bg-white rb:-top-7.5 rb:right-0 rb:rounded-lg", { <Flex align="center" gap={8} className={clsx("rb:absolute rb:p-1! rb:bg-white rb:-top-7.5 rb:right-0 rb:rounded-lg", {
'rb:block': data.isSelected, 'rb:hidden!': !data.isSelected
'rb:hidden': !data.isSelected
})}> })}>
<Dropdown <Dropdown
menu={{ menu={{
@@ -36,7 +35,7 @@ const NodeTools: FC<{ node: Node }> = ({
<div className="rb:cursor-pointer rb:size-4 rb:hover:bg-[#F6F6F6] rb:rounded-sm rb:bg-cover rb:bg-[url(@/assets/images/common/dash.svg)]"> <div className="rb:cursor-pointer rb:size-4 rb:hover:bg-[#F6F6F6] rb:rounded-sm rb:bg-cover rb:bg-[url(@/assets/images/common/dash.svg)]">
</div> </div>
</Dropdown> </Dropdown>
</div> </Flex>
) )
} }

View File

@@ -2,13 +2,48 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-09 18:30:28 * @Date: 2026-02-09 18:30:28
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-30 15:14:02 * @Last Modified time: 2026-05-07 18:38:06
*/ */
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Flex, Popover } from 'antd'; import { Flex, Popover } from 'antd';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import { nodeLibrary, graphNodeLibrary, edgeAttrs, nodeWidth } from '../constant'; import { nodeLibrary, graphNodeLibrary, edgeAttrs, nodeWidth } from '../constant';
// Shared helper: adjust loop/iteration container size to fit child nodes
export const adjustCycleContainerSize = (graph: any, cycleId: string) => {
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
if (parentNode) {
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
if (childNodes.length > 0) {
const bounds = childNodes.reduce((acc: any, child: any) => {
const b = child.getBBox();
return {
minX: Math.min(acc.minX, b.x),
minY: Math.min(acc.minY, b.y),
maxX: Math.max(acc.maxX, b.x + b.width),
maxY: Math.max(acc.maxY, b.y + b.height)
};
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
const padding = 50;
const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2);
const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2);
parentNode.prop('size', { width: newWidth, height: newHeight });
parentNode.getPorts().forEach((port: any) => {
if (port.group === 'right' && port.args) {
parentNode.portProp(port.id!, 'args/x', newWidth);
}
});
// childNodes.forEach((childNode: any) => {
// childNode.off('change:position');
// childNode.on('change:position', () => adjustCycleContainerSize(graph, cycleId));
// });
}
}
};
interface PortClickHandlerProps { interface PortClickHandlerProps {
graph: any; graph: any;
} }
@@ -16,7 +51,6 @@ interface PortClickHandlerProps {
const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => { const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [popoverVisible, setPopoverVisible] = useState(false); const [popoverVisible, setPopoverVisible] = useState(false);
const [popoverPosition, setPopoverPosition] = useState({ x: 0, y: 0 });
const [sourceNode, setSourceNode] = useState<any>(null); const [sourceNode, setSourceNode] = useState<any>(null);
const [sourcePort, setSourcePort] = useState<string>(''); const [sourcePort, setSourcePort] = useState<string>('');
const [tempElement, setTempElement] = useState<HTMLElement | null>(null); const [tempElement, setTempElement] = useState<HTMLElement | null>(null);
@@ -24,12 +58,11 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
useEffect(() => { useEffect(() => {
const handlePortClick = (event: CustomEvent) => { const handlePortClick = (event: CustomEvent) => {
const { node, port, element, rect, edgeInsertion } = event.detail; const { node, port, element, edgeInsertion } = event.detail;
setSourceNode(node); setSourceNode(node);
setSourcePort(port); setSourcePort(port);
setTempElement(element); setTempElement(element);
setEdgeInsertion(edgeInsertion || null); setEdgeInsertion(edgeInsertion || null);
setPopoverPosition({ x: rect.left, y: rect.top });
setPopoverVisible(true); setPopoverVisible(true);
}; };
@@ -53,6 +86,68 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
const newNodeType = selectedNodeType.type; const newNodeType = selectedNodeType.type;
// Save add-node placeholder position before disabling history // Save add-node placeholder position before disabling history
// AddNode placeholder mode: replace the add-node placeholder with the selected node
if (sourceNodeType === 'add-node') {
const placeholderBBox = sourceNode.getBBox();
const cycleId = sourceNodeData.cycle;
const id = `${selectedNodeType.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const newNode = graph.addNode({
...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default),
x: placeholderBBox.x,
y: placeholderBBox.y - 12,
id,
data: {
id,
type: selectedNodeType.type,
icon: selectedNodeType.icon,
name: t(`workflow.${selectedNodeType.type}`),
cycle: cycleId,
parentId: sourceNodeData.parentId,
config: selectedNodeType.config || {},
},
});
if (cycleId) {
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
if (parentNode) parentNode.addChild(newNode);
}
const incomingEdges = graph.getIncomingEdges(sourceNode);
const outgoingEdges = graph.getOutgoingEdges(sourceNode);
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((p: any) => p.group === 'left')?.id || 'left' },
...edgeAttrs,
}));
});
outgoingEdges?.forEach((edge: any) => {
const targetCell = graph.getCellById(edge.getTargetCellId()) as any;
const targetPortId = targetCell?.getPorts?.()?.find((p: any) => p.group === 'left')?.id || edge.getTargetPortId();
addedEdges.push(graph.addEdge({
source: { cell: newNode.id, port: newNode.getPorts().find((p: any) => p.group === 'right')?.id || 'right' },
target: { cell: edge.getTargetCellId(), port: targetPortId },
...edgeAttrs,
}));
});
graph.getNodes().forEach((n: any) => {
if (n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId) n.remove();
});
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);
if (cycleId) adjustCycleContainerSize(graph, cycleId);
if (tempElement) { document.body.removeChild(tempElement); setTempElement(null); }
setPopoverVisible(false);
return;
}
// If it's a cycle-start node, handle the add-node placeholder
let addNodePosition = null; let addNodePosition = null;
if (isCycleSubNode && sourceNodeType === 'cycle-start') { if (isCycleSubNode && sourceNodeType === 'cycle-start') {
const cycleId = sourceNodeData.cycle; const cycleId = sourceNodeData.cycle;
@@ -81,17 +176,30 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
if (gap < requiredSpace) { if (gap < requiredSpace) {
const shiftX = requiredSpace - gap; const shiftX = requiredSpace - gap;
const visited = new Set<string>(); const visited = new Set<string>();
const shiftDownstream = (cell: any) => { const isCycleContainer = (type: string) => type === 'loop' || type === 'iteration';
const shiftDownstream = (cell: any, shiftChildren: boolean = false) => {
if (visited.has(cell.id)) return; if (visited.has(cell.id)) return;
visited.add(cell.id); visited.add(cell.id);
const pos = cell.getPosition(); const pos = cell.getPosition();
cell.setPosition(pos.x + shiftX, pos.y); cell.setPosition(pos.x + shiftX, pos.y);
if (shiftChildren) {
const cellType = cell.getData()?.type;
if (isCycleContainer(cellType)) {
const cycleId = cell.getData()?.id;
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
childNodes.forEach((child: any) => {
const childPos = child.getPosition();
child.setPosition(childPos.x + shiftX, childPos.y);
});
}
}
graph.getConnectedEdges(cell, { outgoing: true }).forEach((e: any) => { graph.getConnectedEdges(cell, { outgoing: true }).forEach((e: any) => {
const tCell = graph.getCellById(e.getTargetCellId()); const tCell = graph.getCellById(e.getTargetCellId());
if (tCell?.isNode()) shiftDownstream(tCell); if (tCell?.isNode()) shiftDownstream(tCell, shiftChildren);
}); });
}; };
shiftDownstream(edgeInsertion.targetCell); const targetCellType = edgeInsertion.targetCell.getData()?.type;
shiftDownstream(edgeInsertion.targetCell, isCycleContainer(targetCellType));
} }
} else if (addNodePosition) { } else if (addNodePosition) {
newX = addNodePosition.x; newX = addNodePosition.x;
@@ -146,7 +254,7 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
if (sourceNodeData.cycle) { if (sourceNodeData.cycle) {
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle); const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle);
if (parentNode) parentNode.addChild(newNode, { silent: true }); if (parentNode) parentNode.addChild(newNode);
} }
if (edgeInsertion) { if (edgeInsertion) {
@@ -158,19 +266,19 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
const newPorts = newNode.getPorts(); const newPorts = newNode.getPorts();
const addedCells: any[] = [newNode]; const addedCells: any[] = [newNode];
const addedEdges: any[] = [];
if (edgeInsertion) { if (edgeInsertion) {
// Edge insertion: create source→new and new→target edges
const { targetCell, targetPort: origTargetPort } = edgeInsertion; const { targetCell, targetPort: origTargetPort } = edgeInsertion;
const newLeftPort = newPorts.find((p: any) => p.group === 'left')?.id || 'left'; const newLeftPort = newPorts.find((p: any) => p.group === 'left')?.id || 'left';
const newRightPort = newPorts.find((p: any) => p.group === 'right')?.id || 'right'; const newRightPort = newPorts.find((p: any) => p.group === 'right')?.id || 'right';
addedCells.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: newLeftPort }, ...edgeAttrs })); addedEdges.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: newLeftPort }, ...edgeAttrs }));
addedCells.push(graph.addEdge({ source: { cell: newNode.id, port: newRightPort }, target: { cell: targetCell.id, port: origTargetPort }, ...edgeAttrs })); addedEdges.push(graph.addEdge({ source: { cell: newNode.id, port: newRightPort }, target: { cell: targetCell.id, port: origTargetPort }, ...edgeAttrs }));
setEdgeInsertion(null); setEdgeInsertion(null);
} else if (sourcePortGroup === 'left') {
const tp = newPorts.find((p: any) => p.group === 'right')?.id || 'right';
addedCells.push(graph.addEdge({ source: { cell: newNode.id, port: tp }, target: { cell: sourceNode.id, port: sourcePort }, ...edgeAttrs }));
} else { } else {
// Connect from right port to new node's left side
const tp = newPorts.find((p: any) => p.group === 'left')?.id || 'left'; const tp = newPorts.find((p: any) => p.group === 'left')?.id || 'left';
addedCells.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: tp }, ...edgeAttrs })); addedEdges.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: tp }, ...edgeAttrs }));
} }
// If adding a loop/iteration node, create cycle-start, add-node and inner edge regardless of source type // If adding a loop/iteration node, create cycle-start, add-node and inner edge regardless of source type
@@ -190,8 +298,8 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
y: parentBBox.y + 70 + 4, y: parentBBox.y + 70 + 4,
data: { type: 'add-node', label: t('workflow.addNode'), icon: '+', parentId: id, cycle: id }, data: { type: 'add-node', label: t('workflow.addNode'), icon: '+', parentId: id, cycle: id },
}); });
newNode.addChild(cycleStartNode, { silent: true }); newNode.addChild(cycleStartNode);
newNode.addChild(addNodePlaceholder, { silent: true }); newNode.addChild(addNodePlaceholder);
const innerEdge = graph.addEdge({ const innerEdge = graph.addEdge({
source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find((p: any) => p.group === 'right')?.id || 'right' }, source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find((p: any) => p.group === 'right')?.id || 'right' },
target: { cell: addNodePlaceholder.id, port: addNodePlaceholder.getPorts().find((p: any) => p.group === 'left')?.id || 'left' }, target: { cell: addNodePlaceholder.id, port: addNodePlaceholder.getPorts().find((p: any) => p.group === 'left')?.id || 'left' },
@@ -200,26 +308,10 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
addedCells.push(cycleStartNode, addNodePlaceholder, innerEdge); addedCells.push(cycleStartNode, addNodePlaceholder, innerEdge);
} }
// Adjust parent size if adding inside a cycle container // Adjust loop node size when child node is added via port within loop node
const cycleId = sourceNodeData.cycle; const cycleId = sourceNodeData.cycle;
if (cycleId) { if (cycleId) {
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); adjustCycleContainerSize(graph, cycleId);
if (parentNode) {
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
if (childNodes.length > 0) {
const bounds = childNodes.reduce((acc: any, child: any) => {
const b = child.getBBox();
return { minX: Math.min(acc.minX, b.x), minY: Math.min(acc.minY, b.y), maxX: Math.max(acc.maxX, b.x + b.width), maxY: Math.max(acc.maxY, b.y + b.height) };
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
const padding = 50;
const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2);
const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2);
parentNode.prop('size', { width: newWidth, height: newHeight });
parentNode.getPorts().forEach((port: any) => {
if (port.group === 'right' && port.args) parentNode.portProp(port.id!, 'args/x', newWidth);
});
}
}
} }
// toFront // toFront
@@ -245,7 +337,7 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
graph.enableHistory(); graph.enableHistory();
const history = graph.getPlugin('history') as any; const history = graph.getPlugin('history') as any;
if (history) { if (history) {
const batchFrame = addedCells.map((cell: any) => ({ const batchFrame = [...addedCells, ...addedEdges].map((cell: any) => ({
batch: true, batch: true,
event: 'cell:added', event: 'cell:added',
data: { id: cell.id, node: cell.isNode(), edge: cell.isEdge(), props: cell.toJSON() }, data: { id: cell.id, node: cell.isNode(), edge: cell.isEdge(), props: cell.toJSON() },
@@ -316,7 +408,7 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
if (!tempElement) return null; if (!tempElement) return null;
return ( return createPortal(
<Popover <Popover
content={content} content={content}
open={popoverVisible} open={popoverVisible}
@@ -324,14 +416,12 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
if (!visible) handlePopoverClose(); if (!visible) handlePopoverClose();
}} }}
placement="right" placement="right"
overlayStyle={{ autoAdjustOverflow
position: 'fixed', getPopupContainer={() => document.body}
left: popoverPosition.x + 10,
top: popoverPosition.y - 10,
}}
> >
<div /> <div style={{ width: '1px', height: '1px' }} />
</Popover> </Popover>,
tempElement
); );
}; };

View File

@@ -355,14 +355,13 @@ const CaseList: FC<CaseListProps> = ({
// Update node ports based on case count changes (add/remove cases) // Update node ports based on case count changes (add/remove cases)
const updateNodePorts = (caseCount: number, removedCaseIndex?: number) => { const updateNodePorts = (caseCount: number, removedCaseIndex?: number) => {
if (!selectedNode || !graphRef?.current) return; if (!selectedNode || !graphRef?.current) return;
const graph = graphRef.current;
// Get current port count to determine if it's an add or remove operation const currentRightPorts = selectedNode.getPorts().filter((port: any) => port.group === 'right');
const currentPorts = selectedNode.getPorts().filter((port: any) => port.group === 'right'); const currentCaseCount = currentRightPorts.length - 1;
const currentCaseCount = currentPorts.length - 1; // Exclude ELSE port
const isAddingCase = removedCaseIndex === undefined && caseCount > currentCaseCount; const isAddingCase = removedCaseIndex === undefined && caseCount > currentCaseCount;
// Save existing edge connections (including left-side port connections) const existingEdges = graph.getEdges().filter((edge: any) =>
const existingEdges = graphRef.current.getEdges().filter((edge: any) =>
edge.getSourceCellId() === selectedNode.id || edge.getTargetCellId() === selectedNode.id edge.getSourceCellId() === selectedNode.id || edge.getTargetCellId() === selectedNode.id
); );
const edgeConnections = existingEdges.map((edge: any) => ({ const edgeConnections = existingEdges.map((edge: any) => ({
@@ -371,113 +370,70 @@ const CaseList: FC<CaseListProps> = ({
targetCellId: edge.getTargetCellId(), targetCellId: edge.getTargetCellId(),
targetPortId: edge.getTargetPortId(), targetPortId: edge.getTargetPortId(),
sourceCellId: edge.getSourceCellId(), sourceCellId: edge.getSourceCellId(),
isIncoming: edge.getTargetCellId() === selectedNode.id isIncoming: edge.getTargetCellId() === selectedNode.id,
})); }));
// Remove all existing right-side ports
const existingPorts = selectedNode.getPorts();
existingPorts.forEach((port: any) => {
if (port.group === 'right') {
selectedNode.removePort(port.id);
}
});
const cases = form.getFieldValue(name) || []; const cases = form.getFieldValue(name) || [];
selectedNode.prop('size', { width: nodeWidth, height: calcConditionNodeTotalHeight(cases) }); const leftPorts = selectedNode.getPorts().filter((p: any) => p.group !== 'right');
const newRightPorts = Array.from({ length: caseCount + 1 }, (_, i) => ({
// Add ELIF ports
for (let i = 0; i < caseCount; i++) {
selectedNode.addPort({
id: `CASE${i + 1}`, id: `CASE${i + 1}`,
group: 'right', group: 'right',
args: { args: { x: nodeWidth, y: getConditionNodeCasePortY(cases, i) },
x: nodeWidth, }));
y: getConditionNodeCasePortY(cases, i),
},
});
}
// Add ELSE port graph.startBatch('update-ports');
selectedNode.addPort({
id: `CASE${caseCount + 1}`,
group: 'right',
args: {
x: nodeWidth,
y: getConditionNodeCasePortY(cases, caseCount),
},
});
// Restore edge connections existingEdges.forEach((edge: any) => graph.removeCell(edge));
setTimeout(() => { // Replace all ports in one prop call — produces a single cell:change:ports command
edgeConnections.forEach(({ edge, sourcePortId, targetCellId, targetPortId, sourceCellId, isIncoming }: any) => { selectedNode.prop('ports/items', [...leftPorts, ...newRightPorts], { rewrite: true });
// If it's an incoming connection (left-side port), restore directly selectedNode.prop('size', { width: nodeWidth, height: calcConditionNodeTotalHeight(cases) });
edgeConnections.forEach(({sourcePortId, targetCellId, targetPortId, sourceCellId, isIncoming }: any) => {
if (isIncoming) { if (isIncoming) {
const sourceCell = graphRef.current?.getCellById(sourceCellId); const sourceCell = graph.getCellById(sourceCellId);
if (sourceCell) { if (sourceCell) {
graphRef.current?.addEdge({ graph.addEdge({
source: { cell: sourceCellId, port: sourcePortId }, source: { cell: sourceCellId, port: sourcePortId },
target: { cell: selectedNode.id, port: targetPortId }, target: { cell: selectedNode.id, port: targetPortId },
...edgeAttrs, ...edgeAttrs
}); });
sourceCell.toFront();
bringLoopChildrenToFront(sourceCell);
selectedNode.toFront();
bringLoopChildrenToFront(selectedNode);
} }
sourceCell.toFront()
selectedNode.toFront()
bringLoopChildrenToFront(sourceCell)
bringLoopChildrenToFront(selectedNode)
graphRef.current?.removeCell(edge);
return; return;
} }
// Handle right-side port connections
const originalCaseNumber = parseInt(sourcePortId.match(/CASE(\d+)/)?.[1] || '0'); const originalCaseNumber = parseInt(sourcePortId.match(/CASE(\d+)/)?.[1] || '0');
if (removedCaseIndex !== undefined && originalCaseNumber === removedCaseIndex + 1) return;
// If it's a remove operation and the port is being removed, delete the connection
if (removedCaseIndex !== undefined && originalCaseNumber === removedCaseIndex + 1) {
graphRef.current?.removeCell(edge);
return;
}
let newPortId = sourcePortId; let newPortId = sourcePortId;
// If it's a remove operation, remap port IDs
if (removedCaseIndex !== undefined) { if (removedCaseIndex !== undefined) {
if (originalCaseNumber > removedCaseIndex + 1) { if (originalCaseNumber > removedCaseIndex + 1) {
// Ports after the removed port, shift numbering forward
newPortId = `CASE${originalCaseNumber - 1}`; newPortId = `CASE${originalCaseNumber - 1}`;
} } else if (originalCaseNumber === currentCaseCount + 1) {
// ELSE port always maps to the new ELSE port position
else if (originalCaseNumber === currentCaseCount + 1) {
newPortId = `CASE${caseCount + 1}`; newPortId = `CASE${caseCount + 1}`;
} }
} else if (isAddingCase) { } else if (isAddingCase && originalCaseNumber === currentCaseCount + 1) {
// If it's an add operation, ELSE port needs to be remapped newPortId = `CASE${caseCount + 1}`;
if (originalCaseNumber === currentCaseCount + 1) {
newPortId = `CASE${caseCount + 1}`; // New ELSE port
} }
// Newly added ports don't restore any connections if (newRightPorts.find((p) => p.id === newPortId)) {
} const targetCell = graph.getCellById(targetCellId);
const newPorts = selectedNode.getPorts();
const matchingPort = newPorts.find((port: any) => port.id === newPortId);
if (matchingPort) {
const targetCell = graphRef.current?.getCellById(targetCellId);
if (targetCell) { if (targetCell) {
graphRef.current?.addEdge({ graph.addEdge({
source: { cell: selectedNode.id, port: newPortId }, source: { cell: selectedNode.id, port: newPortId },
target: { cell: targetCellId, port: targetPortId }, target: { cell: targetCellId, port: targetPortId },
...edgeAttrs ...edgeAttrs
}); });
selectedNode.toFront() selectedNode.toFront();
bringLoopChildrenToFront(selectedNode) bringLoopChildrenToFront(selectedNode);
targetCell.toFront() targetCell.toFront();
bringLoopChildrenToFront(targetCell) bringLoopChildrenToFront(targetCell);
} }
} }
graphRef.current?.removeCell(edge);
}); });
}, 50);
graph.stopBatch('update-ports');
}; };
const handleChangeLogicalOperator = (index: number) => { const handleChangeLogicalOperator = (index: number) => {

View File

@@ -42,109 +42,73 @@ const CategoryList: FC<CategoryListProps> = ({ parentName, selectedNode, graphRe
// Update node ports based on category count changes (add/remove categories) // Update node ports based on category count changes (add/remove categories)
const updateNodePorts = (caseCount: number, removedCaseIndex?: number) => { const updateNodePorts = (caseCount: number, removedCaseIndex?: number) => {
if (!selectedNode || !graphRef?.current) return; if (!selectedNode || !graphRef?.current) return;
const graph = graphRef.current;
// Save existing edge connections (including left-side port connections) const existingEdges = graph.getEdges().filter((edge: any) =>
const existingEdges = graphRef.current.getEdges().filter((edge: any) =>
edge.getSourceCellId() === selectedNode.id || edge.getTargetCellId() === selectedNode.id edge.getSourceCellId() === selectedNode.id || edge.getTargetCellId() === selectedNode.id
); );
const edgeConnections = existingEdges.map((edge: any) => ({ const edgeConnections = existingEdges.map((edge: any) => ({
edge,
sourcePortId: edge.getSourcePortId(), sourcePortId: edge.getSourcePortId(),
targetCellId: edge.getTargetCellId(), targetCellId: edge.getTargetCellId(),
targetPortId: edge.getTargetPortId(), targetPortId: edge.getTargetPortId(),
sourceCellId: edge.getSourceCellId(), sourceCellId: edge.getSourceCellId(),
isIncoming: edge.getTargetCellId() === selectedNode.id isIncoming: edge.getTargetCellId() === selectedNode.id,
})); }));
// Remove all existing right-side ports graph.startBatch('update-ports');
const existingPorts = selectedNode.getPorts();
existingPorts.forEach((port: any) => {
if (port.group === 'right') {
selectedNode.removePort(port.id);
}
});
// Calculate new node height: base height 88px + 30px for each additional port existingEdges.forEach((edge: any) => graph.removeCell(edge));
const newHeight = conditionNodeHeight + (caseCount - 2) * conditionNodeItemHeight; // Replace all ports in one prop call — produces a single cell:change:ports command
const leftPorts = selectedNode.getPorts().filter((p: any) => p.group !== 'right');
selectedNode.prop('size', { width: nodeWidth, height: newHeight < conditionNodeHeight ? conditionNodeHeight : newHeight }) const newRightPorts = Array.from({ length: caseCount }, (_, i) => ({
// Update right port x position
const currentPorts = selectedNode.getPorts();
currentPorts.forEach(port => {
if (port.group === 'right' && port.args) {
selectedNode.portProp(port.id!, 'args/x', nodeWidth);
}
});
// Add category ports
for (let i = 0; i < caseCount; i++) {
selectedNode.addPort({
id: `CASE${i + 1}`, id: `CASE${i + 1}`,
group: 'right', group: 'right',
args: { args: { x: nodeWidth, y: portItemArgsY * i + conditionNodePortItemArgsY },
x: nodeWidth, }));
y: portItemArgsY * i + conditionNodePortItemArgsY, selectedNode.prop('ports/items', [...leftPorts, ...newRightPorts], { rewrite: true });
},
});
}
// Restore edge connections
setTimeout(() => {
edgeConnections.forEach(({ edge, sourcePortId, targetCellId, targetPortId, sourceCellId, isIncoming }: any) => {
graphRef.current?.removeCell(edge);
// If it's an incoming connection (left-side port), restore directly const newHeight = conditionNodeHeight + (caseCount - 2) * conditionNodeItemHeight;
selectedNode.prop('size', { width: nodeWidth, height: newHeight < conditionNodeHeight ? conditionNodeHeight : newHeight });
edgeConnections.forEach(({ sourcePortId, targetCellId, targetPortId, sourceCellId, isIncoming }: any) => {
if (isIncoming) { if (isIncoming) {
const sourceCell = graphRef.current?.getCellById(sourceCellId); const sourceCell = graph.getCellById(sourceCellId);
if (sourceCell) { if (sourceCell) {
graphRef.current?.addEdge({ graph.addEdge({
source: { cell: sourceCellId, port: sourcePortId }, source: { cell: sourceCellId, port: sourcePortId },
target: { cell: selectedNode.id, port: targetPortId }, target: { cell: selectedNode.id, port: targetPortId },
...edgeAttrs ...edgeAttrs
}); });
sourceCell.toFront() sourceCell.toFront();
bringLoopChildrenToFront(sourceCell) bringLoopChildrenToFront(sourceCell);
selectedNode.toFront() selectedNode.toFront();
bringLoopChildrenToFront(selectedNode) bringLoopChildrenToFront(selectedNode);
} }
return; return;
} }
// Handle right-side port connections
const originalCaseNumber = parseInt(sourcePortId.match(/CASE(\d+)/)?.[1] || '0'); const originalCaseNumber = parseInt(sourcePortId.match(/CASE(\d+)/)?.[1] || '0');
if (removedCaseIndex !== undefined && originalCaseNumber === removedCaseIndex + 1) return;
// If it's a removed port, don't recreate the connection
if (removedCaseIndex !== undefined && originalCaseNumber === removedCaseIndex + 1) {
return;
}
let newPortId = sourcePortId; let newPortId = sourcePortId;
// If a port was removed, remap subsequent port IDs
if (removedCaseIndex !== undefined && originalCaseNumber > removedCaseIndex + 1) { if (removedCaseIndex !== undefined && originalCaseNumber > removedCaseIndex + 1) {
newPortId = `CASE${originalCaseNumber - 1}`; newPortId = `CASE${originalCaseNumber - 1}`;
} }
if (newRightPorts.find((p) => p.id === newPortId)) {
// Check if the new port exists const targetCell = graph.getCellById(targetCellId);
const newPorts = selectedNode.getPorts();
const matchingPort = newPorts.find((port: any) => port.id === newPortId);
if (matchingPort) {
const targetCell = graphRef.current?.getCellById(targetCellId);
if (targetCell) { if (targetCell) {
graphRef.current?.addEdge({ graph.addEdge({
source: { cell: selectedNode.id, port: newPortId }, source: { cell: selectedNode.id, port: newPortId },
target: { cell: targetCellId, port: targetPortId }, target: { cell: targetCellId, port: targetPortId },
...edgeAttrs ...edgeAttrs
}); });
selectedNode.toFront() selectedNode.toFront();
bringLoopChildrenToFront(selectedNode) bringLoopChildrenToFront(selectedNode);
targetCell.toFront() targetCell.toFront();
bringLoopChildrenToFront(targetCell) bringLoopChildrenToFront(targetCell);
} }
} }
}); });
}, 50);
graph.stopBatch('update-ports');
}; };
const handleAddCategory = (addFunc: Function) => { const handleAddCategory = (addFunc: Function) => {

View File

@@ -133,7 +133,7 @@ const CycleVarsList: FC<CycleVarsListProps> = ({
return option.dataType === currentType return option.dataType === currentType
})} })}
variant="borderless" variant="filled"
size="small" size="small"
className="select" className="select"
/> />

View File

@@ -67,10 +67,14 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
useEffect(() => { useEffect(() => {
if (values?.retrieve_type) { if (values?.retrieve_type) {
const resetValues: KnowledgeConfigForm = {}
const fieldsToReset = Object.keys(values).filter(key => const fieldsToReset = Object.keys(values).filter(key =>
key !== 'kb_id' && key !== 'retrieve_type' && key !== 'top_k' key !== 'kb_id' && key !== 'retrieve_type' && key !== 'top_k'
) as (keyof KnowledgeConfigForm)[]; ) as (keyof KnowledgeConfigForm)[];
form.resetFields(fieldsToReset); fieldsToReset.forEach(key => {
resetValues[key] = undefined
})
form.setFieldsValue(resetValues);
} }
}, [values?.retrieve_type]) }, [values?.retrieve_type])
@@ -110,7 +114,6 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
label: t(`application.${key}`), label: t(`application.${key}`),
value: key, value: key,
}))} }))}
// onChange={handleChange}
/> />
</FormItem> </FormItem>
{/* Top K */} {/* Top K */}
@@ -124,27 +127,11 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
style={{ width: '100%' }} style={{ width: '100%' }}
min={1} min={1}
max={20} max={20}
// onChange={(value) => form.setFieldValue('top_k', value)} onChange={(value) => form.setFieldValue('top_k', value)}
/> />
</FormItem> </FormItem>
{/* 语义相似度阈值 similarity_threshold */} {/* Vector similarity weight */}
{values?.retrieve_type === 'semantic' && ( {values?.retrieve_type === 'semantic' && (
<FormItem
name="similarity_threshold"
label={t('application.similarity_threshold')}
extra={t('application.similarity_threshold_desc')}
initialValue={0.5}
>
<RbSlider
max={1.0}
step={0.1}
min={0.0}
isInput={true}
/>
</FormItem>
)}
{/* 分词匹配度阈值 vector_similarity_weight */}
{values?.retrieve_type === 'participle' && (
<FormItem <FormItem
name="vector_similarity_weight" name="vector_similarity_weight"
label={t('application.vector_similarity_weight')} label={t('application.vector_similarity_weight')}
@@ -159,7 +146,23 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
/> />
</FormItem> </FormItem>
)} )}
{/* 混合检索权重 */} {/* similarity threshold */}
{values?.retrieve_type === 'participle' && (
<FormItem
name="similarity_threshold"
label={t('application.similarity_threshold')}
extra={t('application.similarity_threshold_desc')}
initialValue={0.5}
>
<RbSlider
max={1.0}
step={0.1}
min={0.0}
isInput={true}
/>
</FormItem>
)}
{/* Hybrid retrieval weight */}
{values?.retrieve_type === 'hybrid' && ( {values?.retrieve_type === 'hybrid' && (
<> <>
<FormItem <FormItem

View File

@@ -47,7 +47,8 @@ const KnowledgeGlobalConfigModal = forwardRef<KnowledgeGlobalConfigModalRef, Kno
useEffect(() => { useEffect(() => {
if (values?.rerank_model) { if (values?.rerank_model) {
form.setFieldsValue({ ...data }) const { rerank_model, ...rest } = data;
form.setFieldsValue({ ...rest })
} else { } else {
form.setFieldsValue({ reranker_id: undefined, reranker_top_k: undefined }) form.setFieldsValue({ reranker_id: undefined, reranker_top_k: undefined })
} }

View File

@@ -14,8 +14,6 @@ const MemoryConfig: FC<{ options: Suggestion[]; parentName: string; }> = ({
const form = Form.useFormInstance(); const form = Form.useFormInstance();
const values = Form.useWatch([], form) || {} const values = Form.useWatch([], form) || {}
console.log('MemoryConfig', values)
const handleChangeEnable = (value: boolean) => { const handleChangeEnable = (value: boolean) => {
if (value) { if (value) {
form.setFieldsValue({ form.setFieldsValue({

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-03-07 14:55:04 * @Date: 2026-03-07 14:55:04
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-17 10:05:32 * @Last Modified time: 2026-04-29 17:08:19
*/ */
import { type FC, useEffect, useState } from "react"; import { type FC, useEffect, useState } from "react";
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -28,7 +28,6 @@ const ModelConfig: FC = () => {
if (model_id && options) { if (model_id && options) {
const model = options.find(item => item.id === model_id) const model = options.find(item => item.id === model_id)
setSelectedModel(model || null) setSelectedModel(model || null)
form.setFieldValue('json_output', false)
} else { } else {
setSelectedModel(null) setSelectedModel(null)
} }
@@ -47,6 +46,7 @@ const ModelConfig: FC = () => {
params={{ type: 'llm,chat' }} params={{ type: 'llm,chat' }}
className="rb:w-full!" className="rb:w-full!"
size="small" size="small"
onChange={() => form.setFieldValue('json_output', false)}
updateOptions={updateOptions} updateOptions={updateOptions}
/> />
</Form.Item> </Form.Item>

View File

@@ -78,7 +78,7 @@ const VariableList: FC<VariableListProps> = ({
className="rb:cursor-pointer rb:group rb:py-2! rb:pl-2.5! rb:pr-2! rb:text-[12px] rb:bg-[#F6F6F6] rb-border rb:rounded-lg" className="rb:cursor-pointer rb:group rb:py-2! rb:pl-2.5! rb:pr-2! rb:text-[12px] rb:bg-[#F6F6F6] rb-border rb:rounded-lg"
onClick={() => handleEditVariable(index, vo)} onClick={() => handleEditVariable(index, vo)}
> >
<span className="rb:font-medium rb:flex-1">{vo.name}·{vo.description}</span> <span className="rb:font-medium rb:flex-1 rb:whitespace-break-spaces rb:wrap-break-word rb:line-clamp-1">{vo.name}·{vo.description}</span>
<Space size={8}> <Space size={8}>
{vo.required && <span className="rb:py-px rb:px-2 rb:bg-white rb-border rb:rounded-sm">{t('workflow.config.start.required')}</span>} {vo.required && <span className="rb:py-px rb:px-2 rb:bg-white rb-border rb:rounded-sm">{t('workflow.config.start.required')}</span>}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-01-19 17:00:26 * @Date: 2026-01-19 17:00:26
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-13 10:44:17 * @Last Modified time: 2026-05-07 18:36:58
*/ */
/** /**
* useVariableList Hook * useVariableList Hook
@@ -97,14 +97,15 @@ const addVariable = (
dataType: string, dataType: string,
value: string, value: string,
nodeData: any, nodeData: any,
extra?: Partial<Suggestion> extra?: Partial<Suggestion>,
defaultValue?: any
) => { ) => {
if (!keys.has(key)) { if (!keys.has(key)) {
keys.add(key); keys.add(key);
const children = dataType === 'file' const children = dataType === 'file'
? buildFileChildren(key, value, nodeData, label) ? buildFileChildren(key, value, nodeData, label)
: undefined; : undefined;
list.push({ key, label, type: 'variable', dataType, value, nodeData, children, ...extra }); list.push({ key, label, type: 'variable', dataType, value, nodeData, children, default: defaultValue, ...extra });
} }
}; };
@@ -153,7 +154,7 @@ const processNodeVariables = (
case 'start': case 'start':
// Add start node variables // Add start node variables
[...(config?.variables?.defaultValue ?? []), ...(config?.variables?.value ?? [])].forEach((v: any) => { [...(config?.variables?.defaultValue ?? []), ...(config?.variables?.value ?? [])].forEach((v: any) => {
if (v?.name) addVariable(variableList, addedKeys, `${dataNodeId}_${v.name}`, v.name, v.type, `${dataNodeId}.${v.name}`, nodeData); if (v?.name) addVariable(variableList, addedKeys, `${dataNodeId}_${v.name}`, v.name, v.type, `${dataNodeId}.${v.name}`, nodeData, undefined, v.defaultValue ?? v.default);
}); });
// Add system variables // Add system variables
config?.variables?.sys?.forEach((v: any) => { config?.variables?.sys?.forEach((v: any) => {
@@ -164,7 +165,7 @@ const processNodeVariables = (
case 'parameter-extractor': case 'parameter-extractor':
// Add extracted parameters // Add extracted parameters
(config?.params?.defaultValue || []).forEach((p: any) => { (config?.params?.defaultValue || []).forEach((p: any) => {
if (p?.name) addVariable(variableList, addedKeys, `${dataNodeId}_${p.name}`, p.name, p.type || 'string', `${dataNodeId}.${p.name}`, nodeData); if (p?.name) addVariable(variableList, addedKeys, `${dataNodeId}_${p.name}`, p.name, p.type || 'string', `${dataNodeId}.${p.name}`, nodeData, undefined, p.defaultValue ?? p.default);
}); });
break; break;
@@ -178,7 +179,7 @@ const processNodeVariables = (
const fv = variableList.find(v => `{{${v.value}}}` === gv.value[0]); const fv = variableList.find(v => `{{${v.value}}}` === gv.value[0]);
if (fv) dt = fv.dataType; if (fv) dt = fv.dataType;
} }
addVariable(variableList, addedKeys, `${dataNodeId}_${gv.key}`, gv.key, dt, `${dataNodeId}.${gv.key}`, nodeData); addVariable(variableList, addedKeys, `${dataNodeId}_${gv.key}`, gv.key, dt, `${dataNodeId}.${gv.key}`, nodeData, undefined, gv.defaultValue ?? gv.default);
} }
}); });
} else { } else {
@@ -205,14 +206,14 @@ const processNodeVariables = (
case 'loop': case 'loop':
// Add loop cycle variables // Add loop cycle variables
(config.cycle_vars.defaultValue || []).forEach((cv: any) => { (config.cycle_vars.defaultValue || []).forEach((cv: any) => {
if (cv.name?.trim()) addVariable(variableList, addedKeys, `${dataNodeId}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${dataNodeId}.${cv.name}`, nodeData); if (cv.name?.trim()) addVariable(variableList, addedKeys, `${dataNodeId}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${dataNodeId}.${cv.name}`, nodeData, undefined, cv.defaultValue ?? cv.default);
}); });
break; break;
case 'code': case 'code':
// Add code node output variables // Add code node output variables
(config.output_variables.defaultValue || []).forEach((cv: any) => { (config.output_variables.defaultValue || []).forEach((cv: any) => {
if (cv.name?.trim()) addVariable(variableList, addedKeys, `${dataNodeId}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${dataNodeId}.${cv.name}`, nodeData); if (cv.name?.trim()) addVariable(variableList, addedKeys, `${dataNodeId}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${dataNodeId}.${cv.name}`, nodeData, undefined, cv.defaultValue ?? cv.default);
}); });
break; break;
} }
@@ -321,13 +322,13 @@ export const getChildNodeVariables = (
// Add parameter-extractor variables // Add parameter-extractor variables
if (type === 'parameter-extractor') { if (type === 'parameter-extractor') {
(nodeData.config?.params?.defaultValue || []).forEach((p: any) => { (nodeData.config?.params?.defaultValue || []).forEach((p: any) => {
if (p?.name) addVariable(list, keys, `${nodeId}_${p.name}`, p.name, p.type || 'string', `${nodeId}.${p.name}`, nodeData); if (p?.name) addVariable(list, keys, `${nodeId}_${p.name}`, p.name, p.type || 'string', `${nodeId}.${p.name}`, nodeData, undefined, p.defaultValue ?? p.default);
}); });
} }
// Add code node variables // Add code node variables
if (type === 'code') { if (type === 'code') {
(nodeData.config?.output_variables?.defaultValue || []).forEach((p: any) => { (nodeData.config?.output_variables?.defaultValue || []).forEach((p: any) => {
if (p?.name) addVariable(list, keys, `${nodeId}_${p.name}`, p.name, p.type || 'string', `${nodeId}.${p.name}`, nodeData); if (p?.name) addVariable(list, keys, `${nodeId}_${p.name}`, p.name, p.type || 'string', `${nodeId}.${p.name}`, nodeData, undefined, p.defaultValue ?? p.default);
}); });
} }
}); });
@@ -393,7 +394,7 @@ export const useVariableList = (
const relevantIds = [...getPreviousNodes(selectedNode.id), ...childIds, ...(parentLoop ? getPreviousNodes(parentLoop.id) : [])]; const relevantIds = [...getPreviousNodes(selectedNode.id), ...childIds, ...(parentLoop ? getPreviousNodes(parentLoop.id) : [])];
// Add chat variables // Add chat variables
chatVariables?.forEach(v => addVariable(list, keys, `CONVERSATION_${v.name}`, v.name, v.type, `conv.${v.name}`, { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' }, { group: 'CONVERSATION' })); chatVariables?.forEach(v => addVariable(list, keys, `CONVERSATION_${v.name}`, v.name, v.type, `conv.${v.name}`, { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' }, { group: 'CONVERSATION' }, v.defaultValue ?? v.default));
// Process each relevant node: deferred types last (they depend on prior variables) // Process each relevant node: deferred types last (they depend on prior variables)
const deferredIds: string[] = []; const deferredIds: string[] = [];

View File

@@ -2,13 +2,13 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 15:39:59 * @Date: 2026-02-03 15:39:59
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-21 20:27:33 * @Last Modified time: 2026-05-07 18:36:31
*/ */
import { type FC, useEffect, useState, useMemo } from "react"; import { type FC, useEffect, useState, useMemo } from "react";
import clsx from 'clsx' import clsx from 'clsx'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Graph, Node } from '@antv/x6'; import { Graph, Node } from '@antv/x6';
import { Form, Input, Select, InputNumber, Switch, Flex, Space, Dropdown, type MenuProps, Button } from 'antd'; import { Form, Input, Select, InputNumber, Switch, Flex, Space, Dropdown, type MenuProps, Button, App, Popover } from 'antd';
import type { NodeConfig, NodeProperties, ChatVariable } from '../../types' import type { NodeConfig, NodeProperties, ChatVariable } from '../../types'
import CustomSelect from "@/components/CustomSelect"; import CustomSelect from "@/components/CustomSelect";
@@ -28,6 +28,7 @@ import ToolConfig from './ToolConfig'
import MemoryConfig from './MemoryConfig' import MemoryConfig from './MemoryConfig'
import VariableList from './VariableList' import VariableList from './VariableList'
import { useVariableList, getCurrentNodeVariables, getChildNodeVariables } from './hooks/useVariableList' import { useVariableList, getCurrentNodeVariables, getChildNodeVariables } from './hooks/useVariableList'
import { useWorkflowStore } from '@/store/workflow'
import styles from './properties.module.css' import styles from './properties.module.css'
import Editor, { type LexicalEditorProps } from "../Editor"; import Editor, { type LexicalEditorProps } from "../Editor";
import RbSlider from '@/components/RbSlider' import RbSlider from '@/components/RbSlider'
@@ -39,6 +40,8 @@ import ModelConfig from './ModelConfig'
import ModelSelect from '@/components/ModelSelect' import ModelSelect from '@/components/ModelSelect'
import ListOperator from './ListOperator' import ListOperator from './ListOperator'
import MappingList from "./MappingList"; import MappingList from "./MappingList";
import SingleNodeRun from '../SingleNodeRun'
import { cannotRunNodes } from '../../constant'
/** /**
* Props for Properties component * Props for Properties component
@@ -58,8 +61,12 @@ interface PropertiesProps {
parseEvent: () => void; parseEvent: () => void;
/** Workflow configuration */ /** Workflow configuration */
config?: any; config?: any;
/** App ID for node run */
appId?: string;
/** Chat variables */ /** Chat variables */
chatVariables: ChatVariable[]; chatVariables: ChatVariable[];
/** Function to save workflow configuration */
handleSave: (flag?: boolean) => Promise<unknown>;
} }
/** /**
@@ -71,9 +78,13 @@ const Properties: FC<PropertiesProps> = ({
selectedNode, selectedNode,
graphRef, graphRef,
chatVariables, chatVariables,
blankClick blankClick,
config,
appId,
handleSave,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { message } = App.useApp()
const [form] = Form.useForm<NodeConfig>(); const [form] = Form.useForm<NodeConfig>();
const [configs, setConfigs] = useState<Record<string, NodeConfig>>({} as Record<string, NodeConfig>) const [configs, setConfigs] = useState<Record<string, NodeConfig>>({} as Record<string, NodeConfig>)
const values = Form.useWatch([], form); const values = Form.useWatch([], form);
@@ -530,11 +541,35 @@ const Properties: FC<PropertiesProps> = ({
} }
} }
const [isRun, setIsRun] = useState(false);
const { getCheckResults } = useWorkflowStore()
const handleRun = () => {
handleSave?.(false)
.then(() => {
if (appId) {
const nodeResult = getCheckResults(appId).find(r => r.id === selectedNode.id)
const configErrors = nodeResult?.errors.filter(e => e.key !== 'notConnected') ?? []
if (configErrors.length) {
message.error(configErrors[0].message)
return
}
}
setIsRun(true)
})
}
return ( return (
<>
<div className={clsx("rb:h-[calc(100vh-88px)] rb:w-90 rb:fixed rb:right-2.5 rb:top-18.5 rb:bottom-2.5 rb:z-1000", styles.properties)}> <div className={clsx("rb:h-[calc(100vh-88px)] rb:w-90 rb:fixed rb:right-2.5 rb:top-18.5 rb:bottom-2.5 rb:z-1000", styles.properties)}>
<RbCard <RbCard
title={t('workflow.nodeProperties')} title={t('workflow.nodeProperties')}
extra={<Space> extra={<Space>
{!cannotRunNodes.includes(selectedNode?.data?.type) && <Popover content={t('workflow.singleRun')} classNames={{ body: 'rb:py-0.5! rb:px-1! rb:rounded-[6px]! rb:text-[12px]!' }}>
<div
className="rb:cursor-pointer rb:size-4 rb:hover:bg-[#F6F6F6] rb:rounded-sm rb:bg-cover rb:bg-[url('@/assets/images/workflow/run.svg')]"
onClick={handleRun}
></div>
</Popover>}
<Dropdown <Dropdown
menu={{ menu={{
items: [ items: [
@@ -986,7 +1021,18 @@ const Properties: FC<PropertiesProps> = ({
</div> </div>
} }
</RbCard> </RbCard>
{isRun && (
<SingleNodeRun
open={isRun}
onClose={() => setIsRun(false)}
selectedNode={selectedNode}
appId={appId || config?.app_id || ''}
variableList={variableList}
/>
)}
</div> </div>
</>
); );
}; };
export default Properties; export default Properties;

View File

@@ -0,0 +1,74 @@
/*
* @Author: ZhaoYing
* @Date: 2026-05-07 18:37:15
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-07 18:37:15
*/
import { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Flex, Form } from 'antd'
import CodeMirrorEditor from '@/components/CodeMirrorEditor'
const defaultContextItem = {
"content": "",
"title": "",
"url": "",
"icon": "",
"metadata": {
"dataset_id": "",
"dataset_name": "",
"document_id": [],
"document_name": "",
"document_data_source_type": "",
"segment_id": "",
"segment_position": "",
"segment_word_count": "",
"segment_hit_count": "",
"segment_index_node_hash": "",
"score": ""
}
}
const ContextList: FC = () => {
const { t } = useTranslation()
return (
<Form.List name="context" initialValue={[JSON.stringify(defaultContextItem, null, 2)]}>
{(fields, { add, remove }) => (
<Flex vertical gap={8}>
<Flex justify="space-between" align="center">
<div className="rb:text-[12px] rb:font-medium rb:leading-4.5">{t('workflow.config.llm.context')}</div>
<Button
onClick={() => add(JSON.stringify(defaultContextItem, null, 2))}
size="small"
className="rb:text-[12px]! rb:rounded-sm!"
>
+ {t('common.add')}
</Button>
</Flex>
{fields.map(({ key, name }) => (
<Flex vertical gap={4} key={key} className="rb:py-1! rb:bg-[#F6F6F6] rb:rounded-lg rb:text-[12px]">
<Flex justify="space-between" align="center" className="rb:font-medium rb:px-2!">
<span>JSON</span>
<div
className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/workflow/deleteBg.svg')] rb:hover:bg-[url('@/assets/images/workflow/deleteBg_hover.svg')]"
onClick={() => remove(name)}
></div>
</Flex>
<Form.Item name={name} noStyle>
<CodeMirrorEditor
language="json"
size="small"
variant="filled"
/>
</Form.Item>
</Flex>
))}
</Flex>
)}
</Form.List>
)
}
export default ContextList

View File

@@ -0,0 +1,134 @@
/*
* @Author: ZhaoYing
* @Date: 2026-05-07 18:37:23
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-07 18:37:23
*/
import { type FC, useState, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Form, Row, Col } from 'antd'
import type { FormInstance } from 'antd'
import UploadFiles, { transform_file_type } from '@/views/Conversation/components/FileUpload'
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
import type { UploadFileListModalRef } from '@/views/Conversation/types'
import FileList from '@/components/Chat/FileList'
import { getFileInfoByUrl } from '@/api/fileStorage'
interface FileVarInputProps {
name: string | string[]
dataType: string
form: FormInstance
}
const FileVarInput: FC<FileVarInputProps> = ({ name, dataType, form }) => {
const { t } = useTranslation()
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
const [fileList, setFileList] = useState<any[]>([])
const isSingle = dataType === 'file'
const setFormFileValue = (updated: any[]) => {
form.setFieldValue(name, isSingle ? (updated[0] ?? null) : updated)
}
const fileChange = (file?: any) => {
const fileObj = file ? {
...file,
type: file.type,
transfer_method: 'local_file',
upload_file_id: file.response?.data?.file_id,
} : undefined
if (isSingle) {
const updated = [fileObj]
setFileList(updated)
setTimeout(() => setFormFileValue(updated), 0)
return
}
setFileList(prev => {
const index = prev.findIndex((item: any) => item.uid === fileObj.uid)
const updated = index > -1
? prev.map((item, i) => i === index ? fileObj : item)
: [...prev, fileObj]
setTimeout(() => setFormFileValue(updated), 0)
return updated
})
}
const addFileList = (list?: any[]) => {
if (!list?.length) return
const uploadingList = list.map(f => ({ ...f, status: 'uploading' }))
setFileList(prev => {
const updated = isSingle ? [uploadingList[0]] : [...prev, ...uploadingList]
setTimeout(() => setFormFileValue(updated), 0)
return updated
});
(isSingle ? [uploadingList[0]] : uploadingList).forEach(file => {
getFileInfoByUrl(file.url)
.then((res) => {
const { file_name, file_size, content_type } = res as { file_name: string; file_size: number; content_type: string }
setFileList(prev => {
const updated = prev.map(f =>
f.uid === file.uid
? { ...f, status: 'done', name: file_name, size: file_size, type: transform_file_type[content_type] || content_type }
: f
)
setFormFileValue(updated)
return updated
})
})
.catch(() => {
setFileList(prev => {
const updated = prev.map(f => f.uid === file.uid ? { ...f, status: 'error' } : f)
setFormFileValue(updated)
return updated
})
})
})
}
const previewFileList = useMemo(() => fileList.map(file => ({
...file,
url: file.thumbUrl || file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : undefined)
})), [fileList])
const handleDelete = (file: any) => {
const updated = fileList.filter(item =>
item.thumbUrl && file.thumbUrl ? item.thumbUrl !== file.thumbUrl
: item.url && file.url ? item.url !== file.url
: item.uid !== file.uid
)
setFileList(updated)
setFormFileValue(updated)
}
return (
<>
<UploadFileListModal ref={uploadFileListModalRef} refresh={addFileList} />
<Form.Item name={name} hidden noStyle />
<Form.Item>
<Row gutter={8}>
<Col span={12}>
<UploadFiles
onChange={fileChange}
block={true}
textType="button"
disabled={isSingle && fileList.length > 0}
/>
</Col>
<Col span={12}>
<Button block
disabled={isSingle && fileList.length > 0}
onClick={() => uploadFileListModalRef.current?.handleOpen()}>
{t('memoryConversation.addRemoteFile')}
</Button>
</Col>
</Row>
{previewFileList.length > 0 && (
<FileList wrap="wrap" fileList={previewFileList} onDelete={handleDelete} className="rb:mt-2!" />
)}
</Form.Item>
</>
)
}
export default FileVarInput

View File

@@ -0,0 +1,341 @@
/*
* @Author: ZhaoYing
* @Date: 2026-05-07 18:37:31
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-07 18:51:58
*/
import { type FC, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Flex, Form, Input, InputNumber, Select, App, Checkbox } from 'antd'
import { Node } from '@antv/x6'
import copy from 'copy-to-clipboard'
import clsx from 'clsx'
import { nodeRun } from '@/api/application'
import CodeBlock from '@/components/Markdown/CodeBlock'
import RbCard from '@/components/RbCard/Card'
import styles from '../Properties/properties.module.css'
import ContextList from './ContextList'
import FileVarInput from './FileVarInput'
import type { Suggestion } from '../Editor/plugin/AutocompletePlugin'
import Markdown from '@/components/Markdown'
import RbAlert from '@/components/RbAlert'
interface RunResult {
status: 'completed' | 'failed' | 'running';
node_id?: string;
node_type?: string;
inputs?: Record<string, any>;
outputs?: any;
token_usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
elapsed_time?: number;
error?: string | null;
}
interface SingleNodeRunProps {
open: boolean;
onClose: () => void
selectedNode: Node
appId: string
variableList: Suggestion[]
}
const SingleNodeRun: FC<SingleNodeRunProps> = ({ open, onClose, selectedNode, appId, variableList }) => {
const { t } = useTranslation()
const { message } = App.useApp()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<RunResult | null>(null)
const [isAutoRun, setIsAutoRun] = useState(false)
const nodeData = selectedNode?.getData() || {}
const nodeName = nodeData.name || t(`workflow.${nodeData.type}`)
const isLlm = nodeData.type === 'llm'
const hasContext = isLlm && nodeData.config.context.defaultValue
// Recursively collect all {{nodeId.var}} references from nodeData, excluding conv. vars
const extractVarRefs = (val: any, refs = new Set<string>()): Set<string> => {
if (typeof val === 'string') {
for (const m of val.matchAll(/\{\{([^}]+)\}\}/g))
if (!m[1].startsWith('conv.') && m[1] !== 'context') {
refs.add(m[1])
}
} else if (Array.isArray(val)) {
val.forEach(v => extractVarRefs(v, refs))
} else if (val && typeof val === 'object') {
Object.values(val).forEach(v => extractVarRefs(v, refs))
}
return refs
}
const varRefs = extractVarRefs(nodeData)
const visionInputRef = isLlm ? nodeData.config.vision_input?.defaultValue?.match(/\{\{([^}]+)\}\}/)?.[1] : undefined
const contextInputRef = isLlm ? nodeData.config.context?.defaultValue?.match(/\{\{([^}]+)\}\}/)?.[1] : undefined
const inputVars = variableList.filter(v => varRefs.has(v.value) && v.value !== visionInputRef && v.value !== contextInputRef)
const handleRun = () => {
form.validateFields()
.then((values) => {
const { inputs = {} } = values
console.log('values', values)
const params: Record<string, any> = {};
Object.keys(inputs).forEach(key => {
const value = inputs[key]
if (typeof value === 'object') {
params[key] = value.map((file: any) => {
if (file.url) {
return file
} else {
return {
type: file.type,
transfer_method: 'local_file',
upload_file_id: file.response.data.file_id
}
}
})
} else {
params[key] = value;
}
})
setLoading(true)
setResult({ status: 'running' })
if (hasContext) {
const contextValues: string[] = form.getFieldValue('context') || []
if (contextValues.length > 0) {
params['context'] = contextValues.map(item => { try { return JSON.parse(item) } catch { return item } })
}
}
nodeRun(appId, nodeData.id, { inputs: params, stream: false })
.then(res => {
setResult(res as RunResult)
})
.catch(err => {
setResult({ status: 'failed', error: err.message })
setLoading(false)
})
.finally(() => setLoading(false))
})
}
const handleCopy = (val: string) => {
copy(val)
message.success(t('common.copySuccess'))
}
const statusColor = result?.status === 'completed' ? '#369F21' : result?.status === 'failed' ? '#FF5D34' : '#5B6167'
useEffect(() => {
if (open) {
if (nodeData?.type === 'iteration' || inputVars.length < 1 && !hasContext && !(isLlm && nodeData?.config?.vision?.defaultValue)) {
setIsAutoRun(true)
}
}
}, [open, inputVars, isLlm, hasContext, nodeData?.type, nodeData?.config?.vision?.defaultValue])
useEffect(() => {
if (isAutoRun) {
handleRun()
}
}, [isAutoRun])
if (!open) return null
return (
// 与 Properties 完全相同的定位容器
<div className={clsx('rb:h-[calc(100vh-88px)] rb:w-90 rb:absolute rb:right-0 rb:top-0 rb:bottom-2.5 rb:z-1002', styles.properties)}>
{/* mask仅覆盖 header 以下的区域header 保持透明露出节点名 */}
<div
className="rb:absolute rb:inset-x-0 rb:bottom-0 rb:top-0 rb:rounded-xl rb:bg-[rgba(0,0,0,0.3)] rb:z-1002"
/>
{/* SingleNodeRun 卡片z-index 高于 mask */}
<div className="rb:absolute rb:inset-x-0 rb:top-25.5 rb:bottom-0 rb:z-1003">
<RbCard
title={`${t('workflow.testRun')} ${nodeName}`}
extra={
<div
className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/close.svg')]"
onClick={onClose}
/>
}
headerType="borderless"
headerClassName="rb:font-[MiSans-Bold] rb:font-bold rb:min-h-[48px]!"
className="rb:h-full! rb:hover:shadow-none!"
bodyClassName="rb:overflow-y-auto! rb:h-[calc(100%-48px)]! rb:px-3! rb:pt-0! rb:pb-3!"
>
<Form form={form} layout="vertical" size="small" className="rb:mb-0!">
<Flex vertical gap={12}>
{/* Variables */}
{nodeData?.type !== 'iteration' && inputVars.length > 0 && (
<Flex vertical gap={8}>
<div className="rb:text-[12px] rb:font-medium rb:text-[#5B6167]">{t('workflow.variables')}</div>
{inputVars.map(v => (
<Form.Item
key={v.value}
name={['inputs', v.value.replace('{{', '').replace('}}', '')]}
label={v.dataType.includes('boolean')
? null
: <Flex gap={4} align="center" className="rb:text-[12px]">
{v.nodeData?.icon && <div className={`rb:size-3.5 rb:bg-cover ${v.nodeData.icon}`} />}
<span className="rb:font-medium">{v.nodeData?.name}</span>
<span className="rb:text-[#5B6167]">/</span>
<span className="rb:text-[#1677ff]">{v.label}</span>
</Flex>
}
// rules={[{
// required: ['knowledge-retrieval', 'loop'].includes(nodeData.type) && !v.dataType.includes('boolean'),
// message: ['array[string]', 'array[number]'].includes(v.dataType) && Array.isArray(v.default) && v.default.length > 0 ? t('common.selectPlaceholder', { title: v.label }) : t('common.inputPlaceholder', { title: v.label })
// }]}
className="rb:mb-0!"
>
{['array[string]', 'array[number]'].includes(v.dataType) && Array.isArray(v.default) && v.default.length > 0
? <Select
placeholder={t('common.pleaseSelect')}
options={v.default.map((item: string) => ({ label: item, value: item }))}
/>
: v.dataType.includes('string') && nodeData.type === 'knowledge-retrieval'
? <Input.TextArea
placeholder={t('common.pleaseEnter')}
size="small"
/>
: v.dataType.includes('string')
? <Input
placeholder={t('common.pleaseEnter')}
size="small"
/>
: v.dataType.includes('number')
? <InputNumber
size="small"
placeholder={t('common.pleaseEnter')}
className="rb:w-full!"
onChange={(value) => form.setFieldValue(['retry', 'retry_interval'], value)}
/>
: v.dataType.includes('file')
? <FileVarInput name={['inputs', v.value.replace('{{', '').replace('}}', '')]} dataType={v.dataType} form={form} />
: v.dataType.includes('boolean')
? <Checkbox>
<Flex gap={4} align="center" className="rb:text-[12px]">
{v.nodeData?.icon && <div className={`rb:size-3.5 rb:bg-cover ${v.nodeData.icon}`} />}
<span className="rb:font-medium">{v.nodeData?.name}</span>
<span className="rb:text-[#5B6167]">/</span>
<span className="rb:text-[#1677ff]">{v.label}</span>
</Flex>
</Checkbox>
: null
}
</Form.Item>
))}
</Flex>
)}
{/* Context */}
{hasContext && <ContextList />}
{isLlm && nodeData?.config?.vision?.defaultValue && (() => {
const ref = nodeData.config.vision_input?.defaultValue
const visionVar = ref ? variableList.find(v => v.value === ref) : undefined
const dataType = visionVar?.dataType ?? 'array[file]'
// if (!visionVar) return null
console.log('visionVar', ref)
return (
<Form.Item
name={['inputs', ref.replace('{{', '').replace('}}', '')]}
label={t('workflow.config.llm.vision')}
className="rb:mb-0!"
>
<FileVarInput name={['inputs', ref.replace('{{', '').replace('}}', '')]} dataType={dataType} form={form} />
</Form.Item>
)
})()}
{/* Run button */}
{(!isAutoRun || result?.status) &&
<Button type="primary" block onClick={handleRun} loading={!result?.status && loading} disabled={loading}>
{result?.status ? t('workflow.reStartRun') : t('workflow.startRun')}
</Button>
}
{/* Status row */}
{result && (
<div className="rb:rounded-lg rb:border rb:border-[#E8E8E8] rb:p-3 rb:bg-[#F6FFF4]">
<Flex justify="space-between" align="start">
<Flex vertical align="start" gap={2}>
<span className="rb:text-[11px] rb:text-[#5B6167]">{t('workflow.status')}</span>
<span className="rb:font-medium rb:text-[13px]" style={{ color: statusColor }}>
{result.status?.toUpperCase()}
</span>
</Flex>
<Flex vertical align="start" gap={2}>
<span className="rb:text-[11px] rb:text-[#5B6167]">{t('workflow.elapsedTime')}</span>
{result.elapsed_time != null && <span className="rb:font-medium rb:text-[13px]">{result.elapsed_time?.toFixed(3)}ms</span>}
</Flex>
<Flex vertical gap={2} align="start">
<span className="rb:text-[11px] rb:text-[#5B6167]">{t('workflow.totalTokens')}</span>
{!loading && <span className="rb:font-medium rb:text-[13px]">{ result?.token_usage?.total_tokens || 0} Tokens</span>}
</Flex>
</Flex>
</div>
)}
{/* Input / Output code blocks */}
{result && (['inputs', 'process', 'outputs'] as const).map(key => {
if (nodeData.type !== 'http-request' && key === 'process') return null
const content = typeof result[key as keyof RunResult] === 'object' && result[key as keyof RunResult] ? JSON.stringify(result[key as keyof RunResult], null, 2) : result[key as keyof RunResult] ? result[key as keyof RunResult] : '{}'
return (
<div key={key} className="rb:bg-[#EBEBEB] rb:rounded-lg">
<div className="rb:py-2 rb:px-3 rb:flex rb:justify-between rb:items-center rb:text-[12px]">
{t(`workflow.${key}_result`)}
<Button
className="rb:py-0! rb:px-1! rb:text-[12px]!"
size="small"
onClick={() => handleCopy(content)}
>{t('common.copy')}</Button>
</div>
<div className="rb:max-h-40 rb:overflow-auto">
<CodeBlock
size="small"
value={content}
needCopy={false}
showLineNumbers={true}
background="#EBEBEB"
/>
</div>
</div>
)
})}
{/* Error */}
{result?.error && (
<RbAlert color="orange" className="rb:pb-0!">
<Flex vertical className="rb:w-full!">
<Flex align="center" justify="space-between">
{t(`workflow.error`)}
<Button
className="rb:py-0! rb:px-1! rb:text-[12px]!"
size="small"
onClick={() => handleCopy(result?.error || '')}
>{t('common.copy')}</Button>
</Flex>
<Markdown className="rb:wrap-break-word!" content={result?.error || ''} />
</Flex>
</RbAlert>
)}
</Flex>
</Form>
</RbCard>
</div>
</div>
)
}
export default SingleNodeRun

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 15:06:18 * @Date: 2026-02-03 15:06:18
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-27 14:07:14 * @Last Modified time: 2026-05-07 18:17:40
*/ */
import type { ReactShapeConfig } from '@antv/x6-react-shape'; import type { ReactShapeConfig } from '@antv/x6-react-shape';
import type { GroupMetadata, PortMetadata } from '@antv/x6/lib/model/port'; import type { GroupMetadata, PortMetadata } from '@antv/x6/lib/model/port';
@@ -16,6 +16,12 @@ import NoteNode from './components/Nodes/NoteNode';
import { memoryConfigListUrl } from '@/api/memory'; import { memoryConfigListUrl } from '@/api/memory';
import type { NodeLibrary } from './types'; import type { NodeLibrary } from './types';
export const cannotRunNodes = [
'start',
'end',
'output',
]
/** /**
* Workflow node library configuration * Workflow node library configuration
* Defines all available node types, their icons, and configuration schemas * Defines all available node types, their icons, and configuration schemas
@@ -954,7 +960,7 @@ export const graphNodeLibrary: Record<string, NodeConfig> = {
height: 76, height: 76,
shape: 'normal-node', shape: 'normal-node',
ports: { ports: {
groups: { left: defaultPortGroup }, groups: { left: leftPortGroup },
items: [defaultPortItems[0]], items: [defaultPortItems[0]],
}, },
} }

View File

@@ -2,13 +2,15 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 15:17:48 * @Date: 2026-02-03 15:17:48
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-28 13:49:11 * @Last Modified time: 2026-05-07 18:22:14
*/ */
import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, History, type Edge } from '@antv/x6'; import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, History, type Edge } from '@antv/x6';
import { register } from '@antv/x6-react-shape'; import { register as registerReactShape } from '@antv/x6-react-shape';
import type { PortMetadata } from '@antv/x6/lib/model/port'; import type { PortMetadata } from '@antv/x6/lib/model/port';
import { App } from 'antd'; import { App } from 'antd';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState, createElement } from 'react';
import type { RefObject, Dispatch, SetStateAction, MutableRefObject, DragEvent } from 'react';
import { createRoot } from 'react-dom/client';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
@@ -20,14 +22,16 @@ import type { ChatVariable, HistoryRecord, NodeProperties, WorkflowConfig } from
import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils'; import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils';
import { useWorkflowStore } from '@/store/workflow'; import { useWorkflowStore } from '@/store/workflow';
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
/** /**
* Props for useWorkflowGraph hook * Props for useWorkflowGraph hook
*/ */
export interface UseWorkflowGraphProps { export interface UseWorkflowGraphProps {
/** Reference to the main graph container element */ /** Reference to the main graph container element */
containerRef: React.RefObject<HTMLDivElement>; containerRef: RefObject<HTMLDivElement>;
/** Reference to the minimap container element */ /** Reference to the minimap container element */
miniMapRef: React.RefObject<HTMLDivElement>; miniMapRef: RefObject<HTMLDivElement>;
/** Callback when features config is loaded */ /** Callback when features config is loaded */
onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void; onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void;
} }
@@ -39,23 +43,23 @@ export interface UseWorkflowGraphReturn {
/** Current workflow configuration */ /** Current workflow configuration */
config: WorkflowConfig | null; config: WorkflowConfig | null;
/** Function to update workflow configuration */ /** Function to update workflow configuration */
setConfig: React.Dispatch<React.SetStateAction<WorkflowConfig | null>>; setConfig: Dispatch<SetStateAction<WorkflowConfig | null>>;
/** Reference to the X6 graph instance */ /** Reference to the X6 graph instance */
graphRef: React.MutableRefObject<Graph | undefined>; graphRef: MutableRefObject<Graph | undefined>;
/** Currently selected node */ /** Currently selected node */
selectedNode: Node | null; selectedNode: Node | null;
/** Function to update selected node */ /** Function to update selected node */
setSelectedNode: React.Dispatch<React.SetStateAction<Node | null>>; setSelectedNode: Dispatch<SetStateAction<Node | null>>;
/** Current zoom level of the graph */ /** Current zoom level of the graph */
zoomLevel: number; zoomLevel: number;
/** Function to update zoom level */ /** Function to update zoom level */
setZoomLevel: React.Dispatch<React.SetStateAction<number>>; setZoomLevel: Dispatch<SetStateAction<number>>;
/** Whether hand/pan mode is enabled */ /** Whether hand/pan mode is enabled */
isHandMode: boolean; isHandMode: boolean;
/** Function to toggle hand mode */ /** Function to toggle hand mode */
setIsHandMode: React.Dispatch<React.SetStateAction<boolean>>; setIsHandMode: Dispatch<SetStateAction<boolean>>;
/** Handler for dropping nodes onto canvas */ /** Handler for dropping nodes onto canvas */
onDrop: (event: React.DragEvent) => void; onDrop: (event: DragEvent) => void;
/** Handler for clicking blank canvas area */ /** Handler for clicking blank canvas area */
blankClick: () => void; blankClick: () => void;
/** Handler for delete keyboard event */ /** Handler for delete keyboard event */
@@ -77,7 +81,7 @@ export interface UseWorkflowGraphReturn {
/** Chat variables for workflow */ /** Chat variables for workflow */
chatVariables: ChatVariable[]; chatVariables: ChatVariable[];
/** Function to update chat variables */ /** Function to update chat variables */
setChatVariables: React.Dispatch<React.SetStateAction<ChatVariable[]>>; setChatVariables: Dispatch<SetStateAction<ChatVariable[]>>;
handleAddNotes: () => void; handleAddNotes: () => void;
handleSaveFeaturesConfig: (value: FeaturesConfigForm) => void; handleSaveFeaturesConfig: (value: FeaturesConfigForm) => void;
@@ -124,8 +128,6 @@ export const useWorkflowGraph = ({
const [canRedo, setCanRedo] = useState(false) const [canRedo, setCanRedo] = useState(false)
const [historyRecords, setHistoryRecords] = useState<HistoryRecord[]>([]) const [historyRecords, setHistoryRecords] = useState<HistoryRecord[]>([])
const lastHistoryRef = useRef<{ cellIds: string[]; timestamp: number; type: string } | null>(null) const lastHistoryRef = useRef<{ cellIds: string[]; timestamp: number; type: string } | null>(null)
const undoRef = useRef<() => void>(() => {})
const redoRef = useRef<() => void>(() => {})
const syncChildRelationshipsRef = useRef<() => void>(() => { }) const syncChildRelationshipsRef = useRef<() => void>(() => { })
const isSyncingRef = useRef(false) const isSyncingRef = useRef(false)
useEffect(() => { useEffect(() => {
@@ -168,6 +170,21 @@ export const useWorkflowGraph = ({
initWorkflow() initWorkflow()
}, [config, graphRef.current]) }, [config, graphRef.current])
/**
* Assign explicit zIndex values to enforce layer order:
* parent nodes (loop/iteration) → child edges → child nodes
* Ports live inside each node's SVG container and are always above
* edges once the node zIndex is higher than the edge zIndex.
*/
const reorderCells = (graph: Graph) => {
// Safari uses x6-html-shape (dual HTML layer architecture).
// zIndex controls order within each HTML layer and SVG layer.
graph.getEdges().forEach(edge => edge.setZIndex(0));
graph.getNodes().forEach(node => {
node.setZIndex(node.getData()?.cycle ? 2 : 1);
});
};
/** /**
* Initialize workflow graph with nodes and edges from configuration * Initialize workflow graph with nodes and edges from configuration
*/ */
@@ -471,11 +488,44 @@ export const useWorkflowGraph = ({
graphRef.current.addEdges(edgeList.filter(vo => vo !== null)) graphRef.current.addEdges(edgeList.filter(vo => vo !== null))
} }
// Check if loop/iteration nodes need add-node added
const parentNodes = graphRef.current.getNodes().filter(node => {
const type = node.getData()?.type;
return type === 'loop' || type === 'iteration';
});
parentNodes.forEach(parentNode => {
const parentData = parentNode.getData();
const allChildren = graphRef.current!.getNodes().filter(n => n.getData()?.cycle === parentData.id);
const cycleStartNodes = allChildren.filter(n => n.getData()?.type === 'cycle-start');
// If only cycle-start exists, add add-node
if (cycleStartNodes.length === 1 && allChildren.length === 1) {
const cycleStartNode = cycleStartNodes[0];
const bbox = cycleStartNode.getBBox();
const addNode = graphRef.current!.addNode({
...graphNodeLibrary.addStart,
x: bbox.x + 84,
y: bbox.y + 4,
data: { type: 'add-node', parentId: parentNode.id, cycle: parentData.id, label: t('workflow.addNode'), icon: '+' },
});
parentNode.addChild(addNode, { silent: true });
graphRef.current!.addEdge({
source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right' },
target: { cell: addNode.id, port: addNode.getPorts().find(p => p.group === 'left')?.id || 'left' },
...edgeAttrs,
});
}
});
graphRef.current.centerContent() graphRef.current.centerContent()
// Initialize after completion, display nodes in visible area // Initialize after completion, display nodes in visible area
if (nodes.length > 0 || edges.length > 0) { if (nodes.length > 0 || edges.length > 0) {
setTimeout(() => { setTimeout(() => {
if (graphRef.current) { if (graphRef.current) {
if (isSafari) {
reorderCells(graphRef.current)
} else {
graphRef.current.getNodes().forEach(node => { graphRef.current.getNodes().forEach(node => {
if (!node.getData()?.cycle) node.toFront(); if (!node.getData()?.cycle) node.toFront();
}); });
@@ -490,10 +540,11 @@ export const useWorkflowGraph = ({
graphRef.current.getNodes().forEach(node => { graphRef.current.getNodes().forEach(node => {
if (node.getData()?.cycle) node.toFront(); if (node.getData()?.cycle) node.toFront();
}); });
}
graphRef.current.enableHistory() graphRef.current.enableHistory()
graphRef.current.cleanHistory() graphRef.current.cleanHistory()
} }
}, 200) }, isSafari ? 0 : 200)
} else { } else {
graphRef.current.enableHistory() graphRef.current.enableHistory()
graphRef.current.cleanHistory() graphRef.current.cleanHistory()
@@ -532,17 +583,74 @@ export const useWorkflowGraph = ({
const graph = graphRef.current const graph = graphRef.current
graph.disableHistory() graph.disableHistory()
graph.getNodes().forEach(node => { graph.getNodes().forEach(node => {
const cycleId = node.getData()?.cycle const nodeData = node.getData()
if (!cycleId) return const children = node.getChildren()
const cycleId = nodeData?.cycle
if (cycleId) {
const parentNode = graph.getCellById(cycleId) as Node | null const parentNode = graph.getCellById(cycleId) as Node | null
if (!parentNode) return if (!parentNode) return
if (!parentNode.getChildren()?.some(c => c.id === node.id)) { if (!parentNode.getChildren()?.some(c => c.id === node.id)) {
parentNode.addChild(node, { silent: true }) parentNode.addChild(node, { silent: true })
} }
}
if (nodeData.type === 'if-else') {
const rightPorts = node.getPorts().filter(p => p.group === 'right')
const caseCount = rightPorts.length - 1 // last port is ELSE
const currentCases: any[] = nodeData.config?.cases?.defaultValue ?? []
const newCases = caseCount !== currentCases.length
? Array.from({ length: caseCount }, (_, i) => currentCases[i] ?? { logical_operator: 'and', expressions: [] })
: currentCases
if (caseCount !== currentCases.length) {
node.setData({
...nodeData,
config: { ...nodeData.config, cases: { ...nodeData.config.cases, defaultValue: newCases } }
}, { deep: false, silent: true })
}
// Sync node height and port Y positions
node.prop('size', { width: nodeWidth, height: calcConditionNodeTotalHeight(newCases) })
newCases.forEach((_c: any, i: number) => {
node.portProp(`CASE${i + 1}`, 'args/y', getConditionNodeCasePortY(newCases, i))
}) })
graph.getNodes().forEach(node => { node.portProp(`CASE${newCases.length + 1}`, 'args/y', getConditionNodeCasePortY(newCases, newCases.length))
const children = node.getChildren() node.toFront()
if (!children?.length) return graph.getEdges().filter(e => e.getSourceCellId() === node.id).forEach(e => {
const tgt = graph.getCellById(e.getTargetCellId())
tgt?.toFront()
})
} else if (nodeData.type === 'question-classifier') {
const rightPorts = node.getPorts().filter(p => p.group === 'right')
const currentCategories: any[] = nodeData.config?.categories?.defaultValue ?? []
const categoryCount = rightPorts.length
const newCategories = categoryCount !== currentCategories.length
? rightPorts.map((port, i) => {
if (currentCategories[i]) return currentCategories[i]
const edge = graph.getEdges().find(e => e.getSourceCellId() === node.id && e.getSourcePortId() === port.id)
return edge ? { name: '' } : {}
})
: currentCategories
if (categoryCount !== currentCategories.length) {
node.setData({
...nodeData,
config: { ...nodeData.config, categories: { ...nodeData.config.categories, defaultValue: [...newCategories] } }
}, { deep: false, silent: true })
}
// Sync node height and port Y positions
const newHeight = conditionNodeHeight + (categoryCount - 2) * conditionNodeItemHeight
node.prop('size', { width: nodeWidth, height: Math.max(newHeight, conditionNodeHeight) })
rightPorts.forEach((_p, i) => {
node.portProp(`CASE${i + 1}`, 'args/y', portItemArgsY * i + conditionNodePortItemArgsY)
})
node.toFront()
graph.getEdges().filter(e => e.getSourceCellId() === node.id).forEach(e => {
const tgt = graph.getCellById(e.getTargetCellId())
tgt?.toFront()
})
}
if (children?.length) {
children.forEach(child => { children.forEach(child => {
if (!child.isNode()) return if (!child.isNode()) return
const childCycleId = (child as Node).getData?.()?.cycle const childCycleId = (child as Node).getData?.()?.cycle
@@ -550,6 +658,7 @@ export const useWorkflowGraph = ({
node.removeChild(child, { silent: true }) node.removeChild(child, { silent: true })
} }
}) })
}
}) })
resizeGroupNodes(graph) resizeGroupNodes(graph)
graph.getEdges().forEach(edge => { graph.getEdges().forEach(edge => {
@@ -652,31 +761,41 @@ export const useWorkflowGraph = ({
* @param node - Clicked node * @param node - Clicked node
*/ */
const nodeClick = ({ node }: { node: Node }) => { const nodeClick = ({ node }: { node: Node }) => {
// add-node type: dispatch port:click to open node selection popover
// Must handle before blankClick() to avoid blank:click closing the popover immediately
const nodeData = node.getData()
if (nodeData?.type === 'add-node') {
const b = node.getBBox();
const screenPos = graphRef.current!.localToClient(b.x + b.width, b.y + b.height / 2);
const tempDiv = document.createElement('div');
tempDiv.style.cssText = `position:fixed;left:${screenPos.x}px;top:${screenPos.y}px;width:1px;height:1px;z-index:9999;`;
document.body.appendChild(tempDiv);
window.dispatchEvent(new CustomEvent('port:click', {
detail: {
node,
port: 'right',
element: tempDiv,
rect: { left: screenPos.x, top: screenPos.y },
edgeInsertion: null,
},
}));
return;
}
blankClick() blankClick()
setTimeout(() => { setTimeout(() => {
// Ignore add-node type node clicks // Ignore add-node type node clicks
const nodeData = node.getData() const nodeData = node.getData()
if (nodeData?.type === 'add-node' || nodeData.type === 'break' || nodeData.type === 'cycle-start') { if (nodeData.type === 'break' || nodeData.type === 'cycle-start') {
setSelectedNode(null) setSelectedNode(null)
return; return;
} }
clearNodeSelect()
const nodes = graphRef.current?.getNodes();
nodes?.forEach(vo => {
const data = vo.getData();
if (data.isSelected) {
vo.setData({
...data,
isSelected: false,
}, { silent: true });
}
});
node.setData({ node.setData({
...nodeData, ...nodeData,
isSelected: true, isSelected: true,
}, { silent: true }); });
clearEdgeSelect() clearEdgeSelect()
if (nodeData.type !== 'notes') { if (nodeData.type !== 'notes') {
setSelectedNode(node); setSelectedNode(node);
@@ -705,7 +824,7 @@ export const useWorkflowGraph = ({
node.setData({ node.setData({
...data, ...data,
isSelected: false, isSelected: false,
}, { silent: true }); });
} }
}); });
setSelectedNode(null); setSelectedNode(null);
@@ -745,7 +864,8 @@ export const useWorkflowGraph = ({
const cycle = node.getData()?.cycle; const cycle = node.getData()?.cycle;
if (cycle) { if (cycle) {
const parentNode = graphRef.current!.getNodes().find(n => n.id === cycle); const parentNode = graphRef.current!.getNodes().find(n => n.id === cycle);
if (parentNode?.getData()?.isGroup) { const parentType = parentNode?.getData()?.type;
if (parentNode?.getData()?.isGroup || (parentNode && (parentType === 'loop' || parentType === 'iteration'))) {
// Get parent node and child node bounding boxes // Get parent node and child node bounding boxes
const parentBBox = parentNode.getBBox(); const parentBBox = parentNode.getBBox();
const childBBox = node.getBBox(); const childBBox = node.getBBox();
@@ -934,6 +1054,12 @@ export const useWorkflowGraph = ({
e.preventDefault(); e.preventDefault();
const portElement = e.target as HTMLElement; const portElement = e.target as HTMLElement;
const rect = portElement.getBoundingClientRect(); const rect = portElement.getBoundingClientRect();
const clickPort = node.getPorts().find(p => p.id === port)
const portGroup = clickPort?.group
if (portGroup === 'left') {
return
}
// Create temporary popover trigger element // Create temporary popover trigger element
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
@@ -965,13 +1091,37 @@ export const useWorkflowGraph = ({
/** /**
* Initialize X6 graph with configuration and event listeners * Initialize X6 graph with configuration and event listeners
*/ */
const init = () => { const init = async () => {
if (!containerRef.current || !miniMapRef.current) return; if (!containerRef.current || !miniMapRef.current) return;
// Register React shapes // Register React shapes
nodeRegisterLibrary.forEach((item) => { // Safari: use x6-html-shape to avoid foreignObject rendering issues
register(item); if (isSafari) {
const { register: registerHtmlShape } = await import('x6-html-shape');
nodeRegisterLibrary.forEach(({ shape, width, height, component }) => {
registerHtmlShape({
shape,
width,
height,
render(node: Node, _graph: unknown, container: HTMLElement) {
const root = createRoot(container);
const doRender = () => {
root.render(createElement(component as any, { node, graph: node.model?.graph, data: node.getData() }));
};
doRender();
node.on('change:data', doRender);
return () => {
node.off('change:data', doRender);
root.unmount();
};
},
}); });
});
} else {
nodeRegisterLibrary.forEach((item) => {
registerReactShape(item);
});
}
const container = containerRef.current; const container = containerRef.current;
graphRef.current = new Graph({ graphRef.current = new Graph({
@@ -1161,10 +1311,71 @@ export const useWorkflowGraph = ({
// Listen to node move event // Listen to node move event
graphRef.current.on('node:moved', nodeMoved); graphRef.current.on('node:moved', nodeMoved);
if (isSafari) {
// When a parent (loop/iteration) node moves, keep child nodes in sync.
// Store each child's offset relative to the parent at drag start, then
// reapply it every frame to avoid cumulative delta errors.
const dragOffsets = new Map<string, { dx: number; dy: number }>();
graphRef.current.on('node:moving', ({ node }: { node: Node }) => {
const data = node.getData();
if (data?.type !== 'loop' && data?.type !== 'iteration') return;
const pos = node.getPosition();
const PORT_RADIUS = 6;
// Update parent componentContainer directly
const parentView = graphRef.current?.findViewByCell(node) as any;
if (parentView?.componentContainer) {
parentView.componentContainer.style.transform =
`translate(${pos.x + PORT_RADIUS}px, ${pos.y}px)`;
}
const children = graphRef.current?.getNodes().filter(child => {
const cycle = child.getData()?.cycle;
return cycle === data.id || cycle === node.id;
}) ?? [];
// First event for this drag: record offsets
if (!dragOffsets.has(node.id)) {
children.forEach(child => {
const cp = child.getPosition();
dragOffsets.set(child.id, { dx: cp.x - pos.x, dy: cp.y - pos.y });
});
}
// Apply stored offsets to keep children in place relative to parent
children.forEach(child => {
const off = dragOffsets.get(child.id);
if (!off) return;
const nx = pos.x + off.dx;
const ny = pos.y + off.dy;
child.setPosition(nx, ny);
const childView = graphRef.current?.findViewByCell(child) as any;
if (childView?.componentContainer) {
childView.componentContainer.style.transform =
`translate(${nx + PORT_RADIUS}px, ${ny}px)`;
}
});
});
graphRef.current.on('node:moved', ({ node }: { node: Node }) => {
// Clear offsets for this parent and all its children
const data = node.getData();
graphRef.current?.getNodes().forEach(child => {
const cycle = child.getData()?.cycle;
if (cycle === data?.id || cycle === node.id) dragOffsets.delete(child.id);
});
dragOffsets.delete(node.id);
nodeMoved({ node });
});
}
graphRef.current.on('node:removed', blankClick) graphRef.current.on('node:removed', blankClick)
// When edge connected, bring connected nodes' ports to front // When edge connected, reorder all cells to maintain correct layer order
graphRef.current.on('edge:connected', ({ isNew, edge }) => { graphRef.current.on('edge:connected', ({ isNew, edge }) => {
if (isNew) { if (isSafari && isNew && graphRef.current) {
reorderCells(graphRef.current);
} else if (!isSafari && isNew) {
const sourceCellId = edge.getSourceCellId() const sourceCellId = edge.getSourceCellId()
const targetCellId = edge.getTargetCellId() const targetCellId = edge.getTargetCellId()
const sourceCell = graphRef.current?.getCellById(sourceCellId); const sourceCell = graphRef.current?.getCellById(sourceCellId);
@@ -1278,7 +1489,7 @@ export const useWorkflowGraph = ({
* Creates new node at drop position * Creates new node at drop position
* @param event - React drag event * @param event - React drag event
*/ */
const onDrop = (event: React.DragEvent) => { const onDrop = (event: DragEvent) => {
if (!graphRef.current) return; if (!graphRef.current) return;
event.preventDefault(); event.preventDefault();
const dragData = JSON.parse(event.dataTransfer.getData('application/json')); const dragData = JSON.parse(event.dataTransfer.getData('application/json'));
@@ -1456,7 +1667,7 @@ export const useWorkflowGraph = ({
...itemConfig, ...itemConfig,
...(data.config[key].defaultValue || {}), ...(data.config[key].defaultValue || {}),
knowledge_bases: knowledge_bases?.map((vo: any) => { knowledge_bases: knowledge_bases?.map((vo: any) => {
const kb_config = vo.config || { similarity_threshold: vo.similarity_threshold, retrieve_type: vo.retrieve_type, top_k: vo.top_k, weight: vo.weight } const kb_config = vo.config || vo
return { kb_id: vo.kb_id || vo.id, ...kb_config, } return { kb_id: vo.kb_id || vo.id, ...kb_config, }
}) })
} }

View File

@@ -118,6 +118,8 @@ const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesC
parseEvent={parseEvent} parseEvent={parseEvent}
config={config} config={config}
chatVariables={chatVariables} chatVariables={chatVariables}
appId={config?.app_id}
handleSave={handleSave}
/> />
} }
<Chat <Chat

View File

@@ -44,6 +44,9 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
'@': resolve(__dirname, 'src'), '@': resolve(__dirname, 'src'),
'x6-html-shape': resolve(__dirname, 'src/vendor/x6-html-shape/index.js'),
'x6-html-shape/dist/react': resolve(__dirname, 'src/vendor/x6-html-shape/react.js'),
'x6-html-shape/dist/utils.js': resolve(__dirname, 'src/vendor/x6-html-shape/utils.js'),
}, },
}, },
base: './', // 使用相对路径,确保资源能正确加载 base: './', // 使用相对路径,确保资源能正确加载