Compare commits
53 Commits
feat/updat
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aac904007f | ||
|
|
f8d1ed51a7 | ||
|
|
24d2fe726a | ||
|
|
4c59e41b95 | ||
|
|
6a43623aa3 | ||
|
|
9fa83ed01e | ||
|
|
f659bc7de2 | ||
|
|
2234024aee | ||
|
|
194026a97e | ||
|
|
e222490bce | ||
|
|
7b43e59172 | ||
|
|
0dc8d8cbeb | ||
|
|
8967b00303 | ||
|
|
2edfaa3863 | ||
|
|
8d3da2fd0e | ||
|
|
cef33fce0d | ||
|
|
595c3517e3 | ||
|
|
d9f08860bc | ||
|
|
7f9dcaebfb | ||
|
|
df556aa396 | ||
|
|
ad2e885f72 | ||
|
|
aa2a3d67d6 | ||
|
|
e6f47da02f | ||
|
|
0adc022f4e | ||
|
|
0361bba33f | ||
|
|
70c6d161c8 | ||
|
|
5118e343d6 | ||
|
|
c684aa55d5 | ||
|
|
577f443459 | ||
|
|
b3e1fdcf90 | ||
|
|
b2f366b031 | ||
|
|
a947d6d095 | ||
|
|
03d9600c49 | ||
|
|
ce6ecef35e | ||
|
|
f47c256863 | ||
|
|
14eb64f7c6 | ||
|
|
6b68ee9fc8 | ||
|
|
e53be0765a | ||
|
|
f47873aaea | ||
|
|
4003d7b019 | ||
|
|
f85c0594c9 | ||
|
|
5fceba54b4 | ||
|
|
b0a4f9fa18 | ||
|
|
6e89302cb2 | ||
|
|
6197d698a2 | ||
|
|
4d7f9c4dae | ||
|
|
90aa4cef21 | ||
|
|
6c47bb77ab | ||
|
|
f667936664 | ||
|
|
64e640d882 | ||
|
|
140311048a | ||
|
|
26b843a605 | ||
|
|
15b352d16b |
@@ -1,5 +1,6 @@
|
||||
import uuid
|
||||
import io
|
||||
import json
|
||||
from typing import Optional, Annotated
|
||||
|
||||
import yaml
|
||||
@@ -1068,6 +1069,62 @@ async def draft_run_compare(
|
||||
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")
|
||||
@cur_workspace_access_guard()
|
||||
async def get_workflow_config(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import os
|
||||
import csv
|
||||
import io
|
||||
from typing import Any, Optional
|
||||
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 sqlalchemy.orm import Session
|
||||
|
||||
@@ -23,6 +25,7 @@ from app.models.user_model import User
|
||||
from app.schemas import chunk_schema
|
||||
from app.schemas.response_schema import ApiResponse
|
||||
from app.services import knowledge_service, document_service, file_service, knowledgeshare_service
|
||||
from app.services.file_storage_service import FileStorageService, get_file_storage_service, generate_kb_file_key
|
||||
from app.services.model_service import ModelApiKeyService
|
||||
|
||||
# Obtain a dedicated API logger
|
||||
@@ -271,6 +274,9 @@ async def create_chunk(
|
||||
"sort_id": sort_id,
|
||||
"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)
|
||||
# 3. Segmented vector storage
|
||||
vector_service.add_chunks([chunk])
|
||||
@@ -282,6 +288,187 @@ async def create_chunk(
|
||||
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)
|
||||
async def get_chunk(
|
||||
kb_id: uuid.UUID,
|
||||
@@ -342,6 +529,9 @@ async def update_chunk(
|
||||
if total:
|
||||
chunk = items[0]
|
||||
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)
|
||||
return success(data=jsonable_encoder(chunk), msg="The document chunk has been successfully updated")
|
||||
else:
|
||||
@@ -356,6 +546,7 @@ async def delete_chunk(
|
||||
kb_id: uuid.UUID,
|
||||
document_id: uuid.UUID,
|
||||
doc_id: str,
|
||||
force_refresh: bool = Query(False, description="Force Elasticsearch refresh after deletion"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
@@ -373,7 +564,7 @@ async def delete_chunk(
|
||||
|
||||
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
|
||||
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
|
||||
db_document = db.query(Document).filter(Document.id == document_id).first()
|
||||
db_document.chunk_num -= 1
|
||||
|
||||
@@ -113,6 +113,33 @@ async def create_chunk(
|
||||
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)
|
||||
@require_api_key(scopes=["rag"])
|
||||
async def get_chunk(
|
||||
@@ -176,6 +203,7 @@ async def delete_chunk(
|
||||
request: Request,
|
||||
api_key_auth: ApiKeyAuth = None,
|
||||
db: Session = Depends(get_db),
|
||||
force_refresh: bool = Query(False, description="Force Elasticsearch refresh after deletion"),
|
||||
):
|
||||
"""
|
||||
delete document chunk
|
||||
@@ -188,6 +216,7 @@ async def delete_chunk(
|
||||
return await chunk_controller.delete_chunk(kb_id=kb_id,
|
||||
document_id=document_id,
|
||||
doc_id=doc_id,
|
||||
force_refresh=force_refresh,
|
||||
db=db,
|
||||
current_user=current_user)
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ class Settings:
|
||||
# File Upload
|
||||
MAX_FILE_SIZE: int = int(os.getenv("MAX_FILE_SIZE", "52428800"))
|
||||
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_URL_EXPIRES: int = int(os.getenv("FILE_URL_EXPIRES", "3600"))
|
||||
|
||||
|
||||
@@ -46,7 +46,10 @@ async def run_graphrag(
|
||||
start = trio.current_time()
|
||||
workspace_id, kb_id, document_id = row["workspace_id"], str(row["kb_id"]), row["document_id"]
|
||||
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"])
|
||||
|
||||
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)
|
||||
for doc in items:
|
||||
# 跳过 QA chunks,只用原文 chunks 构建图谱
|
||||
if (doc.metadata or {}).get("chunk_type") == "qa":
|
||||
continue
|
||||
content = doc.page_content
|
||||
if num_tokens_from_string(current_chunk + content) < 1024:
|
||||
current_chunk += content
|
||||
|
||||
@@ -131,18 +131,52 @@ def keyword_extraction(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)
|
||||
|
||||
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:
|
||||
"""生成问题(向后兼容,返回纯文本问题列表)"""
|
||||
pairs = qa_proposal(chat_mdl, content, topn)
|
||||
if not pairs:
|
||||
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):
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
## Role
|
||||
You are a text analyzer.
|
||||
You are a text analyzer and knowledge extraction expert.
|
||||
|
||||
## Task
|
||||
Propose {{ topn }} questions about a given piece of text content.
|
||||
Generate question-answer pairs from the given text content.
|
||||
|
||||
## 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 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.
|
||||
- One question per line.
|
||||
- Output questions ONLY.
|
||||
|
||||
---
|
||||
|
||||
## Text Content
|
||||
{{ content }}
|
||||
- The answers MUST be concise, accurate, and directly derived from the text content.
|
||||
- The answers SHOULD be self-contained and understandable without additional context.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from elasticsearch import Elasticsearch, helpers
|
||||
from elasticsearch import Elasticsearch, helpers, NotFoundError
|
||||
from elasticsearch.helpers import BulkIndexError
|
||||
from packaging.version import parse as parse_version
|
||||
# langchain-community
|
||||
@@ -53,13 +53,30 @@ class ElasticSearchVector(BaseVector):
|
||||
return "elasticsearch"
|
||||
|
||||
def add_chunks(self, chunks: list[DocumentChunk], **kwargs):
|
||||
# 实现 Elasticsearch 保存向量
|
||||
texts = [chunk.page_content for chunk in chunks]
|
||||
# QA chunks: embedding 只对 question 字段做;source chunks: 不做 embedding
|
||||
texts_for_embedding = []
|
||||
for chunk in chunks:
|
||||
chunk_type = (chunk.metadata or {}).get("chunk_type", "chunk")
|
||||
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:
|
||||
# 普通 chunk: 用 page_content 做 embedding
|
||||
texts_for_embedding.append(chunk.page_content)
|
||||
|
||||
if self.is_multimodal_embedding:
|
||||
# 火山引擎多模态 Embedding
|
||||
embeddings = self.embeddings.embed_batch(texts)
|
||||
embeddings = self.embeddings.embed_batch(texts_for_embedding)
|
||||
else:
|
||||
embeddings = self.embeddings.embed_documents(list(texts))
|
||||
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)
|
||||
|
||||
def create(self, chunks: list[DocumentChunk], embeddings: list[list[float]], **kwargs):
|
||||
@@ -72,13 +89,25 @@ class ElasticSearchVector(BaseVector):
|
||||
uuids = self._get_uuids(chunks)
|
||||
actions = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
source = {
|
||||
Field.CONTENT_KEY.value: chunk.page_content,
|
||||
Field.METADATA_KEY.value: chunk.metadata or {},
|
||||
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": {
|
||||
Field.CONTENT_KEY.value: chunk.page_content,
|
||||
Field.METADATA_KEY.value: chunk.metadata or {},
|
||||
Field.VECTOR.value: embeddings[i] or None
|
||||
}
|
||||
"_source": source
|
||||
}
|
||||
actions.append(action)
|
||||
# using bulk mode
|
||||
@@ -113,7 +142,7 @@ class ElasticSearchVector(BaseVector):
|
||||
|
||||
return True
|
||||
|
||||
def delete_by_ids(self, ids: list[str]):
|
||||
def delete_by_ids(self, ids: list[str], *, refresh: bool = False):
|
||||
if not ids:
|
||||
return
|
||||
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]
|
||||
try:
|
||||
helpers.bulk(self._client, actions)
|
||||
if refresh:
|
||||
self._client.indices.refresh(index=self._collection_name)
|
||||
except BulkIndexError as e:
|
||||
for error in e.errors:
|
||||
delete_error = error.get('delete', {})
|
||||
@@ -153,7 +184,7 @@ class ElasticSearchVector(BaseVector):
|
||||
else:
|
||||
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):
|
||||
return False
|
||||
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]
|
||||
try:
|
||||
helpers.bulk(self._client, actions)
|
||||
if refresh:
|
||||
self._client.indices.refresh(index=self._collection_name)
|
||||
except BulkIndexError as e:
|
||||
for error in e.errors:
|
||||
delete_error = error.get('delete', {})
|
||||
@@ -192,6 +225,8 @@ class ElasticSearchVector(BaseVector):
|
||||
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"
|
||||
if not self._client.indices.exists(index=indices):
|
||||
return 0, []
|
||||
|
||||
# Calculate the start position for the current page
|
||||
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).
|
||||
result = self._client.search(
|
||||
index=indices,
|
||||
from_=from_, # Only use from_ for the first page (simplified)
|
||||
size=pagesize,
|
||||
body=query_str,
|
||||
)
|
||||
try:
|
||||
result = self._client.search(
|
||||
index=indices,
|
||||
from_=from_, # Only use from_ for the first page (simplified)
|
||||
size=pagesize,
|
||||
body=query_str,
|
||||
)
|
||||
except NotFoundError:
|
||||
return 0, []
|
||||
|
||||
if "errors" in result:
|
||||
raise ValueError(f"Error during query: {result['errors']}")
|
||||
@@ -241,10 +279,19 @@ class ElasticSearchVector(BaseVector):
|
||||
for res in result["hits"]["hits"]:
|
||||
source = res["_source"]
|
||||
page_content = source.get(Field.CONTENT_KEY.value)
|
||||
# vector = source.get(Field.VECTOR.value)
|
||||
vector = None
|
||||
metadata = source.get(Field.METADATA_KEY.value, {})
|
||||
chunk_type = source.get(Field.CHUNK_TYPE.value)
|
||||
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 = []
|
||||
@@ -267,13 +314,18 @@ class ElasticSearchVector(BaseVector):
|
||||
List of DocumentChunk objects that match the query.
|
||||
"""
|
||||
indices = kwargs.get("indices", self._collection_name) # Default single index, multi-index available,etc "index1,index2,index3"
|
||||
if not self._client.indices.exists(index=indices):
|
||||
return 0, []
|
||||
query_str = {"query": {"term": {f"{Field.DOC_ID.value}": doc_id}}}
|
||||
result = self._client.search(
|
||||
index=indices,
|
||||
from_=0, # Only use from_ for the first page (simplified)
|
||||
size=1,
|
||||
body=query_str,
|
||||
)
|
||||
try:
|
||||
result = self._client.search(
|
||||
index=indices,
|
||||
from_=0, # Only use from_ for the first page (simplified)
|
||||
size=1,
|
||||
body=query_str,
|
||||
)
|
||||
except NotFoundError:
|
||||
return 0, []
|
||||
# print(result)
|
||||
if "errors" in result:
|
||||
raise ValueError(f"Error during query: {result['errors']}")
|
||||
@@ -308,27 +360,43 @@ class ElasticSearchVector(BaseVector):
|
||||
Returns:
|
||||
updated count.
|
||||
"""
|
||||
indices = kwargs.get("indices", self._collection_name) # Default single index, multi-index available,etc "index1,index2,index3"
|
||||
if self.is_multimodal_embedding:
|
||||
# 火山引擎多模态 Embedding
|
||||
chunk.vector = self.embeddings.embed_text(chunk.page_content)
|
||||
indices = kwargs.get("indices", self._collection_name)
|
||||
chunk_type = (chunk.metadata or {}).get("chunk_type")
|
||||
|
||||
# QA chunk: embedding 基于 question;source chunk: 不更新向量
|
||||
if chunk_type == "source":
|
||||
embed_text = ""
|
||||
elif chunk_type == "qa":
|
||||
embed_text = (chunk.metadata or {}).get("question", chunk.page_content)
|
||||
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 = {
|
||||
"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
|
||||
}
|
||||
"source": script_source,
|
||||
"params": params
|
||||
},
|
||||
"query": {
|
||||
"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,
|
||||
body=body,
|
||||
)
|
||||
# Remove debug printing and use logging instead
|
||||
# print(result)
|
||||
# print(f"Update successful, number of affected documents: {result['updated']}")
|
||||
return result['updated']
|
||||
|
||||
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
|
||||
"term": {
|
||||
"metadata.status": 1
|
||||
}
|
||||
}
|
||||
"filter": [
|
||||
{"term": {"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
|
||||
@@ -415,22 +480,14 @@ class ElasticSearchVector(BaseVector):
|
||||
},
|
||||
"script": {
|
||||
"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}
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter": [
|
||||
{
|
||||
"term": {
|
||||
"metadata.status": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"terms": {
|
||||
"metadata.file_name": file_names_filter # Additional file_name filtering
|
||||
}
|
||||
}
|
||||
{"term": {"metadata.status": 1}},
|
||||
{"terms": {"metadata.file_name": file_names_filter}},
|
||||
{"bool": {"must_not": {"term": {Field.CHUNK_TYPE.value: "source"}}}}
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -451,8 +508,19 @@ class ElasticSearchVector(BaseVector):
|
||||
source = res["_source"]
|
||||
page_content = source.get(Field.CONTENT_KEY.value)
|
||||
metadata = source.get(Field.METADATA_KEY.value, {})
|
||||
chunk_type = source.get(Field.CHUNK_TYPE.value)
|
||||
score = res["_score"]
|
||||
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 = []
|
||||
@@ -491,11 +559,10 @@ class ElasticSearchVector(BaseVector):
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter": { # Add the filter condition of status=1
|
||||
"term": {
|
||||
"metadata.status": 1
|
||||
}
|
||||
}
|
||||
"filter": [
|
||||
{"term": {"metadata.status": 1}},
|
||||
{"bool": {"must_not": {"term": {Field.CHUNK_TYPE.value: "source"}}}}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,16 +579,9 @@ class ElasticSearchVector(BaseVector):
|
||||
}
|
||||
},
|
||||
"filter": [
|
||||
{
|
||||
"term": {
|
||||
"metadata.status": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"terms": {
|
||||
"metadata.file_name": file_names_filter # Additional file_name filtering
|
||||
}
|
||||
}
|
||||
{"term": {"metadata.status": 1}},
|
||||
{"terms": {"metadata.file_name": file_names_filter}},
|
||||
{"bool": {"must_not": {"term": {Field.CHUNK_TYPE.value: "source"}}}}
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -543,6 +603,17 @@ class ElasticSearchVector(BaseVector):
|
||||
source = res["_source"]
|
||||
page_content = source.get(Field.CONTENT_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
|
||||
normalized_score = res["_score"] / max_score
|
||||
docs_and_scores.append((DocumentChunk(page_content=page_content, metadata=metadata), normalized_score))
|
||||
@@ -652,7 +723,7 @@ class ElasticSearchVector(BaseVector):
|
||||
},
|
||||
Field.VECTOR.value: {
|
||||
"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,
|
||||
"similarity": "cosine"
|
||||
}
|
||||
|
||||
@@ -14,3 +14,8 @@ class Field(StrEnum):
|
||||
DOCUMENT_ID = "metadata.document_id"
|
||||
KNOWLEDGE_ID = "metadata.knowledge_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"
|
||||
|
||||
@@ -27,14 +27,14 @@ class BaseVector(ABC):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def delete_by_ids(self, ids: list[str]):
|
||||
def delete_by_ids(self, ids: list[str], *, refresh: bool = False):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_ids_by_metadata_field(self, key: str, value: str):
|
||||
raise NotImplementedError
|
||||
|
||||
@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
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -87,11 +87,11 @@ class SimpleMCPClient:
|
||||
headers = self._build_headers()
|
||||
timeout = aiohttp.ClientTimeout(total=self.timeout)
|
||||
self._session = aiohttp.ClientSession(headers=headers, timeout=timeout)
|
||||
|
||||
|
||||
if self.is_sse:
|
||||
await self._initialize_sse_session()
|
||||
elif "modelscope.net" in self.server_url:
|
||||
await self._initialize_modelscope_session()
|
||||
else:
|
||||
await self._initialize_streamable_session()
|
||||
|
||||
async def _initialize_sse_session(self):
|
||||
"""初始化 SSE MCP 会话 - 参考 Dify 实现"""
|
||||
@@ -208,41 +208,41 @@ class SimpleMCPClient:
|
||||
if not (200 <= response.status < 300):
|
||||
logger.warning(f"通知发送失败: {response.status}")
|
||||
|
||||
async def _initialize_modelscope_session(self):
|
||||
"""初始化 ModelScope MCP 会话"""
|
||||
async def _initialize_streamable_session(self):
|
||||
"""初始化 Streamable HTTP MCP 会话(MCP 2025-03-26 规范)"""
|
||||
init_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": self._get_request_id(),
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {"tools": {}},
|
||||
"clientInfo": {"name": "MemoryBear", "version": "1.0.0"}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
async with self._session.post(self.server_url, json=init_request) as response:
|
||||
if not (200 <= response.status < 300):
|
||||
error_text = await response.text()
|
||||
raise MCPConnectionError(f"初始化失败 {response.status}: {error_text}")
|
||||
|
||||
init_response = await response.json()
|
||||
if "error" in init_response:
|
||||
raise MCPConnectionError(f"初始化失败: {init_response['error']}")
|
||||
|
||||
|
||||
# 提取 session id(Streamable HTTP 规范要求后续请求携带)
|
||||
session_id = response.headers.get("Mcp-Session-Id") or response.headers.get("mcp-session-id")
|
||||
if session_id:
|
||||
self._session.headers.update({"Mcp-Session-Id": session_id})
|
||||
|
||||
initialized_notification = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized"
|
||||
}
|
||||
|
||||
async with self._session.post(self.server_url, json=initialized_notification):
|
||||
pass
|
||||
|
||||
|
||||
init_response = await self._parse_streamable_response(response)
|
||||
if "error" in init_response:
|
||||
raise MCPConnectionError(f"初始化失败: {init_response['error']}")
|
||||
|
||||
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
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
raise MCPConnectionError(f"初始化连接失败: {e}")
|
||||
|
||||
@@ -310,6 +310,21 @@ class SimpleMCPClient:
|
||||
"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]]:
|
||||
"""获取工具列表"""
|
||||
request = {
|
||||
@@ -326,7 +341,7 @@ class SimpleMCPClient:
|
||||
response_data = await self._send_sse_request(request)
|
||||
else:
|
||||
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:
|
||||
raise MCPConnectionError(f"获取工具列表失败: {response_data['error']}")
|
||||
@@ -351,7 +366,7 @@ class SimpleMCPClient:
|
||||
response_data = await self._send_sse_request(request)
|
||||
else:
|
||||
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:
|
||||
error = response_data["error"]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# Author: Eternity
|
||||
# @Email: 1533512157@qq.com
|
||||
# @Time : 2026/2/10 13:33
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
@@ -141,9 +142,10 @@ class GraphBuilder:
|
||||
|
||||
for node_info in source_nodes:
|
||||
if self.get_node_type(node_info["id"]) in BRANCH_NODES:
|
||||
branch_nodes.append(
|
||||
(node_info["id"], node_info["branch"])
|
||||
)
|
||||
if node_info.get("branch") is not None:
|
||||
branch_nodes.append(
|
||||
(node_info["id"], node_info["branch"])
|
||||
)
|
||||
else:
|
||||
if self.get_node_type(node_info["id"]) in (NodeType.END, NodeType.OUTPUT):
|
||||
output_nodes.append(node_info["id"])
|
||||
@@ -314,9 +316,12 @@ class GraphBuilder:
|
||||
for idx in range(len(related_edge)):
|
||||
# Generate a condition expression for each edge
|
||||
# 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 example, if node.123.output == 'CASE1', take the branch labeled 'CASE1'
|
||||
related_edge[idx]['condition'] = f"node['{node_id}']['output'] == '{related_edge[idx]['label']}'"
|
||||
# For LLM nodes, use branch_signal field for routing (output is dynamic text)
|
||||
# For other branch nodes (e.g. HTTP), use output field
|
||||
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:
|
||||
# Wrap node's run method to avoid closure issues
|
||||
|
||||
@@ -18,10 +18,17 @@ class AssignerNode(BaseNode):
|
||||
super().__init__(node_config, workflow_config, down_stream_nodes)
|
||||
self.variable_updater = True
|
||||
self.typed_config: AssignerNodeConfig | None = None
|
||||
self._input_data: dict[str, Any] | None = None
|
||||
|
||||
def _output_types(self) -> dict[str, VariableType]:
|
||||
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:
|
||||
"""
|
||||
Execute the assignment operation defined by this node.
|
||||
@@ -34,6 +41,9 @@ class AssignerNode(BaseNode):
|
||||
Returns:
|
||||
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
|
||||
self.typed_config = AssignerNodeConfig(**self.config)
|
||||
logger.info(f"节点 {self.node_id} 开始执行")
|
||||
|
||||
@@ -70,7 +70,7 @@ class IterationRuntime:
|
||||
self.variable_pool = variable_pool
|
||||
self.cycle_nodes = cycle_nodes
|
||||
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.result: list = []
|
||||
@@ -196,7 +196,7 @@ class IterationRuntime:
|
||||
})
|
||||
result = graph.get_state(config=checkpoint).values
|
||||
else:
|
||||
result = await graph.ainvoke(init_state)
|
||||
result = await graph.ainvoke(init_state, config=checkpoint)
|
||||
|
||||
output = child_pool.get_value(self.output_value)
|
||||
stopped = result["looping"] == 2
|
||||
|
||||
@@ -57,7 +57,7 @@ class LoopRuntime:
|
||||
self.looping = True
|
||||
self.variable_pool = 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(
|
||||
configurable={
|
||||
@@ -223,7 +223,7 @@ class LoopRuntime:
|
||||
})
|
||||
return self.graph.get_state(config=self.checkpoint).values
|
||||
else:
|
||||
return await self.graph.ainvoke(loopstate)
|
||||
return await self.graph.ainvoke(loopstate, config=self.checkpoint)
|
||||
|
||||
async def run(self):
|
||||
"""
|
||||
|
||||
@@ -31,7 +31,7 @@ class NodeType(StrEnum):
|
||||
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):
|
||||
|
||||
@@ -385,6 +385,7 @@ class HttpRequestNode(BaseNode):
|
||||
logger.info(f"Node {self.node_id}: HTTP request succeeded")
|
||||
response = HttpResponse(resp)
|
||||
# Build raw request summary for process_data
|
||||
await resp.request.aread()
|
||||
raw_request = (
|
||||
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())
|
||||
|
||||
@@ -363,11 +363,12 @@ class KnowledgeRetrievalNode(BaseNode):
|
||||
seen_doc_ids = set()
|
||||
for chunk in final_rs:
|
||||
meta = chunk.metadata or {}
|
||||
doc_id = meta.get("document_id") or meta.get("doc_id")
|
||||
if doc_id and doc_id not in seen_doc_ids:
|
||||
seen_doc_ids.add(doc_id)
|
||||
document_id = meta.get("document_id")
|
||||
if document_id and document_id not in seen_doc_ids:
|
||||
seen_doc_ids.add(document_id)
|
||||
citations.append({
|
||||
"document_id": str(doc_id),
|
||||
"document_id": str(document_id),
|
||||
"doc_id": meta.get("doc_id", ""),
|
||||
"file_name": meta.get("file_name", ""),
|
||||
"knowledge_id": str(meta.get("knowledge_id", kb_config.kb_id)),
|
||||
"score": meta.get("score", 0.0),
|
||||
|
||||
@@ -6,6 +6,7 @@ import uuid
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""LLM 节点配置
|
||||
|
||||
@@ -152,6 +167,11 @@ class LLMNodeConfig(BaseNodeConfig):
|
||||
description="输出变量定义(自动生成,通常不需要修改)"
|
||||
)
|
||||
|
||||
error_handle: LLMErrorHandleConfig = Field(
|
||||
default_factory=LLMErrorHandleConfig,
|
||||
description="LLM 异常处理配置",
|
||||
)
|
||||
|
||||
@field_validator("messages", "prompt")
|
||||
@classmethod
|
||||
def validate_input_mode(cls, v):
|
||||
|
||||
@@ -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.variable_pool import VariablePool
|
||||
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.variable.base_variable import VariableType
|
||||
from app.db import get_db_context
|
||||
@@ -76,7 +77,7 @@ class LLMNode(BaseNode):
|
||||
self.messages = []
|
||||
|
||||
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):
|
||||
context = f"<context>{self._render_template(self.typed_config.context, variable_pool)}</context>"
|
||||
@@ -239,7 +240,7 @@ class LLMNode(BaseNode):
|
||||
|
||||
return llm
|
||||
|
||||
async def execute(self, state: WorkflowState, variable_pool: VariablePool) -> AIMessage:
|
||||
async def execute(self, state: WorkflowState, variable_pool: VariablePool):
|
||||
"""非流式执行 LLM 调用
|
||||
|
||||
Args:
|
||||
@@ -247,28 +248,36 @@ class LLMNode(BaseNode):
|
||||
variable_pool: 变量池
|
||||
|
||||
Returns:
|
||||
LLM 响应消息
|
||||
dict: {"llm_result": AIMessage, "branch_signal": "SUCCESS"} on success,
|
||||
{"llm_result": None, "branch_signal": "ERROR"} on branch error
|
||||
"""
|
||||
# self.typed_config = LLMNodeConfig(**self.config)
|
||||
llm = await self._prepare_llm(state, variable_pool, False)
|
||||
try:
|
||||
# self.typed_config = LLMNodeConfig(**self.config)
|
||||
llm = await self._prepare_llm(state, variable_pool, False)
|
||||
|
||||
logger.info(f"节点 {self.node_id} 开始执行 LLM 调用(非流式)")
|
||||
logger.info(f"节点 {self.node_id} 开始执行 LLM 调用(非流式)")
|
||||
|
||||
# 调用 LLM(支持字符串或消息列表)
|
||||
response = await llm.ainvoke(self.messages)
|
||||
# 提取内容
|
||||
if hasattr(response, 'content'):
|
||||
content = self.process_model_output(response.content)
|
||||
else:
|
||||
content = str(response)
|
||||
# 调用 LLM(支持字符串或消息列表)
|
||||
response = await llm.ainvoke(self.messages)
|
||||
# 提取内容
|
||||
if hasattr(response, 'content'):
|
||||
content = self.process_model_output(response.content)
|
||||
else:
|
||||
content = str(response)
|
||||
|
||||
logger.info(f"节点 {self.node_id} LLM 调用完成,输出长度: {len(content)}")
|
||||
logger.info(f"节点 {self.node_id} LLM 调用完成,输出长度: {len(content)}")
|
||||
|
||||
# 返回 AIMessage(包含响应元数据)
|
||||
return AIMessage(content=content, response_metadata={
|
||||
**response.response_metadata,
|
||||
"token_usage": getattr(response, 'usage_metadata', None) or response.response_metadata.get('token_usage')
|
||||
})
|
||||
# 返回 AIMessage(包含响应元数据)
|
||||
return {
|
||||
"llm_result": AIMessage(content=content, response_metadata={
|
||||
**response.response_metadata,
|
||||
"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]:
|
||||
"""提取输入数据(用于记录)"""
|
||||
@@ -286,16 +295,36 @@ class LLMNode(BaseNode):
|
||||
}
|
||||
}
|
||||
|
||||
def _extract_output(self, business_result: Any) -> str:
|
||||
"""从 AIMessage 中提取文本内容"""
|
||||
def _extract_output(self, business_result: Any) -> dict:
|
||||
"""从业务结果中提取输出变量
|
||||
|
||||
支持新旧两种格式:
|
||||
- 新格式:{"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):
|
||||
return business_result.content
|
||||
return str(business_result)
|
||||
return {"output": business_result.content, "branch_signal": "SUCCESS"}
|
||||
return {"output": str(business_result), "branch_signal": "SUCCESS"}
|
||||
|
||||
def _extract_token_usage(self, business_result: Any) -> dict[str, int] | None:
|
||||
"""从 AIMessage 中提取 token 使用情况"""
|
||||
if isinstance(business_result, AIMessage) and hasattr(business_result, 'response_metadata'):
|
||||
usage = business_result.response_metadata.get('token_usage')
|
||||
"""从业务结果中提取 token 使用情况"""
|
||||
llm_result = business_result
|
||||
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:
|
||||
return {
|
||||
"prompt_tokens": usage.get('input_tokens', 0),
|
||||
@@ -304,6 +333,44 @@ class LLMNode(BaseNode):
|
||||
}
|
||||
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):
|
||||
"""流式执行 LLM 调用
|
||||
|
||||
@@ -316,54 +383,58 @@ class LLMNode(BaseNode):
|
||||
"""
|
||||
self.typed_config = LLMNodeConfig(**self.config)
|
||||
|
||||
llm = await self._prepare_llm(state, variable_pool, True)
|
||||
try:
|
||||
llm = await self._prepare_llm(state, variable_pool, True)
|
||||
|
||||
logger.info(f"节点 {self.node_id} 开始执行 LLM 调用(流式)")
|
||||
# logger.debug(f"LLM 配置: streaming={getattr(llm._model, 'streaming', 'unknown')}")
|
||||
logger.info(f"节点 {self.node_id} 开始执行 LLM 调用(流式)")
|
||||
|
||||
# 累积完整响应
|
||||
full_response = ""
|
||||
chunk_count = 0
|
||||
# 累积完整响应
|
||||
full_response = ""
|
||||
chunk_count = 0
|
||||
|
||||
# 调用 LLM(流式,支持字符串或消息列表)
|
||||
last_meta_data = {}
|
||||
last_usage_metadata = {}
|
||||
async for chunk in llm.astream(self.messages):
|
||||
if hasattr(chunk, 'content'):
|
||||
content = self.process_model_output(chunk.content)
|
||||
else:
|
||||
content = str(chunk)
|
||||
if hasattr(chunk, 'response_metadata') and chunk.response_metadata:
|
||||
last_meta_data = chunk.response_metadata
|
||||
if hasattr(chunk, 'usage_metadata') and chunk.usage_metadata:
|
||||
last_usage_metadata = chunk.usage_metadata
|
||||
# 调用 LLM(流式,支持字符串或消息列表)
|
||||
last_meta_data = {}
|
||||
last_usage_metadata = {}
|
||||
async for chunk in llm.astream(self.messages):
|
||||
if hasattr(chunk, 'content'):
|
||||
content = self.process_model_output(chunk.content)
|
||||
else:
|
||||
content = str(chunk)
|
||||
if hasattr(chunk, 'response_metadata') and chunk.response_metadata:
|
||||
last_meta_data = chunk.response_metadata
|
||||
if hasattr(chunk, 'usage_metadata') and chunk.usage_metadata:
|
||||
last_usage_metadata = chunk.usage_metadata
|
||||
|
||||
# 只有当内容不为空时才处理
|
||||
if content:
|
||||
full_response += content
|
||||
chunk_count += 1
|
||||
# 只有当内容不为空时才处理
|
||||
if content:
|
||||
full_response += content
|
||||
chunk_count += 1
|
||||
|
||||
# 流式返回每个文本片段
|
||||
yield {
|
||||
"__final__": False,
|
||||
"chunk": content
|
||||
}
|
||||
# 流式返回每个文本片段
|
||||
yield {
|
||||
"__final__": False,
|
||||
"chunk": content
|
||||
}
|
||||
|
||||
yield {
|
||||
"__final__": False,
|
||||
"chunk": "",
|
||||
"done": True
|
||||
}
|
||||
logger.info(f"节点 {self.node_id} LLM 调用完成,输出长度: {len(full_response)}, 总 chunks: {chunk_count}")
|
||||
|
||||
# 构建完整的 AIMessage(包含元数据)
|
||||
final_message = AIMessage(
|
||||
content=full_response,
|
||||
response_metadata={
|
||||
**last_meta_data,
|
||||
"token_usage": last_usage_metadata or last_meta_data.get('token_usage')
|
||||
yield {
|
||||
"__final__": False,
|
||||
"chunk": "",
|
||||
"done": True
|
||||
}
|
||||
)
|
||||
logger.info(f"节点 {self.node_id} LLM 调用完成,输出长度: {len(full_response)}, 总 chunks: {chunk_count}")
|
||||
|
||||
# yield 完成标记
|
||||
yield {"__final__": True, "result": final_message}
|
||||
# 构建完整的 AIMessage(包含元数据)
|
||||
final_message = AIMessage(
|
||||
content=full_response,
|
||||
response_metadata={
|
||||
**last_meta_data,
|
||||
"token_usage": last_usage_metadata or last_meta_data.get('token_usage')
|
||||
}
|
||||
)
|
||||
|
||||
# yield 完成标记
|
||||
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}
|
||||
|
||||
@@ -205,6 +205,7 @@ class CitationConfig(BaseModel):
|
||||
|
||||
class Citation(BaseModel):
|
||||
document_id: str
|
||||
doc_id: str
|
||||
file_name: str
|
||||
knowledge_id: str
|
||||
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):
|
||||
"""多模型对比试运行请求"""
|
||||
message: str = Field(..., description="用户消息")
|
||||
|
||||
@@ -20,13 +20,26 @@ class ChunkCreate(BaseModel):
|
||||
|
||||
@property
|
||||
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):
|
||||
return f"question: {self.content.question} answer: {self.content.answer}"
|
||||
return self.content.question # QA 模式下 page_content 存 question
|
||||
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):
|
||||
content: Union[str, QAChunk] = Field(
|
||||
@@ -35,13 +48,26 @@ class ChunkUpdate(BaseModel):
|
||||
|
||||
@property
|
||||
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):
|
||||
return f"question: {self.content.question} answer: {self.content.answer}"
|
||||
return self.content.question # QA 模式下 page_content 存 question
|
||||
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):
|
||||
query: str
|
||||
@@ -51,3 +77,8 @@ class ChunkRetrieve(BaseModel):
|
||||
vector_similarity_weight: float | None = Field(None)
|
||||
top_k: int | None = Field(None)
|
||||
retrieve_type: RetrieveType | None = Field(None)
|
||||
|
||||
|
||||
class ChunkBatchCreate(BaseModel):
|
||||
"""批量创建 chunk"""
|
||||
items: list[ChunkCreate] = Field(..., min_length=1, description="chunk 列表")
|
||||
|
||||
@@ -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}
|
||||
for chunk in retrieve_chunks_result:
|
||||
meta = chunk.metadata or {}
|
||||
doc_id = meta.get("document_id") or meta.get("doc_id")
|
||||
if doc_id and doc_id not in seen_doc_ids:
|
||||
seen_doc_ids.add(doc_id)
|
||||
document_id = meta.get("document_id")
|
||||
if document_id and document_id not in seen_doc_ids:
|
||||
seen_doc_ids.add(document_id)
|
||||
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", ""),
|
||||
knowledge_id=str(meta.get("knowledge_id", "")),
|
||||
score=meta.get("score", 0)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
工作流服务层
|
||||
"""
|
||||
import datetime
|
||||
import time
|
||||
import logging
|
||||
import uuid
|
||||
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.validator import validate_workflow_config
|
||||
from app.db import get_db
|
||||
from sqlalchemy import select
|
||||
from app.models import App
|
||||
from app.models.workflow_model import WorkflowConfig, WorkflowExecution, WorkflowNodeExecution
|
||||
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_chunk(LLM 等流式节点)-> 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
|
||||
def get_start_node_variables(config: dict) -> list:
|
||||
nodes = config.get("nodes", [])
|
||||
|
||||
254
api/app/tasks.py
254
api/app/tasks.py
@@ -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.sequence2txt_model import QWenSeq2txt
|
||||
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 (
|
||||
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))
|
||||
# 2.2 Vectorize and import batch documents
|
||||
auto_questions_topn = db_document.parser_config.get("auto_questions", 0)
|
||||
qa_prompt = db_document.parser_config.get("qa_prompt", None)
|
||||
chat_model = None
|
||||
if auto_questions_topn:
|
||||
chat_model = Base(
|
||||
@@ -318,62 +319,123 @@ def parse_document(file_key: str, document_id: uuid.UUID, file_name: str = ""):
|
||||
model_name=db_knowledge.llm.api_keys[0].model_name,
|
||||
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 全局有序
|
||||
all_batch_chunks: list[list[DocumentChunk]] = []
|
||||
|
||||
if auto_questions_topn:
|
||||
# auto_questions 开启:先并发生成所有 chunk 的问题,再按 batch 分组
|
||||
# 构建 (global_idx, item) 列表
|
||||
# QA 模式(FastGPT 方案):
|
||||
# 1. 原 chunk 标记为 source(保留供 GraphRAG 使用,不参与检索)
|
||||
# 2. LLM 生成 QA 对,每个 QA 对独立存储为 qa chunk
|
||||
indexed_items = list(enumerate(res))
|
||||
|
||||
def _generate_question(idx_item: tuple[int, dict]) -> tuple[int, str]:
|
||||
"""为单个 chunk 生成问题(带缓存),返回 (global_idx, question_text)"""
|
||||
def _generate_qa(idx_item: tuple[int, dict]) -> tuple[int, list]:
|
||||
"""为单个 chunk 生成 QA 对(带缓存),返回 (global_idx, qa_pairs)"""
|
||||
global_idx, item = idx_item
|
||||
content = item["content_with_weight"]
|
||||
cached = get_llm_cache(chat_model.model_name, content, "question",
|
||||
{"topn": auto_questions_topn})
|
||||
cache_params = {"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:
|
||||
cached = question_proposal(chat_model, content, auto_questions_topn)
|
||||
set_llm_cache(chat_model.model_name, content, cached, "question",
|
||||
{"topn": auto_questions_topn})
|
||||
return global_idx, cached
|
||||
logger.info(f"[QA] Cache miss for chunk {global_idx}, calling LLM. cache_params={cache_params}")
|
||||
try:
|
||||
pairs = qa_proposal(chat_model, content, auto_questions_topn, custom_prompt=qa_prompt)
|
||||
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 生成问题
|
||||
question_map: dict[int, str] = {}
|
||||
# 并发调用 LLM 生成 QA 对
|
||||
qa_map: dict[int, list] = {}
|
||||
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 future in futures:
|
||||
global_idx, cached = future.result()
|
||||
question_map[global_idx] = cached
|
||||
global_idx, pairs = future.result()
|
||||
qa_map[global_idx] = pairs
|
||||
|
||||
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}).")
|
||||
|
||||
# 按 batch 分组组装 DocumentChunk
|
||||
for batch_start in range(0, total_chunks, EMBEDDING_BATCH_SIZE):
|
||||
batch_end = min(batch_start + EMBEDDING_BATCH_SIZE, total_chunks)
|
||||
chunks = []
|
||||
for global_idx in range(batch_start, batch_end):
|
||||
item = res[global_idx]
|
||||
metadata = {
|
||||
# 组装 chunks:source chunks + qa chunks
|
||||
source_chunks = []
|
||||
qa_chunks = []
|
||||
qa_sort_id = 0
|
||||
|
||||
for global_idx in range(total_chunks):
|
||||
item = res[global_idx]
|
||||
source_chunk_id = uuid.uuid4().hex
|
||||
|
||||
# source chunk:保留原文,供 GraphRAG 使用,不参与向量检索
|
||||
source_meta = {
|
||||
"doc_id": source_chunk_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(db_document.id),
|
||||
"knowledge_id": str(db_document.kb_id),
|
||||
"sort_id": global_idx,
|
||||
"status": 1,
|
||||
"chunk_type": "source",
|
||||
}
|
||||
source_chunks.append(
|
||||
DocumentChunk(page_content=item["content_with_weight"], metadata=source_meta))
|
||||
|
||||
# qa chunks:每个 QA 对独立存储
|
||||
pairs = qa_map.get(global_idx, [])
|
||||
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": global_idx,
|
||||
"sort_id": qa_sort_id,
|
||||
"status": 1,
|
||||
"chunk_type": "qa",
|
||||
"question": pair["question"],
|
||||
"answer": pair["answer"],
|
||||
"source_chunk_id": source_chunk_id,
|
||||
}
|
||||
cached = question_map[global_idx]
|
||||
chunks.append(
|
||||
DocumentChunk(
|
||||
page_content=f"question: {cached} answer: {item['content_with_weight']}",
|
||||
metadata=metadata))
|
||||
all_batch_chunks.append(chunks)
|
||||
# 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:
|
||||
# 无 auto_questions:直接构建 chunks
|
||||
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}"
|
||||
|
||||
|
||||
@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")
|
||||
def sync_knowledge_for_kb(kb_id: uuid.UUID):
|
||||
"""
|
||||
|
||||
@@ -19,5 +19,8 @@ export default defineConfig([
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': false
|
||||
}
|
||||
},
|
||||
])
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"x6-html-shape": "^0.4.9",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 13:59:45
|
||||
* @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 type { ApplicationModalData } from '@/views/ApplicationManagement/types'
|
||||
@@ -178,4 +178,8 @@ export const getAppLogDetail = (app_id: string, conversation_id: string) => {
|
||||
// Reset agent model config to default
|
||||
export const resetAppModelConfig = (app_id: string) => {
|
||||
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)
|
||||
}
|
||||
@@ -154,6 +154,19 @@ export const uploadFile = async (data: FormData, options?: UploadFileOptions) =>
|
||||
});
|
||||
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) => {
|
||||
@@ -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);
|
||||
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) => {
|
||||
const response = await request.post(`${apiPrefix}/chunks/${kb_id}/${document_id}/chunk`, data);
|
||||
|
||||
1
web/src/assets/csv_template.csv
Normal file
1
web/src/assets/csv_template.csv
Normal file
@@ -0,0 +1 @@
|
||||
Q A
|
||||
|
@@ -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">
|
||||
<title>编组 31</title>
|
||||
<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="编组-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>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -54,10 +54,14 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
|
||||
|
||||
useEffect(() => {
|
||||
if (values?.retrieve_type) {
|
||||
const resetValues: KnowledgeConfigForm = {}
|
||||
const fieldsToReset = Object.keys(values).filter(key =>
|
||||
key !== 'kb_id' && key !== 'retrieve_type' && key !== 'top_k'
|
||||
) as (keyof KnowledgeConfigForm)[];
|
||||
form.resetFields(fieldsToReset);
|
||||
fieldsToReset.forEach(key => {
|
||||
resetValues[key] = undefined
|
||||
})
|
||||
form.setFieldsValue(resetValues);
|
||||
}
|
||||
}, [values?.retrieve_type])
|
||||
|
||||
|
||||
@@ -40,7 +40,8 @@ const KnowledgeGlobalConfigModal = forwardRef<KnowledgeGlobalConfigModalRef, Kno
|
||||
|
||||
useEffect(() => {
|
||||
if (values?.rerank_model) {
|
||||
form.setFieldsValue({ ...data })
|
||||
const { rerank_model, ...rest } = data;
|
||||
form.setFieldsValue({ ...rest })
|
||||
} else {
|
||||
form.setFieldsValue({ reranker_id: undefined, reranker_top_k: undefined })
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface TagProps {
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
variant?: 'outline' | 'borderless'
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/** Color theme mappings with text, border, and background colors */
|
||||
@@ -38,9 +39,9 @@ const colors = {
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<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}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -709,6 +709,8 @@ export const en = {
|
||||
localFile: 'Local File',
|
||||
uploadFileTypes: 'Upload PDF, TXT, DOCX, IMAGE, MEDIA and other format files',
|
||||
webLink: 'Web Link',
|
||||
csvFile: 'Tabular Dataset',
|
||||
csvUploadFileTypes: 'Upload files in CSV format',
|
||||
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',
|
||||
selectorTutorial: 'Selector Usage Tutorial',
|
||||
@@ -949,7 +951,8 @@ export const en = {
|
||||
feishuFolderToken: 'Folder Token',
|
||||
feishuFolderTokenRequired: 'Please enter Folder Token',
|
||||
feishuFolderTokenPlaceholder: 'Enter your Feishu Folder Token',
|
||||
}
|
||||
},
|
||||
csvTemplate: 'Click to download CSV template',
|
||||
},
|
||||
api: {
|
||||
pageTitle: 'Memory library IAP document',
|
||||
@@ -1281,13 +1284,13 @@ export const en = {
|
||||
hybrid: 'Hybrid Retrieval',
|
||||
graph: 'Graph Retrieval',
|
||||
|
||||
similarity_threshold: 'Semantic similarity threshold',
|
||||
similarity_threshold_desc: 'Only return results with semantic similarity higher than this threshold',
|
||||
similarity_threshold_desc1: 'The minimum similarity threshold for semantic retrieval',
|
||||
vector_similarity_weight: 'Semantic similarity threshold',
|
||||
vector_similarity_weight_desc: 'Only return results with semantic similarity higher than this threshold',
|
||||
vector_similarity_weight_desc1: 'The minimum similarity threshold for semantic retrieval',
|
||||
|
||||
vector_similarity_weight: 'Vector Similarity Weight',
|
||||
vector_similarity_weight_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: 'Vector Similarity Weight',
|
||||
similarity_threshold_desc: 'Only return results with BM25 scores above this threshold',
|
||||
similarity_threshold_desc1: 'The minimum BM25 score threshold for word segmentation retrieval',
|
||||
|
||||
description: 'Description',
|
||||
shareVersion: 'Share Version',
|
||||
@@ -2534,6 +2537,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
input_result: 'Input',
|
||||
output_result: 'Output',
|
||||
process_result: 'Data Processing',
|
||||
inputs_result: 'Input',
|
||||
outputs_result: 'Output',
|
||||
error: 'Error Message',
|
||||
loopNum: ' loops',
|
||||
iterationNum: ' iterations',
|
||||
@@ -2544,6 +2549,13 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
output_cycle_vars: 'Final Loop Variables',
|
||||
},
|
||||
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',
|
||||
checkListDesc: 'Ensure all issues are resolved before publishing',
|
||||
checkListEmpty: 'No issues found',
|
||||
|
||||
@@ -194,6 +194,8 @@ export const zh = {
|
||||
localFile: '本地文件',
|
||||
uploadFileTypes: '上传 PDF、 TXT、 DOCX、 IMAGE、 MEDIA 等格式的文件',
|
||||
webLink: '网页链接',
|
||||
csvFile: '表格数据集',
|
||||
csvUploadFileTypes: '上传 CSV 格式的文件',
|
||||
webLinkPlaceholder: '请输入',
|
||||
webLinkDesc: '仅支持静态链接。如果上传的数据显示为空,则该链接可能无法读取。每行一个,一次最多{{count}}个链接',
|
||||
selectorTutorial: '选择器使用教程',
|
||||
@@ -283,6 +285,7 @@ export const zh = {
|
||||
qaExtract: '问答对提取',
|
||||
default: '默认',
|
||||
customize: '自定义',
|
||||
qaPrompt: 'QA 拆分引导词',
|
||||
defaultSettings: '使用系统默认的参数和规则',
|
||||
customSettings: '自定义设置数据处理规则',
|
||||
fileName: '文件名称',
|
||||
@@ -435,7 +438,8 @@ export const zh = {
|
||||
feishuFolderToken: '文件夹 Token',
|
||||
feishuFolderTokenRequired: '请输入文件夹 Token',
|
||||
feishuFolderTokenPlaceholder: '请输入您的飞书文件夹 Token',
|
||||
}
|
||||
},
|
||||
csvTemplate: '点击下载 CSV 模板',
|
||||
},
|
||||
application: {
|
||||
searchPlaceholder: '搜索应用',
|
||||
@@ -663,13 +667,13 @@ export const zh = {
|
||||
hybrid: '混合检索',
|
||||
graph: '图谱检索',
|
||||
|
||||
similarity_threshold: '语义相似度阈值',
|
||||
similarity_threshold_desc: '仅返回语义相似度高于此阈值的结果',
|
||||
similarity_threshold_desc1: '语义检索的最小相似度阈值',
|
||||
similarity_threshold: '向量相似度权重',
|
||||
similarity_threshold_desc: '仅返回BM25分数高于此阈值的结果',
|
||||
similarity_threshold_desc1: '分词检索的最小BM25分数阈值',
|
||||
|
||||
vector_similarity_weight: '向量相似度权重',
|
||||
vector_similarity_weight_desc: '仅返回BM25分数高于此阈值的结果',
|
||||
vector_similarity_weight_desc1: '分词检索的最小BM25分数阈值',
|
||||
vector_similarity_weight: '语义相似度阈值',
|
||||
vector_similarity_weight_desc: '仅返回语义相似度高于此阈值的结果',
|
||||
vector_similarity_weight_desc1: '语义检索的最小相似度阈值',
|
||||
|
||||
description: '描述',
|
||||
shareVersion: '分享版本',
|
||||
@@ -2498,6 +2502,8 @@ export const zh = {
|
||||
input_result: '输入',
|
||||
output_result: '输出',
|
||||
process_result: '数据处理',
|
||||
inputs_result: '输入',
|
||||
outputs_result: '输出',
|
||||
error: '错误信息',
|
||||
loopNum: '个循环',
|
||||
iterationNum: '个迭代',
|
||||
@@ -2508,6 +2514,13 @@ export const zh = {
|
||||
output_cycle_vars: '最终循环变量',
|
||||
},
|
||||
sureReplace: '确认替换',
|
||||
testRun: '测试运行',
|
||||
variables: '变量',
|
||||
startRun: '开始运行',
|
||||
reStartRun: '重新运行',
|
||||
status: '状态',
|
||||
elapsedTime: '运行时间',
|
||||
totalTokens: '总 TOKEN 数',
|
||||
checkList: '检查清单',
|
||||
checkListDesc: '发布前确保所有问题均已解决',
|
||||
checkListEmpty: '没有发现问题',
|
||||
@@ -2552,6 +2565,7 @@ export const zh = {
|
||||
variableSelect: {
|
||||
empty: '暂无变量',
|
||||
},
|
||||
singleRun: '运行此节点',
|
||||
},
|
||||
emotionEngine: {
|
||||
emotionEngineConfig: '情感引擎配置',
|
||||
|
||||
182
web/src/vendor/x6-html-shape/index.js
vendored
Normal file
182
web/src/vendor/x6-html-shape/index.js
vendored
Normal 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
7
web/src/vendor/x6-html-shape/react.js
vendored
Normal 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
104
web/src/vendor/x6-html-shape/utils.js
vendored
Normal 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
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:25:37
|
||||
* @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
|
||||
@@ -91,10 +91,14 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
|
||||
|
||||
useEffect(() => {
|
||||
if (values?.retrieve_type) {
|
||||
const fieldsToReset = Object.keys(values).filter(key =>
|
||||
const resetValues: KnowledgeConfigForm = {}
|
||||
const fieldsToReset = Object.keys(values).filter(key =>
|
||||
key !== 'kb_id' && key !== 'retrieve_type' && key !== 'top_k'
|
||||
) as (keyof KnowledgeConfigForm)[];
|
||||
form.resetFields(fieldsToReset);
|
||||
fieldsToReset.forEach(key => {
|
||||
resetValues[key] = undefined
|
||||
})
|
||||
form.setFieldsValue(resetValues);
|
||||
}
|
||||
}, [values?.retrieve_type])
|
||||
|
||||
@@ -127,7 +131,7 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
|
||||
extra={t('application.retrieve_type_desc')}
|
||||
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||
>
|
||||
|
||||
|
||||
<Select
|
||||
options={retrieveTypes.map(key => ({
|
||||
label: t(`application.${key}`),
|
||||
@@ -150,33 +154,35 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
|
||||
onChange={(value) => form.setFieldValue('top_k', value)}
|
||||
/>
|
||||
</FormItem>
|
||||
{/* Semantic similarity threshold */}
|
||||
{/* Vector similarity weight */}
|
||||
{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
|
||||
name="vector_similarity_weight"
|
||||
label={t('application.vector_similarity_weight')}
|
||||
extra={t('application.vector_similarity_weight_desc')}
|
||||
initialValue={0.5}
|
||||
>
|
||||
<RbSlider
|
||||
<RbSlider
|
||||
max={1.0}
|
||||
step={0.1}
|
||||
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>
|
||||
)}
|
||||
@@ -189,10 +195,11 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
|
||||
extra={t('application.similarity_threshold_desc1')}
|
||||
initialValue={0.5}
|
||||
>
|
||||
<RbSlider
|
||||
<RbSlider
|
||||
max={1.0}
|
||||
step={0.1}
|
||||
min={0.0}
|
||||
isInput={true}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
@@ -201,10 +208,11 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
|
||||
extra={t('application.vector_similarity_weight_desc1')}
|
||||
initialValue={0.5}
|
||||
>
|
||||
<RbSlider
|
||||
<RbSlider
|
||||
max={1.0}
|
||||
step={0.1}
|
||||
min={0.0}
|
||||
isInput={true}
|
||||
/>
|
||||
</FormItem>
|
||||
</>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:25:42
|
||||
* @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
|
||||
@@ -67,7 +67,8 @@ const KnowledgeGlobalConfigModal = forwardRef<KnowledgeGlobalConfigModalRef, Kno
|
||||
|
||||
useEffect(() => {
|
||||
if (values?.rerank_model) {
|
||||
form.setFieldsValue({ ...data })
|
||||
const { rerank_model, ...rest } = data;
|
||||
form.setFieldsValue({ ...rest })
|
||||
} else {
|
||||
form.setFieldsValue({ reranker_id: undefined, reranker_top_k: undefined })
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:09:42
|
||||
* @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
|
||||
@@ -56,7 +56,7 @@ interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
|
||||
/** Custom file removal callback */
|
||||
onRemove?: (file: UploadFile) => boolean | void | Promise<boolean | void>;
|
||||
|
||||
featureConfig: FeaturesConfigForm['file_upload'];
|
||||
featureConfig?: FeaturesConfigForm['file_upload'];
|
||||
textType?: 'button' | 'text';
|
||||
block?: boolean;
|
||||
}
|
||||
@@ -184,7 +184,7 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
audio: 'audio_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 isLtMaxSize = fileSizeMB < maxSize;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:09:47
|
||||
* @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
|
||||
@@ -31,7 +31,7 @@ const FormItem = Form.Item;
|
||||
interface UploadFileListModalProps {
|
||||
/** Callback to refresh parent component with new file list */
|
||||
refresh: (fileList?: any[]) => void;
|
||||
featureConfig: FeaturesConfigForm['file_upload']
|
||||
featureConfig?: FeaturesConfigForm['file_upload']
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import type { UploadFile } from 'antd';
|
||||
import UploadFiles from '@/components/Upload/UploadFiles';
|
||||
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 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 ParameterSettings = 'defaultSettings' | 'customSettings';
|
||||
const stepKeys = ['selectFile', 'parameterSettings', 'dataPreview', 'confirmUpload'] as const;
|
||||
@@ -63,6 +63,8 @@ interface ContentFormData {
|
||||
title: 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 { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -91,11 +93,12 @@ const CreateDataset = () => {
|
||||
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [delimiter, setDelimiter] = useState<string | undefined>(undefined);
|
||||
const [blockSize, setBlockSize] = useState<number>(130);
|
||||
const [qaPrompt, setQaPrompt] = useState<string | undefined>()
|
||||
console.log('qaPrompt', qaPrompt)
|
||||
const [processingMethod, setProcessingMethod] = useState<ProcessingMethod>('directBlock');
|
||||
const [parameterSettings, setParameterSettings] = useState<ParameterSettings>('defaultSettings');
|
||||
const [pdfEnhancementEnabled, setPdfEnhancementEnabled] = useState<boolean>(true);
|
||||
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(
|
||||
() => [
|
||||
{ title: t('knowledgeBase.selectFile') },
|
||||
@@ -112,8 +115,11 @@ const CreateDataset = () => {
|
||||
const handleNext = async () => {
|
||||
// Temporarily hide step 3: adjust step index (0->1->2 corresponds to select file->parameter settings->confirm upload)
|
||||
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
|
||||
if (rechunkFileIds.length === 0) {
|
||||
// If no files, prompt user to upload first
|
||||
@@ -159,6 +165,7 @@ const CreateDataset = () => {
|
||||
delimiter: delimiter,
|
||||
chunk_token_num: blockSize,
|
||||
auto_questions: processingMethod === 'directBlock' ? 0 : 1,
|
||||
qa_prompt: qaPrompt
|
||||
}
|
||||
}
|
||||
updateDocument(id, params)
|
||||
@@ -378,40 +385,67 @@ const CreateDataset = () => {
|
||||
formData.append('parent_id', parentId);
|
||||
}
|
||||
|
||||
uploadFile(formData, {
|
||||
kb_id: knowledgeBaseId,
|
||||
parent_id: parentId,
|
||||
signal: abortController.signal,
|
||||
onUploadProgress: (event) => {
|
||||
if (!event.total) return;
|
||||
const percent = Math.round((event.loaded / event.total) * 100);
|
||||
onProgress?.({ percent }, file);
|
||||
},
|
||||
})
|
||||
.then((res: UploadFileResponse) => {
|
||||
// Upload successful, remove AbortController
|
||||
abortControllersRef.current.delete(fileUid);
|
||||
|
||||
onSuccess?.(res, new XMLHttpRequest());
|
||||
if (res?.id) {
|
||||
setRechunkFileIds((prev) => {
|
||||
if (prev.includes(res.id)) return prev;
|
||||
const next = [...prev, res.id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
if (source === 'csv') {
|
||||
uploadQaFile(formData, {
|
||||
kb_id: knowledgeBaseId,
|
||||
parent_id: parentId,
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.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);
|
||||
});
|
||||
.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, {
|
||||
kb_id: knowledgeBaseId,
|
||||
parent_id: parentId,
|
||||
signal: abortController.signal,
|
||||
onUploadProgress: (event) => {
|
||||
if (!event.total) return;
|
||||
const percent = Math.round((event.loaded / event.total) * 100);
|
||||
onProgress?.({ percent }, file);
|
||||
},
|
||||
})
|
||||
.then((res: UploadFileResponse) => {
|
||||
// Upload successful, remove AbortController
|
||||
abortControllersRef.current.delete(fileUid);
|
||||
|
||||
onSuccess?.(res, new XMLHttpRequest());
|
||||
if (res?.id) {
|
||||
setRechunkFileIds((prev) => {
|
||||
if (prev.includes(res.id)) return prev;
|
||||
const next = [...prev, res.id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
})
|
||||
.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);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -557,116 +591,126 @@ const CreateDataset = () => {
|
||||
<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>
|
||||
</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" />
|
||||
</div>
|
||||
</div> }
|
||||
<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'>
|
||||
{source && source === 'local' && (
|
||||
<UploadFiles
|
||||
ref={uploadRef}
|
||||
isCanDrag={true}
|
||||
fileSize={100}
|
||||
multiple={true}
|
||||
maxCount={99}
|
||||
fileType={fileType}
|
||||
customRequest={handleUpload}
|
||||
onChange={(fileList) => {
|
||||
console.log('File list changed:', fileList);
|
||||
}}
|
||||
onRemove={async (file) => {
|
||||
// 如果文件正在上传,取消上传
|
||||
const fileUid = file.uid;
|
||||
const abortController = abortControllersRef.current.get(fileUid);
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
abortControllersRef.current.delete(fileUid);
|
||||
console.log('Upload cancelled:', (file as any).name);
|
||||
// 取消上传后直接返回 true,允许移除文件
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only delete server file when file upload was successful (has response.id)
|
||||
if (file.response?.id) {
|
||||
try {
|
||||
await deleteDocument(file.response.id);
|
||||
setRechunkFileIds(prev => prev.filter(id => id !== file.response.id));
|
||||
console.log('Server file deleted:', file.response.id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error);
|
||||
messageApi.error(t('common.deleteFailed') || 'Failed to delete file');
|
||||
return false; // Don't remove file when deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Also allow removal in other cases (such as failed uploads)
|
||||
return true;
|
||||
}} />
|
||||
)}
|
||||
{source && source === 'link' && (
|
||||
<div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-40'>
|
||||
{source && (source === 'local' || source === 'csv') && (
|
||||
<UploadFiles
|
||||
ref={uploadRef}
|
||||
isCanDrag={true}
|
||||
fileSize={100}
|
||||
multiple={source !== 'csv'}
|
||||
maxCount={source === 'csv' ? 1 : 99}
|
||||
fileType={source === 'csv' ? csvFileType : fileType}
|
||||
customRequest={handleUpload}
|
||||
onChange={(fileList) => {
|
||||
console.log('File list changed:', fileList);
|
||||
}}
|
||||
onRemove={async (file) => {
|
||||
// 如果文件正在上传,取消上传
|
||||
const fileUid = file.uid;
|
||||
const abortController = abortControllersRef.current.get(fileUid);
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
abortControllersRef.current.delete(fileUid);
|
||||
console.log('Upload cancelled:', (file as any).name);
|
||||
// 取消上传后直接返回 true,允许移除文件
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only delete server file when file upload was successful (has response.id)
|
||||
if (file.response?.id) {
|
||||
try {
|
||||
await deleteDocument(file.response.id);
|
||||
setRechunkFileIds(prev => prev.filter(id => id !== file.response.id));
|
||||
console.log('Server file deleted:', file.response.id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error);
|
||||
messageApi.error(t('common.deleteFailed') || 'Failed to delete file');
|
||||
return false; // Don't remove file when deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Also allow removal in other cases (such as failed uploads)
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{source && source === 'link' && (
|
||||
<div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-40'>
|
||||
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
|
||||
{t('knowledgeBase.webLink')}
|
||||
</div>
|
||||
<TextArea rows={6} placeholder={t('knowledgeBase.webLinkPlaceholder')} />
|
||||
<div className='rb:text-sm rb:text-gray-500 rb:mt-3'>
|
||||
{t('knowledgeBase.webLinkDesc',{count: 5})}
|
||||
</div>
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mt-10 rb:mb-3'>
|
||||
{t('knowledgeBase.selectorTutorial')}
|
||||
</div>
|
||||
<Input className='rb:w-full' placeholder={t('knowledgeBase.webLinkPlaceholder')}/>
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
|
||||
{t('knowledgeBase.webLink')}
|
||||
</div>
|
||||
<TextArea rows={6} placeholder={t('knowledgeBase.webLinkPlaceholder')} />
|
||||
<div className='rb:text-sm rb:text-gray-500 rb:mt-3'>
|
||||
{t('knowledgeBase.webLinkDesc',{count: 5})}
|
||||
</div>
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mt-10 rb:mb-3'>
|
||||
{t('knowledgeBase.selectorTutorial')}
|
||||
</div>
|
||||
<Input className='rb:w-full' placeholder={t('knowledgeBase.webLinkPlaceholder')}/>
|
||||
</div>
|
||||
)}
|
||||
{source && source === 'text' && (
|
||||
<div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-20'>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onValuesChange={() => {
|
||||
// 检查表单字段是否都已填写
|
||||
const values = form.getFieldsValue();
|
||||
const isValid = !!(values.title?.trim() && values.content?.trim());
|
||||
setTextFormValid(isValid);
|
||||
}}
|
||||
<div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-20'>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onValuesChange={() => {
|
||||
// 检查表单字段是否都已填写
|
||||
const values = form.getFieldsValue();
|
||||
const isValid = !!(values.title?.trim() && values.content?.trim());
|
||||
setTextFormValid(isValid);
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label={t('knowledgeBase.title')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.pleaseEnterTitle') }]}
|
||||
>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label={t('knowledgeBase.title')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.pleaseEnterTitle') }]}
|
||||
>
|
||||
<Input placeholder={t('knowledgeBase.pleaseEnterTitle')} />
|
||||
</Form.Item>
|
||||
<Input placeholder={t('knowledgeBase.pleaseEnterTitle')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="content"
|
||||
label={t('knowledgeBase.customContent')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.pleaseEnterContent') }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder={t('knowledgeBase.pleaseEnterContent')}
|
||||
rows={8}
|
||||
showCount
|
||||
maxLength={5000}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{/* <div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
|
||||
{t('knowledgeBase.customText')}
|
||||
</div>
|
||||
<Input className='rb:w-full' placeholder={t('knowledgeBase.webLinkPlaceholder')}/>
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mt-10 rb:mb-3'>
|
||||
{t('knowledgeBase.customContent')}
|
||||
</div>
|
||||
<TextArea rows={6} placeholder={t('knowledgeBase.webLinkPlaceholder')} /> */}
|
||||
<Form.Item
|
||||
name="content"
|
||||
label={t('knowledgeBase.customContent')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.pleaseEnterContent') }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder={t('knowledgeBase.pleaseEnterContent')}
|
||||
rows={8}
|
||||
showCount
|
||||
maxLength={5000}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{/* <div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
|
||||
{t('knowledgeBase.customText')}
|
||||
</div>
|
||||
<Input className='rb:w-full' placeholder={t('knowledgeBase.webLinkPlaceholder')}/>
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mt-10 rb:mb-3'>
|
||||
{t('knowledgeBase.customContent')}
|
||||
</div>
|
||||
<TextArea rows={6} placeholder={t('knowledgeBase.webLinkPlaceholder')} /> */}
|
||||
</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 && (
|
||||
<div className='rb:flex rb:flex-col rb:mt-10 rb:px-40'>
|
||||
@@ -765,18 +809,23 @@ const CreateDataset = () => {
|
||||
</Flex>
|
||||
</Radio>
|
||||
</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>
|
||||
<div className='rb:w-full rb:text-[#5B6167] rb:leading-5 rb:mb-2'>
|
||||
{t('knowledgeBase.delimiter')}
|
||||
</div>
|
||||
<DelimiterSelector value={delimiter} onChange={setDelimiter} />
|
||||
<div>
|
||||
<div className='rb:w-full rb:text-[#5B6167] rb:leading-5 rb:mb-2'>
|
||||
{t('knowledgeBase.delimiter')}
|
||||
</div>
|
||||
<DelimiterSelector value={delimiter} onChange={setDelimiter} />
|
||||
</div>
|
||||
<SliderInput label={t('knowledgeBase.suggestedBlockSize')} max={1024} min={1} step={1} value={blockSize} onChange={handleChange} />
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -853,7 +902,7 @@ const CreateDataset = () => {
|
||||
{t('common.previous') || 'Prev'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
{source !== 'csv' && <Button
|
||||
type='primary'
|
||||
onClick={current === 2 ? handleStartUpload : handleNext}
|
||||
disabled={
|
||||
@@ -863,7 +912,7 @@ const CreateDataset = () => {
|
||||
}
|
||||
>
|
||||
{current === 2 ? t('knowledgeBase.startUploading') || 'Start Upload' : t('common.next') || 'Next'}
|
||||
</Button>
|
||||
</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,8 @@ import { useEffect, useState, useRef, type FC } from 'react';
|
||||
import { useNavigate, useParams, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBreadcrumbManager, type BreadcrumbPath } from '@/hooks/useBreadcrumbManager';
|
||||
import { Button, Spin, message, Switch } from 'antd';
|
||||
import { getDocumentDetail, getDocumentChunkList, downloadFile, updateDocument, updateDocumentChunk, createDocumentChunk, getFileUrl } from '@/api/knowledgeBase';
|
||||
import { Button, Spin, message, Switch, App } from 'antd';
|
||||
import { getDocumentDetail, getDocumentChunkList, downloadFile, updateDocument, updateDocumentChunk, createDocumentChunk } from '@/api/knowledgeBase';
|
||||
import type { KnowledgeBaseDocumentData, RecallTestData } from '@/views/KnowledgeBase/types';
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import InfoPanel, { type InfoItem } from '../components/InfoPanel';
|
||||
@@ -20,10 +20,11 @@ import SearchInput from '@/components/SearchInput';
|
||||
import DocumentPreview from '@/components/DocumentPreview';
|
||||
import InsertModal, { type InsertModalRef } from '../components/InsertModal';
|
||||
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 { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { message: messageApi } = App.useApp()
|
||||
const { knowledgeBaseId } = useParams<{ knowledgeBaseId: string }>();
|
||||
const location = useLocation();
|
||||
const { updateBreadcrumbs } = useBreadcrumbManager({
|
||||
@@ -100,9 +101,25 @@ const DocumentDetails: FC = () => {
|
||||
}, [keywords]);
|
||||
|
||||
|
||||
const handleCopy = (value?: string) => {
|
||||
if (!value) return
|
||||
copy(value)
|
||||
messageApi.success(t('common.copySuccess'))
|
||||
}
|
||||
|
||||
|
||||
const formatDocumentInfo = (doc: KnowledgeBaseDocumentData): InfoItem[] => {
|
||||
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',
|
||||
label: t('knowledgeBase.fileName') || '文件名',
|
||||
@@ -210,6 +227,11 @@ const DocumentDetails: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshChunks = () => {
|
||||
let nextPage = 1;
|
||||
setPage(nextPage);
|
||||
ChunkList(nextPage);
|
||||
}
|
||||
const loadMoreChunks = () => {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
@@ -345,8 +367,8 @@ const DocumentDetails: FC = () => {
|
||||
fileName={document?.file_name}
|
||||
fileExt={document?.file_ext}
|
||||
height="calc(100% - 40px)"
|
||||
mode="google"
|
||||
showModeSwitch={true}
|
||||
// mode="google"
|
||||
// showModeSwitch={true}
|
||||
/>
|
||||
</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]">
|
||||
{/* 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: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
|
||||
title={t('knowledgeBase.documentInfo') || '文档信息'}
|
||||
items={infoItems}
|
||||
@@ -407,7 +429,7 @@ const DocumentDetails: FC = () => {
|
||||
{t('knowledgeBase.chunkList') || '分块列表'}
|
||||
</h2>
|
||||
<RecallTestResult
|
||||
|
||||
refresh={refreshChunks}
|
||||
data={chunkList}
|
||||
showEmpty={false}
|
||||
hasMore={hasMore}
|
||||
@@ -417,6 +439,7 @@ const DocumentDetails: FC = () => {
|
||||
editable={true}
|
||||
onItemClick={handleChunkClick}
|
||||
parserMode={parserMode}
|
||||
handleCopy={handleCopy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,8 @@ import { formatDateTime } from '@/utils/format';
|
||||
import KnowledgeGraphCard from '../components/KnowledgeGraphCard';
|
||||
import { useBreadcrumbManager, type BreadcrumbItem } from '@/hooks/useBreadcrumbManager';
|
||||
import './Private.css'
|
||||
import Tag from '@/components/Tag'
|
||||
import copy from 'copy-to-clipboard'
|
||||
// Tree node data type
|
||||
|
||||
const Private: FC = () => {
|
||||
@@ -456,29 +458,35 @@ const Private: FC = () => {
|
||||
|
||||
}
|
||||
// Generate dropdown menu items (based on current row)
|
||||
const getOptMenuItems = (row: KnowledgeBaseListItem): MenuProps['items'] => [
|
||||
{
|
||||
key: '1',
|
||||
label: t('knowledgeBase.rechunking'),
|
||||
onClick: () => {
|
||||
handleRechunking(row);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: t('knowledgeBase.download'),
|
||||
onClick: () => {
|
||||
handleDownload(row);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: t('knowledgeBase.delete'),
|
||||
onClick: () => {
|
||||
handleDelete(row);
|
||||
const getOptMenuItems = (row: KnowledgeBaseListItem): MenuProps['items'] => {
|
||||
const options = [{
|
||||
key: '2',
|
||||
label: t('knowledgeBase.download'),
|
||||
onClick: () => {
|
||||
handleDownload(row);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: t('knowledgeBase.delete'),
|
||||
onClick: () => {
|
||||
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) => {
|
||||
if (!knowledgeBaseId) return;
|
||||
const document = item as unknown as KnowledgeBaseDocumentData;
|
||||
@@ -570,7 +578,7 @@ const Private: FC = () => {
|
||||
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: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' }}
|
||||
></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'),
|
||||
dataIndex: 'parser_id',
|
||||
key: 'parser_id',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: t('knowledgeBase.dataSize'),
|
||||
@@ -629,6 +638,11 @@ const Private: FC = () => {
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
},
|
||||
|
||||
{
|
||||
title: t('common.operation'),
|
||||
@@ -762,11 +776,16 @@ const Private: FC = () => {
|
||||
setIsSyncing(false);
|
||||
};
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
copy(value)
|
||||
messageApi.success(t('common.copySuccess'))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rb:flex rb:h-full rb:bg-white rb:rounded-xl">
|
||||
{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
|
||||
multiple
|
||||
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"
|
||||
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>
|
||||
</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.updated')} {t('knowledgeBase.time')}: {formatDateTime(knowledgeBase.updated_at) || '-'}</span>
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
|
||||
title: t('knowledgeBase.customText'),
|
||||
description: t('knowledgeBase.manuallyInputText')
|
||||
},
|
||||
{
|
||||
title: t('knowledgeBase.csvFile'),
|
||||
description: t('knowledgeBase.csvUploadFileTypes')
|
||||
},
|
||||
]
|
||||
// 封装取消方法,添加关闭弹窗逻辑
|
||||
const handleClose = () => {
|
||||
@@ -86,7 +90,7 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
|
||||
// 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) {
|
||||
navigate(`/knowledge-base/${knowledgeBaseId}/create-dataset`,{
|
||||
state: {
|
||||
@@ -139,6 +143,12 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
|
||||
<span className='rb:text-base rb:font-medium rb:text-gray-800'>{items[1].title}</span>
|
||||
<span className='rb:text-xs rb:text-gray-500'>{items[1].description}</span>
|
||||
</Flex>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
* @LastEditTime: 2025-11-19 19:59:36
|
||||
*/
|
||||
import { Divider } from 'antd';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export interface InfoItem {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string | number | undefined;
|
||||
value: string | number | undefined | ReactElement;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,8 @@ const KnowledgeGraph: FC<KnowledgeGraphProps> = ({ data, loading = false }) => {
|
||||
}
|
||||
}, [nodes])
|
||||
|
||||
console.log('selectedNode', selectedNode)
|
||||
|
||||
return (
|
||||
<Col span={24}>
|
||||
<RbCard
|
||||
|
||||
@@ -7,25 +7,28 @@
|
||||
* @LastEditTime: 2025-12-22 13:47:53
|
||||
*/
|
||||
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 type { RecallTestData } from '@/views/KnowledgeBase/types';
|
||||
import { NoData } from './noData';
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import InfiniteScroll from 'react-infinite-scroll-component';
|
||||
import RbMarkdown from '@/components/Markdown';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, type MouseEvent } from 'react';
|
||||
import { deleteDocumentChunk } from '@/api/knowledgeBase'
|
||||
|
||||
interface RecallTestResultProps {
|
||||
data: RecallTestData[];
|
||||
showEmpty?: boolean;
|
||||
hasMore?: boolean;
|
||||
loadMore?: () => void;
|
||||
refresh?: () => void;
|
||||
loading?: boolean;
|
||||
scrollableTarget?: string;
|
||||
editable?: boolean; // Whether editable
|
||||
onItemClick?: (item: RecallTestData, index: number) => void; // Click item callback
|
||||
parserMode?: number; // Parser mode, 1 means QA format
|
||||
handleCopy?: (text?: string) => void;
|
||||
}
|
||||
|
||||
const RecallTestResult = ({
|
||||
@@ -33,13 +36,17 @@ const RecallTestResult = ({
|
||||
showEmpty = true,
|
||||
hasMore = false,
|
||||
loadMore,
|
||||
refresh,
|
||||
loading = false,
|
||||
scrollableTarget,
|
||||
editable = false,
|
||||
onItemClick,
|
||||
parserMode = 0,
|
||||
handleCopy,
|
||||
}: RecallTestResultProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { modal, message } = App.useApp()
|
||||
console.log('chunk data', data)
|
||||
|
||||
// Parse QA format content
|
||||
const parseQAContent = (content: string) => {
|
||||
@@ -130,6 +137,24 @@ const RecallTestResult = ({
|
||||
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
|
||||
if (loading && data.length === 0) {
|
||||
@@ -183,17 +208,21 @@ const RecallTestResult = ({
|
||||
{scorePercentage.toFixed(1)}% {t('knowledgeBase.similarity')}
|
||||
</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'>
|
||||
<FileOutlined /> {item.metadata?.file_name || '-'}
|
||||
</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}
|
||||
</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 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);
|
||||
if (qaContent) {
|
||||
@@ -204,13 +233,21 @@ const RecallTestResult = ({
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{item.metadata?.file_created_at && (
|
||||
<div className='rb:flex rb:items-center rb:justify-start rb:mt-3'>
|
||||
<span className='rb:text-gray-500 rb:text-xs'>
|
||||
<FieldTimeOutlined /> {formatDateTime(item.metadata.file_created_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Flex align="center" justify={item.metadata?.file_created_at ? 'space-between' : 'end'} className="rb:mt-3!">
|
||||
{item.metadata?.file_created_at && (
|
||||
<div className='rb:flex rb:items-center rb:justify-start'>
|
||||
<span className='rb:text-gray-500 rb:text-xs'>
|
||||
<FieldTimeOutlined /> {formatDateTime(item.metadata.file_created_at)}
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
@@ -228,7 +265,7 @@ const RecallTestResult = ({
|
||||
<div className='rb:flex rb:h-full rb:flex-col'>
|
||||
<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-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>
|
||||
</div>
|
||||
@@ -245,12 +282,13 @@ const RecallTestResult = ({
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Otherwise use normal rendering
|
||||
return (
|
||||
<div className='rb:flex rb:flex-col'>
|
||||
<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-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>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import RbCard from '@/components/RbCard/Card'
|
||||
import SearchInput from '@/components/SearchInput'
|
||||
import Empty from '@/components/Empty'
|
||||
import { getKnowledgeBaseList, getModelList, getModelTypeList, deleteKnowledgeBase, getKnowledgeBaseTypeList } from '@/api/knowledgeBase'
|
||||
import copy from 'copy-to-clipboard'
|
||||
|
||||
import InfiniteScroll from 'react-infinite-scroll-component';
|
||||
|
||||
@@ -527,6 +528,10 @@ const KnowledgeBaseManagement: FC = () => {
|
||||
fetchData(1, false);
|
||||
}
|
||||
}, [modelTypes, query.parent_id, query.keywords, query.orderby, query.desc])
|
||||
const handleCopy = (value: string) => {
|
||||
copy(value)
|
||||
messageApi.success(t('common.copySuccess'))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -574,6 +579,8 @@ const KnowledgeBaseManagement: FC = () => {
|
||||
title={item.name}
|
||||
headerType="borderless"
|
||||
headerClassName="rb:py-3!"
|
||||
className="rb:cursor-pointer"
|
||||
onClick={() => handleToDetail(item)}
|
||||
extra={
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Dropdown
|
||||
@@ -585,7 +592,7 @@ const KnowledgeBaseManagement: FC = () => {
|
||||
</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:font-medium rb:w-20">{t('knowledgeBase.description')} </div> */}
|
||||
<Tooltip title={item.description}>
|
||||
@@ -593,6 +600,13 @@ const KnowledgeBaseManagement: FC = () => {
|
||||
</Tooltip>
|
||||
</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'>
|
||||
<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>) => (
|
||||
<div
|
||||
key={description.key as string}
|
||||
|
||||
@@ -95,7 +95,7 @@ export interface ParserConfig {
|
||||
auto_keywords?: number; // 自动关键词
|
||||
auto_questions?: number; // 自动问题
|
||||
html4excel?: boolean; // 是否为Excel文件
|
||||
graphrag: GraphragConfig; // 知识图谱生成
|
||||
graphrag?: GraphragConfig; // 知识图谱生成
|
||||
|
||||
// Web 类型特有字段
|
||||
entry_url?: string; // 入口网址
|
||||
@@ -135,6 +135,7 @@ export interface KnowledgeBaseDocumentData { // 知识库文档数据
|
||||
status?: number; // 状态 1 可检索 0 不可检索
|
||||
created_at?: string; // 创建时间
|
||||
updated_at?: string; // 更新时间
|
||||
qa_prompt?: string; // 提示词
|
||||
}
|
||||
export interface DocumentModalRef {
|
||||
handleOpen: (file?: KnowledgeBaseDocumentData | null) => void;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:09:03
|
||||
* @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
|
||||
@@ -62,14 +62,13 @@ export interface TestParams {
|
||||
message: string;
|
||||
/** Search mode switch (0: deep thinking, 1: normal, 2: quick) */
|
||||
search_switch: string;
|
||||
/** Conversation history */
|
||||
history: { role: string; content: string }[];
|
||||
/** Enable web keyword */
|
||||
web_search?: boolean;
|
||||
/** Enable memory function */
|
||||
memory?: boolean;
|
||||
/** Conversation ID */
|
||||
conversation_id?: string;
|
||||
session_id?: string;
|
||||
}
|
||||
/**
|
||||
* Data item in analysis logs
|
||||
@@ -118,6 +117,7 @@ const MemoryConversation: FC = () => {
|
||||
const [search_switch, setSearchSwitch] = useState('0')
|
||||
const [msg, setMsg] = useState<string>('')
|
||||
const [expandedLogs, setExpandedLogs] = useState<Record<number, boolean>>({})
|
||||
const [sessionId, setSessionId] = useState<string | undefined>(undefined)
|
||||
|
||||
/** Handle message send */
|
||||
const handleSend = () => {
|
||||
@@ -132,13 +132,14 @@ const MemoryConversation: FC = () => {
|
||||
message: msg,
|
||||
end_user_id: userId,
|
||||
search_switch: search_switch,
|
||||
history: [],
|
||||
session_id: sessionId
|
||||
})
|
||||
.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' }])
|
||||
setLogs(response.intermediate_outputs)
|
||||
setExpandedLogs(Object.fromEntries(response.intermediate_outputs.map((_, i) => [i, true])))
|
||||
setSessionId(response.session_id)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
@@ -153,6 +154,12 @@ const MemoryConversation: FC = () => {
|
||||
if (!file_path) return
|
||||
window.open(file_path, '_blank')
|
||||
}
|
||||
const handleChangeUser = (opt: DefaultOptionType) => {
|
||||
setUserId(opt?.value as string)
|
||||
setSessionId(undefined)
|
||||
setChatData([])
|
||||
setLogs([])
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -169,7 +176,7 @@ const MemoryConversation: FC = () => {
|
||||
}))}
|
||||
placeholder={t('memoryConversation.searchPlaceholder')}
|
||||
style={{ width: '100%', marginBottom: '16px' }}
|
||||
onChange={(opt: DefaultOptionType) => setUserId(opt?.value as string)}
|
||||
onChange={handleChangeUser}
|
||||
variant="borderless"
|
||||
className="rb:bg-white rb:rounded-lg"
|
||||
showSearch
|
||||
|
||||
@@ -77,13 +77,11 @@ const AddChatVariable = forwardRef<AddChatVariableRef, AddChatVariableProps>(({
|
||||
renderItem={(item, index) => (
|
||||
<List.Item>
|
||||
<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">
|
||||
<div className="rb:leading-4">
|
||||
<span className="rb:font-medium">{item.name}</span>
|
||||
<span className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular"> ({t(`workflow.config.parameter-extractor.${item.type}`)})</span>
|
||||
</div>
|
||||
<Flex align="center" justify="space-between" className="rb:leading-4 rb:max-w-[calc(100%-60px)]">
|
||||
<div className="rb:flex-1 rb:font-medium rb:whitespace-break-spaces rb:wrap-break-word rb:line-clamp-1">{item.name}</div>
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular"> ({t(`workflow.config.parameter-extractor.${item.type}`)})</div>
|
||||
</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">
|
||||
<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')]"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-04-09 18:58:21
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-20 10:39:17
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-05-07 18:35:54
|
||||
*/
|
||||
import { useState, useCallback, useEffect, useRef, type FC } from 'react'
|
||||
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)))
|
||||
},
|
||||
// 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': (val: any[]) => !Array.isArray(val) || !val.length,
|
||||
// assigner.assignments: every item needs variable_selector + operation; value required unless operation is 'clear'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-23 16:22:51
|
||||
* @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 { createPortal } from 'react-dom';
|
||||
@@ -27,6 +27,7 @@ export interface Suggestion {
|
||||
disabled?: boolean; // Flag for disabled state
|
||||
children?: Suggestion[]; // Sub-variables (e.g. file fields)
|
||||
parentLabel?: string; // Parent variable label (for child display)
|
||||
default?: any;
|
||||
}
|
||||
|
||||
// Autocomplete plugin for variable suggestions triggered by '/' character
|
||||
|
||||
@@ -4,180 +4,22 @@
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-30 11:55:10
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Popover, Flex } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { Flex } from 'antd';
|
||||
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 { 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 (
|
||||
<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
|
||||
align="center"
|
||||
justify="center"
|
||||
gap={4}
|
||||
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"
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
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', {
|
||||
'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>
|
||||
{data.label}
|
||||
</Flex>
|
||||
</Popover>
|
||||
<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/workflow/node_plus.png')]"></div>
|
||||
{data.label}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -20,9 +20,8 @@ const NodeTools: FC<{ node: Node }> = ({
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div 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
|
||||
<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:hidden!': !data.isSelected
|
||||
})}>
|
||||
<Dropdown
|
||||
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>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,48 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-09 18:30:28
|
||||
* @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 { Flex, Popover } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
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 {
|
||||
graph: any;
|
||||
}
|
||||
@@ -16,7 +51,6 @@ interface PortClickHandlerProps {
|
||||
const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
||||
const { t } = useTranslation();
|
||||
const [popoverVisible, setPopoverVisible] = useState(false);
|
||||
const [popoverPosition, setPopoverPosition] = useState({ x: 0, y: 0 });
|
||||
const [sourceNode, setSourceNode] = useState<any>(null);
|
||||
const [sourcePort, setSourcePort] = useState<string>('');
|
||||
const [tempElement, setTempElement] = useState<HTMLElement | null>(null);
|
||||
@@ -24,12 +58,11 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const handlePortClick = (event: CustomEvent) => {
|
||||
const { node, port, element, rect, edgeInsertion } = event.detail;
|
||||
const { node, port, element, edgeInsertion } = event.detail;
|
||||
setSourceNode(node);
|
||||
setSourcePort(port);
|
||||
setTempElement(element);
|
||||
setEdgeInsertion(edgeInsertion || null);
|
||||
setPopoverPosition({ x: rect.left, y: rect.top });
|
||||
setPopoverVisible(true);
|
||||
};
|
||||
|
||||
@@ -53,6 +86,68 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
||||
const newNodeType = selectedNodeType.type;
|
||||
|
||||
// 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;
|
||||
if (isCycleSubNode && sourceNodeType === 'cycle-start') {
|
||||
const cycleId = sourceNodeData.cycle;
|
||||
@@ -81,17 +176,30 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
||||
if (gap < requiredSpace) {
|
||||
const shiftX = requiredSpace - gap;
|
||||
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;
|
||||
visited.add(cell.id);
|
||||
const pos = cell.getPosition();
|
||||
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) => {
|
||||
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) {
|
||||
newX = addNodePosition.x;
|
||||
@@ -146,7 +254,7 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
||||
|
||||
if (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) {
|
||||
@@ -158,21 +266,21 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
||||
const newPorts = newNode.getPorts();
|
||||
const addedCells: any[] = [newNode];
|
||||
|
||||
const addedEdges: any[] = [];
|
||||
if (edgeInsertion) {
|
||||
// Edge insertion: create source→new and new→target edges
|
||||
const { targetCell, targetPort: origTargetPort } = edgeInsertion;
|
||||
const newLeftPort = newPorts.find((p: any) => p.group === 'left')?.id || 'left';
|
||||
const newRightPort = newPorts.find((p: any) => p.group === 'right')?.id || 'right';
|
||||
addedCells.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: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: newLeftPort }, ...edgeAttrs }));
|
||||
addedEdges.push(graph.addEdge({ source: { cell: newNode.id, port: newRightPort }, target: { cell: targetCell.id, port: origTargetPort }, ...edgeAttrs }));
|
||||
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 {
|
||||
// Connect from right port to new node's left side
|
||||
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 (isCycleContainer(newNodeType)) {
|
||||
const parentBBox = newNode.getBBox();
|
||||
@@ -190,8 +298,8 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
||||
y: parentBBox.y + 70 + 4,
|
||||
data: { type: 'add-node', label: t('workflow.addNode'), icon: '+', parentId: id, cycle: id },
|
||||
});
|
||||
newNode.addChild(cycleStartNode, { silent: true });
|
||||
newNode.addChild(addNodePlaceholder, { silent: true });
|
||||
newNode.addChild(cycleStartNode);
|
||||
newNode.addChild(addNodePlaceholder);
|
||||
const innerEdge = graph.addEdge({
|
||||
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' },
|
||||
@@ -200,26 +308,10 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
||||
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;
|
||||
if (cycleId) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
adjustCycleContainerSize(graph, cycleId);
|
||||
}
|
||||
|
||||
// toFront
|
||||
@@ -245,7 +337,7 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
||||
graph.enableHistory();
|
||||
const history = graph.getPlugin('history') as any;
|
||||
if (history) {
|
||||
const batchFrame = addedCells.map((cell: any) => ({
|
||||
const batchFrame = [...addedCells, ...addedEdges].map((cell: any) => ({
|
||||
batch: true,
|
||||
event: 'cell:added',
|
||||
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;
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<Popover
|
||||
content={content}
|
||||
open={popoverVisible}
|
||||
@@ -324,14 +416,12 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
||||
if (!visible) handlePopoverClose();
|
||||
}}
|
||||
placement="right"
|
||||
overlayStyle={{
|
||||
position: 'fixed',
|
||||
left: popoverPosition.x + 10,
|
||||
top: popoverPosition.y - 10,
|
||||
}}
|
||||
autoAdjustOverflow
|
||||
getPopupContainer={() => document.body}
|
||||
>
|
||||
<div />
|
||||
</Popover>
|
||||
<div style={{ width: '1px', height: '1px' }} />
|
||||
</Popover>,
|
||||
tempElement
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -355,14 +355,13 @@ const CaseList: FC<CaseListProps> = ({
|
||||
// Update node ports based on case count changes (add/remove cases)
|
||||
const updateNodePorts = (caseCount: number, removedCaseIndex?: number) => {
|
||||
if (!selectedNode || !graphRef?.current) return;
|
||||
|
||||
// Get current port count to determine if it's an add or remove operation
|
||||
const currentPorts = selectedNode.getPorts().filter((port: any) => port.group === 'right');
|
||||
const currentCaseCount = currentPorts.length - 1; // Exclude ELSE port
|
||||
const graph = graphRef.current;
|
||||
|
||||
const currentRightPorts = selectedNode.getPorts().filter((port: any) => port.group === 'right');
|
||||
const currentCaseCount = currentRightPorts.length - 1;
|
||||
const isAddingCase = removedCaseIndex === undefined && caseCount > currentCaseCount;
|
||||
|
||||
// Save existing edge connections (including left-side port connections)
|
||||
const existingEdges = graphRef.current.getEdges().filter((edge: any) =>
|
||||
|
||||
const existingEdges = graph.getEdges().filter((edge: any) =>
|
||||
edge.getSourceCellId() === selectedNode.id || edge.getTargetCellId() === selectedNode.id
|
||||
);
|
||||
const edgeConnections = existingEdges.map((edge: any) => ({
|
||||
@@ -371,113 +370,70 @@ const CaseList: FC<CaseListProps> = ({
|
||||
targetCellId: edge.getTargetCellId(),
|
||||
targetPortId: edge.getTargetPortId(),
|
||||
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 leftPorts = selectedNode.getPorts().filter((p: any) => p.group !== 'right');
|
||||
const newRightPorts = Array.from({ length: caseCount + 1 }, (_, i) => ({
|
||||
id: `CASE${i + 1}`,
|
||||
group: 'right',
|
||||
args: { x: nodeWidth, y: getConditionNodeCasePortY(cases, i) },
|
||||
}));
|
||||
|
||||
graph.startBatch('update-ports');
|
||||
|
||||
existingEdges.forEach((edge: any) => graph.removeCell(edge));
|
||||
// Replace all ports in one prop call — produces a single cell:change:ports command
|
||||
selectedNode.prop('ports/items', [...leftPorts, ...newRightPorts], { rewrite: true });
|
||||
selectedNode.prop('size', { width: nodeWidth, height: calcConditionNodeTotalHeight(cases) });
|
||||
|
||||
edgeConnections.forEach(({sourcePortId, targetCellId, targetPortId, sourceCellId, isIncoming }: any) => {
|
||||
if (isIncoming) {
|
||||
const sourceCell = graph.getCellById(sourceCellId);
|
||||
if (sourceCell) {
|
||||
graph.addEdge({
|
||||
source: { cell: sourceCellId, port: sourcePortId },
|
||||
target: { cell: selectedNode.id, port: targetPortId },
|
||||
...edgeAttrs
|
||||
});
|
||||
sourceCell.toFront();
|
||||
bringLoopChildrenToFront(sourceCell);
|
||||
selectedNode.toFront();
|
||||
bringLoopChildrenToFront(selectedNode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const originalCaseNumber = parseInt(sourcePortId.match(/CASE(\d+)/)?.[1] || '0');
|
||||
if (removedCaseIndex !== undefined && originalCaseNumber === removedCaseIndex + 1) return;
|
||||
let newPortId = sourcePortId;
|
||||
|
||||
if (removedCaseIndex !== undefined) {
|
||||
if (originalCaseNumber > removedCaseIndex + 1) {
|
||||
newPortId = `CASE${originalCaseNumber - 1}`;
|
||||
} else if (originalCaseNumber === currentCaseCount + 1) {
|
||||
newPortId = `CASE${caseCount + 1}`;
|
||||
}
|
||||
} else if (isAddingCase && originalCaseNumber === currentCaseCount + 1) {
|
||||
newPortId = `CASE${caseCount + 1}`;
|
||||
}
|
||||
if (newRightPorts.find((p) => p.id === newPortId)) {
|
||||
const targetCell = graph.getCellById(targetCellId);
|
||||
if (targetCell) {
|
||||
graph.addEdge({
|
||||
source: { cell: selectedNode.id, port: newPortId },
|
||||
target: { cell: targetCellId, port: targetPortId },
|
||||
...edgeAttrs
|
||||
});
|
||||
selectedNode.toFront();
|
||||
bringLoopChildrenToFront(selectedNode);
|
||||
targetCell.toFront();
|
||||
bringLoopChildrenToFront(targetCell);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const cases = form.getFieldValue(name) || [];
|
||||
selectedNode.prop('size', { width: nodeWidth, height: calcConditionNodeTotalHeight(cases) });
|
||||
|
||||
// Add ELIF ports
|
||||
for (let i = 0; i < caseCount; i++) {
|
||||
selectedNode.addPort({
|
||||
id: `CASE${i + 1}`,
|
||||
group: 'right',
|
||||
args: {
|
||||
x: nodeWidth,
|
||||
y: getConditionNodeCasePortY(cases, i),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add ELSE port
|
||||
selectedNode.addPort({
|
||||
id: `CASE${caseCount + 1}`,
|
||||
group: 'right',
|
||||
args: {
|
||||
x: nodeWidth,
|
||||
y: getConditionNodeCasePortY(cases, caseCount),
|
||||
},
|
||||
});
|
||||
|
||||
// Restore edge connections
|
||||
setTimeout(() => {
|
||||
edgeConnections.forEach(({ edge, sourcePortId, targetCellId, targetPortId, sourceCellId, isIncoming }: any) => {
|
||||
// If it's an incoming connection (left-side port), restore directly
|
||||
if (isIncoming) {
|
||||
const sourceCell = graphRef.current?.getCellById(sourceCellId);
|
||||
if (sourceCell) {
|
||||
graphRef.current?.addEdge({
|
||||
source: { cell: sourceCellId, port: sourcePortId },
|
||||
target: { cell: selectedNode.id, port: targetPortId },
|
||||
...edgeAttrs,
|
||||
});
|
||||
}
|
||||
sourceCell.toFront()
|
||||
selectedNode.toFront()
|
||||
bringLoopChildrenToFront(sourceCell)
|
||||
bringLoopChildrenToFront(selectedNode)
|
||||
graphRef.current?.removeCell(edge);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle right-side port connections
|
||||
const originalCaseNumber = parseInt(sourcePortId.match(/CASE(\d+)/)?.[1] || '0');
|
||||
|
||||
// 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;
|
||||
|
||||
// If it's a remove operation, remap port IDs
|
||||
if (removedCaseIndex !== undefined) {
|
||||
if (originalCaseNumber > removedCaseIndex + 1) {
|
||||
// Ports after the removed port, shift numbering forward
|
||||
newPortId = `CASE${originalCaseNumber - 1}`;
|
||||
}
|
||||
// ELSE port always maps to the new ELSE port position
|
||||
else if (originalCaseNumber === currentCaseCount + 1) {
|
||||
newPortId = `CASE${caseCount + 1}`;
|
||||
}
|
||||
} else if (isAddingCase) {
|
||||
// If it's an add operation, ELSE port needs to be remapped
|
||||
if (originalCaseNumber === currentCaseCount + 1) {
|
||||
newPortId = `CASE${caseCount + 1}`; // New ELSE port
|
||||
}
|
||||
// Newly added ports don't restore any connections
|
||||
}
|
||||
|
||||
const newPorts = selectedNode.getPorts();
|
||||
const matchingPort = newPorts.find((port: any) => port.id === newPortId);
|
||||
|
||||
if (matchingPort) {
|
||||
const targetCell = graphRef.current?.getCellById(targetCellId);
|
||||
if (targetCell) {
|
||||
graphRef.current?.addEdge({
|
||||
source: { cell: selectedNode.id, port: newPortId },
|
||||
target: { cell: targetCellId, port: targetPortId },
|
||||
...edgeAttrs
|
||||
});
|
||||
selectedNode.toFront()
|
||||
bringLoopChildrenToFront(selectedNode)
|
||||
targetCell.toFront()
|
||||
bringLoopChildrenToFront(targetCell)
|
||||
}
|
||||
}
|
||||
|
||||
graphRef.current?.removeCell(edge);
|
||||
});
|
||||
}, 50);
|
||||
graph.stopBatch('update-ports');
|
||||
};
|
||||
|
||||
const handleChangeLogicalOperator = (index: number) => {
|
||||
|
||||
@@ -42,109 +42,73 @@ const CategoryList: FC<CategoryListProps> = ({ parentName, selectedNode, graphRe
|
||||
// Update node ports based on category count changes (add/remove categories)
|
||||
const updateNodePorts = (caseCount: number, removedCaseIndex?: number) => {
|
||||
if (!selectedNode || !graphRef?.current) return;
|
||||
const graph = graphRef.current;
|
||||
|
||||
// Save existing edge connections (including left-side port connections)
|
||||
const existingEdges = graphRef.current.getEdges().filter((edge: any) =>
|
||||
const existingEdges = graph.getEdges().filter((edge: any) =>
|
||||
edge.getSourceCellId() === selectedNode.id || edge.getTargetCellId() === selectedNode.id
|
||||
);
|
||||
const edgeConnections = existingEdges.map((edge: any) => ({
|
||||
edge,
|
||||
sourcePortId: edge.getSourcePortId(),
|
||||
targetCellId: edge.getTargetCellId(),
|
||||
targetPortId: edge.getTargetPortId(),
|
||||
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);
|
||||
}
|
||||
});
|
||||
graph.startBatch('update-ports');
|
||||
|
||||
existingEdges.forEach((edge: any) => graph.removeCell(edge));
|
||||
// Replace all ports in one prop call — produces a single cell:change:ports command
|
||||
const leftPorts = selectedNode.getPorts().filter((p: any) => p.group !== 'right');
|
||||
const newRightPorts = Array.from({ length: caseCount }, (_, i) => ({
|
||||
id: `CASE${i + 1}`,
|
||||
group: 'right',
|
||||
args: { x: nodeWidth, y: portItemArgsY * i + conditionNodePortItemArgsY },
|
||||
}));
|
||||
selectedNode.prop('ports/items', [...leftPorts, ...newRightPorts], { rewrite: true });
|
||||
|
||||
// Calculate new node height: base height 88px + 30px for each additional port
|
||||
const newHeight = conditionNodeHeight + (caseCount - 2) * conditionNodeItemHeight;
|
||||
selectedNode.prop('size', { width: nodeWidth, height: newHeight < conditionNodeHeight ? conditionNodeHeight : newHeight });
|
||||
|
||||
selectedNode.prop('size', { width: nodeWidth, height: newHeight < conditionNodeHeight ? conditionNodeHeight : newHeight })
|
||||
|
||||
// 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);
|
||||
edgeConnections.forEach(({ sourcePortId, targetCellId, targetPortId, sourceCellId, isIncoming }: any) => {
|
||||
if (isIncoming) {
|
||||
const sourceCell = graph.getCellById(sourceCellId);
|
||||
if (sourceCell) {
|
||||
graph.addEdge({
|
||||
source: { cell: sourceCellId, port: sourcePortId },
|
||||
target: { cell: selectedNode.id, port: targetPortId },
|
||||
...edgeAttrs
|
||||
});
|
||||
sourceCell.toFront();
|
||||
bringLoopChildrenToFront(sourceCell);
|
||||
selectedNode.toFront();
|
||||
bringLoopChildrenToFront(selectedNode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const originalCaseNumber = parseInt(sourcePortId.match(/CASE(\d+)/)?.[1] || '0');
|
||||
if (removedCaseIndex !== undefined && originalCaseNumber === removedCaseIndex + 1) return;
|
||||
let newPortId = sourcePortId;
|
||||
if (removedCaseIndex !== undefined && originalCaseNumber > removedCaseIndex + 1) {
|
||||
newPortId = `CASE${originalCaseNumber - 1}`;
|
||||
}
|
||||
if (newRightPorts.find((p) => p.id === newPortId)) {
|
||||
const targetCell = graph.getCellById(targetCellId);
|
||||
if (targetCell) {
|
||||
graph.addEdge({
|
||||
source: { cell: selectedNode.id, port: newPortId },
|
||||
target: { cell: targetCellId, port: targetPortId },
|
||||
...edgeAttrs
|
||||
});
|
||||
selectedNode.toFront();
|
||||
bringLoopChildrenToFront(selectedNode);
|
||||
targetCell.toFront();
|
||||
bringLoopChildrenToFront(targetCell);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add category ports
|
||||
for (let i = 0; i < caseCount; i++) {
|
||||
selectedNode.addPort({
|
||||
id: `CASE${i + 1}`,
|
||||
group: 'right',
|
||||
args: {
|
||||
x: nodeWidth,
|
||||
y: portItemArgsY * i + conditionNodePortItemArgsY,
|
||||
},
|
||||
});
|
||||
}
|
||||
// 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
|
||||
if (isIncoming) {
|
||||
const sourceCell = graphRef.current?.getCellById(sourceCellId);
|
||||
if (sourceCell) {
|
||||
graphRef.current?.addEdge({
|
||||
source: { cell: sourceCellId, port: sourcePortId },
|
||||
target: { cell: selectedNode.id, port: targetPortId },
|
||||
...edgeAttrs
|
||||
});
|
||||
sourceCell.toFront()
|
||||
bringLoopChildrenToFront(sourceCell)
|
||||
selectedNode.toFront()
|
||||
bringLoopChildrenToFront(selectedNode)
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle right-side port connections
|
||||
const originalCaseNumber = parseInt(sourcePortId.match(/CASE(\d+)/)?.[1] || '0');
|
||||
|
||||
// If it's a removed port, don't recreate the connection
|
||||
if (removedCaseIndex !== undefined && originalCaseNumber === removedCaseIndex + 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newPortId = sourcePortId;
|
||||
|
||||
// If a port was removed, remap subsequent port IDs
|
||||
if (removedCaseIndex !== undefined && originalCaseNumber > removedCaseIndex + 1) {
|
||||
newPortId = `CASE${originalCaseNumber - 1}`;
|
||||
}
|
||||
|
||||
// Check if the new port exists
|
||||
const newPorts = selectedNode.getPorts();
|
||||
const matchingPort = newPorts.find((port: any) => port.id === newPortId);
|
||||
|
||||
if (matchingPort) {
|
||||
const targetCell = graphRef.current?.getCellById(targetCellId);
|
||||
if (targetCell) {
|
||||
graphRef.current?.addEdge({
|
||||
source: { cell: selectedNode.id, port: newPortId },
|
||||
target: { cell: targetCellId, port: targetPortId },
|
||||
...edgeAttrs
|
||||
});
|
||||
selectedNode.toFront()
|
||||
bringLoopChildrenToFront(selectedNode)
|
||||
targetCell.toFront()
|
||||
bringLoopChildrenToFront(targetCell)
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 50);
|
||||
graph.stopBatch('update-ports');
|
||||
};
|
||||
|
||||
const handleAddCategory = (addFunc: Function) => {
|
||||
|
||||
@@ -133,7 +133,7 @@ const CycleVarsList: FC<CycleVarsListProps> = ({
|
||||
|
||||
return option.dataType === currentType
|
||||
})}
|
||||
variant="borderless"
|
||||
variant="filled"
|
||||
size="small"
|
||||
className="select"
|
||||
/>
|
||||
|
||||
@@ -67,10 +67,14 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
|
||||
|
||||
useEffect(() => {
|
||||
if (values?.retrieve_type) {
|
||||
const resetValues: KnowledgeConfigForm = {}
|
||||
const fieldsToReset = Object.keys(values).filter(key =>
|
||||
key !== 'kb_id' && key !== 'retrieve_type' && key !== 'top_k'
|
||||
) as (keyof KnowledgeConfigForm)[];
|
||||
form.resetFields(fieldsToReset);
|
||||
fieldsToReset.forEach(key => {
|
||||
resetValues[key] = undefined
|
||||
})
|
||||
form.setFieldsValue(resetValues);
|
||||
}
|
||||
}, [values?.retrieve_type])
|
||||
|
||||
@@ -91,7 +95,7 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
|
||||
<Flex align="center" justify="space-between" className="rb:mb-6! rb-border rb:rounded-lg rb:p-[17px_16px]! rb:cursor-pointer rb:bg-[#F0F3F8] rb:text-[#212332]">
|
||||
<div className="rb:text-[16px] rb:leading-5.5">
|
||||
{data.name}
|
||||
<div className="rb:text-[12px] rb:leading-4 rb:text-[#5B6167] rb:mt-2">{t('application.contains', {include_count: data.doc_num})}</div>
|
||||
<div className="rb:text-[12px] rb:leading-4 rb:text-[#5B6167] rb:mt-2">{t('application.contains', { include_count: data.doc_num })}</div>
|
||||
</div>
|
||||
<div className="rb:text-[12px] rb:leading-4 rb:text-[#5B6167]">{formatDateTime(data.updated_at, 'YYYY-MM-DD HH:mm:ss')}</div>
|
||||
</Flex>
|
||||
@@ -104,13 +108,12 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
|
||||
extra={t('application.retrieve_type_desc')}
|
||||
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||
>
|
||||
|
||||
|
||||
<Select
|
||||
options={retrieveTypes.map(key => ({
|
||||
label: t(`application.${key}`),
|
||||
value: key,
|
||||
}))}
|
||||
// onChange={handleChange}
|
||||
/>
|
||||
</FormItem>
|
||||
{/* Top K */}
|
||||
@@ -124,34 +127,18 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
|
||||
style={{ width: '100%' }}
|
||||
min={1}
|
||||
max={20}
|
||||
// onChange={(value) => form.setFieldValue('top_k', value)}
|
||||
onChange={(value) => form.setFieldValue('top_k', value)}
|
||||
/>
|
||||
</FormItem>
|
||||
{/* 语义相似度阈值 similarity_threshold */}
|
||||
{/* Vector similarity weight */}
|
||||
{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
|
||||
name="vector_similarity_weight"
|
||||
label={t('application.vector_similarity_weight')}
|
||||
extra={t('application.vector_similarity_weight_desc')}
|
||||
initialValue={0.5}
|
||||
>
|
||||
<RbSlider
|
||||
<RbSlider
|
||||
max={1.0}
|
||||
step={0.1}
|
||||
min={0.0}
|
||||
@@ -159,7 +146,23 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
|
||||
/>
|
||||
</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' && (
|
||||
<>
|
||||
<FormItem
|
||||
@@ -168,7 +171,7 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
|
||||
extra={t('application.similarity_threshold_desc1')}
|
||||
initialValue={0.5}
|
||||
>
|
||||
<RbSlider
|
||||
<RbSlider
|
||||
max={1.0}
|
||||
step={0.1}
|
||||
min={0.0}
|
||||
@@ -181,7 +184,7 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
|
||||
extra={t('application.vector_similarity_weight_desc1')}
|
||||
initialValue={0.5}
|
||||
>
|
||||
<RbSlider
|
||||
<RbSlider
|
||||
max={1.0}
|
||||
step={0.1}
|
||||
min={0.0}
|
||||
|
||||
@@ -47,7 +47,8 @@ const KnowledgeGlobalConfigModal = forwardRef<KnowledgeGlobalConfigModalRef, Kno
|
||||
|
||||
useEffect(() => {
|
||||
if (values?.rerank_model) {
|
||||
form.setFieldsValue({ ...data })
|
||||
const { rerank_model, ...rest } = data;
|
||||
form.setFieldsValue({ ...rest })
|
||||
} else {
|
||||
form.setFieldsValue({ reranker_id: undefined, reranker_top_k: undefined })
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ const MemoryConfig: FC<{ options: Suggestion[]; parentName: string; }> = ({
|
||||
const { t } = useTranslation()
|
||||
const form = Form.useFormInstance();
|
||||
const values = Form.useWatch([], form) || {}
|
||||
|
||||
console.log('MemoryConfig', values)
|
||||
|
||||
const handleChangeEnable = (value: boolean) => {
|
||||
if (value) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-07 14:55:04
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-17 10:05:32
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-29 17:08:19
|
||||
*/
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -28,7 +28,6 @@ const ModelConfig: FC = () => {
|
||||
if (model_id && options) {
|
||||
const model = options.find(item => item.id === model_id)
|
||||
setSelectedModel(model || null)
|
||||
form.setFieldValue('json_output', false)
|
||||
} else {
|
||||
setSelectedModel(null)
|
||||
}
|
||||
@@ -47,6 +46,7 @@ const ModelConfig: FC = () => {
|
||||
params={{ type: 'llm,chat' }}
|
||||
className="rb:w-full!"
|
||||
size="small"
|
||||
onChange={() => form.setFieldValue('json_output', false)}
|
||||
updateOptions={updateOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -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"
|
||||
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}>
|
||||
{vo.required && <span className="rb:py-px rb:px-2 rb:bg-white rb-border rb:rounded-sm">{t('workflow.config.start.required')}</span>}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-01-19 17:00:26
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-13 10:44:17
|
||||
* @Last Modified time: 2026-05-07 18:36:58
|
||||
*/
|
||||
/**
|
||||
* useVariableList Hook
|
||||
@@ -97,14 +97,15 @@ const addVariable = (
|
||||
dataType: string,
|
||||
value: string,
|
||||
nodeData: any,
|
||||
extra?: Partial<Suggestion>
|
||||
extra?: Partial<Suggestion>,
|
||||
defaultValue?: any
|
||||
) => {
|
||||
if (!keys.has(key)) {
|
||||
keys.add(key);
|
||||
const children = dataType === 'file'
|
||||
? buildFileChildren(key, value, nodeData, label)
|
||||
: 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':
|
||||
// Add start node variables
|
||||
[...(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
|
||||
config?.variables?.sys?.forEach((v: any) => {
|
||||
@@ -164,7 +165,7 @@ const processNodeVariables = (
|
||||
case 'parameter-extractor':
|
||||
// Add extracted parameters
|
||||
(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;
|
||||
|
||||
@@ -178,7 +179,7 @@ const processNodeVariables = (
|
||||
const fv = variableList.find(v => `{{${v.value}}}` === gv.value[0]);
|
||||
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 {
|
||||
@@ -205,14 +206,14 @@ const processNodeVariables = (
|
||||
case 'loop':
|
||||
// Add loop cycle variables
|
||||
(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;
|
||||
|
||||
case 'code':
|
||||
// Add code node output variables
|
||||
(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;
|
||||
}
|
||||
@@ -321,13 +322,13 @@ export const getChildNodeVariables = (
|
||||
// Add parameter-extractor variables
|
||||
if (type === 'parameter-extractor') {
|
||||
(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
|
||||
if (type === 'code') {
|
||||
(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) : [])];
|
||||
|
||||
// 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)
|
||||
const deferredIds: string[] = [];
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:39:59
|
||||
* @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 clsx from 'clsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 CustomSelect from "@/components/CustomSelect";
|
||||
@@ -28,6 +28,7 @@ import ToolConfig from './ToolConfig'
|
||||
import MemoryConfig from './MemoryConfig'
|
||||
import VariableList from './VariableList'
|
||||
import { useVariableList, getCurrentNodeVariables, getChildNodeVariables } from './hooks/useVariableList'
|
||||
import { useWorkflowStore } from '@/store/workflow'
|
||||
import styles from './properties.module.css'
|
||||
import Editor, { type LexicalEditorProps } from "../Editor";
|
||||
import RbSlider from '@/components/RbSlider'
|
||||
@@ -39,6 +40,8 @@ import ModelConfig from './ModelConfig'
|
||||
import ModelSelect from '@/components/ModelSelect'
|
||||
import ListOperator from './ListOperator'
|
||||
import MappingList from "./MappingList";
|
||||
import SingleNodeRun from '../SingleNodeRun'
|
||||
import { cannotRunNodes } from '../../constant'
|
||||
|
||||
/**
|
||||
* Props for Properties component
|
||||
@@ -58,8 +61,12 @@ interface PropertiesProps {
|
||||
parseEvent: () => void;
|
||||
/** Workflow configuration */
|
||||
config?: any;
|
||||
/** App ID for node run */
|
||||
appId?: string;
|
||||
/** Chat variables */
|
||||
chatVariables: ChatVariable[];
|
||||
/** Function to save workflow configuration */
|
||||
handleSave: (flag?: boolean) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,9 +78,13 @@ const Properties: FC<PropertiesProps> = ({
|
||||
selectedNode,
|
||||
graphRef,
|
||||
chatVariables,
|
||||
blankClick
|
||||
blankClick,
|
||||
config,
|
||||
appId,
|
||||
handleSave,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { message } = App.useApp()
|
||||
const [form] = Form.useForm<NodeConfig>();
|
||||
const [configs, setConfigs] = useState<Record<string, NodeConfig>>({} as Record<string, NodeConfig>)
|
||||
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 (
|
||||
<>
|
||||
<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
|
||||
title={t('workflow.nodeProperties')}
|
||||
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
|
||||
menu={{
|
||||
items: [
|
||||
@@ -986,7 +1021,18 @@ const Properties: FC<PropertiesProps> = ({
|
||||
</div>
|
||||
}
|
||||
</RbCard>
|
||||
|
||||
{isRun && (
|
||||
<SingleNodeRun
|
||||
open={isRun}
|
||||
onClose={() => setIsRun(false)}
|
||||
selectedNode={selectedNode}
|
||||
appId={appId || config?.app_id || ''}
|
||||
variableList={variableList}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default Properties;
|
||||
@@ -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
|
||||
134
web/src/views/Workflow/components/SingleNodeRun/FileVarInput.tsx
Normal file
134
web/src/views/Workflow/components/SingleNodeRun/FileVarInput.tsx
Normal 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
|
||||
341
web/src/views/Workflow/components/SingleNodeRun/index.tsx
Normal file
341
web/src/views/Workflow/components/SingleNodeRun/index.tsx
Normal 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
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:06:18
|
||||
* @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 { GroupMetadata, PortMetadata } from '@antv/x6/lib/model/port';
|
||||
@@ -16,6 +16,12 @@ import NoteNode from './components/Nodes/NoteNode';
|
||||
import { memoryConfigListUrl } from '@/api/memory';
|
||||
import type { NodeLibrary } from './types';
|
||||
|
||||
|
||||
export const cannotRunNodes = [
|
||||
'start',
|
||||
'end',
|
||||
'output',
|
||||
]
|
||||
/**
|
||||
* Workflow node library configuration
|
||||
* Defines all available node types, their icons, and configuration schemas
|
||||
@@ -954,7 +960,7 @@ export const graphNodeLibrary: Record<string, NodeConfig> = {
|
||||
height: 76,
|
||||
shape: 'normal-node',
|
||||
ports: {
|
||||
groups: { left: defaultPortGroup },
|
||||
groups: { left: leftPortGroup },
|
||||
items: [defaultPortItems[0]],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:17:48
|
||||
* @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 { 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 { 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 { useParams } from 'react-router-dom';
|
||||
|
||||
@@ -20,14 +22,16 @@ import type { ChatVariable, HistoryRecord, NodeProperties, WorkflowConfig } from
|
||||
import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils';
|
||||
import { useWorkflowStore } from '@/store/workflow';
|
||||
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
|
||||
/**
|
||||
* Props for useWorkflowGraph hook
|
||||
*/
|
||||
export interface UseWorkflowGraphProps {
|
||||
/** Reference to the main graph container element */
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
/** Reference to the minimap container element */
|
||||
miniMapRef: React.RefObject<HTMLDivElement>;
|
||||
miniMapRef: RefObject<HTMLDivElement>;
|
||||
/** Callback when features config is loaded */
|
||||
onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void;
|
||||
}
|
||||
@@ -39,23 +43,23 @@ export interface UseWorkflowGraphReturn {
|
||||
/** Current workflow configuration */
|
||||
config: WorkflowConfig | null;
|
||||
/** Function to update workflow configuration */
|
||||
setConfig: React.Dispatch<React.SetStateAction<WorkflowConfig | null>>;
|
||||
setConfig: Dispatch<SetStateAction<WorkflowConfig | null>>;
|
||||
/** Reference to the X6 graph instance */
|
||||
graphRef: React.MutableRefObject<Graph | undefined>;
|
||||
graphRef: MutableRefObject<Graph | undefined>;
|
||||
/** Currently selected node */
|
||||
selectedNode: Node | null;
|
||||
/** Function to update selected node */
|
||||
setSelectedNode: React.Dispatch<React.SetStateAction<Node | null>>;
|
||||
setSelectedNode: Dispatch<SetStateAction<Node | null>>;
|
||||
/** Current zoom level of the graph */
|
||||
zoomLevel: number;
|
||||
/** Function to update zoom level */
|
||||
setZoomLevel: React.Dispatch<React.SetStateAction<number>>;
|
||||
setZoomLevel: Dispatch<SetStateAction<number>>;
|
||||
/** Whether hand/pan mode is enabled */
|
||||
isHandMode: boolean;
|
||||
/** Function to toggle hand mode */
|
||||
setIsHandMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsHandMode: Dispatch<SetStateAction<boolean>>;
|
||||
/** Handler for dropping nodes onto canvas */
|
||||
onDrop: (event: React.DragEvent) => void;
|
||||
onDrop: (event: DragEvent) => void;
|
||||
/** Handler for clicking blank canvas area */
|
||||
blankClick: () => void;
|
||||
/** Handler for delete keyboard event */
|
||||
@@ -77,7 +81,7 @@ export interface UseWorkflowGraphReturn {
|
||||
/** Chat variables for workflow */
|
||||
chatVariables: ChatVariable[];
|
||||
/** Function to update chat variables */
|
||||
setChatVariables: React.Dispatch<React.SetStateAction<ChatVariable[]>>;
|
||||
setChatVariables: Dispatch<SetStateAction<ChatVariable[]>>;
|
||||
|
||||
handleAddNotes: () => void;
|
||||
handleSaveFeaturesConfig: (value: FeaturesConfigForm) => void;
|
||||
@@ -124,9 +128,7 @@ export const useWorkflowGraph = ({
|
||||
const [canRedo, setCanRedo] = useState(false)
|
||||
const [historyRecords, setHistoryRecords] = useState<HistoryRecord[]>([])
|
||||
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)
|
||||
useEffect(() => {
|
||||
if (!graphRef.current) return
|
||||
@@ -168,6 +170,21 @@ export const useWorkflowGraph = ({
|
||||
initWorkflow()
|
||||
}, [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
|
||||
*/
|
||||
@@ -471,29 +488,63 @@ export const useWorkflowGraph = ({
|
||||
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()
|
||||
// Initialize after completion, display nodes in visible area
|
||||
if (nodes.length > 0 || edges.length > 0) {
|
||||
setTimeout(() => {
|
||||
if (graphRef.current) {
|
||||
graphRef.current.getNodes().forEach(node => {
|
||||
if (!node.getData()?.cycle) node.toFront();
|
||||
});
|
||||
// Bring edges to front first, then child nodes above edges; parent nodes stay behind
|
||||
graphRef.current.getEdges().forEach(edge => {
|
||||
const sourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
|
||||
const targetCell = graphRef.current?.getCellById(edge.getTargetCellId());
|
||||
if (sourceCell?.getData()?.cycle || targetCell?.getData()?.cycle) {
|
||||
edge.toFront();
|
||||
}
|
||||
});
|
||||
graphRef.current.getNodes().forEach(node => {
|
||||
if (node.getData()?.cycle) node.toFront();
|
||||
});
|
||||
if (isSafari) {
|
||||
reorderCells(graphRef.current)
|
||||
} else {
|
||||
graphRef.current.getNodes().forEach(node => {
|
||||
if (!node.getData()?.cycle) node.toFront();
|
||||
});
|
||||
// Bring edges to front first, then child nodes above edges; parent nodes stay behind
|
||||
graphRef.current.getEdges().forEach(edge => {
|
||||
const sourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
|
||||
const targetCell = graphRef.current?.getCellById(edge.getTargetCellId());
|
||||
if (sourceCell?.getData()?.cycle || targetCell?.getData()?.cycle) {
|
||||
edge.toFront();
|
||||
}
|
||||
});
|
||||
graphRef.current.getNodes().forEach(node => {
|
||||
if (node.getData()?.cycle) node.toFront();
|
||||
});
|
||||
}
|
||||
graphRef.current.enableHistory()
|
||||
graphRef.current.cleanHistory()
|
||||
}
|
||||
}, 200)
|
||||
}, isSafari ? 0 : 200)
|
||||
} else {
|
||||
graphRef.current.enableHistory()
|
||||
graphRef.current.cleanHistory()
|
||||
@@ -532,24 +583,82 @@ export const useWorkflowGraph = ({
|
||||
const graph = graphRef.current
|
||||
graph.disableHistory()
|
||||
graph.getNodes().forEach(node => {
|
||||
const cycleId = node.getData()?.cycle
|
||||
if (!cycleId) return
|
||||
const parentNode = graph.getCellById(cycleId) as Node | null
|
||||
if (!parentNode) return
|
||||
if (!parentNode.getChildren()?.some(c => c.id === node.id)) {
|
||||
parentNode.addChild(node, { silent: true })
|
||||
}
|
||||
})
|
||||
graph.getNodes().forEach(node => {
|
||||
const nodeData = node.getData()
|
||||
const children = node.getChildren()
|
||||
if (!children?.length) return
|
||||
children.forEach(child => {
|
||||
if (!child.isNode()) return
|
||||
const childCycleId = (child as Node).getData?.()?.cycle
|
||||
if (childCycleId !== node.id && childCycleId !== node.getData?.()?.id) {
|
||||
node.removeChild(child, { silent: true })
|
||||
|
||||
const cycleId = nodeData?.cycle
|
||||
|
||||
if (cycleId) {
|
||||
const parentNode = graph.getCellById(cycleId) as Node | null
|
||||
if (!parentNode) return
|
||||
if (!parentNode.getChildren()?.some(c => c.id === node.id)) {
|
||||
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))
|
||||
})
|
||||
node.portProp(`CASE${newCases.length + 1}`, 'args/y', getConditionNodeCasePortY(newCases, newCases.length))
|
||||
node.toFront()
|
||||
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 => {
|
||||
if (!child.isNode()) return
|
||||
const childCycleId = (child as Node).getData?.()?.cycle
|
||||
if (childCycleId !== node.id && childCycleId !== node.getData?.()?.id) {
|
||||
node.removeChild(child, { silent: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
resizeGroupNodes(graph)
|
||||
graph.getEdges().forEach(edge => {
|
||||
@@ -652,31 +761,41 @@ export const useWorkflowGraph = ({
|
||||
* @param node - Clicked 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()
|
||||
|
||||
setTimeout(() => {
|
||||
// Ignore add-node type node clicks
|
||||
// Ignore add-node type node clicks
|
||||
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)
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = graphRef.current?.getNodes();
|
||||
|
||||
nodes?.forEach(vo => {
|
||||
const data = vo.getData();
|
||||
if (data.isSelected) {
|
||||
vo.setData({
|
||||
...data,
|
||||
isSelected: false,
|
||||
}, { silent: true });
|
||||
}
|
||||
});
|
||||
clearNodeSelect()
|
||||
node.setData({
|
||||
...nodeData,
|
||||
isSelected: true,
|
||||
}, { silent: true });
|
||||
});
|
||||
clearEdgeSelect()
|
||||
if (nodeData.type !== 'notes') {
|
||||
setSelectedNode(node);
|
||||
@@ -705,7 +824,7 @@ export const useWorkflowGraph = ({
|
||||
node.setData({
|
||||
...data,
|
||||
isSelected: false,
|
||||
}, { silent: true });
|
||||
});
|
||||
}
|
||||
});
|
||||
setSelectedNode(null);
|
||||
@@ -745,7 +864,8 @@ export const useWorkflowGraph = ({
|
||||
const cycle = node.getData()?.cycle;
|
||||
if (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
|
||||
const parentBBox = parentNode.getBBox();
|
||||
const childBBox = node.getBBox();
|
||||
@@ -934,6 +1054,12 @@ export const useWorkflowGraph = ({
|
||||
e.preventDefault();
|
||||
const portElement = e.target as HTMLElement;
|
||||
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
|
||||
const tempDiv = document.createElement('div');
|
||||
@@ -965,13 +1091,37 @@ export const useWorkflowGraph = ({
|
||||
/**
|
||||
* Initialize X6 graph with configuration and event listeners
|
||||
*/
|
||||
const init = () => {
|
||||
const init = async () => {
|
||||
if (!containerRef.current || !miniMapRef.current) return;
|
||||
|
||||
// Register React shapes
|
||||
nodeRegisterLibrary.forEach((item) => {
|
||||
register(item);
|
||||
});
|
||||
// Safari: use x6-html-shape to avoid foreignObject rendering issues
|
||||
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;
|
||||
graphRef.current = new Graph({
|
||||
@@ -1161,10 +1311,71 @@ export const useWorkflowGraph = ({
|
||||
// Listen to node move event
|
||||
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)
|
||||
// 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 }) => {
|
||||
if (isNew) {
|
||||
if (isSafari && isNew && graphRef.current) {
|
||||
reorderCells(graphRef.current);
|
||||
} else if (!isSafari && isNew) {
|
||||
const sourceCellId = edge.getSourceCellId()
|
||||
const targetCellId = edge.getTargetCellId()
|
||||
const sourceCell = graphRef.current?.getCellById(sourceCellId);
|
||||
@@ -1278,7 +1489,7 @@ export const useWorkflowGraph = ({
|
||||
* Creates new node at drop position
|
||||
* @param event - React drag event
|
||||
*/
|
||||
const onDrop = (event: React.DragEvent) => {
|
||||
const onDrop = (event: DragEvent) => {
|
||||
if (!graphRef.current) return;
|
||||
event.preventDefault();
|
||||
const dragData = JSON.parse(event.dataTransfer.getData('application/json'));
|
||||
@@ -1456,7 +1667,7 @@ export const useWorkflowGraph = ({
|
||||
...itemConfig,
|
||||
...(data.config[key].defaultValue || {}),
|
||||
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, }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -118,6 +118,8 @@ const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesC
|
||||
parseEvent={parseEvent}
|
||||
config={config}
|
||||
chatVariables={chatVariables}
|
||||
appId={config?.app_id}
|
||||
handleSave={handleSave}
|
||||
/>
|
||||
}
|
||||
<Chat
|
||||
|
||||
@@ -44,6 +44,9 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': 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: './', // 使用相对路径,确保资源能正确加载
|
||||
|
||||
Reference in New Issue
Block a user