Merge branch 'release/v0.2.8' into develop

* release/v0.2.8: (23 commits)
  [add] migration script
  fix(workflow and tool): Output processing modification of tool nodes and error modification for tool tests
  feat(workflow): add configurable workflow feature options
  fix(web): app features
  fix(web): app features
  fix(web): app bugfix
  fix(web): agent add tools bugfix
  fix(web): workflow node ports bugfix
  fix(web): my sharing app add empty
  fix(app): The bugs that were fixed in the previous version but were later rolled back.
  fix(web): app sharing bugfix
  fix(app): 1.The end users are still bound to the app. 2. Multi-modal file support includes xlsx, csv, and json. 3. The file routing protocol is consistent with the page routing.
  fix(web): audio recorder add max size check
  fix(web): max_file_count precision
  feat(workflow): expose workflow memory enable status in app share config API
  revert(web): file download
  fix(perceptual): resolve inconsistency between local filename and actual filename
  fix(multimodel): filter unsupported files during perception memory write
  fix(web): file download
  fix(web): file download
  ...
This commit is contained in:
Mark
2026-03-18 17:43:00 +08:00
57 changed files with 1764 additions and 1432 deletions

View File

@@ -537,6 +537,7 @@ async def draft_run(
# 先获取 app 的 workspace_id # 先获取 app 的 workspace_id
end_user_repo = EndUserRepository(db) end_user_repo = EndUserRepository(db)
new_end_user = end_user_repo.get_or_create_end_user( new_end_user = end_user_repo.get_or_create_end_user(
app_id=app_id,
workspace_id=app.workspace_id, workspace_id=app.workspace_id,
other_id=str(current_user.id), other_id=str(current_user.id),
) )
@@ -869,6 +870,7 @@ async def draft_run_compare(
# 先获取 app 的 workspace_id # 先获取 app 的 workspace_id
end_user_repo = EndUserRepository(db) end_user_repo = EndUserRepository(db)
new_end_user = end_user_repo.get_or_create_end_user( new_end_user = end_user_repo.get_or_create_end_user(
app_id=app_id,
workspace_id=app.workspace_id, workspace_id=app.workspace_id,
other_id=str(current_user.id), other_id=str(current_user.id),
) )

View File

@@ -15,7 +15,7 @@ import os
import uuid import uuid
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -47,6 +47,19 @@ router = APIRouter(
) )
def _match_scheme(request: Request, url: str) -> str:
"""
将 presigned URL 的协议替换为与当前请求一致的协议http/https
解决反向代理场景下 presigned URL 协议与请求协议不匹配的问题。
"""
incoming_scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
if url.startswith("http://") and incoming_scheme == "https":
return "https://" + url[7:]
if url.startswith("https://") and incoming_scheme == "http":
return "http://" + url[8:]
return url
@router.post("/files", response_model=ApiResponse) @router.post("/files", response_model=ApiResponse)
async def upload_file( async def upload_file(
file: UploadFile = File(...), file: UploadFile = File(...),
@@ -280,6 +293,7 @@ async def upload_file_with_share_token(
@router.get("/files/{file_id}", response_model=Any) @router.get("/files/{file_id}", response_model=Any)
async def download_file( async def download_file(
request: Request,
file_id: uuid.UUID, file_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@@ -327,6 +341,7 @@ async def download_file(
else: else:
try: try:
presigned_url = await storage_service.get_file_url(file_key, expires=3600) presigned_url = await storage_service.get_file_url(file_key, expires=3600)
presigned_url = _match_scheme(request, presigned_url)
api_logger.info(f"Redirecting to presigned URL: file_key={file_key}") api_logger.info(f"Redirecting to presigned URL: file_key={file_key}")
return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND) return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND)
except FileNotFoundError: except FileNotFoundError:
@@ -400,6 +415,7 @@ async def delete_file(
@router.get("/files/{file_id}/url", response_model=ApiResponse) @router.get("/files/{file_id}/url", response_model=ApiResponse)
async def get_file_url( async def get_file_url(
request: Request,
file_id: uuid.UUID, file_id: uuid.UUID,
expires: int = None, expires: int = None,
permanent: bool = False, permanent: bool = False,
@@ -463,6 +479,7 @@ async def get_file_url(
else: else:
# For remote storage (OSS/S3), get presigned URL # For remote storage (OSS/S3), get presigned URL
url = await storage_service.get_file_url(file_key, expires=expires) url = await storage_service.get_file_url(file_key, expires=expires)
url = _match_scheme(request, url)
api_logger.info(f"Generated file URL: file_id={file_id}") api_logger.info(f"Generated file URL: file_id={file_id}")
return success( return success(
@@ -484,6 +501,7 @@ async def get_file_url(
@router.get("/public/{file_id}", response_model=Any) @router.get("/public/{file_id}", response_model=Any)
async def public_download_file( async def public_download_file(
request: Request,
file_id: uuid.UUID, file_id: uuid.UUID,
expires: int = 0, expires: int = 0,
signature: str = "", signature: str = "",
@@ -555,6 +573,7 @@ async def public_download_file(
# For remote storage, redirect to presigned URL # For remote storage, redirect to presigned URL
try: try:
presigned_url = await storage_service.get_file_url(file_key, expires=3600) presigned_url = await storage_service.get_file_url(file_key, expires=3600)
presigned_url = _match_scheme(request, presigned_url)
return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND) return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND)
except Exception as e: except Exception as e:
api_logger.error(f"Failed to get presigned URL: {e}") api_logger.error(f"Failed to get presigned URL: {e}")
@@ -566,6 +585,7 @@ async def public_download_file(
@router.get("/permanent/{file_id}", response_model=Any) @router.get("/permanent/{file_id}", response_model=Any)
async def permanent_download_file( async def permanent_download_file(
request: Request,
file_id: uuid.UUID, file_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
storage_service: FileStorageService = Depends(get_file_storage_service), storage_service: FileStorageService = Depends(get_file_storage_service),
@@ -625,6 +645,7 @@ async def permanent_download_file(
try: try:
# Use a very long expiration (7 days max for most cloud providers) # Use a very long expiration (7 days max for most cloud providers)
presigned_url = await storage_service.get_file_url(file_key, expires=604800) presigned_url = await storage_service.get_file_url(file_key, expires=604800)
presigned_url = _match_scheme(request, presigned_url)
return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND) return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND)
except Exception as e: except Exception as e:
api_logger.error(f"Failed to get presigned URL: {e}") api_logger.error(f"Failed to get presigned URL: {e}")

View File

@@ -219,6 +219,7 @@ def list_conversations(
app_service = AppService(db) app_service = AppService(db)
app = app_service._get_app_or_404(share.app_id) app = app_service._get_app_or_404(share.app_id)
new_end_user = end_user_repo.get_or_create_end_user( new_end_user = end_user_repo.get_or_create_end_user(
app_id=share.app_id,
workspace_id=app.workspace_id, workspace_id=app.workspace_id,
other_id=other_id other_id=other_id
) )
@@ -315,6 +316,7 @@ async def chat(
app = app_service._get_app_or_404(share.app_id) app = app_service._get_app_or_404(share.app_id)
workspace_id = app.workspace_id workspace_id = app.workspace_id
new_end_user = end_user_repo.get_or_create_end_user( new_end_user = end_user_repo.get_or_create_end_user(
app_id=share.app_id,
workspace_id=workspace_id, workspace_id=workspace_id,
other_id=other_id, other_id=other_id,
original_user_id=user_id original_user_id=user_id
@@ -661,6 +663,7 @@ async def config_query(
content = { content = {
"app_type": release.app.type, "app_type": release.app.type,
"variables": workflow_service.get_start_node_variables(release.config), "variables": workflow_service.get_start_node_variables(release.config),
"memory": workflow_service.is_memory_enable(release.config),
"features": release.config.get("features") "features": release.config.get("features")
} }
elif release.app.type == AppType.AGENT: elif release.app.type == AppType.AGENT:

View File

@@ -94,6 +94,7 @@ async def chat(
workspace_id = app.workspace_id workspace_id = app.workspace_id
end_user_repo = EndUserRepository(db) end_user_repo = EndUserRepository(db)
new_end_user = end_user_repo.get_or_create_end_user( new_end_user = end_user_repo.get_or_create_end_user(
app_id=app.id,
workspace_id=workspace_id, workspace_id=workspace_id,
other_id=other_id, other_id=other_id,
) )

View File

@@ -3,6 +3,8 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.error_codes import BizCode
from app.schemas.tool_schema import ( from app.schemas.tool_schema import (
ToolCreateRequest, ToolUpdateRequest, ToolExecuteRequest, ParseSchemaRequest, ToolCreateRequest, ToolUpdateRequest, ToolExecuteRequest, ParseSchemaRequest,
CustomToolTestRequest, ToolActiveUpdate CustomToolTestRequest, ToolActiveUpdate
@@ -250,8 +252,10 @@ async def sync_mcp_tools(
try: try:
result = await service.sync_mcp_tools(tool_id, current_user.tenant_id) result = await service.sync_mcp_tools(tool_id, current_user.tenant_id)
if not result.get("success", False): if not result.get("success", False):
raise HTTPException(status_code=400, detail=result.get("message", "同步失败")) raise BusinessException(result.get("message", "工具列表同步失败"), BizCode.BAD_REQUEST)
return success(data=result, msg="MCP工具列表同步完成") return success(data=result, msg="MCP工具列表同步完成")
except BusinessException:
raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@@ -274,8 +278,10 @@ async def test_tool_connection(
# 普通连接测试 # 普通连接测试
result = await service.test_connection(tool_id, current_user.tenant_id) result = await service.test_connection(tool_id, current_user.tenant_id)
if result["success"] is False: if result["success"] is False:
raise HTTPException(status_code=400, detail=result["message"]) raise BusinessException(result["message"], BizCode.SERVICE_UNAVAILABLE)
return success(data=result, msg="连接测试完成") return success(data=result, msg="连接测试完成")
except BusinessException:
raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View File

@@ -195,6 +195,6 @@ class MCPToolManager:
except Exception as e: except Exception as e:
return { return {
"success": False, "success": False,
"error": str(e), "error": "连接失败",
"message": "连接失败" "message": str(e)
} }

View File

@@ -5,7 +5,7 @@
import re import re
from typing import AsyncGenerator from typing import AsyncGenerator
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, PrivateAttr
from app.core.logging_config import get_logger from app.core.logging_config import get_logger
from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.engine.variable_pool import VariablePool
@@ -52,10 +52,11 @@ class OutputContent(BaseModel):
) )
) )
_SCOPE: str | None = None _SCOPE: str | None = PrivateAttr(default=None)
def get_scope(self) -> str: def get_scope(self) -> str | None:
self._SCOPE = SCOPE_PATTERN.findall(self.literal)[0] matches = SCOPE_PATTERN.findall(self.literal)
self._SCOPE = matches[0] if matches else None
return self._SCOPE return self._SCOPE
def depends_on_scope(self, scope: str) -> bool: def depends_on_scope(self, scope: str) -> bool:
@@ -68,6 +69,8 @@ class OutputContent(BaseModel):
Returns: Returns:
bool: True if this segment references the given scope. bool: True if this segment references the given scope.
""" """
if not self.is_variable:
return False
if self._SCOPE: if self._SCOPE:
return self._SCOPE == scope return self._SCOPE == scope
return self.get_scope() == scope return self.get_scope() == scope
@@ -152,7 +155,7 @@ class StreamOutputConfig(BaseModel):
""" """
# Case 1: resolve control branch dependency # Case 1: resolve control branch dependency
if scope in self.control_nodes.keys(): if scope in self.control_nodes:
if status is None: if status is None:
raise RuntimeError("[Stream Output] Control node activation status not provided") raise RuntimeError("[Stream Output] Control node activation status not provided")
if status in self.control_nodes[scope]: if status in self.control_nodes[scope]:

View File

@@ -27,7 +27,6 @@ class ToolNode(BaseNode):
def _output_types(self) -> dict[str, VariableType]: def _output_types(self) -> dict[str, VariableType]:
return { return {
"data": VariableType.STRING, "data": VariableType.STRING,
"error_code": VariableType.STRING,
"execution_time": VariableType.NUMBER "execution_time": VariableType.NUMBER
} }
@@ -48,10 +47,7 @@ class ToolNode(BaseNode):
if not tenant_id: if not tenant_id:
logger.error(f"节点 {self.node_id} 缺少租户ID") logger.error(f"节点 {self.node_id} 缺少租户ID")
return { raise ValueError("缺少租户ID")
"success": False,
"data": "缺少租户ID"
}
# 渲染工具参数 # 渲染工具参数
rendered_parameters = {} rendered_parameters = {}
@@ -83,13 +79,8 @@ class ToolNode(BaseNode):
logger.info(f"节点 {self.node_id} 工具执行成功") logger.info(f"节点 {self.node_id} 工具执行成功")
return { return {
"data": result.data if isinstance(result.data, str) else json.dumps(result.data, ensure_ascii=False), "data": result.data if isinstance(result.data, str) else json.dumps(result.data, ensure_ascii=False),
"error_code": "",
"execution_time": result.execution_time "execution_time": result.execution_time
} }
else: else:
logger.error(f"节点 {self.node_id} 工具执行失败: {result.error}") logger.error(f"节点 {self.node_id} 工具执行失败: {result.error}")
return { raise ValueError(f"工具执行失败: {result.error if isinstance(result.error, str) else json.dumps(result.error, ensure_ascii=False)}")
"data": result.error if isinstance(result.error, str) else json.dumps(result.error, ensure_ascii=False),
"error_code": result.error_code,
"execution_time": result.execution_time
}

View File

@@ -35,6 +35,7 @@ class WorkflowConfig(Base):
# 执行配置 # 执行配置
execution_config = Column(JSONB, nullable=False, default=dict) execution_config = Column(JSONB, nullable=False, default=dict)
features = Column(JSONB, nullable=True, default=dict)
# 触发器配置(可选) # 触发器配置(可选)
triggers = Column(JSONB, default=list) triggers = Column(JSONB, default=list)

View File

@@ -67,6 +67,7 @@ class EndUserRepository:
def get_or_create_end_user( def get_or_create_end_user(
self, self,
app_id: uuid.UUID,
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
other_id: str, other_id: str,
original_user_id: Optional[str] = None original_user_id: Optional[str] = None
@@ -74,6 +75,7 @@ class EndUserRepository:
"""获取或创建终端用户 """获取或创建终端用户
Args: Args:
app_id: 应用ID
workspace_id: 工作空间ID workspace_id: 工作空间ID
other_id: 第三方ID other_id: 第三方ID
original_user_id: 原始用户ID (存储到 other_id) original_user_id: 原始用户ID (存储到 other_id)
@@ -92,10 +94,14 @@ class EndUserRepository:
if end_user: if end_user:
db_logger.debug(f"找到现有终端用户: 应用ID {workspace_id}、第三方ID {other_id}") db_logger.debug(f"找到现有终端用户: 应用ID {workspace_id}、第三方ID {other_id}")
end_user.app_id=app_id
self.db.commit()
self.db.refresh(end_user)
return end_user return end_user
# 创建新用户 # 创建新用户
end_user = EndUser( end_user = EndUser(
app_id=app_id,
workspace_id=workspace_id, workspace_id=workspace_id,
other_id=other_id other_id=other_id
) )

View File

@@ -80,6 +80,7 @@ class WorkflowConfigCreate(BaseModel):
variables: list[VariableDefinition] = Field(default_factory=list, description="变量列表") variables: list[VariableDefinition] = Field(default_factory=list, description="变量列表")
execution_config: ExecutionConfig = Field(default_factory=ExecutionConfig, description="执行配置") execution_config: ExecutionConfig = Field(default_factory=ExecutionConfig, description="执行配置")
triggers: list[TriggerConfig] = Field(default_factory=list, description="触发器列表") triggers: list[TriggerConfig] = Field(default_factory=list, description="触发器列表")
features: dict = Field(default_factory=dict, description="功能特性配置")
class WorkflowConfigUpdate(BaseModel): class WorkflowConfigUpdate(BaseModel):
@@ -87,6 +88,7 @@ class WorkflowConfigUpdate(BaseModel):
nodes: list[NodeDefinition] | None = None nodes: list[NodeDefinition] | None = None
edges: list[EdgeDefinition] | None = None edges: list[EdgeDefinition] | None = None
variables: list[VariableDefinition] | None = None variables: list[VariableDefinition] | None = None
features: dict | None = None
execution_config: ExecutionConfig | None = None execution_config: ExecutionConfig | None = None
triggers: list[TriggerConfig] | None = None triggers: list[TriggerConfig] | None = None
@@ -102,6 +104,7 @@ class WorkflowConfig(BaseModel):
variables: list[dict[str, Any]] variables: list[dict[str, Any]]
execution_config: dict[str, Any] execution_config: dict[str, Any]
triggers: list[dict[str, Any]] triggers: list[dict[str, Any]]
features: dict | None
is_active: bool is_active: bool
created_at: datetime.datetime created_at: datetime.datetime
updated_at: datetime.datetime updated_at: datetime.datetime
@@ -114,6 +117,10 @@ class WorkflowConfig(BaseModel):
def _serialize_updated_at(self, dt: datetime.datetime): def _serialize_updated_at(self, dt: datetime.datetime):
return int(dt.timestamp() * 1000) if dt else None return int(dt.timestamp() * 1000) if dt else None
@field_serializer("features", when_used="json")
def _serialize_features(self, features: dict | None):
return features or {}
# ==================== 工作流执行 ==================== # ==================== 工作流执行 ====================

View File

@@ -1609,6 +1609,7 @@ class AppService:
variables=[var.model_dump() for var in data.variables] if data.variables else [], variables=[var.model_dump() for var in data.variables] if data.variables else [],
execution_config=data.execution_config.model_dump() if data.execution_config else {}, execution_config=data.execution_config.model_dump() if data.execution_config else {},
triggers=[trigger.model_dump() for trigger in data.triggers] if data.triggers else [], triggers=[trigger.model_dump() for trigger in data.triggers] if data.triggers else [],
features=data.features or {},
is_active=True, is_active=True,
created_at=now, created_at=now,
updated_at=now updated_at=now
@@ -1622,6 +1623,7 @@ class AppService:
workflow_cfg.variables = [var.model_dump() for var in data.variables] if data.variables else [] workflow_cfg.variables = [var.model_dump() for var in data.variables] if data.variables else []
workflow_cfg.execution_config = data.execution_config.model_dump() if data.execution_config else {} workflow_cfg.execution_config = data.execution_config.model_dump() if data.execution_config else {}
workflow_cfg.triggers = [trigger.model_dump() for trigger in data.triggers] if data.triggers else [] workflow_cfg.triggers = [trigger.model_dump() for trigger in data.triggers] if data.triggers else []
workflow_cfg.features = data.features or {}
workflow_cfg.updated_at = now workflow_cfg.updated_at = now
self.db.commit() self.db.commit()
@@ -1875,7 +1877,8 @@ class AppService:
"edges": workflow_cfg.edges, "edges": workflow_cfg.edges,
"variables": workflow_cfg.variables, "variables": workflow_cfg.variables,
"execution_config": workflow_cfg.execution_config, "execution_config": workflow_cfg.execution_config,
"triggers": workflow_cfg.triggers "triggers": workflow_cfg.triggers,
"features": workflow_cfg.features or {}
} }
is_valid, errors = WorkflowValidator.validate_for_publish(config) is_valid, errors = WorkflowValidator.validate_for_publish(config)
@@ -2062,7 +2065,8 @@ class AppService:
) )
if memory_config_id: if memory_config_id:
updated_count = self._update_endusers_memory_config(app_id, memory_config_id)
updated_count = self._update_endusers_memory_config_by_workspace(app.workspace_id, memory_config_id)
logger.info( logger.info(
f"回滚时更新终端用户记忆配置: app_id={app_id}, version={version}, " f"回滚时更新终端用户记忆配置: app_id={app_id}, version={version}, "
f"memory_config_id={memory_config_id}, updated_count={updated_count}" f"memory_config_id={memory_config_id}, updated_count={updated_count}"

View File

@@ -374,7 +374,7 @@ class AgentRunService:
files: Optional[List[FileInput]] files: Optional[List[FileInput]]
) -> None: ) -> None:
"""校验上传文件是否符合 file_upload 配置""" """校验上传文件是否符合 file_upload 配置"""
if not files: if not files or not features_config:
return return
fu = features_config.get("file_upload", {}) fu = features_config.get("file_upload", {})
if not (isinstance(fu, dict) and fu.get("enabled")): if not (isinstance(fu, dict) and fu.get("enabled")):

View File

@@ -5,12 +5,14 @@ from urllib.parse import urlparse, unquote
import json_repair import json_repair
from jinja2 import Template from jinja2 import Template
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.error_codes import BizCode from app.core.error_codes import BizCode
from app.core.exceptions import BusinessException from app.core.exceptions import BusinessException
from app.core.logging_config import get_business_logger from app.core.logging_config import get_business_logger
from app.core.models import RedBearLLM, RedBearModelConfig from app.core.models import RedBearLLM, RedBearModelConfig
from app.models import FileMetadata
from app.models.memory_perceptual_model import PerceptualType, FileStorageService from app.models.memory_perceptual_model import PerceptualType, FileStorageService
from app.models.prompt_optimizer_model import RoleType from app.models.prompt_optimizer_model import RoleType
from app.repositories.memory_perceptual_repository import MemoryPerceptualRepository from app.repositories.memory_perceptual_repository import MemoryPerceptualRepository
@@ -245,6 +247,18 @@ class MemoryPerceptualService:
filename = os.path.basename(path) filename = os.path.basename(path)
filename = unquote(filename) filename = unquote(filename)
file_ext = os.path.splitext(filename)[1] file_ext = os.path.splitext(filename)[1]
try:
file_id = uuid.UUID(filename)
stmt = select(FileMetadata).where(
FileMetadata.id == file_id
)
file = self.db.execute(stmt).scalar_one_or_none()
if file:
filename = file.file_name
file_ext = file.file_ext
except ValueError:
business_logger.debug(f"Remote file, file_id={filename}")
if not file_ext: if not file_ext:
if file_type == FileType.AUDIO: if file_type == FileType.AUDIO:
file_ext = ".mp3" file_ext = ".mp3"
@@ -262,17 +276,17 @@ class MemoryPerceptualService:
} }
if file_type in [FileType.IMAGE, FileType.VIDEO]: if file_type in [FileType.IMAGE, FileType.VIDEO]:
file_modalities = { file_modalities = {
"scene": content.get("scene") "scene": content.get("scene", [])
} }
elif file_type in [FileType.DOCUMENT]: elif file_type in [FileType.DOCUMENT]:
file_modalities = { file_modalities = {
"section_count": content.get("section_count"), "section_count": content.get("section_count", 0),
"title": content.get("title"), "title": content.get("title", ""),
"first_line": content.get("first_line") "first_line": content.get("first_line", "")
} }
else: else:
file_modalities = { file_modalities = {
"speaker_count": content.get("speaker_count") "speaker_count": content.get("speaker_count", 0)
} }
self.repository.create_perceptual_memory( self.repository.create_perceptual_memory(
end_user_id=uuid.UUID(end_user_id), end_user_id=uuid.UUID(end_user_id),
@@ -280,7 +294,7 @@ class MemoryPerceptualService:
file_path=file_url, file_path=file_url,
file_name=filename, file_name=filename,
file_ext=file_ext, file_ext=file_ext,
summary=content.get('summary'), summary=content.get('summary', ""),
meta_data={ meta_data={
"content": file_content, "content": file_content,
"modalities": file_modalities "modalities": file_modalities

View File

@@ -14,9 +14,13 @@ import uuid
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
import csv
import json
import PyPDF2 import PyPDF2
import httpx import httpx
import magic import magic
import openpyxl
from docx import Document from docx import Document
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -39,6 +43,13 @@ DOC_MIME = [
'application/msword', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
] ]
XLSX_MIME = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'application/zip'
]
CSV_MIME = ['text/csv', 'application/csv']
JSON_MIME = ['application/json']
class MultimodalFormatStrategy(ABC): class MultimodalFormatStrategy(ABC):
@@ -48,22 +59,22 @@ class MultimodalFormatStrategy(ABC):
self.file = file self.file = file
@abstractmethod @abstractmethod
async def format_image(self, url: str, content: bytes | None = None) -> Dict[str, Any]: async def format_image(self, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
"""格式化图片""" """格式化图片"""
pass pass
@abstractmethod @abstractmethod
async def format_document(self, file_name: str, text: str) -> Dict[str, Any]: async def format_document(self, file_name: str, text: str) -> tuple[bool, Dict[str, Any]]:
"""格式化文档""" """格式化文档"""
pass pass
@abstractmethod @abstractmethod
async def format_audio(self, file_type: str, url: str, content: bytes | None = None) -> Dict[str, Any]: async def format_audio(self, file_type: str, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
"""格式化音频""" """格式化音频"""
pass pass
@abstractmethod @abstractmethod
async def format_video(self, url: str) -> Dict[str, Any]: async def format_video(self, url: str) -> tuple[bool, Dict[str, Any]]:
"""格式化视频""" """格式化视频"""
pass pass
@@ -71,16 +82,16 @@ class MultimodalFormatStrategy(ABC):
class DashScopeFormatStrategy(MultimodalFormatStrategy): class DashScopeFormatStrategy(MultimodalFormatStrategy):
"""通义千问策略""" """通义千问策略"""
async def format_image(self, url: str, content: bytes | None = None) -> Dict[str, Any]: async def format_image(self, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
"""通义千问图片格式:{"type": "image", "image": "url"}""" """通义千问图片格式:{"type": "image", "image": "url"}"""
return { return True, {
"type": "image", "type": "image",
"image": url "image": url
} }
async def format_document(self, file_name: str, text: str) -> Dict[str, Any]: async def format_document(self, file_name: str, text: str) -> tuple[bool, Dict[str, Any]]:
"""通义千问文档格式""" """通义千问文档格式"""
return { return True, {
"type": "text", "type": "text",
"text": f"<document name=\"{file_name}\">\n{text}\n</document>" "text": f"<document name=\"{file_name}\">\n{text}\n</document>"
} }
@@ -91,26 +102,26 @@ class DashScopeFormatStrategy(MultimodalFormatStrategy):
url: str, url: str,
content: bytes | None = None, content: bytes | None = None,
transcription: Optional[str] = None transcription: Optional[str] = None
) -> Dict[str, Any]: ) -> tuple[bool, Dict[str, Any]]:
""" """
通义千问音频格式 通义千问音频格式
- 原生支持: qwen-audio 系列 - 原生支持: qwen-audio 系列
- 其他模型: 需要转录为文本 - 其他模型: 需要转录为文本
""" """
if transcription: if transcription:
return { return True, {
"type": "text", "type": "text",
"text": f"<audio url=\"{url}\">\ntext_transcription:{transcription}\n</audio>" "text": f"<audio url=\"{url}\">\ntext_transcription:{transcription}\n</audio>"
} }
# 通义千问音频格式:{"type": "audio", "audio": "url"} # 通义千问音频格式:{"type": "audio", "audio": "url"}
return { return True, {
"type": "audio", "type": "audio",
"audio": url "audio": url
} }
async def format_video(self, url: str) -> Dict[str, Any]: async def format_video(self, url: str) -> tuple[bool, Dict[str, Any]]:
"""通义千问视频格式qwen-vl 系列原生支持)""" """通义千问视频格式qwen-vl 系列原生支持)"""
return { return True, {
"type": "video", "type": "video",
"video": url "video": url
} }
@@ -119,7 +130,7 @@ class DashScopeFormatStrategy(MultimodalFormatStrategy):
class BedrockFormatStrategy(MultimodalFormatStrategy): class BedrockFormatStrategy(MultimodalFormatStrategy):
"""Bedrock/Anthropic 策略""" """Bedrock/Anthropic 策略"""
async def format_image(self, url: str, content: bytes | None = None) -> Dict[str, Any]: async def format_image(self, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
""" """
Bedrock/Anthropic 格式: base64 编码 Bedrock/Anthropic 格式: base64 编码
{"type": "image", "source": {"type": "base64", "media_type": "...", "data": "..."}} {"type": "image", "source": {"type": "base64", "media_type": "...", "data": "..."}}
@@ -142,7 +153,7 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
logger.info(f"图片编码完成: media_type={media_type}, size={len(base64_data)}") logger.info(f"图片编码完成: media_type={media_type}, size={len(base64_data)}")
return { return True, {
"type": "image", "type": "image",
"source": { "source": {
"type": "base64", "type": "base64",
@@ -151,13 +162,13 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
} }
} }
async def format_document(self, file_name: str, text: str) -> Dict[str, Any]: async def format_document(self, file_name: str, text: str) -> tuple[bool, Dict[str, Any]]:
"""Bedrock/Anthropic 文档格式(需要 base64 编码)""" """Bedrock/Anthropic 文档格式(需要 base64 编码)"""
# Bedrock 文档需要 base64 编码 # Bedrock 文档需要 base64 编码
text_bytes = text.encode('utf-8') text_bytes = text.encode('utf-8')
base64_text = base64.b64encode(text_bytes).decode('utf-8') base64_text = base64.b64encode(text_bytes).decode('utf-8')
return { return True, {
"type": "document", "type": "document",
"source": { "source": {
"type": "base64", "type": "base64",
@@ -171,24 +182,24 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
url: str, url: str,
content: bytes | None = None, content: bytes | None = None,
transcription: Optional[str] = None transcription: Optional[str] = None
) -> Dict[str, Any]: ) -> tuple[bool, Dict[str, Any]]:
""" """
Bedrock/Anthropic 音频格式 Bedrock/Anthropic 音频格式
不支持原生音频,必须转录为文本 不支持原生音频,必须转录为文本
""" """
if transcription: if transcription:
return { return True, {
"type": "text", "type": "text",
"text": f"[音频转录]\n{transcription}" "text": f"[音频转录]\n{transcription}"
} }
return { return False, {
"type": "text", "type": "text",
"text": "[音频文件Bedrock 不支持原生音频,请启用音频转文本功能]" "text": "[音频文件Bedrock 不支持原生音频,请启用音频转文本功能]"
} }
async def format_video(self, url: str) -> Dict[str, Any]: async def format_video(self, url: str) -> tuple[bool, Dict[str, Any]]:
"""Bedrock/Anthropic 视频格式""" """Bedrock/Anthropic 视频格式"""
return { return False, {
"type": "text", "type": "text",
"text": f"<video url=\"{url}\">\n[视频文件,当前 provider 暂不支持]\n</video>" "text": f"<video url=\"{url}\">\n[视频文件,当前 provider 暂不支持]\n</video>"
} }
@@ -197,18 +208,18 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
class OpenAIFormatStrategy(MultimodalFormatStrategy): class OpenAIFormatStrategy(MultimodalFormatStrategy):
"""OpenAI 策略""" """OpenAI 策略"""
async def format_image(self, url: str, content: bytes | None = None) -> Dict[str, Any]: async def format_image(self, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
"""OpenAI 格式: {"type": "image_url", "image_url": {"url": "..."}}""" """OpenAI 格式: {"type": "image_url", "image_url": {"url": "..."}}"""
return { return True, {
"type": "image_url", "type": "image_url",
"image_url": { "image_url": {
"url": url "url": url
} }
} }
async def format_document(self, file_name: str, text: str) -> Dict[str, Any]: async def format_document(self, file_name: str, text: str) -> tuple[bool, Dict[str, Any]]:
"""OpenAI 文档格式""" """OpenAI 文档格式"""
return { return True, {
"type": "text", "type": "text",
"text": f"<document name=\"{file_name}\">\n{text}\n</document>" "text": f"<document name=\"{file_name}\">\n{text}\n</document>"
} }
@@ -219,14 +230,14 @@ class OpenAIFormatStrategy(MultimodalFormatStrategy):
url: str, url: str,
content: bytes | None = None, content: bytes | None = None,
transcription: Optional[str] = None transcription: Optional[str] = None
) -> Dict[str, Any]: ) -> tuple[bool, Dict[str, Any]]:
""" """
OpenAI 音频格式 OpenAI 音频格式
- gpt-4o-audio 系列支持原生音频(需要 base64 编码) - gpt-4o-audio 系列支持原生音频(需要 base64 编码)
- 其他模型使用转录文本 - 其他模型使用转录文本
""" """
if transcription: if transcription:
return { return True, {
"type": "text", "type": "text",
"text": f"<audio url=\"{url}\">\n{transcription}\n</audio>" "text": f"<audio url=\"{url}\">\n{transcription}\n</audio>"
} }
@@ -255,7 +266,7 @@ class OpenAIFormatStrategy(MultimodalFormatStrategy):
# supported_ext = {"wav", "mp3", "mp4", "ogg", "flac", "webm", "m4a", "wave", "x-m4a"} # supported_ext = {"wav", "mp3", "mp4", "ogg", "flac", "webm", "m4a", "wave", "x-m4a"}
file_ext = "wav" if not file_ext else file_ext file_ext = "wav" if not file_ext else file_ext
return { return True, {
"type": "input_audio", "type": "input_audio",
"input_audio": { "input_audio": {
"data": f"data:;base64,{base64_audio}", "data": f"data:;base64,{base64_audio}",
@@ -264,14 +275,14 @@ class OpenAIFormatStrategy(MultimodalFormatStrategy):
} }
except Exception as e: except Exception as e:
logger.error(f"下载音频失败: {e}") logger.error(f"下载音频失败: {e}")
return { return False, {
"type": "text", "type": "text",
"text": f"[音频处理失败: {str(e)}]" "text": f"[音频处理失败: {str(e)}]"
} }
async def format_video(self, url: str) -> Dict[str, Any]: async def format_video(self, url: str) -> tuple[bool, Dict[str, Any]]:
"""OpenAI 视频格式""" """OpenAI 视频格式"""
return { return True, {
"type": "video_url", "type": "video_url",
"video_url": { "video_url": {
"url": url "url": url
@@ -366,21 +377,25 @@ class MultimodalService:
file.url = await self.get_file_url(file) file.url = await self.get_file_url(file)
try: try:
if file.type == FileType.IMAGE and "vision" in self.capability: if file.type == FileType.IMAGE and "vision" in self.capability:
content = await self._process_image(file, strategy) is_support, content = await self._process_image(file, strategy)
result.append(content) result.append(content)
self.write_perceptual_memory(end_user_id, file.type, file.url, content) if is_support:
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
elif file.type == FileType.DOCUMENT: elif file.type == FileType.DOCUMENT:
content = await self._process_document(file, strategy) is_support, content = await self._process_document(file, strategy)
result.append(content) result.append(content)
self.write_perceptual_memory(end_user_id, file.type, file.url, content) if is_support:
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
elif file.type == FileType.AUDIO and "audio" in self.capability: elif file.type == FileType.AUDIO and "audio" in self.capability:
content = await self._process_audio(file, strategy) is_support, content = await self._process_audio(file, strategy)
result.append(content) result.append(content)
self.write_perceptual_memory(end_user_id, file.type, file.url, content) if is_support:
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
elif file.type == FileType.VIDEO and "video" in self.capability: elif file.type == FileType.VIDEO and "video" in self.capability:
content = await self._process_video(file, strategy) is_support, content = await self._process_video(file, strategy)
result.append(content) result.append(content)
self.write_perceptual_memory(end_user_id, file.type, file.url, content) if is_support:
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
else: else:
logger.warning(f"不支持的文件类型: {file.type}") logger.warning(f"不支持的文件类型: {file.type}")
except Exception as e: except Exception as e:
@@ -413,7 +428,7 @@ class MultimodalService:
if end_user_id and self.api_config: if end_user_id and self.api_config:
write_perceptual_memory.delay(end_user_id, self.api_config.model_dump(), file_type, file_url, file_message) write_perceptual_memory.delay(end_user_id, self.api_config.model_dump(), file_type, file_url, file_message)
async def _process_image(self, file: FileInput, strategy) -> Dict[str, Any]: async def _process_image(self, file: FileInput, strategy) -> tuple[bool, Dict[str, Any]]:
""" """
处理图片文件 处理图片文件
@@ -425,16 +440,16 @@ class MultimodalService:
Dict: 根据 provider 返回不同格式的图片内容 Dict: 根据 provider 返回不同格式的图片内容
""" """
try: try:
url = await self.get_file_url(file) # url = await self.get_file_url(file)
return await strategy.format_image(url, content=file.get_content()) return await strategy.format_image(file.url, content=file.get_content())
except Exception as e: except Exception as e:
logger.error(f"处理图片失败: {e}", exc_info=True) logger.error(f"处理图片失败: {e}", exc_info=True)
return { return False, {
"type": "text", "type": "text",
"text": f"[图片处理失败: {str(e)}]" "text": f"[图片处理失败: {str(e)}]"
} }
async def _process_document(self, file: FileInput, strategy) -> Dict[str, Any]: async def _process_document(self, file: FileInput, strategy) -> tuple[bool, Dict[str, Any]]:
""" """
处理文档文件PDF、Word 等) 处理文档文件PDF、Word 等)
@@ -446,7 +461,7 @@ class MultimodalService:
Dict: 根据 provider 返回不同格式的文档内容 Dict: 根据 provider 返回不同格式的文档内容
""" """
if file.transfer_method == TransferMethod.REMOTE_URL: if file.transfer_method == TransferMethod.REMOTE_URL:
return { return True, {
"type": "text", "type": "text",
"text": f"<document url=\"{file.url}\">\n{await self._extract_document_text(file)}\n</document>" "text": f"<document url=\"{file.url}\">\n{await self._extract_document_text(file)}\n</document>"
} }
@@ -464,7 +479,7 @@ class MultimodalService:
# 使用策略格式化文档 # 使用策略格式化文档
return await strategy.format_document(file_name, text) return await strategy.format_document(file_name, text)
async def _process_audio(self, file: FileInput, strategy) -> Dict[str, Any]: async def _process_audio(self, file: FileInput, strategy) -> tuple[bool, Dict[str, Any]]:
""" """
处理音频文件 处理音频文件
@@ -476,28 +491,28 @@ class MultimodalService:
Dict: 根据 provider 返回不同格式的音频内容 Dict: 根据 provider 返回不同格式的音频内容
""" """
try: try:
url = await self.get_file_url(file) # url = await self.get_file_url(file)
# 如果启用音频转文本且有 API Key # 如果启用音频转文本且有 API Key
transcription = None transcription = None
if self.enable_audio_transcription and self.audio_api_key: if self.enable_audio_transcription and self.audio_api_key:
logger.info(f"开始音频转文本: {url}") logger.info(f"开始音频转文本: {file.url}")
if self.provider == "dashscope": if self.provider == "dashscope":
transcription = await AudioTranscriptionService.transcribe_dashscope(url, self.audio_api_key) transcription = await AudioTranscriptionService.transcribe_dashscope(file.url, self.audio_api_key)
elif self.provider == "openai": elif self.provider == "openai":
transcription = await AudioTranscriptionService.transcribe_openai(url, self.audio_api_key) transcription = await AudioTranscriptionService.transcribe_openai(file.url, self.audio_api_key)
else: else:
logger.warning(f"Provider {self.provider} 不支持音频转文本") logger.warning(f"Provider {self.provider} 不支持音频转文本")
return await strategy.format_audio(file.file_type, url, file.get_content(), transcription) return await strategy.format_audio(file.file_type, file.url, file.get_content(), transcription)
except Exception as e: except Exception as e:
logger.error(f"处理音频失败: {e}", exc_info=True) logger.error(f"处理音频失败: {e}", exc_info=True)
return { return False, {
"type": "text", "type": "text",
"text": f"[音频处理失败: {str(e)}]" "text": f"[音频处理失败: {str(e)}]"
} }
async def _process_video(self, file: FileInput, strategy) -> Dict[str, Any]: async def _process_video(self, file: FileInput, strategy) -> tuple[bool, Dict[str, Any]]:
""" """
处理视频文件 处理视频文件
@@ -509,11 +524,11 @@ class MultimodalService:
Dict: 根据 provider 返回不同格式的视频内容 Dict: 根据 provider 返回不同格式的视频内容
""" """
try: try:
url = await self.get_file_url(file) # url = await self.get_file_url(file)
return await strategy.format_video(url) return await strategy.format_video(file.url)
except Exception as e: except Exception as e:
logger.error(f"处理视频失败: {e}", exc_info=True) logger.error(f"处理视频失败: {e}", exc_info=True)
return { return False, {
"type": "text", "type": "text",
"text": f"[视频处理失败: {str(e)}]" "text": f"[视频处理失败: {str(e)}]"
} }
@@ -577,6 +592,12 @@ class MultimodalService:
return await self._extract_pdf_text(file_content) return await self._extract_pdf_text(file_content)
elif file_mime_type in DOC_MIME: elif file_mime_type in DOC_MIME:
return await self._extract_word_text(file_content) return await self._extract_word_text(file_content)
elif file_mime_type in XLSX_MIME and file.file_type.endswith(("xlsx", "xls")):
return await self._extract_xlsx_text(file_content)
elif file_mime_type in CSV_MIME:
return await self._extract_csv_text(file_content)
elif file_mime_type in JSON_MIME:
return await self._extract_json_text(file_content)
else: else:
return f"[Unsupported file type: {file_mime_type}]" return f"[Unsupported file type: {file_mime_type}]"
except Exception as e: except Exception as e:
@@ -602,7 +623,6 @@ class MultimodalService:
async def _extract_word_text(file_content: bytes) -> str: async def _extract_word_text(file_content: bytes) -> str:
"""提取 Word 文档文本""" """提取 Word 文档文本"""
try: try:
# 使用 BytesIO 读取 Word 文档
word_file = io.BytesIO(file_content) word_file = io.BytesIO(file_content)
doc = Document(word_file) doc = Document(word_file)
text_parts = [paragraph.text for paragraph in doc.paragraphs] text_parts = [paragraph.text for paragraph in doc.paragraphs]
@@ -611,6 +631,42 @@ class MultimodalService:
logger.error(f"提取 Word 文本失败: {e}") logger.error(f"提取 Word 文本失败: {e}")
return f"[Word 提取失败: {str(e)}]" return f"[Word 提取失败: {str(e)}]"
@staticmethod
async def _extract_xlsx_text(file_content: bytes) -> str:
"""提取 Excel 文本"""
try:
wb = openpyxl.load_workbook(io.BytesIO(file_content), read_only=True, data_only=True)
parts = []
for sheet in wb.worksheets:
parts.append(f"[Sheet: {sheet.title}]")
for row in sheet.iter_rows(values_only=True):
parts.append('\t'.join('' if v is None else str(v) for v in row))
return '\n'.join(parts)
except Exception as e:
logger.error(f"提取 Excel 文本失败: {e}")
return f"[Excel 提取失败: {str(e)}]"
@staticmethod
async def _extract_csv_text(file_content: bytes) -> str:
"""提取 CSV 文本"""
try:
text = file_content.decode('utf-8-sig')
reader = csv.reader(io.StringIO(text))
return '\n'.join('\t'.join(row) for row in reader)
except Exception as e:
logger.error(f"提取 CSV 文本失败: {e}")
return f"[CSV 提取失败: {str(e)}]"
@staticmethod
async def _extract_json_text(file_content: bytes) -> str:
"""提取 JSON 文本"""
try:
data = json.loads(file_content.decode('utf-8'))
return json.dumps(data, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"提取 JSON 文本失败: {e}")
return f"[JSON 提取失败: {str(e)}]"
def get_multimodal_service(db: Session) -> MultimodalService: def get_multimodal_service(db: Session) -> MultimodalService:
"""获取多模态服务实例(依赖注入)""" """获取多模态服务实例(依赖注入)"""

View File

@@ -570,6 +570,9 @@ class WorkflowService:
message=f"工作流配置不存在: app_id={app_id}" message=f"工作流配置不存在: app_id={app_id}"
) )
feature_configs = config.features or {}
self._validate_file_upload(feature_configs, payload.files)
input_data = { input_data = {
"message": payload.message, "variables": payload.variables, "message": payload.message, "variables": payload.variables,
"conversation_id": payload.conversation_id, "conversation_id": payload.conversation_id,
@@ -737,6 +740,8 @@ class WorkflowService:
code=BizCode.CONFIG_MISSING, code=BizCode.CONFIG_MISSING,
message=f"工作流配置不存在: app_id={app_id}" message=f"工作流配置不存在: app_id={app_id}"
) )
feature_configs = config.features or {}
self._validate_file_upload(feature_configs, payload.files)
input_data = { input_data = {
"message": payload.message, "variables": payload.variables, "message": payload.message, "variables": payload.variables,
@@ -845,7 +850,10 @@ class WorkflowService:
yield event yield event
except Exception as e: except Exception as e:
logger.error(f"工作流流式执行失败: execution_id={execution.execution_id}, error={e}", exc_info=True) logger.error(
f"Workflow streaming execution failed: execution_id={execution.execution_id}, error={e}",
exc_info=True
)
self.update_execution_status( self.update_execution_status(
execution.execution_id, execution.execution_id,
"failed", "failed",
@@ -868,6 +876,80 @@ class WorkflowService:
return node.get("config", {}).get("variables", []) return node.get("config", {}).get("variables", [])
raise BusinessException("workflow config error - start node not found") raise BusinessException("workflow config error - start node not found")
@staticmethod
def is_memory_enable(config: dict) -> bool:
nodes = config.get("nodes", [])
for node in nodes:
if node.get("type") in [NodeType.MEMORY_READ, NodeType.MEMORY_WRITE]:
return True
return False
@staticmethod
def _validate_file_upload(
features_config: dict[str, Any],
files: Optional[list[FileInput]]
) -> None:
"""校验上传文件是否符合 file_upload 配置"""
if not files:
return
fu = features_config.get("file_upload")
if fu is None:
return
if not (isinstance(fu, dict) and fu.get("enabled")):
raise BusinessException(
"The application does not have file upload functionality enabled",
BizCode.BAD_REQUEST
)
max_count = fu.get("max_file_count", 5)
if len(files) > max_count:
raise BusinessException(
f"File count exceeds limit (maximum {max_count} files)",
BizCode.BAD_REQUEST
)
# 校验传输方式
allowed_methods = fu.get("allowed_transfer_methods", ["local_file", "remote_url"])
for f in files:
if f.transfer_method.value not in allowed_methods:
raise BusinessException(
f"Unsupport file transfer method{f.transfer_method.value},"
f"allowed method:{', '.join(allowed_methods)}",
BizCode.BAD_REQUEST
)
# 各类型对应的开关和大小限制配置键
type_cfg = {
"image": ("image_enabled", "image_max_size_mb", 20, "image"),
"audio": ("audio_enabled", "audio_max_size_mb", 50, "audio"),
"document": ("document_enabled", "document_max_size_mb", 100, "document"),
"video": ("video_enabled", "video_max_size_mb", 500, "video"),
}
for f in files:
ftype = str(f.type) # 如 "image", "audio", "document", "video"
cfg = type_cfg.get(ftype)
if cfg is None:
continue
enabled_key, size_key, default_max_mb, label = cfg
# 校验类型开关
if not fu.get(enabled_key):
raise BusinessException(
f"The application has not enabled {label} file upload",
BizCode.BAD_REQUEST
)
# 校验文件大小(仅当内容已加载时)
content = f.get_content()
if content is not None:
max_mb = fu.get(size_key, default_max_mb)
size_mb = len(content) / (1024 * 1024)
if size_mb > max_mb:
raise BusinessException(
f"{label} File size exceeds the limit (maximum {max_mb} MB, current {size_mb:.1f} MB)",
BizCode.BAD_REQUEST
)
# ==================== 依赖注入函数 ==================== # ==================== 依赖注入函数 ====================

View File

@@ -100,7 +100,8 @@ def agent_config_4_app_release(release: AppRelease) -> AgentConfig:
memory=config_dict.get("memory"), memory=config_dict.get("memory"),
variables=config_dict.get("variables", []), variables=config_dict.get("variables", []),
tools=config_dict.get("tools", []), tools=config_dict.get("tools", []),
skills=config_dict.get("skills", {}) skills=config_dict.get("skills", {}),
features=config_dict.get("features", {})
) )
return agent_config return agent_config

View File

@@ -0,0 +1,30 @@
"""202603181652
Revision ID: f017efe4831c
Revises: 818c6c535e14
Create Date: 2026-03-18 16:52:21.639695
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'f017efe4831c'
down_revision: Union[str, None] = '818c6c535e14'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('workflow_configs', sa.Column('features', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('workflow_configs', 'features')
# ### end Alembic commands ###

View File

@@ -2,10 +2,12 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-06 21:11:51 * @Date: 2026-02-06 21:11:51
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-13 17:11:14 * @Last Modified time: 2026-03-17 18:39:09
*/ */
import { type FC, useRef, useState } from 'react' import { type FC, useRef, useState } from 'react'
import RecordRTC from 'recordrtc' import RecordRTC from 'recordrtc'
import { App } from 'antd'
import { useTranslation } from 'react-i18next';
import { fileUploadUrlWithoutApiPrefix } from '@/api/fileStorage' import { fileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
import { request } from '@/utils/request' import { request } from '@/utils/request'
@@ -19,14 +21,20 @@ interface AudioRecorderProps {
action?: string; action?: string;
/** Additional config passed to the upload request */ /** Additional config passed to the upload request */
requestConfig?: Record<string, any>; requestConfig?: Record<string, any>;
disabled?: boolean;
maxSize?: number;
} }
const AudioRecorder: FC<AudioRecorderProps> = ({ const AudioRecorder: FC<AudioRecorderProps> = ({
onRecordingComplete, onRecordingComplete,
className = '', className = '',
action = fileUploadUrlWithoutApiPrefix, action = fileUploadUrlWithoutApiPrefix,
requestConfig = {} requestConfig = {},
disabled = false,
maxSize,
}) => { }) => {
const { message } = App.useApp()
const { t } = useTranslation();
// Whether the recorder is currently capturing audio // Whether the recorder is currently capturing audio
const [isRecording, setIsRecording] = useState(false) const [isRecording, setIsRecording] = useState(false)
// Holds the RecordRTC instance across renders // Holds the RecordRTC instance across renders
@@ -34,6 +42,7 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
/** Request microphone access and start recording */ /** Request microphone access and start recording */
const startRecording = async () => { const startRecording = async () => {
if (disabled) return
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
recorderRef.current = new RecordRTC(stream, { recorderRef.current = new RecordRTC(stream, {
@@ -49,10 +58,17 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
/** Stop recording, upload the audio blob, then invoke the completion callback */ /** Stop recording, upload the audio blob, then invoke the completion callback */
const stopRecording = () => { const stopRecording = () => {
if (disabled) return
if (recorderRef.current) { if (recorderRef.current) {
recorderRef.current.stopRecording(() => { recorderRef.current.stopRecording(() => {
const blob = recorderRef.current!.getBlob() const blob = recorderRef.current!.getBlob()
const url = recorderRef.current!.toURL() const url = recorderRef.current!.toURL()
if (maxSize && blob.size > maxSize * 1024 * 1024) {
message.error(t('common.fileSizeTip', { size: maxSize }));
return
}
const formData = new FormData() const formData = new FormData()
formData.append('file', blob, `recording_${Date.now()}.webm`) formData.append('file', blob, `recording_${Date.now()}.webm`)
request request
@@ -76,7 +92,7 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
// swap background image to reflect current state // swap background image to reflect current state
return ( return (
<div <div
className={`rb:size-5.5 rb:cursor-pointer rb:bg-cover ${className} ${ className={`rb:size-5.5 rb:bg-cover ${disabled ? 'rb:opacity-65 rb:cursor-not-allowed' : 'rb:cursor-pointer'} ${className} ${
isRecording isRecording
? `rb:bg-[url('@/assets/images/conversation/audio_ing.gif')]` ? `rb:bg-[url('@/assets/images/conversation/audio_ing.gif')]`
: `rb:bg-[url('@/assets/images/conversation/audio.svg')]` : `rb:bg-[url('@/assets/images/conversation/audio.svg')]`

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-02 15:01:59 * @Date: 2026-02-02 15:01:59
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-12 14:59:38 * @Last Modified time: 2026-03-17 15:35:34
*/ */
/** /**
@@ -63,9 +63,9 @@ const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
align="center" align="center"
justify={cicle ? 'center' : 'start'} justify={cicle ? 'center' : 'start'}
gap={4} gap={4}
className={clsx("rb:flex rb:items-center rb:cursor-pointer rb:border rb:hover:bg-[#F6F6F6]", { className={clsx("rb:flex rb:items-center rb:cursor-pointer rb:px-2! rb:border rb:hover:bg-[#F6F6F6]", {
'rb:size-7 rb:rounded-[14px] rb:border-[0.5px] rb:border-[#EBEBEB]': cicle, 'rb:size-7 rb:rounded-[14px] rb:border-[0.5px] rb:border-[#EBEBEB]': cicle,
'rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6': !cicle, 'rb:rounded-lg rb:text-[12px] rb:h-6': !cicle,
// Checked state: blue background and border // Checked state: blue background and border
"rb:bg-[rgba(21,94,239,0.06)] rb:border-[rgba(21,94,239,0.25)] rb:hover:bg-[rgba(21,94,239,0.06)] rb:text-[#155EEF]": checked, "rb:bg-[rgba(21,94,239,0.06)] rb:border-[rgba(21,94,239,0.25)] rb:hover:bg-[rgba(21,94,239,0.06)] rb:text-[#155EEF]": checked,
// Unchecked state: gray border and dark text // Unchecked state: gray border and dark text

View File

@@ -2,13 +2,14 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2025-12-10 16:46:17 * @Date: 2025-12-10 16:46:17
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-06 21:05:52 * @Last Modified time: 2026-03-17 14:11:24
*/ */
import { type FC, useRef, useEffect } from 'react' import { type FC, useRef, useEffect, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import Markdown from '@/components/Markdown' import Markdown from '@/components/Markdown'
import type { ChatContentProps } from './types' import type { ChatContentProps } from './types'
import { Spin } from 'antd' import { Spin, Divider, Space } from 'antd'
import { SoundOutlined } from '@ant-design/icons'
/** /**
* Chat Content Display Component * Chat Content Display Component
@@ -28,7 +29,25 @@ const ChatContent: FC<ChatContentProps> = ({
// Scroll container reference for controlling auto-scroll to bottom // Scroll container reference for controlling auto-scroll to bottom
const scrollContainerRef = useRef<(HTMLDivElement | null)>(null) const scrollContainerRef = useRef<(HTMLDivElement | null)>(null)
const prevDataLengthRef = useRef(data.length); const prevDataLengthRef = useRef(data.length);
const isScrolledToBottomRef = useRef(true); // Track if user is scrolled to bottom const isScrolledToBottomRef = useRef(true);
const audioRef = useRef<HTMLAudioElement | null>(null)
const [playingIndex, setPlayingIndex] = useState<number | null>(null)
const handlePlay = (index: number, audioUrl: string) => {
if (playingIndex === index) {
audioRef.current?.pause()
setPlayingIndex(null)
return
}
if (audioRef.current) {
audioRef.current.pause()
}
const audio = new Audio(audioUrl)
audioRef.current = audio
audio.play()
setPlayingIndex(index)
audio.onended = () => setPlayingIndex(null)
}
// Track scroll position to determine if user is at bottom // Track scroll position to determine if user is at bottom
useEffect(() => { useEffect(() => {
@@ -101,6 +120,19 @@ const ChatContent: FC<ChatContentProps> = ({
{item.subContent && renderRuntime && renderRuntime(item, index)} {item.subContent && renderRuntime && renderRuntime(item, index)}
{/* Render message content using Markdown component */} {/* Render message content using Markdown component */}
<Markdown content={renderRuntime ? item.content ?? '' : item.content ?? errorDesc ?? ''} /> <Markdown content={renderRuntime ? item.content ?? '' : item.content ?? errorDesc ?? ''} />
{item.audioUrl && <>
<Divider className="rb:my-3!" />
<Space size={12} className="rb:pb-2 rb:pl-1">
{playingIndex !== index
? <SoundOutlined className="rb:cursor-pointer rb:hover:text-[#155EEF]! rb:size-5.5" onClick={() => handlePlay(index, item.audioUrl!)} />
: <div
className="rb:size-5.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/audio_ing.gif')]"
onClick={() => handlePlay(index, item.audioUrl!)}
/>
}
</Space>
</>}
</div> </div>
{/* Bottom label (such as timestamp, username, etc.) */} {/* Bottom label (such as timestamp, username, etc.) */}
{labelPosition === 'bottom' && {labelPosition === 'bottom' &&

View File

@@ -0,0 +1,204 @@
/*
* @Author: ZhaoYing
* @Date: 2026-03-17 14:22:25
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-18 15:55:13
*/
// Toolbar component for chat input area, supporting file upload, audio recording, and variable configuration
import { useRef, forwardRef, useImperativeHandle, type ReactNode, useEffect } from 'react'
import { Flex, Dropdown, Divider, App, Form, type MenuProps } from 'antd'
import { SettingOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import clsx from 'clsx'
import AudioRecorder from '@/components/AudioRecorder'
import UploadFiles from '@/views/Conversation/components/FileUpload'
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal'
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
import type { UploadFileListModalRef } from '@/views/Conversation/types'
import type { VariableConfigModalRef } from '@/views/Workflow/types'
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
// Exposed methods via ref for parent components to access/set form state
export interface ChatToolbarRef {
getFiles: () => any[]
getVariables: () => Variable[]
setFiles: (files: any[]) => void
setVariables: (variables: Variable[]) => void
}
// Props for configuring toolbar features, upload settings, and event callbacks
export interface ChatToolbarProps {
features: FeaturesConfigForm
extra?: ReactNode
uploadAction?: string
uploadRequestConfig?: {
data?: Record<string, string | number | boolean>
headers?: Record<string, string>
}
onFilesChange?: (files: any[]) => void
onVariablesChange?: (variables: Variable[]) => void
onRecordingComplete?: (file: any) => void;
defaultValue?: { memory: boolean }
}
interface FormValues {
files: any[]
variables: Variable[];
memory?: boolean;
}
const ChatToolbar = forwardRef<ChatToolbarRef, ChatToolbarProps>(({
features,
extra,
uploadAction,
uploadRequestConfig,
onFilesChange,
onVariablesChange,
onRecordingComplete,
defaultValue,
}, ref) => {
const { t } = useTranslation()
const { message: messageApi } = App.useApp()
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
const [form] = Form.useForm<FormValues>()
const queryValues = Form.useWatch([], form)
useEffect(() => {
if (!defaultValue) return
form.setFieldsValue(defaultValue)
}, [defaultValue])
useImperativeHandle(ref, () => ({
getFiles: () => form.getFieldValue('files') || [],
getVariables: () => form.getFieldValue('variables') || [],
setFiles: (files) => form.setFieldValue('files', files),
setVariables: (variables) => {
console.log('variables', variables)
form.setFieldValue('variables', variables)
},
}))
const { file_upload } = features || {}
// Append newly uploaded file to the file list when upload is complete
const fileChange = (file?: any) => {
if (file?.status !== 'done') return
const files = [...(queryValues?.files || []), file]
form.setFieldValue('files', files)
onFilesChange?.(files)
}
// Append recorded audio file to the file list and notify parent
const handleRecordingComplete = (file: any) => {
const files = [...(queryValues?.files || []), file]
form.setFieldValue('files', files)
onFilesChange?.(files)
onRecordingComplete?.(file)
}
// Merge a batch of files (e.g. from remote URL modal) into the file list
const addFileList = (list?: any[]) => {
if (!list?.length) return
const files = [...(queryValues?.files || []), ...list]
form.setFieldValue('files', files)
onFilesChange?.(files)
}
// Persist variable values from the config modal and notify parent
const handleVariablesSave = (values: Variable[]) => {
form.setFieldValue('variables', values)
onVariablesChange?.(values)
}
// True when any required variable is missing a value, used to highlight the config button
const isNeedVariableConfig = queryValues?.variables?.some(
vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === '')
)
// Build dropdown menu items based on allowed transfer methods
const fileMenus: MenuProps['items'] = []
const enabledTypes = ['image', 'document', 'video', 'audio'].filter(
type => file_upload?.[`${type}_enabled` as keyof FeaturesConfigForm['file_upload']]
)
if (file_upload?.allowed_transfer_methods?.includes('remote_url') && enabledTypes.length > 0) {
fileMenus.push({
key: 'url',
label: t('memoryConversation.addRemoteFile'),
onClick: () => {
if ((queryValues?.files?.length || 0) >= file_upload.max_file_count) {
messageApi.warning(t('common.fileNumTip', { num: file_upload.max_file_count }))
return
}
uploadFileListModalRef.current?.handleOpen()
}
})
}
if (file_upload?.allowed_transfer_methods?.includes('local_file') && enabledTypes.length > 0) {
fileMenus.push({
key: 'upload',
label: (
<UploadFiles
action={uploadAction}
onChange={fileChange}
requestConfig={uploadRequestConfig}
featureConfig={file_upload}
disabled={(queryValues?.files?.length || 0) >= file_upload.max_file_count}
/>
)
})
}
return (
<Form form={form} initialValues={{ files: [], variables: [] }}>
<Flex justify="space-between" className="rb:flex-1">
<Flex gap={8} align="center">
<Form.Item name="files" noStyle hidden={!file_upload?.enabled || fileMenus.length === 0}>
<Dropdown menu={{ items: fileMenus }}>
<div className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]" />
</Dropdown>
</Form.Item>
{extra}
<Form.Item name="variables" className="rb:mb-0!" hidden={queryValues?.variables?.length < 1}>
<div
className={clsx('rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]', {
'rb:border-[#FF5D34] rb:text-[#FF5D34]': isNeedVariableConfig,
'rb:border-[#DFE4ED]': !isNeedVariableConfig,
})}
onClick={() => variableConfigModalRef.current?.handleOpen(queryValues.variables)}
>
<SettingOutlined className="rb:mr-1" />
{t('memoryConversation.variableConfig')}
</div>
</Form.Item>
</Flex>
{file_upload?.audio_enabled && file_upload?.allowed_transfer_methods?.includes('local_file') && (
<Flex align="center">
<AudioRecorder
disabled={(queryValues?.files?.length || 0) >= file_upload.max_file_count}
action={uploadAction}
requestConfig={uploadRequestConfig}
onRecordingComplete={handleRecordingComplete}
maxSize={file_upload?.audio_max_size_mb}
/>
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
</Flex>
)}
</Flex>
<UploadFileListModal
ref={uploadFileListModalRef}
refresh={addFileList}
featureConfig={file_upload}
/>
<VariableConfigModal
ref={variableConfigModalRef}
refresh={handleVariablesSave}
/>
</Form>
)
})
export default ChatToolbar

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2025-12-10 16:45:54 * @Date: 2025-12-10 16:45:54
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-12 13:57:51 * @Last Modified time: 2026-03-17 13:46:24
*/ */
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
@@ -24,6 +24,7 @@ export interface ChatItem {
subContent?: Record<string, any>[]; subContent?: Record<string, any>[];
files?: any[]; files?: any[];
error?: string; error?: string;
audioUrl?: string;
} }
/** /**

View File

@@ -4,10 +4,10 @@
* @Author: yujiangping * @Author: yujiangping
* @Date: 2026-03-16 19:01:12 * @Date: 2026-03-16 19:01:12
* @LastEditors: yujiangping * @LastEditors: yujiangping
* @LastEditTime: 2026-03-16 19:17:47 * @LastEditTime: 2026-03-17 16:19:45
*/ */
import { useState, useEffect, useRef, useCallback, type FC } from 'react'; import { useState, useEffect, useRef, useCallback, type FC } from 'react';
import { Spin, Alert, Button, Table, InputNumber } from 'antd'; import { Spin, Alert, Button, Table, InputNumber, Image } from 'antd';
import { import {
ReloadOutlined, ReloadOutlined,
DownloadOutlined, DownloadOutlined,
@@ -21,12 +21,10 @@ import { cookieUtils } from '@/utils/request';
import mammoth from 'mammoth'; import mammoth from 'mammoth';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.mjs?url';
// 设置 pdf.js worker // 设置 pdf.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
'pdfjs-dist/build/pdf.worker.mjs',
import.meta.url,
).toString();
interface DocumentPreviewProps { interface DocumentPreviewProps {
fileUrl: string; fileUrl: string;
@@ -65,9 +63,12 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
const [pptCurrentPage, setPptCurrentPage] = useState(1); const [pptCurrentPage, setPptCurrentPage] = useState(1);
const [pptTotalPages, setPptTotalPages] = useState(0); const [pptTotalPages, setPptTotalPages] = useState(0);
// 图片状态
const [imageBlobUrl, setImageBlobUrl] = useState<string>('');
// 支持预览的文件类型 // 支持预览的文件类型
const previewableTypes = [ const previewableTypes = [
'.pdf', '.txt', '.md', '.pdf', '.txt', '.md', '.csv',
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp',
'.doc', '.docx', '.xls', '.xlsx', '.doc', '.docx', '.xls', '.xlsx',
'.ppt', '.pptx', '.ppt', '.pptx',
@@ -90,7 +91,7 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
}; };
const isPdfFile = () => getFileExtension() === '.pdf'; const isPdfFile = () => getFileExtension() === '.pdf';
const isWordFile = () => ['.doc', '.docx'].includes(getFileExtension()); const isWordFile = () => ['.doc', '.docx'].includes(getFileExtension());
const isExcelFile = () => ['.xls', '.xlsx'].includes(getFileExtension()); const isExcelFile = () => ['.xls', '.xlsx', '.csv'].includes(getFileExtension());
const isPptFile = () => ['.ppt', '.pptx'].includes(getFileExtension()); const isPptFile = () => ['.ppt', '.pptx'].includes(getFileExtension());
const isPreviewable = () => previewableTypes.includes(getFileExtension()); const isPreviewable = () => previewableTypes.includes(getFileExtension());
@@ -227,6 +228,28 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
} }
}, [fileUrl]); }, [fileUrl]);
// ========== 图片加载逻辑 ==========
const loadImageFile = async () => {
setLoading(true);
setError(false);
setErrorMessage('');
try {
const arrayBuffer = await fetchFileBuffer(fileUrl);
const ext = getFileExtension().replace('.', '');
const mimeMap: Record<string, string> = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
gif: 'image/gif', bmp: 'image/bmp', webp: 'image/webp', svg: 'image/svg+xml',
};
const blob = new Blob([arrayBuffer], { type: mimeMap[ext] || 'image/png' });
const url = URL.createObjectURL(blob);
setImageBlobUrl(url);
setLoading(false);
} catch (err: any) {
console.error('加载图片文件失败:', err);
handleError(err.message || '图片加载失败');
}
};
// ========== 文本/Word/Excel 加载逻辑 ========== // ========== 文本/Word/Excel 加载逻辑 ==========
const loadTextFile = async () => { const loadTextFile = async () => {
setLoading(true); setLoading(true);
@@ -274,12 +297,42 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
} }
}; };
const isCsvFile = () => getFileExtension() === '.csv';
const loadExcelFile = async () => { const loadExcelFile = async () => {
setLoading(true); setLoading(true);
setError(false); setError(false);
setErrorMessage(''); setErrorMessage('');
try { try {
const arrayBuffer = await fetchFileBuffer(fileUrl); const arrayBuffer = await fetchFileBuffer(fileUrl);
// CSV 文件需要处理编码问题(可能是 GBK/GB2312
if (isCsvFile()) {
let csvText: string;
// 先尝试 UTF-8 解码
const utf8Text = new TextDecoder('utf-8').decode(arrayBuffer);
// 检测是否有乱码特征(常见的 GBK 被错误解析为 UTF-8 的替换字符)
if (utf8Text.includes('\uFFFD') || /[\x80-\xff]/.test(utf8Text.slice(0, 200))) {
// 尝试 GBK 解码
try {
csvText = new TextDecoder('gbk').decode(arrayBuffer);
} catch {
csvText = utf8Text;
}
} else {
csvText = utf8Text;
}
const workbook = XLSX.read(csvText, { type: 'string' });
const sheets = workbook.SheetNames.map(sheetName => {
const worksheet = workbook.Sheets[sheetName];
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
return { sheetName, data };
});
setExcelData(sheets);
setLoading(false);
return;
}
const workbook = XLSX.read(arrayBuffer, { type: 'array' }); const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const sheets = workbook.SheetNames.map(sheetName => { const sheets = workbook.SheetNames.map(sheetName => {
const worksheet = workbook.Sheets[sheetName]; const worksheet = workbook.Sheets[sheetName];
@@ -311,7 +364,7 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
else if (isExcelFile()) loadExcelFile(); else if (isExcelFile()) loadExcelFile();
else if (isPdfFile()) loadPdfFile(); else if (isPdfFile()) loadPdfFile();
else if (isPptFile()) loadPptFile(); else if (isPptFile()) loadPptFile();
else if (isImageFile()) setLoading(false); else if (isImageFile()) loadImageFile();
}, [fileUrl]); }, [fileUrl]);
// PDF 翻页/缩放后重新渲染 // PDF 翻页/缩放后重新渲染
@@ -412,11 +465,11 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
{/* 图片预览 */} {/* 图片预览 */}
{isImageFile() && !error && !loading && ( {isImageFile() && !error && !loading && (
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-gray-50 rb:flex rb:items-center rb:justify-center"> <div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-gray-50 rb:flex rb:items-center rb:justify-center">
<img <Image
src={fileUrl} src={imageBlobUrl}
alt={fileName || '图片预览'} alt={fileName || '图片预览'}
className="rb:max-w-full rb:max-h-full rb:object-contain" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
onError={() => handleError('图片加载失败')} onError={() => handleError('图片渲染失败')}
/> />
</div> </div>
)} )}

View File

@@ -449,6 +449,7 @@ export const en = {
fileSizeTip: 'File size cannot exceed {{size}}MB', fileSizeTip: 'File size cannot exceed {{size}}MB',
fileAcceptTip: 'Unsupported file type:', fileAcceptTip: 'Unsupported file type:',
fileNumTip: 'File count cannot exceed {{num}}',
nextStep: 'Next Step', nextStep: 'Next Step',
prevStep: 'Previous Step', prevStep: 'Previous Step',
exportSuccess: 'Export successful', exportSuccess: 'Export successful',
@@ -1373,9 +1374,9 @@ export const en = {
dify: 'Dify', dify: 'Dify',
pleaseUploadFile: 'Please upload file', pleaseUploadFile: 'Please upload file',
setting: 'Settings', setting: 'Settings',
funConfig: 'Features', features: 'Conversation Features',
fileUpload: 'File Upload', file_upload: 'File Upload',
fileUploadDesc: 'The chat input box supports file uploads. Types include images, documents, and other types', file_upload_desc: 'The chat input box supports file uploads. Types include images, documents, and other types',
settings: 'File Upload Settings', settings: 'File Upload Settings',
uploadType: 'Upload Type', uploadType: 'Upload Type',
local: 'Local Upload', local: 'Local Upload',
@@ -1392,8 +1393,8 @@ export const en = {
maxCount: 'Max Files', maxCount: 'Max Files',
singleMaxSize: 'Max Size', singleMaxSize: 'Max Size',
unix: 'items', unix: 'items',
textTranfer: 'Text to Speech', text_to_speech: 'Text to Speech',
textTranferDesc: 'Text can be converted to speech', text_to_speech_desc: 'Text can be converted to speech',
apps: 'My Apps', apps: 'My Apps',
sharing: 'Sharing', sharing: 'Sharing',
@@ -1781,6 +1782,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
fileUrl: 'File URL', fileUrl: 'File URL',
addRemoteFile: 'Add Remote File', addRemoteFile: 'Add Remote File',
variableConfig: 'Variable Configuration', variableConfig: 'Variable Configuration',
memoryCancelTipTitle: 'Are you sure you want to disable conversation memory? Conversations will no longer be saved to the memory store.',
memoryTipTitle: 'Are you sure you want to enable conversation memory? Conversations will be saved to the memory store.',
}, },
login: { login: {
title: 'Red Bear Memory Science', title: 'Red Bear Memory Science',

View File

@@ -756,9 +756,9 @@ export const zh = {
dify: 'Dify', dify: 'Dify',
pleaseUploadFile: '请上传文件', pleaseUploadFile: '请上传文件',
setting: '设置', setting: '设置',
funConfig: '功能', features: '对话功能',
fileUpload: '文件上传', file_upload: '文件上传',
fileUploadDesc: '聊天输入框支持上传文件。类型包括图片、文档以及其它类型', file_upload_desc: '聊天输入框支持上传文件。类型包括图片、文档以及其它类型',
settings: '文件上传设置', settings: '文件上传设置',
uploadType: '上传类型', uploadType: '上传类型',
local: '本地上传', local: '本地上传',
@@ -775,8 +775,8 @@ export const zh = {
maxCount: '最大文件数', maxCount: '最大文件数',
singleMaxSize: '单文件最大大小', singleMaxSize: '单文件最大大小',
unix: '个', unix: '个',
textTranfer: '文字转语音', text_to_speech: '文字转语音',
textTranferDesc: '文本可以转换成语', text_to_speech_desc: '文本可以转换成语',
apps: '我的应用', apps: '我的应用',
sharing: '共享', sharing: '共享',
@@ -1082,6 +1082,7 @@ export const zh = {
fileSizeTip: '文件大小不能超过 {{size}}MB', fileSizeTip: '文件大小不能超过 {{size}}MB',
fileAcceptTip: '不支持的文件类型:', fileAcceptTip: '不支持的文件类型:',
fileNumTip: '文件数量不能超过{{num}}个',
nextStep: '下一步', nextStep: '下一步',
prevStep: '上一步', prevStep: '上一步',
exportSuccess: '导出成功', exportSuccess: '导出成功',
@@ -1777,6 +1778,8 @@ export const zh = {
fileUrl: '文件链接', fileUrl: '文件链接',
addRemoteFile: '添加远程文件', addRemoteFile: '添加远程文件',
variableConfig: '变量配置', variableConfig: '变量配置',
memoryCancelTipTitle: '确定关闭对话记忆功能吗?关闭后对话将不会保存到记忆库中',
memoryTipTitle: '确定打开对话记忆功能吗?打开后对话将会保存到记忆库中',
}, },
login: { login: {
title: '红熊记忆科学', title: '红熊记忆科学',

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-02 16:35:43 * @Date: 2026-02-02 16:35:43
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-04 18:19:24 * @Last Modified time: 2026-03-18 14:32:40
*/ */
/** /**
* Server-Sent Events (SSE) Stream Utility Module * Server-Sent Events (SSE) Stream Utility Module
@@ -176,17 +176,23 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe
case 500: case 500:
case 502: case 502:
const errorData = await response.json(); const errorData = await response.json();
let errorInfo = errorData.error || i18n.t('common.serviceUpgrading') const errorInfo = errorData.error || i18n.t('common.serviceUpgrading');
message.warning(errorInfo); message.warning(errorInfo);
throw errorInfo; throw new Error(errorData);
case 400: case 400:
const error = await response.json(); const error = await response.json();
message.warning(error.error); const error400 = error.error || 'Bad Request';
throw error.error || 'Bad Request'; message.warning(error400);
throw new Error(error);
case 403:
const errors = await response.json();
message.warning(i18n.t('common.permissionDenied'));
throw new Error(errors);
case 504: case 504:
const errorJson = await response.json(); const errorJson = await response.json();
message.warning(errorJson.error || i18n.t('common.serverError')); const errorMsg = errorJson.error || i18n.t('common.serverError');
throw errorData.error; message.warning(errorMsg);
throw new Error(errorJson);
case 401: case 401:
if (url?.includes('/public')) { if (url?.includes('/public')) {
return message.warning(i18n.t('common.publicApiCannotRefreshToken')); return message.warning(i18n.t('common.publicApiCannotRefreshToken'));

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:29:21 * @Date: 2026-02-03 16:29:21
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-13 16:58:15 * @Last Modified time: 2026-03-17 14:24:29
*/ */
import { type FC, type ReactNode, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react'; import { type FC, type ReactNode, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
import clsx from 'clsx' import clsx from 'clsx'
@@ -24,7 +24,7 @@ import type {
AiPromptModalRef, AiPromptModalRef,
Source, Source,
ChatVariableConfigModalRef, ChatVariableConfigModalRef,
FunConfigForm FeaturesConfigForm
} from './types' } from './types'
import type { Variable } from './components/VariableList/types' import type { Variable } from './components/VariableList/types'
import type { KnowledgeConfig } from './components/Knowledge/types' import type { KnowledgeConfig } from './components/Knowledge/types'
@@ -42,7 +42,7 @@ import ToolList from './components/ToolList/ToolList'
import SkillList from './components/Skill' import SkillList from './components/Skill'
import ChatVariableConfigModal from './components/ChatVariableConfigModal'; import ChatVariableConfigModal from './components/ChatVariableConfigModal';
import type { Skill } from '@/views/Skills/types' import type { Skill } from '@/views/Skills/types'
import FunConfig from './components/FunConfig' import FeaturesConfig from './components/FeaturesConfig'
/** /**
* Description wrapper component * Description wrapper component
@@ -129,7 +129,7 @@ const SelectWrapper: FC<{ title: string, desc: string, name: string | string[],
* Agent configuration component * Agent configuration component
* Manages single agent configuration including prompts, knowledge, memory, variables, and tools * Manages single agent configuration including prompts, knowledge, memory, variables, and tools
*/ */
const Agent = forwardRef<AgentRef>((_props, ref) => { const Agent = forwardRef<AgentRef, { onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void }>(({ onFeaturesLoad }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { id } = useParams(); const { id } = useParams();
const { message } = App.useApp() const { message } = App.useApp()
@@ -200,6 +200,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
...response, ...response,
tools: allTools tools: allTools
}) })
onFeaturesLoad?.(response.features)
}).finally(() => { }).finally(() => {
setLoading(false) setLoading(false)
}) })
@@ -356,7 +357,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
handleSave, handleSave,
funConfig: values?.funConfig features: values?.features
})) }))
const aiPromptModalRef = useRef<AiPromptModalRef>(null) const aiPromptModalRef = useRef<AiPromptModalRef>(null)
@@ -411,8 +412,8 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
setChatVariables(values?.variables || []) setChatVariables(values?.variables || [])
}, [values?.variables]) }, [values?.variables])
const handleSaveFunConfig = (value: FunConfigForm) => { const handleSaveFeaturesConfig = (value: FeaturesConfigForm) => {
form.setFieldValue('funConfig', value) form.setFieldValue('features', value)
} }
console.log('agent', values) console.log('agent', values)
return ( return (
@@ -426,7 +427,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
{defaultModel?.name ? <div className="rb:w-4 rb:h-4 rb:bg-[url('@/assets/images/application/model.svg')] rb:group-hover:bg-[url('@/assets/images/application/model_hover.svg')]"></div> : null} {defaultModel?.name ? <div className="rb:w-4 rb:h-4 rb:bg-[url('@/assets/images/application/model.svg')] rb:group-hover:bg-[url('@/assets/images/application/model_hover.svg')]"></div> : null}
{defaultModel?.name || t('application.chooseModel')} {defaultModel?.name || t('application.chooseModel')}
</Button> </Button>
{/* <FunConfig value={values?.funConfig as FunConfigForm} refresh={handleSaveFunConfig} /> */} <FeaturesConfig value={values?.features as FeaturesConfigForm} refresh={handleSaveFeaturesConfig} />
<Button type="primary" onClick={() => handleSave()}> <Button type="primary" onClick={() => handleSave()}>
{t('common.save')} {t('common.save')}
</Button> </Button>
@@ -435,7 +436,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
<Form form={form}> <Form form={form}>
<Form.Item name="default_model_config_id" hidden noStyle></Form.Item> <Form.Item name="default_model_config_id" hidden noStyle></Form.Item>
<Form.Item name="model_parameters" hidden noStyle></Form.Item> <Form.Item name="model_parameters" hidden noStyle></Form.Item>
<Form.Item name="funConfig" hidden noStyle></Form.Item> <Form.Item name="features" hidden noStyle></Form.Item>
<Space size={16} direction="vertical" style={{ width: '100%' }}> <Space size={16} direction="vertical" style={{ width: '100%' }}>
<Card title={t('application.promptConfiguration')}> <Card title={t('application.promptConfiguration')}>
<div className="rb:flex rb:items-center rb:justify-between rb:mb-2.75"> <div className="rb:flex rb:items-center rb:justify-between rb:mb-2.75">
@@ -512,7 +513,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
</div> </div>
<RbCard height="calc(100vh - 160px)" bodyClassName="rb:p-[0]! rb:h-full rb:overflow-hidden"> <RbCard height="calc(100vh - 160px)" bodyClassName="rb:p-[0]! rb:h-full rb:overflow-hidden">
<Chat <Chat
data={data as Config} data={values as Config}
chatList={chatList} chatList={chatList}
updateChatList={setChatList} updateChatList={setChatList}
handleSave={handleSave} handleSave={handleSave}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:29:33 * @Date: 2026-02-03 16:29:33
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-05 13:47:23 * @Last Modified time: 2026-03-17 14:48:57
*/ */
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react' import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -19,7 +19,8 @@ import type {
ChatData, ChatData,
SubAgentItem, SubAgentItem,
ClusterRef, ClusterRef,
ModelConfigModalRef ModelConfigModalRef,
FeaturesConfigForm
} from './types' } from './types'
import Chat from './components/Chat' import Chat from './components/Chat'
import RbCard from '@/components/RbCard/Card' import RbCard from '@/components/RbCard/Card'
@@ -29,7 +30,7 @@ import RadioGroupCard from '@/components/RadioGroupCard'
import { getModelListUrl } from '@/api/models' import { getModelListUrl } from '@/api/models'
import ModelConfigModal from './components/ModelConfigModal' import ModelConfigModal from './components/ModelConfigModal'
import type { Application } from '@/views/ApplicationManagement/types' import type { Application } from '@/views/ApplicationManagement/types'
import FeaturesConfig from './components/FeaturesConfig'
const tagColors = ['processing', 'warning', 'default'] const tagColors = ['processing', 'warning', 'default']
const MAX_LENGTH = 5; const MAX_LENGTH = 5;
@@ -37,7 +38,7 @@ const MAX_LENGTH = 5;
* Multi-agent cluster configuration component * Multi-agent cluster configuration component
* Manages multi-agent orchestration, sub-agents, and collaboration modes * Manages multi-agent orchestration, sub-agents, and collaboration modes
*/ */
const Cluster = forwardRef<ClusterRef>((_props, ref) => { const Cluster = forwardRef<ClusterRef, { onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void }>(({ onFeaturesLoad }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { message } = App.useApp() const { message } = App.useApp()
const [form] = Form.useForm() const [form] = Form.useForm()
@@ -130,6 +131,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
} else { } else {
setSubAgents(sub_agents) setSubAgents(sub_agents)
} }
onFeaturesLoad?.(response.features)
}) })
} }
/** /**
@@ -166,7 +168,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
} }
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
handleSave, handleSave,
funConfig: data?.funConfig features: data?.features
})) }))
const modelConfigModalRef = useRef<ModelConfigModalRef>(null) const modelConfigModalRef = useRef<ModelConfigModalRef>(null)
@@ -185,16 +187,21 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
model_parameters: values model_parameters: values
}) })
} }
const handleSaveFeaturesConfig = (value: FeaturesConfigForm) => {
form.setFieldValue('features', value)
}
return ( return (
<Row className="rb:h-[calc(100vh-64px)]"> <Row className="rb:h-[calc(100vh-64px)]">
<Col span={12} className="rb:h-full rb:overflow-x-auto rb:border-r rb:border-[#DFE4ED] rb:p-[20px_16px_24px_16px]"> <Col span={12} className="rb:h-full rb:overflow-x-auto rb:border-r rb:border-[#DFE4ED] rb:p-[20px_16px_24px_16px]">
<div className="rb:flex rb:items-center rb:justify-end rb:mb-5"> <Flex gap={10} justify="end" align="center" className="rb:mb-5!">
<FeaturesConfig value={values?.features as FeaturesConfigForm} refresh={handleSaveFeaturesConfig} />
<Button type="primary" onClick={() => handleSave()}> <Button type="primary" onClick={() => handleSave()}>
{t('common.save')} {t('common.save')}
</Button> </Button>
</div> </Flex>
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item name="features" hidden noStyle></Form.Item>
<Space size={20} direction="vertical" style={{width: '100%'}}> <Space size={20} direction="vertical" style={{width: '100%'}}>
<Card title={t('application.collaboration')}> <Card title={t('application.collaboration')}>
<Form.Item <Form.Item

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:29:41 * @Date: 2026-02-03 16:29:41
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-11 17:44:24 * @Last Modified time: 2026-03-18 14:30:41
*/ */
import { type FC, useState, useEffect, useRef } from 'react'; import { type FC, useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -70,7 +70,8 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
}) })
} }
const handleExport = () => { const handleExport = () => {
appExport(data.id, data.name) if (!selectedVersion) return
appExport(data.id, data.name, {release_version: selectedVersion.id})
} }
return ( return (
<div className="rb:flex rb:h-[calc(100vh-64px)]"> <div className="rb:flex rb:h-[calc(100vh-64px)]">

View File

@@ -1,36 +1,25 @@
import { type FC, useState, useRef, useEffect, useMemo } from 'react' import { type FC, useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { App, Flex, Dropdown, type MenuProps, Divider, Form, Space } from 'antd' import { App } from 'antd'
import { SettingOutlined } from '@ant-design/icons'
import clsx from 'clsx' import clsx from 'clsx'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import ChatIcon from '@/assets/images/application/chat.png' import ChatIcon from '@/assets/images/application/chat.png'
import { draftRun } from '@/api/application'
import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal'
import { draftRun } from '@/api/application';
import Empty from '@/components/Empty' import Empty from '@/components/Empty'
import Chat from '@/components/Chat' import Chat from '@/components/Chat'
import AudioRecorder from '@/components/AudioRecorder'
import RbCard from '@/components/RbCard/Card' import RbCard from '@/components/RbCard/Card'
import UploadFiles from '@/views/Conversation/components/FileUpload' import ChatToolbar, { type ChatToolbarRef } from '@/components/Chat/ChatToolbar'
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal' import Runtime from '@/views/Workflow/components/Chat/Runtime'
import Runtime from '@/views/Workflow/components/Chat/Runtime';
import { nodeLibrary } from '@/views/Workflow/constant' import { nodeLibrary } from '@/views/Workflow/constant'
// import ButtonCheckbox from '@/components/ButtonCheckbox';
// import MemoryFunctionIcon from '@/assets/images/conversation/memoryFunction.svg'
// import OnlineIcon from '@/assets/images/conversation/online.svg'
// import OnlineCheckedIcon from '@/assets/images/conversation/onlineChecked.svg'
// import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFunctionChecked.svg'
import type { ChatItem } from '@/components/Chat/types' import type { ChatItem } from '@/components/Chat/types'
import type { VariableConfigModalRef, WorkflowConfig } from '@/views/Workflow/types' import type { WorkflowConfig } from '@/views/Workflow/types'
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types' import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
import type { TestChatProps } from './type'; import type { TestChatProps } from './type'
import type { UploadFileListModalRef } from '@/views/Conversation/types'
import type { SSEMessage } from '@/utils/stream' import type { SSEMessage } from '@/utils/stream'
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
const formatParams = (message: string, conversation_id: string | null, files: any[] = [], variables: Record<string, any>) => { const formatParams = (message: string, conversation_id: string | null, files: any[] = [], variables: Record<string, any>) => {
return { return {
@@ -65,29 +54,25 @@ interface NodeData {
elapsed_time?: string; elapsed_time?: string;
error?: any; error?: any;
state: Record<string, any>; state: Record<string, any>;
status?: 'completed' | 'failed' status?: 'completed' | 'failed';
audio_url?: string;
} }
interface FormData {
files: any[];
variables: Variable[]
}
const TestChat: FC<TestChatProps> = ({ const TestChat: FC<TestChatProps> = ({
application, application,
config config
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { message: messageApi } = App.useApp() const { message: messageApi } = App.useApp()
const variableConfigModalRef = useRef<VariableConfigModalRef>(null) const toolbarRef = useRef<ChatToolbarRef>(null)
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
const [loading, setLoading] = useState(false) // Send button loading state const [loading, setLoading] = useState(false)
const [chatList, setChatList] = useState<ChatItem[]>([]) // Chat message history const [chatList, setChatList] = useState<ChatItem[]>([])
const [streamLoading, setStreamLoading] = useState(false) // SSE streaming state const [streamLoading, setStreamLoading] = useState(false)
const [conversationId, setConversationId] = useState<string | null>(null) // Current conversation ID const [conversationId, setConversationId] = useState<string | null>(null)
const [message, setMessage] = useState<string | undefined>(undefined) // Current input message const [message, setMessage] = useState<string | undefined>(undefined)
const [form] = Form.useForm<FormData>() const [fileList, setFileList] = useState<any[]>([])
const queryValues = Form.useWatch([], form) const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
useEffect(() => { useEffect(() => {
getVariables() getVariables()
@@ -96,6 +81,8 @@ const TestChat: FC<TestChatProps> = ({
const getVariables = () => { const getVariables = () => {
if (!application || !config) return if (!application || !config) return
setFeatures(config?.features || {} as FeaturesConfigForm)
let initVariables: Variable[] = [] let initVariables: Variable[] = []
switch (application.type) { switch (application.type) {
@@ -104,85 +91,35 @@ const TestChat: FC<TestChatProps> = ({
const startNodes = nodes.filter(vo => vo.type === 'start') const startNodes = nodes.filter(vo => vo.type === 'start')
if (startNodes.length) { if (startNodes.length) {
const curVariables = startNodes[0].config.variables as Variable[] const curVariables = startNodes[0].config.variables as Variable[]
curVariables.forEach((vo) => {
curVariables.forEach((vo) => { if (typeof vo.default !== 'undefined') {
if (typeof vo.default !== 'undefined') { vo.value = vo.default
vo.value = vo.default }
} const lastVo = curVariables.find(item => item.name === vo.name)
const lastVo = curVariables.find(item => item.name === vo.name) if (lastVo?.value) {
if (lastVo?.value) { vo.value = lastVo.value
vo.value = lastVo.value }
} })
}) initVariables = curVariables
initVariables = curVariables }
}
break break
case 'agent': case 'agent':
initVariables = config.variables as Variable[] initVariables = config.variables as Variable[]
break break
} }
form.setFieldValue('variables', [...initVariables]) toolbarRef.current?.setVariables([...initVariables])
} }
/**
* Opens the variable configuration modal
*/
const handleEditVariables = () => {
variableConfigModalRef.current?.handleOpen(queryValues.variables)
}
/**
* Saves updated variable values from the modal
*/
const handleSave = (values: Variable[]) => {
form.setFieldValue('variables', [...values])
}
/**
* Handles file upload from local device
*/
const fileChange = (file?: any) => {
form.setFieldValue('files', [...(queryValues.files || []), file])
}
const handleRecordingComplete = async (file: any) => {
form.setFieldValue('files', [...(queryValues.files || []), file])
}
/**
* Handles dropdown menu actions for file upload
*/
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
switch(key) {
case 'define':
uploadFileListModalRef.current?.handleOpen()
break
}
}
/**
* Adds files from remote URL modal
*/
const addFileList = (list?: any[]) => {
if (!list || list.length <= 0) return
form.setFieldValue('files', [...(queryValues.files || []), ...(list || [])])
}
/**
* Updates the entire file list (used when removing files)
*/
const updateFileList = (list?: any[]) => {
form.setFieldValue('files', [...list || []])
}
const isNeedVariableConfig = useMemo(() => {
return queryValues?.variables.some(vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === ''))
}, [queryValues?.variables])
const addUserMessage = (message: string, files: any[]) => { const addUserMessage = (message: string, files: any[]) => {
const newUserMessage: ChatItem = { setChatList(prev => [...prev, {
role: 'user', role: 'user',
content: message, content: message,
created_at: Date.now(), created_at: Date.now(),
files files
}; }])
setChatList(prev => [...prev, newUserMessage])
} }
const addAssistantMessage = () => { const addAssistantMessage = () => {
const { type } = application || {} const { type } = application || {}
setChatList(prev => [...prev, { setChatList(prev => [...prev, {
@@ -193,20 +130,22 @@ const TestChat: FC<TestChatProps> = ({
}]) }])
} }
const updateAssistantMessage = (content: string) => { const updateAssistantMessage = (content: string, audio_url?: string) => {
setChatList(prev => { setChatList(prev => {
let newList = [...prev] const newList = [...prev]
const lastMsg = newList[newList.length - 1] const lastMsg = newList[newList.length - 1]
if (lastMsg.role === 'assistant') { if (lastMsg.role === 'assistant') {
lastMsg.content += content lastMsg.content += content;
lastMsg.audioUrl = audio_url
} }
return newList return newList
}) })
} }
const updateErrorAssistantMessage = (message_length: number) => { const updateErrorAssistantMessage = (message_length: number) => {
if (message_length > 0) return if (message_length > 0) return
setChatList(prev => { setChatList(prev => {
let newList = [...prev] const newList = [...prev]
const lastMsg = newList[newList.length - 1] const lastMsg = newList[newList.length - 1]
if (lastMsg.role === 'assistant') { if (lastMsg.role === 'assistant') {
lastMsg.content = null lastMsg.content = null
@@ -214,34 +153,37 @@ const TestChat: FC<TestChatProps> = ({
return newList return newList
}) })
} }
const handleSend = () => {
if (loading || !application || !message || !message?.trim()) return const buildVariableParams = (variables: Variable[]) => {
// Validate required variables before sending
const { variables, files } = queryValues;
let isCanSend = true let isCanSend = true
const params: Record<string, any> = {} const params: Record<string, any> = {}
if (variables && variables.length > 0) { if (variables?.length > 0) {
const needRequired: string[] = [] const needRequired: string[] = []
variables.forEach(vo => { variables.forEach(vo => {
params[vo.name] = vo.value params[vo.name] = vo.value ?? vo.defaultValue
if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) { if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
isCanSend = false isCanSend = false
needRequired.push(vo.name) needRequired.push(vo.name)
} }
}) })
if (needRequired.length) { if (needRequired.length) {
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`) messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
} }
} }
if (!isCanSend) { return { isCanSend, params }
setLoading(false) }
return
} const handleSend = () => {
if (loading || !application || !message || !message?.trim()) return
const files = toolbarRef.current?.getFiles() || []
const variables = toolbarRef.current?.getVariables() || []
const { isCanSend, params } = buildVariableParams(variables)
if (!isCanSend) return
addUserMessage(message, files) addUserMessage(message, files)
setMessage(undefined) setMessage(undefined)
form.setFieldValue('files', []) toolbarRef.current?.setFiles([])
setFileList([])
addAssistantMessage() addAssistantMessage()
setStreamLoading(true) setStreamLoading(true)
setLoading(true) setLoading(true)
@@ -252,6 +194,7 @@ const TestChat: FC<TestChatProps> = ({
handleStreamMessage handleStreamMessage
) )
.catch(() => { .catch(() => {
updateErrorAssistantMessage(0)
setLoading(false) setLoading(false)
}) })
.finally(() => { .finally(() => {
@@ -259,105 +202,77 @@ const TestChat: FC<TestChatProps> = ({
setStreamLoading(false) setStreamLoading(false)
}) })
} }
const handleStreamMessage = (data: SSEMessage[]) => { const handleStreamMessage = (data: SSEMessage[]) => {
data.map(item => { data.map(item => {
const { conversation_id, content, message_length } = item.data as { conversation_id: string, content: string, message_length: number }; const { conversation_id, content, message_length, audio_url } = item.data as { conversation_id: string, content: string, message_length: number; audio_url?: string; };
switch (item.event) { switch (item.event) {
case 'start': case 'start':
if (conversation_id && conversationId !== conversation_id) { if (conversation_id && conversationId !== conversation_id) setConversationId(conversation_id)
setConversationId(conversation_id);
}
break break
case 'message': case 'message':
updateAssistantMessage(content) updateAssistantMessage(content)
if (conversation_id && conversationId !== conversation_id) { if (conversation_id && conversationId !== conversation_id) setConversationId(conversation_id)
setConversationId(conversation_id); break
}
break;
case 'end': case 'end':
if (audio_url) {
updateAssistantMessage(content, audio_url)
}
updateErrorAssistantMessage(message_length) updateErrorAssistantMessage(message_length)
setStreamLoading(false) setStreamLoading(false)
break; break
} }
}) })
}; }
const handleWorkflowSend = () => { const handleWorkflowSend = () => {
if (loading || !application || !message || !message?.trim()) return if (loading || !application || !message || !message?.trim()) return
const files = toolbarRef.current?.getFiles() || []
// Validate required variables before sending const variables = toolbarRef.current?.getVariables() || []
const { variables, files } = queryValues; const { isCanSend, params } = buildVariableParams(variables)
let isCanSend = true if (!isCanSend) return
const params: Record<string, any> = {}
if (variables.length > 0) {
const needRequired: string[] = []
variables.forEach(vo => {
params[vo.name] = vo.value ?? vo.defaultValue
if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
isCanSend = false
needRequired.push(vo.name)
}
})
if (needRequired.length) {
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
}
}
if (!isCanSend) {
return
}
setLoading(true) setLoading(true)
addUserMessage(message, files) addUserMessage(message, files)
addAssistantMessage() addAssistantMessage()
form.setFieldsValue({ toolbarRef.current?.setFiles([])
files: [], setFileList([])
})
setMessage(undefined) setMessage(undefined)
setStreamLoading(true) setStreamLoading(true)
draftRun( draftRun(
application.id, application.id,
formatParams(message, conversationId, files, params), formatParams(message, conversationId, files, params),
handleWorkflowStreamMessage handleWorkflowStreamMessage
) )
.catch((error) => { .catch((error) => {
console.log('draftRun error', error) const errorInfo = JSON.parse(error.message)
setChatList(prev => { setChatList(prev => {
const newList = [...prev] const newList = [...prev]
const lastIndex = newList.length - 1 const lastIndex = newList.length - 1
if (lastIndex >= 0) { if (lastIndex >= 0) {
newList[lastIndex] = { newList[lastIndex] = { ...newList[lastIndex], status: 'failed', content: null, subContent: errorInfo.error }
...newList[lastIndex],
status: 'failed',
content: null,
subContent: error.error
}
} }
return newList return newList
}) })
}).finally(() => { })
.finally(() => {
setLoading(false) setLoading(false)
setStreamLoading(false) setStreamLoading(false)
}) })
} }
const handleWorkflowStreamMessage = (data: SSEMessage[]) => { const handleWorkflowStreamMessage = (data: SSEMessage[]) => {
data.forEach(item => { data.forEach(item => {
const { content, conversation_id } = item.data as NodeData; const { content, conversation_id } = item.data as NodeData;
switch (item.event) { switch (item.event) {
// Append streaming text chunks to assistant message // Append streaming text chunks to assistant message
case 'message': case 'message':
setChatList(prev => { setChatList(prev => {
const newList = [...prev] const newList = [...prev]
const lastIndex = newList.length - 1 const lastIndex = newList.length - 1
if (lastIndex >= 0) { if (lastIndex >= 0) {
newList[lastIndex] = { newList[lastIndex] = { ...newList[lastIndex], content: newList[lastIndex].content + content }
...newList[lastIndex],
content: newList[lastIndex].content + content
}
} }
return newList return newList
}) })
@@ -388,10 +303,10 @@ const TestChat: FC<TestChatProps> = ({
} }
}) })
} }
const addWorkflowNodeStartMessage = (data: NodeData) => { const addWorkflowNodeStartMessage = (data: NodeData) => {
const { node_id } = data; const { node_id } = data;
const { nodes } = config as WorkflowConfig const { nodes } = config as WorkflowConfig
const node = nodes.find(n => n.id === node_id); const node = nodes.find(n => n.id === node_id);
const { name, type } = node || {} const { name, type } = node || {}
const icon = nodeLibrary.flatMap(g => g.nodes).find(n => n.type === type)?.icon const icon = nodeLibrary.flatMap(g => g.nodes).find(n => n.type === type)?.icon
@@ -428,6 +343,7 @@ const TestChat: FC<TestChatProps> = ({
return newList return newList
}) })
} }
const updateWorkflowNodeEndMessage = (data: NodeData) => { const updateWorkflowNodeEndMessage = (data: NodeData) => {
const { node_id, input, output, error, elapsed_time, status } = data; const { node_id, input, output, error, elapsed_time, status } = data;
setChatList(prev => { setChatList(prev => {
@@ -456,10 +372,10 @@ const TestChat: FC<TestChatProps> = ({
return newList return newList
}) })
} }
const updateWorkflowCycleMessage = (data: NodeData) => { const updateWorkflowCycleMessage = (data: NodeData) => {
const { node_id, cycle_id, cycle_idx, input, output, error, elapsed_time, status } = data; const { node_id, cycle_id, cycle_idx, input, output, error, elapsed_time, status } = data;
const { nodes } = config as WorkflowConfig const { nodes } = config as WorkflowConfig
const node = nodes.find(n => n.id === node_id); const node = nodes.find(n => n.id === node_id);
const { name, type } = node || {} const { name, type } = node || {}
const icon = nodeLibrary.flatMap(g => g.nodes).find(n => n.type === type)?.icon const icon = nodeLibrary.flatMap(g => g.nodes).find(n => n.type === type)?.icon
@@ -500,22 +416,9 @@ const TestChat: FC<TestChatProps> = ({
return newList return newList
}) })
} }
const updateWorkflowEndMessage = (data: NodeData) => { const updateWorkflowEndMessage = (data: NodeData) => {
const { error, status } = data as { const { error, status, audio_url } = data;
content: string;
conversation_id: string | null;
cycle_id: string;
cycle_idx: number;
node_id: string;
node_name?: string;
node_type?: string;
input?: any;
output?: any;
elapsed_time?: string;
error?: any;
state: Record<string, any>;
status?: 'completed' | 'failed'
};
setChatList(prev => { setChatList(prev => {
const newList = [...prev] const newList = [...prev]
const lastIndex = newList.length - 1 const lastIndex = newList.length - 1
@@ -525,13 +428,13 @@ const TestChat: FC<TestChatProps> = ({
status, status,
error, error,
content: newList[lastIndex].content === '' ? null : newList[lastIndex].content, content: newList[lastIndex].content === '' ? null : newList[lastIndex].content,
audioUrl: audio_url
} }
} }
return newList return newList
}) })
} }
console.log('queryValues', queryValues)
return ( return (
<div className="rb:w-250 rb:p-3 rb:mx-auto"> <div className="rb:w-250 rb:p-3 rb:mx-auto">
<RbCard <RbCard
@@ -543,97 +446,29 @@ const TestChat: FC<TestChatProps> = ({
<Chat <Chat
empty={<Empty url={ChatIcon} title={t('application.testChatEmpty')} isNeedSubTitle={false} size={[240, 200]} />} empty={<Empty url={ChatIcon} title={t('application.testChatEmpty')} isNeedSubTitle={false} size={[240, 200]} />}
contentClassName={clsx(`rb:mx-[16px] rb:pt-[24px]`, { contentClassName={clsx(`rb:mx-[16px] rb:pt-[24px]`, {
'rb:h-[calc(100%-140px)]': !queryValues?.files?.length, 'rb:h-[calc(100%-140px)]': !fileList.length,
'rb:h-[calc(100%-208px)]': !!queryValues?.files?.length, 'rb:h-[calc(100%-208px)]': !!fileList.length,
})} })}
data={chatList} data={chatList}
streamLoading={streamLoading} streamLoading={streamLoading}
loading={loading} loading={loading}
onChange={setMessage} onChange={setMessage}
onSend={application?.type === 'workflow' ? handleWorkflowSend : handleSend} onSend={application?.type === 'workflow' ? handleWorkflowSend : handleSend}
fileList={queryValues?.files || []} fileList={fileList}
fileChange={updateFileList} fileChange={(list) => {
setFileList(list || [])
toolbarRef.current?.setFiles(list || [])
}}
labelFormat={(item) => item.role === 'user' ? t('application.you') : dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')} labelFormat={(item) => item.role === 'user' ? t('application.you') : dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
errorDesc={t('application.ReplyException')} errorDesc={t('application.ReplyException')}
renderRuntime={application?.type === 'workflow' ? (item, index) => { renderRuntime={application?.type === 'workflow' ? (item, index) => <Runtime item={item} index={index} /> : undefined}
return <Runtime item={item} index={index} />
} : undefined}
> >
<Form form={form}> <ChatToolbar
<Flex justify="space-between" className="rb:flex-1"> ref={toolbarRef}
<Space size={8} align="center"> features={features}
<Form.Item name="files" noStyle> onFilesChange={setFileList}
<Dropdown />
menu={{
items: [
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
{
key: 'upload', label: (
<UploadFiles
onChange={fileChange}
/>
)
},
],
onClick: handleShowUpload
}}
>
<Flex align="center" justify="center" className="rb:size-7 rb:cursor-pointer rb:rounded-[14px] rb:border rb:border-[#EBEBEB] rb:hover:bg-[#F6F6F6]">
<div
className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')]"
></div>
</Flex>
</Dropdown>
</Form.Item>
{/* <Form.Item name="web_search" valuePropName="checked" className="rb:mb-0!">
<ButtonCheckbox
icon={OnlineIcon}
checkedIcon={OnlineCheckedIcon}
>
{t(`memoryConversation.web_search`)}
</ButtonCheckbox>
</Form.Item>
<Tooltip title={t(`memoryConversation.memory`)}></Tooltip>
<Form.Item name="memory" valuePropName="checked" className="rb:mb-0!">
<ButtonCheckbox
icon={MemoryFunctionIcon}
checkedIcon={MemoryFunctionCheckedIcon}
cicle={true}
>
</ButtonCheckbox>
</Form.Item> */}
<Form.Item name="variables" className="rb:mb-0!" hidden={!queryValues?.variables?.length}>
<div
className={clsx("rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]", {
'rb:border-[#FF5D34] rb:text-[#FF5D34]': isNeedVariableConfig,
'rb:border-[#DFE4ED]': !isNeedVariableConfig,
})}
onClick={handleEditVariables}
>
<SettingOutlined className="rb:mr-1" />
{t(`memoryConversation.variableConfig`)}
</div>
</Form.Item>
</Space>
<Space size={8} align="center">
<AudioRecorder
onRecordingComplete={handleRecordingComplete}
/>
<Divider type="vertical" className="rb:ml-0! rb:mr-2!" />
</Space>
</Flex>
</Form>
</Chat> </Chat>
<VariableConfigModal
ref={variableConfigModalRef}
refresh={handleSave}
/>
<UploadFileListModal
ref={uploadFileListModalRef}
refresh={addFileList}
/>
</RbCard> </RbCard>
</div> </div>
) )

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-03-13 17:19:13 * @Date: 2026-03-13 17:19:13
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-13 17:26:57 * @Last Modified time: 2026-03-18 16:03:46
*/ */
import { forwardRef, useImperativeHandle, useState } from 'react'; import { forwardRef, useImperativeHandle, useState } from 'react';
import { Checkbox, App, Form } from 'antd'; import { Checkbox, App, Form } from 'antd';
@@ -78,7 +78,7 @@ const AppSharingModal = forwardRef<AppSharingModalRef, AppSharingModalProps>(({
*/ */
const handleToggle = (id: string, isShared: boolean) => { const handleToggle = (id: string, isShared: boolean) => {
if (isShared) return; if (isShared) return;
const prev = form.getFieldValue('target_workspace_ids') as string[] ?? []; const prev: string[] = form.getFieldValue('target_workspace_ids') ?? [];
form.setFieldValue( form.setFieldValue(
'target_workspace_ids', 'target_workspace_ids',
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id] prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
@@ -135,10 +135,16 @@ const AppSharingModal = forwardRef<AppSharingModalRef, AppSharingModalProps>(({
{/* Target space: scrollable list of workspaces with checkbox selection */} {/* Target space: scrollable list of workspaces with checkbox selection */}
<Form.Item <Form.Item
name="target_workspace_ids"
label={t('application.selectTargetSpace')} label={t('application.selectTargetSpace')}
rules={[{ required: true, message: t('common.pleaseSelect') }]} required
> >
<Form.Item
name="target_workspace_ids"
noStyle
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<input type="hidden" />
</Form.Item>
<div className="rb:rounded-lg rb:border rb:border-[#EBEBEB] rb:divide-y rb:divide-[#EBEBEB] rb:max-h-50 rb:overflow-y-auto"> <div className="rb:rounded-lg rb:border rb:border-[#EBEBEB] rb:divide-y rb:divide-[#EBEBEB] rb:max-h-50 rb:overflow-y-auto">
{spaceList.map(space => { {spaceList.map(space => {
const isShared = sharedIds.includes(space.id); const isShared = sharedIds.includes(space.id);
@@ -146,11 +152,11 @@ const AppSharingModal = forwardRef<AppSharingModalRef, AppSharingModalProps>(({
<div key={space.id} className="rb:flex rb:items-center rb:gap-2 rb:px-4 rb:py-3 rb:cursor-pointer" onClick={() => handleToggle(space.id, isShared)}> <div key={space.id} className="rb:flex rb:items-center rb:gap-2 rb:px-4 rb:py-3 rb:cursor-pointer" onClick={() => handleToggle(space.id, isShared)}>
<Checkbox <Checkbox
checked={isShared || selectedIds.includes(space.id)} checked={isShared || selectedIds.includes(space.id)}
disabled={isShared} // already-shared workspaces cannot be unselected disabled={isShared}
onClick={(e) => e.stopPropagation()}
onChange={() => handleToggle(space.id, isShared)} onChange={() => handleToggle(space.id, isShared)}
/> />
<span className="rb:flex-1 rb:text-sm">{space.name}</span> <span className="rb:flex-1 rb:text-sm">{space.name}</span>
{/* Badge shown when the app is already shared with this workspace */}
{isShared && ( {isShared && (
<span className="rb:text-xs rb:text-[#5B6167]">{t('application.alreadyShared')}</span> <span className="rb:text-xs rb:text-[#5B6167]">{t('application.alreadyShared')}</span>
)} )}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:27:39 * @Date: 2026-02-03 16:27:39
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-13 15:20:32 * @Last Modified time: 2026-03-17 15:27:57
*/ */
/** /**
* Chat debugging component for application testing * Chat debugging component for application testing
@@ -12,25 +12,25 @@
import { type FC, useEffect, useState, useRef } from 'react'; import { type FC, useEffect, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'
import clsx from 'clsx' import clsx from 'clsx'
import { Flex, Dropdown, type MenuProps, App, Divider } from 'antd'; import { App } from 'antd';
import { SettingOutlined } from '@ant-design/icons' import { SettingOutlined } from '@ant-design/icons'
import ChatIcon from '@/assets/images/application/chat.png' import ChatIcon from '@/assets/images/application/chat.png'
import DebuggingEmpty from '@/assets/images/application/debuggingEmpty.png' import DebuggingEmpty from '@/assets/images/application/debuggingEmpty.png'
import type { ChatData, Config } from '../types' import type { ChatData, Config, FeaturesConfigForm } from '../types'
import { runCompare, draftRun } from '@/api/application' import { runCompare, draftRun } from '@/api/application'
import Empty from '@/components/Empty' import Empty from '@/components/Empty'
import ChatContent from '@/components/Chat/ChatContent' import ChatContent from '@/components/Chat/ChatContent'
import type { ChatItem } from '@/components/Chat/types' import type { ChatItem } from '@/components/Chat/types'
import { type SSEMessage } from '@/utils/stream' import { type SSEMessage } from '@/utils/stream'
import ChatInput from '@/components/Chat/ChatInput' import ChatInput from '@/components/Chat/ChatInput'
import UploadFiles from '@/views/Conversation/components/FileUpload' import ChatToolbar from '@/components/Chat/ChatToolbar'
import AudioRecorder from '@/components/AudioRecorder' import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar'
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
import type { UploadFileListModalRef } from '@/views/Conversation/types'
import type { Variable } from './VariableList/types' import type { Variable } from './VariableList/types'
/** /**
* Component props * Component props
*/ */
@@ -45,10 +45,12 @@ interface ChatProps {
handleSave: (flag?: boolean) => Promise<unknown>; handleSave: (flag?: boolean) => Promise<unknown>;
/** Source type: multi-agent cluster or single agent */ /** Source type: multi-agent cluster or single agent */
source?: 'multi_agent' | 'agent'; source?: 'multi_agent' | 'agent';
chatVariables?: Variable[]; // Add chatVariables prop /** chatVariables prop */
chatVariables?: Variable[];
handleEditVariables?: () => void; handleEditVariables?: () => void;
} }
/** /**
* Chat debugging component * Chat debugging component
* Allows testing application with different model configurations side-by-side * Allows testing application with different model configurations side-by-side
@@ -58,18 +60,29 @@ const Chat: FC<ChatProps> = ({
handleEditVariables handleEditVariables
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { id } = useParams()
const { message: messageApi } = App.useApp() const { message: messageApi } = App.useApp()
const toolbarRef = useRef<ChatToolbarRef>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [isCluster, setIsCluster] = useState(source === 'multi_agent') const [isCluster, setIsCluster] = useState(source === 'multi_agent')
const [conversationId, setConversationId] = useState<string | null>(null) const [conversationId, setConversationId] = useState<string | null>(null)
const [compareLoading, setCompareLoading] = useState(false) const [compareLoading, setCompareLoading] = useState(false)
const [fileList, setFileList] = useState<any[]>([]) const [fileList, setFileList] = useState<any[]>([])
const [message, setMessage] = useState<string | undefined>(undefined) const [message, setMessage] = useState<string | undefined>(undefined)
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null) const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
useEffect(() => {
setCompareLoading(false)
setLoading(false)
}, [chatList.map(item => item.label).join(',')])
useEffect(() => {
if (data?.features) setFeatures(data.features)
}, [data?.features])
useEffect(() => { useEffect(() => {
setIsCluster(source === 'multi_agent') setIsCluster(source === 'multi_agent')
setFileList([]) toolbarRef.current?.setFiles([])
setMessage(undefined) setMessage(undefined)
}, [source]) }, [source])
@@ -111,8 +124,8 @@ const Chat: FC<ChatProps> = ({
} }
} }
/** Update assistant message with streaming content */ /** Update assistant message with streaming content */
const updateAssistantMessage = (content?: string, model_config_id?: string, conversation_id?: string) => { const updateAssistantMessage = (content?: string, model_config_id?: string, conversation_id?: string, audio_url?: string) => {
if (!content || !model_config_id) return if ((!content && !audio_url) || !model_config_id) return
updateChatList(prev => { updateChatList(prev => {
const targetIndex = prev.findIndex(item => item.model_config_id === model_config_id); const targetIndex = prev.findIndex(item => item.model_config_id === model_config_id);
if (targetIndex !== -1) { if (targetIndex !== -1) {
@@ -123,12 +136,13 @@ const Chat: FC<ChatProps> = ({
if (lastMsg && lastMsg.role === 'assistant') { if (lastMsg && lastMsg.role === 'assistant') {
modelChatList[targetIndex] = { modelChatList[targetIndex] = {
...modelChatList[targetIndex], ...modelChatList[targetIndex],
conversation_id: conversation_id, conversation_id,
list: [ list: [
...curChatMsgList.slice(0, curChatMsgList.length - 1), ...curChatMsgList.slice(0, curChatMsgList.length - 1),
{ {
...lastMsg, ...lastMsg,
content: lastMsg.content + content content: lastMsg.content + (content || ''),
audioUrl: audio_url
} }
] ]
} }
@@ -146,8 +160,7 @@ const Chat: FC<ChatProps> = ({
const targetIndex = prev.findIndex(item => item.model_config_id === model_config_id); const targetIndex = prev.findIndex(item => item.model_config_id === model_config_id);
if (targetIndex > -1) { if (targetIndex > -1) {
const modelChatList = [...prev] const modelChatList = [...prev]
const curModelChat = modelChatList[targetIndex] const curChatMsgList = modelChatList[targetIndex].list || []
const curChatMsgList = curModelChat.list || []
const lastMsg = curChatMsgList[curChatMsgList.length - 1] const lastMsg = curChatMsgList[curChatMsgList.length - 1]
if (lastMsg.role === 'assistant') { if (lastMsg.role === 'assistant') {
modelChatList[targetIndex] = { modelChatList[targetIndex] = {
@@ -169,13 +182,14 @@ const Chat: FC<ChatProps> = ({
} }
/** Send message for agent comparison mode */ /** Send message for agent comparison mode */
const handleSend = (msg?: string) => { const handleSend = (msg?: string) => {
if (loading) return if (loading || !id) return
setLoading(true) setLoading(true)
setCompareLoading(true) setCompareLoading(true)
handleSave(false) handleSave(false)
.then(() => { .then(() => {
const message = msg const message = msg
if (!message?.trim()) return if (!message?.trim()) return
const files = toolbarRef.current?.getFiles() || []
// Validate required variables before sending // Validate required variables before sending
let isCanSend = true let isCanSend = true
const params: Record<string, any> = {} const params: Record<string, any> = {}
@@ -200,8 +214,9 @@ const Chat: FC<ChatProps> = ({
return return
} }
addUserMessage(message, fileList) addUserMessage(message, files)
setMessage(message) setMessage(message)
toolbarRef.current?.setFiles([])
setFileList([]) setFileList([])
addAssistantMessage() addAssistantMessage()
@@ -209,13 +224,16 @@ const Chat: FC<ChatProps> = ({
setCompareLoading(false) setCompareLoading(false)
data.map(item => { data.map(item => {
const { model_config_id, conversation_id, content, message_length } = item.data as { model_config_id: string; conversation_id: string; content: string; message_length: number }; const { model_config_id, conversation_id, content, message_length, audio_url } = item.data as { model_config_id: string; conversation_id: string; content: string; message_length: number; audio_url: string };
switch (item.event) { switch (item.event) {
case 'model_message': case 'model_message':
updateAssistantMessage(content, model_config_id, conversation_id) updateAssistantMessage(content, model_config_id, conversation_id, audio_url)
break; break;
case 'model_end': case 'model_end':
if (audio_url) {
updateAssistantMessage(content, model_config_id, conversation_id, audio_url)
}
updateErrorAssistantMessage(message_length, model_config_id) updateErrorAssistantMessage(message_length, model_config_id)
break; break;
case 'compare_end': case 'compare_end':
@@ -226,9 +244,9 @@ const Chat: FC<ChatProps> = ({
}; };
setTimeout(() => { setTimeout(() => {
runCompare(data.app_id, { runCompare(id, {
message, message,
files: fileList.map(file => { files: files.map(file => {
if (file.url) { if (file.url) {
return file return file
} else { } else {
@@ -246,9 +264,9 @@ const Chat: FC<ChatProps> = ({
conversation_id: item.conversation_id conversation_id: item.conversation_id
})), })),
variables: params, variables: params,
"parallel": true, parallel: true,
"stream": true, stream: true,
"timeout": 60, timeout: 60,
}, handleStreamMessage) }, handleStreamMessage)
.catch(() => { .catch(() => {
setLoading(false) setLoading(false)
@@ -272,7 +290,7 @@ const Chat: FC<ChatProps> = ({
const assistantMessage: ChatItem = { const assistantMessage: ChatItem = {
role: 'assistant', role: 'assistant',
content: '', content: '',
created_at: Date.now(), created_at: Date.now()
}; };
updateChatList(prev => prev.map(item => ({ updateChatList(prev => prev.map(item => ({
...item, ...item,
@@ -284,8 +302,7 @@ const Chat: FC<ChatProps> = ({
if (!content) return if (!content) return
updateChatList(prev => { updateChatList(prev => {
const modelChatList = [...prev] const modelChatList = [...prev]
const curModelChat = modelChatList[0] const curChatMsgList = modelChatList[0].list || []
const curChatMsgList = curModelChat.list || []
const lastMsg = curChatMsgList[curChatMsgList.length - 1] const lastMsg = curChatMsgList[curChatMsgList.length - 1]
if (lastMsg.role === 'assistant') { if (lastMsg.role === 'assistant') {
modelChatList[0] = { modelChatList[0] = {
@@ -305,11 +322,9 @@ const Chat: FC<ChatProps> = ({
/** Update cluster message when error occurs */ /** Update cluster message when error occurs */
const updateClusterErrorAssistantMessage = (message_length: number) => { const updateClusterErrorAssistantMessage = (message_length: number) => {
if (message_length > 0) return if (message_length > 0) return
updateChatList(prev => { updateChatList(prev => {
const modelChatList = [...prev] const modelChatList = [...prev]
const curModelChat = modelChatList[0] const curChatMsgList = modelChatList[0].list || []
const curChatMsgList = curModelChat.list || []
const lastMsg = curChatMsgList[curChatMsgList.length - 1] const lastMsg = curChatMsgList[curChatMsgList.length - 1]
if (lastMsg.role === 'assistant') { if (lastMsg.role === 'assistant') {
modelChatList[0] = { modelChatList[0] = {
@@ -326,17 +341,19 @@ const Chat: FC<ChatProps> = ({
return [...modelChatList] return [...modelChatList]
}) })
} }
/** Send message for cluster mode */
const handleClusterSend = (msg?: string) => { const handleClusterSend = (msg?: string) => {
if (loading) return if (loading || !id) return
setLoading(true) setLoading(true)
setCompareLoading(true) setCompareLoading(true)
handleSave(false) handleSave(false)
.then(() => { .then(() => {
const message = msg const message = msg
if (!message || message.trim() === '') return if (!message || message.trim() === '') return
addUserMessage(message, fileList) const files = toolbarRef.current?.getFiles() || []
addUserMessage(message, files)
setMessage(undefined) setMessage(undefined)
toolbarRef.current?.setFiles([])
setFileList([]) setFileList([])
addClusterAssistantMessage() addClusterAssistantMessage()
@@ -369,13 +386,12 @@ const Chat: FC<ChatProps> = ({
}; };
setTimeout(() => { setTimeout(() => {
draftRun( draftRun(id,
data.app_id,
{ {
message, message,
conversation_id: conversationId, conversation_id: conversationId,
stream: true, stream: true,
files: fileList.map(file => { files: files.map(file => {
if (file.url) { if (file.url) {
return file return file
} else { } else {
@@ -410,36 +426,6 @@ const Chat: FC<ChatProps> = ({
const handleDelete = (index: number) => { const handleDelete = (index: number) => {
updateChatList(chatList.filter((_, voIndex) => voIndex !== index)) updateChatList(chatList.filter((_, voIndex) => voIndex !== index))
} }
const handleMessageChange = (message: string) => {
setMessage(message)
}
const fileChange = (file?: any) => {
setFileList([...fileList, file])
}
const handleRecordingComplete = async (file: any) => {
setFileList([...fileList, {
uid: file.file_id,
response: { data: file },
thumbUrl: file.url,
type: file.type
}])
}
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
switch (key) {
case 'define':
uploadFileListModalRef.current?.handleOpen()
break
}
}
const addFileList = (list?: any[]) => {
if (!list || list.length <= 0) return
setFileList([...fileList, ...(list || [])])
}
const updateFileList = (list?: any[]) => {
setFileList([...list || []])
}
const isNeedVariableConfig = chatVariables?.some(vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === ''))
return ( return (
<div className="rb:relative rb:h-full rb:flex rb:flex-col"> <div className="rb:relative rb:h-full rb:flex rb:flex-col">
@@ -458,13 +444,10 @@ const Chat: FC<ChatProps> = ({
"rb:border-r rb:border-[#DFE4ED]": index !== chatList.length - 1 && chatList.length > 1, "rb:border-r rb:border-[#DFE4ED]": index !== chatList.length - 1 && chatList.length > 1,
})}> })}>
{chat.label && {chat.label &&
<div className={clsx( <div className={clsx("rb:grid rb:bg-[#F0F3F8] rb:text-center rb:flex-[0_0_auto]", {
"rb:grid rb:bg-[#F0F3F8] rb:text-center rb:flex-[0_0_auto]", 'rb:rounded-tr-xl': index === chatList.length - 1,
{ 'rb:rounded-tl-xl': index === 0,
'rb:rounded-tr-xl': index === chatList.length - 1, })}>
'rb:rounded-tl-xl': index === 0,
}
)}>
<div className='rb:relative rb:p-[10px_12px] rb:overflow-hidden'> <div className='rb:relative rb:p-[10px_12px] rb:overflow-hidden'>
<div className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:w-[calc(100%-24px)]">{chat.label}</div> <div className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:w-[calc(100%-24px)]">{chat.label}</div>
<div <div
@@ -501,59 +484,37 @@ const Chat: FC<ChatProps> = ({
message={message} message={message}
className="rb:relative!" className="rb:relative!"
loading={loading} loading={loading}
fileChange={updateFileList} fileChange={(list) => {
setFileList(list || [])
toolbarRef.current?.setFiles(list || [])
}}
fileList={fileList} fileList={fileList}
onSend={isCluster ? handleClusterSend : handleSend} onSend={isCluster ? handleClusterSend : handleSend}
onChange={handleMessageChange} onChange={setMessage}
> >
<Flex justify="space-between" className="rb:flex-1"> <ChatToolbar
<Flex gap={8} align="center"> ref={toolbarRef}
<Dropdown features={features}
menu={{ onFilesChange={setFileList}
items: [ extra={
{ key: 'define', label: t('memoryConversation.addRemoteFile') }, chatVariables && chatVariables.length > 0 ? (
{
key: 'upload', label: (
<UploadFiles
onChange={fileChange}
/>
)
},
],
onClick: handleShowUpload
}}
>
<div <div
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]" className={clsx('rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]', {
></div> 'rb:border-[#FF5D34] rb:text-[#FF5D34]': chatVariables.some(vo => vo.required && !vo.value),
</Dropdown> 'rb:border-[#DFE4ED]': !chatVariables.some(vo => vo.required && !vo.value),
{chatVariables && chatVariables.length > 0 && (
<div
className={clsx("rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]", {
'rb:border-[#FF5D34] rb:text-[#FF5D34]': isNeedVariableConfig,
'rb:border-[#DFE4ED]': !isNeedVariableConfig,
})} })}
onClick={handleEditVariables} onClick={handleEditVariables}
> >
<SettingOutlined className="rb:mr-1" /> <SettingOutlined className="rb:mr-1" />
{t(`memoryConversation.variableConfig`)} {t('memoryConversation.variableConfig')}
</div> </div>
)} ) : null
</Flex> }
<Flex align="center"> />
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
</Flex>
</Flex>
</ChatInput> </ChatInput>
</div> </div>
</> </>
} }
<UploadFileListModal
ref={uploadFileListModalRef}
refresh={addFileList}
/>
</div> </div>
) )
} }

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:27:52 * @Date: 2026-02-03 16:27:52
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 15:58:10 * @Last Modified time: 2026-03-18 15:40:53
*/ */
import { type FC, useRef, useMemo, useCallback } from 'react'; import { type FC, useRef, useMemo, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
@@ -18,10 +18,10 @@ import exportIcon from '@/assets/images/export_hover.svg'
import deleteIcon from '@/assets/images/delete_hover.svg' import deleteIcon from '@/assets/images/delete_hover.svg'
import type { Application, ApplicationModalRef } from '@/views/ApplicationManagement/types'; import type { Application, ApplicationModalRef } from '@/views/ApplicationManagement/types';
import ApplicationModal from '@/views/ApplicationManagement/components/ApplicationModal' import ApplicationModal from '@/views/ApplicationManagement/components/ApplicationModal'
import type { CopyModalRef, AgentRef, ClusterRef, WorkflowRef, FunConfigForm } from '../types' import type { CopyModalRef, AgentRef, ClusterRef, WorkflowRef, FeaturesConfigForm } from '../types'
import { deleteApplication, appExport } from '@/api/application' import { deleteApplication, appExport } from '@/api/application'
import CopyModal from './CopyModal' import CopyModal from './CopyModal'
import FunConfig from './FunConfig' import FeaturesConfig from './FeaturesConfig'
const { Header } = Layout; const { Header } = Layout;
@@ -61,6 +61,10 @@ interface ConfigHeaderProps {
workflowRef: React.RefObject<WorkflowRef> workflowRef: React.RefObject<WorkflowRef>
/** App component ref (Agent/Cluster/Workflow) */ /** App component ref (Agent/Cluster/Workflow) */
appRef?: React.RefObject<AgentRef | ClusterRef | WorkflowRef> appRef?: React.RefObject<AgentRef | ClusterRef | WorkflowRef>
/** Features config from parent state */
features?: FeaturesConfigForm;
/** Callback to update features in parent */
onFeaturesChange?: (value: FeaturesConfigForm) => void;
} }
/** /**
@@ -71,6 +75,8 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
application, activeTab, handleChangeTab, refresh, application, activeTab, handleChangeTab, refresh,
workflowRef, workflowRef,
appRef, appRef,
features,
onFeaturesChange,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -173,14 +179,10 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
return items return items
}, [t, handleClick, application]) }, [t, handleClick, application])
const funConfig = useMemo(() => { const handleSaveFeaturesConfig = useCallback((value: FeaturesConfigForm) => {
return (appRef?.current?.funConfig || { file_type: [] }) as FunConfigForm appRef?.current?.handleSaveFeaturesConfig?.(value)
}, [appRef]) onFeaturesChange?.(value)
const handleSaveFunConfig = useCallback((value: FunConfigForm) => { }, [appRef, onFeaturesChange])
appRef?.current?.handleSaveFunConfig?.(value)
}, [appRef])
console.log('formatMenuItems', formatMenuItems)
return ( return (
<> <>
<Header className="rb:w-full rb:h-16 rb:grid rb:grid-cols-3 rb:p-[16px_16px_16px_24px]! rb:border-b rb:border-[#EAECEE] rb:leading-8"> <Header className="rb:w-full rb:h-16 rb:grid rb:grid-cols-3 rb:p-[16px_16px_16px_24px]! rb:border-b rb:border-[#EAECEE] rb:leading-8">
@@ -211,7 +213,7 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
</div> </div>
{application?.type === 'workflow' {application?.type === 'workflow'
? <div className="rb:h-8 rb:flex rb:items-center rb:justify-end rb:gap-2.5"> ? <div className="rb:h-8 rb:flex rb:items-center rb:justify-end rb:gap-2.5">
{/* <FunConfig value={funConfig} refresh={handleSaveFunConfig} /> */} <FeaturesConfig source={application?.type} value={features} refresh={handleSaveFeaturesConfig} />
<Button onClick={clear}>{t('workflow.clear')}</Button> <Button onClick={clear}>{t('workflow.clear')}</Button>
<Button onClick={addvariable}>{t('workflow.addvariable')}</Button> <Button onClick={addvariable}>{t('workflow.addvariable')}</Button>
<Button onClick={run}>{t('workflow.run')}</Button> <Button onClick={run}>{t('workflow.run')}</Button>

View File

@@ -0,0 +1,156 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 16:27:56
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-18 15:38:14
*/
/**
* Copy Application Modal
* Allows users to duplicate an existing application with a new name
*/
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
import { Form, Button, Flex } from 'antd';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx'
import type { FeaturesConfigModalRef, FeaturesConfigForm } from '../../types'
import RbModal from '@/components/RbModal'
import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
import FileUploadSettingModal from './FileUploadSettingModal'
import type { Application } from '@/views/ApplicationManagement/types';
interface FeaturesConfigModalProps {
refresh: (value: FeaturesConfigForm) => void;
source?: Application['type'];
}
/**
* Modal for copying applications
*/
const FeaturesConfigModal = forwardRef<FeaturesConfigModalRef, FeaturesConfigModalProps>(({
refresh,
source,
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<FeaturesConfigForm>();
const values = Form.useWatch([], form)
const fileUploadSettingModalRef = useRef<any>(null)
/** Close modal and reset form */
const handleClose = () => {
setVisible(false);
form.resetFields();
};
/** Open modal */
const handleOpen = (initValue: FeaturesConfigForm) => {
setVisible(true);
console.log('initValue', initValue)
form.setFieldsValue(initValue)
};
/** Copy application with new name */
const handleSave = () => {
setVisible(false);
refresh(form.getFieldsValue())
}
const handleOpenSettings = () => {
fileUploadSettingModalRef.current?.handleOpen(values?.file_upload)
}
const handleSaveSettings = (settings: FeaturesConfigForm['file_upload']) => {
form.setFieldValue('file_upload', { ...settings, enabled: values?.file_upload?.enabled ?? false })
}
/** Expose methods to parent component */
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<>
<RbModal
title={t('application.features')}
open={visible}
onCancel={handleClose}
okText={t('common.confirm')}
onOk={handleSave}
>
<Form
form={form}
layout="vertical"
>
<Flex vertical gap={12}>
{source !== 'workflow' && <>
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
<SwitchFormItem
title={t(`memoryConversation.web_search`)}
name={['web_search', "enabled"]}
/>
</div>
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
<SwitchFormItem
title={t('application.text_to_speech')}
name={['text_to_speech', "enabled"]}
desc={t('application.text_to_speech_desc')}
/>
</div>
</>}
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
<SwitchFormItem
title={t('application.file_upload')}
name={['file_upload', "enabled"]}
desc={values?.file_upload?.enabled ? undefined : t('application.file_upload_desc')}
/>
{values?.file_upload?.enabled && (() => {
const fu = values.file_upload
const types = [
{ type: 'image', enabled: fu.image_enabled, maxSize: fu.image_max_size_mb },
{ type: 'audio', enabled: fu.audio_enabled, maxSize: fu.audio_max_size_mb },
{ type: 'document', enabled: fu.document_enabled, maxSize: fu.document_max_size_mb },
{ type: 'video', enabled: fu.video_enabled, maxSize: fu.video_max_size_mb },
].filter(item => item.enabled)
return types.length > 0 ? <>
<Flex gap={12} className="rb:py-2!">
<div className="rb:flex-1 rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:bg-white rb:text-[12px]">
<div className="rb:grid rb:grid-cols-2 rb:gap-2 rb:text-[12px] rb:text-[#5B6167] rb:border-b rb:border-b-[#DFE4ED]">
<div className="rb:px-3 rb:py-1">{t(`application.supportedTypes`)}</div>
<div className="rb:px-3 rb:py-1">{t('application.singleMaxSize')}</div>
</div>
{types.map((item, index) => (
<div key={item.type} className={clsx('rb:grid rb:grid-cols-2 rb:gap-2', {
'rb:border-b rb:border-b-[#DFE4ED]': index !== types.length - 1
})}>
<div className="rb:px-3 rb:py-1">{t(`application.${item.type}`)}</div>
<div className="rb:px-3 rb:py-1">{item.maxSize} MB</div>
</div>
))}
</div>
<div>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:py-1">{t('application.maxCount')}</div>
{fu.max_file_count} {t('application.unix')}
</div>
</Flex>
<Button block onClick={handleOpenSettings}>{t('application.setting')}</Button>
</> : <Button block onClick={handleOpenSettings}>{t('application.setting')}</Button>
})()}
<Form.Item name="file_upload" hidden />
</div>
</Flex>
</Form>
</RbModal>
<FileUploadSettingModal
ref={fileUploadSettingModalRef}
onSave={handleSaveSettings}
/>
</>
);
});
export default FeaturesConfigModal;

View File

@@ -0,0 +1,180 @@
/*
* @Author: ZhaoYing
* @Date: 2026-03-05
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-17 18:10:47
*/
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, InputNumber, Flex, Switch, Row, Col, Radio } from 'antd';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import RbModal from '@/components/RbModal';
import type { FeaturesConfigForm } from '../../types'
type FileUpload = Omit<FeaturesConfigForm['file_upload'], 'settings'>
interface FileUploadSettingModalRef {
handleOpen: (values?: FileUpload) => void;
handleClose: () => void;
}
interface FileUploadSettingModalProps {
onSave: (values: FileUpload) => void;
}
const fileTypeOptions = [
{
type: 'document',
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/txt.svg')]"></div>,
formats: 'TXT, MD, MDX, MARKDOWN, PDF, DOC, DOCX',
},
{
type: 'image',
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/image.svg')]"></div>,
formats: 'JPG, JPEG, PNG, GIF, WEBP, SVG',
},
{
type: 'audio',
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/audio.svg')]"></div>,
formats: 'MP3, M4A, WAV, AMR, MPGA',
},
{
type: 'video',
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/video.svg')]"></div>,
formats: 'MP4, MOV, MPEG, WEBM',
},
];
const defaultValues: FileUpload = {
enabled: false,
image_enabled: false,
image_max_size_mb: 20,
image_allowed_extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'],
audio_enabled: false,
audio_max_size_mb: 50,
audio_allowed_extensions: ['mp3', 'wav', 'm4a', 'ogg', 'flac'],
document_enabled: false,
document_max_size_mb: 100,
document_allowed_extensions: ['pdf', 'docx', 'xlsx', 'txt', 'csv', 'json'],
video_enabled: false,
video_max_size_mb: 500,
video_allowed_extensions: ['mp4', 'mov', 'avi', 'webm'],
max_file_count: 5,
allowed_transfer_methods: 'both'
}
const FileUploadSettingModal = forwardRef<FileUploadSettingModalRef, FileUploadSettingModalProps>(({
onSave,
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<FileUpload>();
const values = Form.useWatch([], form)
const handleClose = () => {
setVisible(false);
form.resetFields();
};
const handleOpen = (values?: FileUpload) => {
setVisible(true);
if (values) {
const methods = values.allowed_transfer_methods
const transferMethod = Array.isArray(methods)
? methods.length === 2 ? 'both' : methods[0]
: methods
form.setFieldsValue({ ...values, allowed_transfer_methods: transferMethod as any })
} else {
form.setFieldsValue(defaultValues)
}
};
const handleSave = async () => {
const vals = await form.validateFields();
const methodMap: Record<string, string[]> = {
local_file: ['local_file'],
remote_url: ['remote_url'],
both: ['local_file', 'remote_url'],
}
onSave({ ...vals, allowed_transfer_methods: methodMap[vals.allowed_transfer_methods as unknown as string] ?? [] });
handleClose();
};
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={t('application.settings')}
open={visible}
onCancel={handleClose}
onOk={handleSave}
width={600}
>
<Form form={form} layout="vertical" initialValues={defaultValues}>
<Form.Item
label={t('application.uploadType')}
name="allowed_transfer_methods"
>
<Radio.Group block buttonStyle="solid">
<Radio.Button value="local_file">{t('application.local')}</Radio.Button>
<Radio.Button value="remote_url">URL</Radio.Button>
<Radio.Button value="both">{t('application.both')}</Radio.Button>
</Radio.Group>
</Form.Item>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mb-1">{t('application.maxCount')}</div>
<Form.Item label={t('application.maxCount')} name="max_file_count">
<InputNumber min={1} max={100} precision={0} className="rb:w-full!" placeholder={t('common.pleaseEnter')} />
</Form.Item>
<Form.Item label={t('application.supportedTypes')}>
<Flex vertical gap={12}>
{fileTypeOptions.map((option) => {
const enabledKey = `${option.type}_enabled` as keyof FileUpload
const sizeKey = `${option.type}_max_size_mb` as keyof FileUpload
const isEnabled = values?.[enabledKey]
return (
<div
key={option.type}
className={clsx('rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:p-3', {
'rb:bg-[#f5f7fc]': isEnabled
})}
>
<Row gutter={12}>
<Col flex="36px" className="rb:self-center">{option.icon}</Col>
<Col flex="1">
<Flex align="center" justify="space-between">
<Flex vertical>
<div className="rb:font-medium">{t(`application.${option.type}`)}</div>
<div className="rb:text-[12px] rb:text-[#5B6167]">{option.formats}</div>
</Flex>
<Form.Item name={enabledKey} valuePropName="checked" noStyle>
<Switch />
</Form.Item>
</Flex>
</Col>
</Row>
{isEnabled && (
<Flex align="center" gap={12} className="rb:mt-3! rb:pt-3! rb:border-t rb:border-[#DFE4ED]">
<div>{t('application.singleMaxSize')}: </div>
<Form.Item name={sizeKey} noStyle>
<InputNumber min={1} max={500} suffix="MB" className="rb:flex-1" />
</Form.Item>
<Form.Item name={`${option.type}_allowed_extensions`} hidden />
</Flex>
)}
</div>
)
})}
</Flex>
</Form.Item>
</Form>
</RbModal>
);
});
export default FileUploadSettingModal;

View File

@@ -0,0 +1,54 @@
/*
* @Author: ZhaoYing
* @Date: 2026-03-13 17:20:21
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-18 15:38:59
*/
import { type FC, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from 'antd';
import FeaturesConfigModal from './FeaturesConfigModal'
import type { FeaturesConfigModalRef, FeaturesConfigForm } from '../../types'
import type { Application } from '@/views/ApplicationManagement/types';
/** Props for the FeaturesConfig component */
interface FeaturesConfigProps {
/** Current feature configuration values */
value: FeaturesConfigForm;
/** Callback to propagate updated config back to the parent */
refresh: (value: FeaturesConfigForm) => void;
source?: Application['type'];
}
const FeaturesConfig: FC<FeaturesConfigProps> = ({
value,
refresh,
source
}) => {
const { t } = useTranslation();
// Ref used to imperatively open the config modal
const funConfigModalRef = useRef<FeaturesConfigModalRef>(null)
/** Open the feature config modal pre-populated with the current values */
const handleFeaturesConfig = () => {
console.log('handleFeaturesConfig', value)
funConfigModalRef.current?.handleOpen(value)
}
return (
<>
{/* Button that triggers the feature configuration modal */}
<Button onClick={handleFeaturesConfig}>{t('application.features')}</Button>
{/* Modal for editing feature settings; calls refresh on save */}
<FeaturesConfigModal
ref={funConfigModalRef}
refresh={refresh}
source={source}
/>
</>
)
}
export default FeaturesConfig

View File

@@ -1,182 +0,0 @@
/*
* @Author: ZhaoYing
* @Date: 2026-03-05
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-11 15:42:13
*/
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Radio, InputNumber, Flex, Switch, Row, Col } from 'antd';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import RbModal from '@/components/RbModal';
import type { FunConfigForm } from '../../types'
interface FileUploadSettingModalRef {
handleOpen: (values?: FileUploadSettings) => void;
handleClose: () => void;
}
interface FileUploadSettings extends Omit<FunConfigForm, 'enabled'> {}
interface FileUploadSettingModalProps {
onSave: (values: FileUploadSettings) => void;
}
const fileTypeOptions = [
{
type: 'document',
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/txt.svg')]"></div>,
formats: 'TXT, MD, MDX, MARKDOWN, PDF, DOC, DOCX',
defaultMaxCount: 1,
defaultMaxSize: 2
},
{
type: 'image',
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/image.svg')]"></div>,
formats: 'JPG, JPEG, PNG, GIF, WEBP, SVG',
defaultMaxCount: 1,
defaultMaxSize: 2
},
{
type: 'audio',
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/audio.svg')]"></div>,
formats: 'MP3, M4A, WAV, AMR, MPGA',
defaultMaxCount: 1,
defaultMaxSize: 2
},
{
type: 'video',
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/video.svg')]"></div>,
formats: 'MP4, MOV, MPEG, WEBM',
defaultMaxCount: 1,
defaultMaxSize: 2
},
];
const FileUploadSettingModal = forwardRef<FileUploadSettingModalRef, FileUploadSettingModalProps>(({
onSave,
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm();
const values = Form.useWatch([], form)
const handleClose = () => {
setVisible(false);
form.resetFields();
};
const handleOpen = (values?: FileUploadSettings) => {
setVisible(true);
// if (values) {
// form.setFieldsValue(values);
// }
};
const handleSave = async () => {
const values = await form.validateFields();
onSave(values);
handleClose();
};
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={t('application.settings')}
open={visible}
onCancel={handleClose}
onOk={handleSave}
width={600}
>
<Form
form={form}
layout="vertical"
initialValues={{
uploadType: 'both',
fileTypes: fileTypeOptions.map(opt => ({
type: opt.type,
enabled: false,
maxCount: opt.defaultMaxCount,
maxSize: opt.defaultMaxSize
}))
}}
>
<Form.Item
label={t('application.uploadType')}
name="uploadType"
>
<Radio.Group block buttonStyle="solid">
<Radio.Button value="local">{t('application.local')}</Radio.Button>
<Radio.Button value="url">URL</Radio.Button>
<Radio.Button value="both">{t('application.both')}</Radio.Button>
</Radio.Group>
</Form.Item>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mb-1">{t('application.maxCount')}</div>
<Form.Item
name="maxCount"
label={t('application.maxCount')}
>
<InputNumber min={1} max={100} className="rb:w-full!" placeholder={t('common.pleaseEnter')} />
</Form.Item>
<Form.Item label={t('application.supportedTypes')}>
<Form.List name="fileTypes">
{(fields) => (
<Flex vertical gap={12}>
{fields.map((field, index) => {
const option = fileTypeOptions[index];
const isEnabled = values?.fileTypes?.[index]?.enabled;
return (
<div
key={field.key}
className={clsx("rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:p-3", {
'rb:bg-[#f5f7fc]': isEnabled
})}
>
<Row gutter={12}>
<Col flex="36px" className="rb:self-center">
{option.icon}
</Col>
<Col flex="1">
<Flex align="center" justify="space-between">
<Flex vertical>
<div className="rb:font-medium">{t(`application.${option.type}`)}</div>
<div className="rb:text-[12px] rb:text-[#5B6167]">{option.formats}</div>
</Flex>
<Form.Item name={[field.name, 'enabled']} valuePropName="checked" noStyle>
<Switch />
</Form.Item>
</Flex>
</Col>
</Row>
{isEnabled && (
<Flex align="center" gap={12} className="rb:mt-3! rb:pt-3! rb:border-t rb:border-[#DFE4ED]">
<div>{t('application.singleMaxSize')}: </div>
<Form.Item name={[field.name, 'maxSize']} noStyle>
<InputNumber min={1} max={500} suffix="MB" className="rb:flex-1" />
</Form.Item>
</Flex>
)}
<Form.Item name={[field.name, 'type']} hidden>
<input type="hidden" />
</Form.Item>
</div>
);
})}
</Flex>
)}
</Form.List>
</Form.Item>
</Form>
</RbModal>
);
});
export default FileUploadSettingModal;

View File

@@ -1,140 +0,0 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 16:27:56
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-13 17:20:30
*/
/**
* Copy Application Modal
* Allows users to duplicate an existing application with a new name
*/
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
import { Form, Button, Flex } from 'antd';
import { useTranslation } from 'react-i18next';
import type { FunConfigModalRef } from '../../types'
import RbModal from '@/components/RbModal'
import type { FunConfigForm } from '../../types'
import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
import FileUploadSettingModal from './FileUploadSettingModal'
const FormItem = Form.Item;
interface FunConfigModalProps {
refresh: (value: FunConfigForm) => void;
}
/**
* Modal for copying applications
*/
const FunConfigModal = forwardRef<FunConfigModalRef, FunConfigModalProps>(({
refresh,
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<FunConfigForm>();
const [loading, setLoading] = useState(false)
const values = Form.useWatch([], form)
const fileUploadSettingModalRef = useRef<any>(null)
/** Close modal and reset form */
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
};
/** Open modal */
const handleOpen = (initValue: FunConfigForm) => {
setVisible(true);
form.setFieldsValue(initValue)
};
/** Copy application with new name */
const handleSave = () => {
setVisible(false);
setLoading(true)
const values = form.getFieldsValue()
refresh(values)
}
const handleOpenSettings = () => {
fileUploadSettingModalRef.current?.handleOpen(values)
}
const handleSaveSettings = (settings: any) => {
form.setFieldsValue(settings)
}
/** Expose methods to parent component */
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<>
<RbModal
title={t('application.funConfig')}
open={visible}
onCancel={handleClose}
okText={t('common.copy')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
<Flex vertical gap={12}>
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
<SwitchFormItem
title={t(`memoryConversation.web_search`)}
name={['web_search', "enabled"]}
/>
</div>
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
<SwitchFormItem
title={t('application.textTranfer')}
name={['textTranfer', "enabled"]}
desc={t('application.textTranferDesc')}
/>
</div>
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
<SwitchFormItem
title={t('application.fileUpload')}
name={['fileUpload', "enabled"]}
desc={values?.fileUpload?.enabled ? undefined : t('application.fileUploadDesc')}
/>
{values?.fileUpload?.enabled && values?.fileTypes?.length > 0 ? <>
<div className="rb:grid rb:grid-cols-3 rb:gap-2 rb:text-[12px] rb:text-[#5B6167]">
<div>{t(`application.supportedTypes`)}</div>
<div>{t('application.maxCount')}</div>
<div>{t('application.singleMaxSize')}</div>
</div>
{values?.fileTypes?.filter(item => item.enabled).map(item => (
<div key={item.type} className="rb:grid rb:grid-cols-3 rb:gap-2">
<div>{t(`application.${item.type}`)}</div>
<div>{item.maxCount} {t('application.unix')}</div>
<div>{item.maxSize} MB</div>
</div>
))}
<Button block onClick={handleOpenSettings}>{t('application.setting')}</Button>
</> : null}
<FormItem name="fileTypes" noStyle hidden></FormItem>
<FormItem name="uploadType" noStyle hidden></FormItem>
</div>
</Flex>
</Form>
</RbModal>
<FileUploadSettingModal
ref={fileUploadSettingModalRef}
onSave={handleSaveSettings}
/>
</>
);
});
export default FunConfigModal;

View File

@@ -1,50 +0,0 @@
/*
* @Author: ZhaoYing
* @Date: 2026-03-13 17:20:21
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-13 17:20:21
*/
import { type FC, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from 'antd';
import FunConfigModal from './FunConfigModal'
import type { FunConfigModalRef, FunConfigForm } from '../../types'
/** Props for the FunConfig component */
interface FunConfigProps {
/** Current feature configuration values */
value: FunConfigForm;
/** Callback to propagate updated config back to the parent */
refresh: (value: FunConfigForm) => void;
}
const FunConfig: FC<FunConfigProps> = ({
value,
refresh
}) => {
const { t } = useTranslation();
// Ref used to imperatively open the config modal
const funConfigModalRef = useRef<FunConfigModalRef>(null)
/** Open the feature config modal pre-populated with the current values */
const handleFunConfig = () => {
console.log('funConfig', value)
funConfigModalRef.current?.handleOpen(value)
}
return (
<>
{/* Button that triggers the feature configuration modal */}
<Button onClick={handleFunConfig}>{t('application.funConfig')}</Button>
{/* Modal for editing feature settings; calls refresh on save */}
<FunConfigModal
ref={funConfigModalRef}
refresh={refresh}
/>
</>
)
}
export default FunConfig

View File

@@ -1,8 +1,8 @@
/* /*
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:26:03 * @Date: 2026-02-03 16:26:03
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 16:26:03 * @Last Modified time: 2026-03-18 14:01:13
*/ */
/** /**
* Tool List Component * Tool List Component
@@ -22,6 +22,7 @@ import type {
import Empty from '@/components/Empty' import Empty from '@/components/Empty'
import ToolModal from './ToolModal' import ToolModal from './ToolModal'
import { getToolMethods, getToolDetail } from '@/api/tools' import { getToolMethods, getToolDetail } from '@/api/tools'
import Tag from '@/components/Tag'
/** /**
* Tool list management component * Tool list management component
@@ -42,23 +43,25 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
getToolMethods(item.tool_id) getToolMethods(item.tool_id)
]) ])
console.log('toolDetail', toolDetail)
switch ((toolDetail as any).tool_type) { switch ((toolDetail as any).tool_type) {
case 'mcp': case 'mcp':
const mcpFilterItem = (methods as any[]).find(vo => vo.name === item.operation) const mcpFilterItem = (methods as any[]).find(vo => vo.name === item.operation)
return { return {
...item, ...item,
is_active: (toolDetail as any).is_active,
label: mcpFilterItem?.description, label: mcpFilterItem?.description,
method_id: mcpFilterItem?.method_id, method_id: mcpFilterItem?.method_id,
value: mcpFilterItem?.name, value: mcpFilterItem?.name,
description: mcpFilterItem?.description, description: mcpFilterItem?.description,
parameters: mcpFilterItem?.parameters parameters: mcpFilterItem?.parameters
} }
break
case 'builtin': case 'builtin':
if ((methods as any[]).length > 1) { if ((methods as any[]).length > 1) {
const builtinFilterItem = (methods as any[]).find(vo => vo.name === item.operation) const builtinFilterItem = (methods as any[]).find(vo => vo.name === item.operation)
return { return {
...item, ...item,
is_active: (toolDetail as any).is_active,
label: builtinFilterItem?.description, label: builtinFilterItem?.description,
method_id: builtinFilterItem?.method_id, method_id: builtinFilterItem?.method_id,
value: builtinFilterItem?.name, value: builtinFilterItem?.name,
@@ -68,17 +71,18 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
} }
return { return {
...item, ...item,
is_active: (toolDetail as any).is_active,
label: (methods as any[])[0]?.description, label: (methods as any[])[0]?.description,
method_id: (methods as any[])[0]?.method_id, method_id: (methods as any[])[0]?.method_id,
value: (methods as any[])[0]?.name, value: (methods as any[])[0]?.name,
description: (methods as any[])[0]?.description, description: (methods as any[])[0]?.description,
parameters: (methods as any[])[0]?.parameters parameters: (methods as any[])[0]?.parameters
} }
break
default: default:
const customFilterItem = (methods as any[]).find(vo => vo.method_id === item.operation) const customFilterItem = (methods as any[]).find(vo => vo.method_id === item.operation)
return { return {
...item, ...item,
is_active: (toolDetail as any).is_active,
label: customFilterItem?.name, label: customFilterItem?.name,
method_id: customFilterItem?.method_id, method_id: customFilterItem?.method_id,
value: customFilterItem?.name, value: customFilterItem?.name,
@@ -103,7 +107,10 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
} }
/** Add new tool to list */ /** Add new tool to list */
const updateTools = (tool: ToolOption) => { const updateTools = (tool: ToolOption) => {
const list = [...toolList, tool] const list = [...toolList, {
...tool,
is_active: true,
}]
setToolList(list) setToolList(list)
onChange && onChange(list) onChange && onChange(list)
} }
@@ -127,6 +134,7 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
setToolList([...list]) setToolList([...list])
onChange && onChange(list) onChange && onChange(list)
} }
console.log('toolList', toolList)
return ( return (
<Card <Card
title={t('application.toolConfiguration')} title={t('application.toolConfiguration')}
@@ -143,8 +151,13 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
renderItem={(item, index) => ( renderItem={(item, index) => (
<List.Item> <List.Item>
<div key={index} className="rb:flex rb:items-center rb:justify-between rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg"> <div key={index} className="rb:flex rb:items-center rb:justify-between rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg">
<div className="rb:font-medium rb:leading-4"> <div>
{item.label} <div className="rb:font-medium rb:leading-4">
{item.label}
</div>
<Tag color={item.is_active ? 'success' : 'error'} className="rb:mt-1">
{item.is_active ? t('common.enable') : t('common.deleted')}
</Tag>
</div> </div>
<Space size={12}> <Space size={12}>
<div <div

View File

@@ -1,8 +1,8 @@
/* /*
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:26:10 * @Date: 2026-02-03 16:26:10
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 16:26:10 * @Last Modified time: 2026-03-17 15:50:48
*/ */
/** /**
* Type definitions for tool configuration in application settings * Type definitions for tool configuration in application settings
@@ -32,6 +32,7 @@ export interface ToolOption {
tool_id?: string; tool_id?: string;
/** Whether tool is enabled */ /** Whether tool is enabled */
enabled?: boolean; enabled?: boolean;
is_active?: boolean;
} }
/** /**

View File

@@ -37,6 +37,7 @@ const ApplicationConfig: React.FC = () => {
// State // State
const [application, setApplication] = useState<Application | null>(null); const [application, setApplication] = useState<Application | null>(null);
const [activeTab, setActiveTab] = useState('arrangement'); const [activeTab, setActiveTab] = useState('arrangement');
const [features, setFeatures] = useState<import('./types').FeaturesConfigForm | undefined>(undefined);
useEffect(() => { useEffect(() => {
setActiveTab(source === 'sharing' ? 'test' : 'arrangement') setActiveTab(source === 'sharing' ? 'test' : 'arrangement')
@@ -114,10 +115,12 @@ const ApplicationConfig: React.FC = () => {
refresh={getApplicationInfo} refresh={getApplicationInfo}
appRef={application?.type === 'agent' ? agentRef : application?.type === 'multi_agent' ? clusterRef : application?.type === 'workflow' ? workflowRef : undefined} appRef={application?.type === 'agent' ? agentRef : application?.type === 'multi_agent' ? clusterRef : application?.type === 'workflow' ? workflowRef : undefined}
workflowRef={workflowRef} workflowRef={workflowRef}
features={features}
onFeaturesChange={setFeatures}
/> />
{activeTab === 'arrangement' && application?.type === 'agent' && <Agent ref={agentRef} />} {activeTab === 'arrangement' && application?.type === 'agent' && <Agent ref={agentRef} onFeaturesLoad={setFeatures} />}
{activeTab === 'arrangement' && application?.type === 'multi_agent' && <Cluster ref={clusterRef} />} {activeTab === 'arrangement' && application?.type === 'multi_agent' && <Cluster ref={clusterRef} onFeaturesLoad={setFeatures} />}
{activeTab === 'arrangement' && application?.type === 'workflow' && <Workflow ref={workflowRef} />} {activeTab === 'arrangement' && application?.type === 'workflow' && <Workflow ref={workflowRef} onFeaturesLoad={setFeatures} />}
{activeTab === 'api' && <Api application={application} />} {activeTab === 'api' && <Api application={application} />}
{activeTab === 'release' && <ReleasePage data={application as Application} refresh={getApplicationInfo} />} {activeTab === 'release' && <ReleasePage data={application as Application} refresh={getApplicationInfo} />}
{activeTab === 'statistics' && <Statistics application={application} />} {activeTab === 'statistics' && <Statistics application={application} />}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:29:49 * @Date: 2026-02-03 16:29:49
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-13 17:01:04 * @Last Modified time: 2026-03-16 17:42:12
*/ */
import type { KnowledgeConfig } from './components/Knowledge/types' import type { KnowledgeConfig } from './components/Knowledge/types'
import type { Variable } from './components/VariableList/types' import type { Variable } from './components/VariableList/types'
@@ -78,7 +78,7 @@ export interface Config extends MultiAgentConfig {
updated_at: number; updated_at: number;
skills?: SkillConfigForm | null; skills?: SkillConfigForm | null;
funConfig?: FunConfigForm; features?: FeaturesConfigForm;
} }
/** /**
@@ -129,8 +129,8 @@ export interface AgentRef {
* @param flag - Whether to show success message * @param flag - Whether to show success message
*/ */
handleSave: (flag?: boolean) => Promise<unknown>; handleSave: (flag?: boolean) => Promise<unknown>;
funConfig: Config['funConfig']; features: Config['features'];
handleSaveFunConfig?: (value: FunConfigForm) => void; handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
} }
/** /**
@@ -142,8 +142,8 @@ export interface ClusterRef {
* @param flag - Whether to show success message * @param flag - Whether to show success message
*/ */
handleSave: (flag?: boolean) => Promise<unknown>; handleSave: (flag?: boolean) => Promise<unknown>;
funConfig: Config['funConfig']; features: Config['features'];
handleSaveFunConfig?: (value: FunConfigForm) => void; handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
} }
/** /**
@@ -162,8 +162,8 @@ export interface WorkflowRef {
/** Add variable */ /** Add variable */
addVariable: () => void; addVariable: () => void;
config: WorkflowConfig | null; config: WorkflowConfig | null;
funConfig: WorkflowConfig['funConfig']; features: WorkflowConfig['features'];
handleSaveFunConfig?: (value: FunConfigForm) => void; handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
} }
/** /**
@@ -416,17 +416,55 @@ export interface FileTypeConfig {
maxCount: number; maxCount: number;
maxSize: number; maxSize: number;
} }
export interface FunConfigForm { interface FileSetttings {
enabled: boolean; image_enabled: boolean;
fileTypes: FileTypeConfig[] image_max_size_mb: number;
uploadType: 'local' | 'url' | 'both'; image_allowed_extensions: string[];
audio_enabled: boolean;
audio_max_size_mb: number;
audio_allowed_extensions: string[];
document_enabled: boolean;
document_max_size_mb: number;
document_allowed_extensions: string[];
video_enabled: boolean;
video_max_size_mb: number;
video_allowed_extensions: string[];
max_file_count: number;
allowed_transfer_methods: string[] | string;
}
export type FeaturesConfigForm = {
file_upload: FileSetttings & {
enabled: boolean;
settings?: FileSetttings
};
opening_statement: {
enabled: boolean;
statement: string | null;
suggested_questions: string[];
};
suggested_questions_after_answer: {
enabled: boolean;
};
text_to_speech: {
enabled: boolean;
voice: string | null;
language: string | null;
autoplay: boolean;
};
citation: {
enabled: boolean;
};
web_search: {
enabled: boolean;
search_engine: string | null;
};
} }
/** /**
* Function config modal ref methods * Function config modal ref methods
*/ */
export interface FunConfigModalRef { export interface FeaturesConfigModalRef {
/** Open function config modal */ /** Open function config modal */
handleOpen: (value: FunConfigForm) => void; handleOpen: (value: FeaturesConfigForm) => void;
} }
/** /**

View File

@@ -2,15 +2,16 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:34:12 * @Date: 2026-02-03 16:34:12
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-13 17:36:16 * @Last Modified time: 2026-03-18 16:15:43
*/ */
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo, type MouseEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, App, Flex, Row, Col, Collapse } from 'antd'; import { Button, App, Flex, Row, Col, Collapse } from 'antd';
import clsx from 'clsx'; import clsx from 'clsx';
import type { MySharedOutItem } from './types'; import type { MySharedOutItem } from './types';
import { mySharedOutList, cancelShare, cancelSpaceShare } from '@/api/application' import { mySharedOutList, cancelShare, cancelSpaceShare } from '@/api/application'
import BodyWrapper from '@/components/Empty/BodyWrapper'
const MySharing: React.FC = () => { const MySharing: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -20,7 +21,8 @@ const MySharing: React.FC = () => {
useEffect(() => { getList() }, []) useEffect(() => { getList() }, [])
const getList = () => { const getList = () => {
mySharedOutList().then(res => setData(res as MySharedOutItem[])) mySharedOutList()
.then(res => setData(res as MySharedOutItem[]))
} }
/** Group items by target_workspace_id */ /** Group items by target_workspace_id */
@@ -57,7 +59,8 @@ const MySharing: React.FC = () => {
}); });
}; };
const handleCancelOne = (item: MySharedOutItem) => { const handleCancelOne = (item: MySharedOutItem, e: MouseEvent) => {
e.stopPropagation()
modal.confirm({ modal.confirm({
title: t('application.confirmAppCancelShareDesc', { app: item.source_app_name, workspace: item.target_workspace_name }), title: t('application.confirmAppCancelShareDesc', { app: item.source_app_name, workspace: item.target_workspace_name }),
okText: t('common.confirm'), okText: t('common.confirm'),
@@ -71,87 +74,94 @@ const MySharing: React.FC = () => {
} }
}); });
}; };
/** Navigate to application configuration page */
const handleEdit = (item: MySharedOutItem) => {
let url = `/#/application/config/${item.source_app_id}`
window.open(url);
}
return ( return (
<Flex vertical gap={12}> <Flex vertical gap={12} className="rb:h-[calc(100vh-148px)]! rb:overflow-y-auto!">
{grouped.map(({ workspace, items }) => ( <BodyWrapper loading={false} empty={data.length === 0}>
<Collapse {grouped.map(({ workspace, items }) => (
key={workspace.target_workspace_id} <Collapse
defaultActiveKey={[workspace.target_workspace_id]} key={workspace.target_workspace_id}
items={[{ defaultActiveKey={[workspace.target_workspace_id]}
key: workspace.target_workspace_id, items={[{
label: ( key: workspace.target_workspace_id,
<Flex align="center" gap={12}> label: (
{workspace.target_workspace_icon <Flex align="center" gap={12}>
? <img src={workspace.target_workspace_icon} className="rb:w-8 rb:h-8 rb:rounded-lg rb:object-cover" /> {workspace.target_workspace_icon
: <div className="rb:w-8 rb:h-8 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white"> ? <img src={workspace.target_workspace_icon} className="rb:w-8 rb:h-8 rb:rounded-lg rb:object-cover" />
{workspace.target_workspace_name[0]} : <div className="rb:w-8 rb:h-8 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
</div> {workspace.target_workspace_name[0]}
}
<div>
<span className="rb:font-medium">{workspace.target_workspace_name}</span>
<div className="rb:text-[#5B6167] rb:text-[12px]">{t('application.appCount', { count: items.length })}</div>
</div>
</Flex>
),
extra: (
<Button
size="small"
onClick={e => { e.stopPropagation(); handleAllCancel(workspace); }}
>
{t('application.allCancel')}
</Button>
),
children: (
<Row gutter={[12, 12]}>
{items.map(item => (
<Col key={item.id} span={6} className="rb:bg-[#F6F6F6] rb:rounded-lg rb:py-3! rb:px-4! rb:relative">
<div
className="rb:absolute rb:top-3 rb:right-3 rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('src/assets/images/close.svg')]"
onClick={() => handleCancelOne(item)}
/>
<Flex gap={8} align="center">
<div className="rb:size-7 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
{item.source_app_name[0]}
</div> </div>
<div className="rb:font-medium">{item.source_app_name}</div> }
</Flex> <div>
<Flex vertical gap={4} className="rb:mt-3! rb:text-[12px]!"> <span className="rb:font-medium">{workspace.target_workspace_name}</span>
<Flex gap={5} justify="space-between"> <div className="rb:text-[#5B6167] rb:text-[12px]">{t('application.appCount', { count: items.length })}</div>
<span className="rb:text-[#5B6167]">{t('application.type')}</span> </div>
<span className={clsx({ </Flex>
'rb:text-[#155EEF] rb:font-medium': item.source_app_type === 'agent', ),
'rb:text-[#369F21] rb:font-medium': item.source_app_type === 'multi_agent', extra: (
})}> <Button
{t(`application.${item.source_app_type}`)} size="small"
</span> onClick={e => { e.stopPropagation(); handleAllCancel(workspace); }}
>
{t('application.allCancel')}
</Button>
),
children: (
<Row gutter={[12, 12]}>
{items.map(item => (
<Col key={item.id} span={6} className="rb:bg-[#F6F6F6] rb:rounded-lg rb:py-3! rb:px-4! rb:relative rb:cursor-pointer" onClick={() => handleEdit(item)}>
<div
className="rb:absolute rb:top-3 rb:right-3 rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/close.svg')]"
onClick={(e) => handleCancelOne(item, e)}
/>
<Flex gap={8} align="center">
<div className="rb:size-7 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
{item.source_app_name[0]}
</div>
<div className="rb:font-medium">{item.source_app_name}</div>
</Flex> </Flex>
<Flex gap={5} justify="space-between"> <Flex vertical gap={4} className="rb:mt-3! rb:text-[12px]!">
<span className="rb:text-[#5B6167]">{t('application.version')}</span> <Flex gap={5} justify="space-between">
<span>{item.source_app_version}</span> <span className="rb:text-[#5B6167]">{t('application.type')}</span>
<span className={clsx({
'rb:text-[#155EEF] rb:font-medium': item.source_app_type === 'agent',
'rb:text-[#369F21] rb:font-medium': item.source_app_type === 'multi_agent',
})}>
{t(`application.${item.source_app_type}`)}
</span>
</Flex>
<Flex gap={5} justify="space-between">
<span className="rb:text-[#5B6167]">{t('application.version')}</span>
<span>{item.source_app_version}</span>
</Flex>
<Flex gap={5} justify="space-between">
<span className="rb:text-[#5B6167]">{t('application.permission')}</span>
<span className={clsx({
'rb:text-[#369F21] rb:font-medium': item.permission === 'editable',
'rb:text-[#5B6167] rb:font-medium': item.permission === 'readonly',
})}>
{t(`application.${item.permission}`)}
</span>
</Flex>
<Flex gap={5} justify="space-between">
<span className="rb:text-[#5B6167]">{t('application.souceStatus')}</span>
<span>{item.source_app_is_active ? t('application.sourceActive') : t('application.sourceInactive')}</span>
</Flex>
</Flex> </Flex>
<Flex gap={5} justify="space-between"> </Col>
<span className="rb:text-[#5B6167]">{t('application.permission')}</span> ))}
<span className={clsx({ </Row>
'rb:text-[#369F21] rb:font-medium': item.permission === 'editable', ),
'rb:text-[#5B6167] rb:font-medium': item.permission === 'readonly', }]}
})}> />
{t(`application.${item.permission}`)} ))}
</span> </BodyWrapper>
</Flex> </Flex>
<Flex gap={5} justify="space-between">
<span className="rb:text-[#5B6167]">{t('application.souceStatus')}</span>
<span>{item.source_app_is_active ? t('application.sourceActive') : t('application.sourceInactive')}</span>
</Flex>
</Flex>
</Col>
))}
</Row>
),
}]}
/>
))}
</Flex>
); );
}; };

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:34:12 * @Date: 2026-02-03 16:34:12
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 09:56:02 * @Last Modified time: 2026-03-18 10:50:33
*/ */
/** /**
* Application Management Page * Application Management Page
@@ -185,7 +185,7 @@ const ApplicationManagement: React.FC = () => {
<PageScrollList<Application, Query> <PageScrollList<Application, Query>
ref={scrollListRef} ref={scrollListRef}
url={getApplicationListUrl} url={getApplicationListUrl}
query={{ ...query, shared_only: activeTab === 'sharing' }} query={{ ...query, shared_only: activeTab === 'sharing', include_shared: activeTab !== 'apps' }}
renderItem={(item) => ( renderItem={(item) => (
<RbCard <RbCard
title={item.name} title={item.name}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:34:15 * @Date: 2026-02-03 16:34:15
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 09:55:52 * @Last Modified time: 2026-03-18 10:50:27
*/ */
/** /**
* Type definitions for Application Management * Type definitions for Application Management
@@ -16,6 +16,7 @@ export interface Query {
search: string; search: string;
type?: string; type?: string;
shared_only?: boolean; shared_only?: boolean;
include_shared?: boolean;
} }
/** /**

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-06 21:09:42 * @Date: 2026-02-06 21:09:42
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-06 12:20:43 * @Last Modified time: 2026-03-17 14:42:31
*/ */
/** /**
* File Upload Component * File Upload Component
@@ -20,7 +20,7 @@
* *
* @component * @component
*/ */
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; import { useState, useEffect, forwardRef, useImperativeHandle, useMemo } from 'react';
import { Upload, Progress, App } from 'antd'; import { Upload, Progress, App } from 'antd';
import type { UploadProps, UploadFile } from 'antd'; import type { UploadProps, UploadFile } from 'antd';
import type { UploadProps as RcUploadProps } from 'antd/es/upload/interface'; import type { UploadProps as RcUploadProps } from 'antd/es/upload/interface';
@@ -28,6 +28,7 @@ import { useTranslation } from 'react-i18next';
import { request } from '@/utils/request' import { request } from '@/utils/request'
import { fileUploadUrlWithoutApiPrefix } from '@/api/fileStorage' import { fileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
interface UploadFilesProps extends Omit<UploadProps, 'onChange'> { interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
/** Upload API endpoint */ /** Upload API endpoint */
@@ -48,14 +49,14 @@ interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
disabled?: boolean; disabled?: boolean;
/** File size limit in MB */ /** File size limit in MB */
fileSize?: number; fileSize?: number;
/** Allowed file types ['doc', 'xls', 'ppt', 'pdf'] */
fileType?: string[];
/** Auto-upload on file selection, default is true */ /** Auto-upload on file selection, default is true */
isAutoUpload?: boolean; isAutoUpload?: boolean;
/** Maximum number of files allowed */ /** Maximum number of files allowed */
maxCount?: number; maxCount?: number;
/** Custom file removal callback */ /** Custom file removal callback */
onRemove?: (file: UploadFile) => boolean | void | Promise<boolean | void>; onRemove?: (file: UploadFile) => boolean | void | Promise<boolean | void>;
featureConfig: FeaturesConfigForm['file_upload']
} }
const transform_file_type = { const transform_file_type = {
@@ -130,11 +131,11 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
onChange, onChange,
disabled = false, disabled = false,
fileSize = 5, fileSize = 5,
fileType = Object.entries(ALL_FILE_TYPE).map(([key]) => key),
isAutoUpload = true, isAutoUpload = true,
maxCount = 1, maxCount = 1,
onRemove: customOnRemove, onRemove: customOnRemove,
requestConfig, requestConfig,
featureConfig,
...props ...props
}, ref) => { }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -142,18 +143,37 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
const [fileList, setFileList] = useState<UploadFile[]>(propFileList); const [fileList, setFileList] = useState<UploadFile[]>(propFileList);
const [accept, setAccept] = useState<string | undefined>(); const [accept, setAccept] = useState<string | undefined>();
const fileType = useMemo(() => {
let types: string[] = [];
['image', 'document', 'video', 'audio'].forEach(type => {
if (featureConfig[`${type}_enabled` as keyof FeaturesConfigForm['file_upload']]) {
types = types.concat(featureConfig[`${type}_allowed_extensions` as keyof FeaturesConfigForm['file_upload']] as string[])
}
})
return types
}, [featureConfig])
/** /**
* Validates file type and size before upload * Validates file type and size before upload
* @returns Upload.LIST_IGNORE to prevent upload, or true to proceed * @returns Upload.LIST_IGNORE to prevent upload, or true to proceed
*/ */
const beforeUpload: RcUploadProps['beforeUpload'] = (file) => { const beforeUpload: RcUploadProps['beforeUpload'] = (file) => {
// Validate file size // Determine file category and get max size from featureConfig
if (fileSize) { const mimePrefix = file.type?.split('/')[0]
const isLtMaxSize = (file.size / 1024 / 1024) < fileSize; const categoryMap: Record<string, keyof FeaturesConfigForm['file_upload']> = {
if (!isLtMaxSize) { image: 'image_max_size_mb',
message.error(t('common.fileSizeTip', { size: fileSize })); video: 'video_max_size_mb',
return Upload.LIST_IGNORE; audio: 'audio_max_size_mb',
} }
const maxSizeKey = categoryMap[mimePrefix] ?? 'document_max_size_mb'
const maxSize = (featureConfig[maxSizeKey] as number) ?? fileSize
const fileSizeMB = file.size / 1024 / 1024
const isLtMaxSize = fileSizeMB < maxSize;
if (!isLtMaxSize) {
message.error(t('common.fileSizeTip', { size: maxSize }));
return Upload.LIST_IGNORE;
} }
// Validate file type // Validate file type
if (fileType && fileType.length > 0) { if (fileType && fileType.length > 0) {

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-06 21:09:47 * @Date: 2026-02-06 21:09:47
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-04 17:47:09 * @Last Modified time: 2026-03-18 15:50:31
*/ */
/** /**
* Upload File List Modal Component * Upload File List Modal Component
@@ -18,25 +18,28 @@
* *
* @component * @component
*/ */
import { forwardRef, useImperativeHandle, useState } from 'react'; import { forwardRef, useImperativeHandle, useState, useMemo } from 'react';
import { Form, Input, Select, Button, Flex } from 'antd'; import { Form, Input, Select, Button, Flex } from 'antd';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { UploadFileListModalRef } from '../types' import type { UploadFileListModalRef } from '../types'
import RbModal from '@/components/RbModal' import RbModal from '@/components/RbModal'
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
const FormItem = Form.Item; const FormItem = Form.Item;
interface UploadFileListModalProps { interface UploadFileListModalProps {
/** Callback to refresh parent component with new file list */ /** Callback to refresh parent component with new file list */
refresh: (fileList?: any[]) => void; refresh: (fileList?: any[]) => void;
featureConfig: FeaturesConfigForm['file_upload']
} }
/** /**
* Modal for adding remote files via URL * Modal for adding remote files via URL
*/ */
const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListModalProps>(({ const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListModalProps>(({
refresh refresh,
featureConfig
}, ref) => { }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
@@ -79,6 +82,20 @@ const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListMod
handleOpen handleOpen
})); }));
const fileTypeOptions = useMemo(() => {
const options = [];
if (featureConfig?.image_enabled) {
options.push({ label: t('memoryConversation.image'), value: 'image' });
}
if (featureConfig?.audio_enabled) {
options.push({ label: t('memoryConversation.audio'), value: 'audio' });
}
if (featureConfig?.video_enabled) {
options.push({ label: t('memoryConversation.video'), value: 'video' });
}
return options;
}, [featureConfig, t])
return ( return (
<RbModal <RbModal
title={t('memoryConversation.addRemoteFile')} title={t('memoryConversation.addRemoteFile')}
@@ -98,16 +115,11 @@ const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListMod
<FormItem <FormItem
{...restField} {...restField}
name={[name, 'type']} name={[name, 'type']}
initialValue="image"
className="rb:mb-0!" className="rb:mb-0!"
> >
<Select <Select
placeholder={t('memoryConversation.fileType')} placeholder={t('memoryConversation.fileType')}
options={[ options={fileTypeOptions}
{ label: t('memoryConversation.image'), value: 'image' },
{ label: t('memoryConversation.audio'), value: 'audio' },
{ label: t('memoryConversation.video'), value: 'video' },
]}
className="rb:w-30" className="rb:w-30"
/> />
</FormItem> </FormItem>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:58:03 * @Date: 2026-02-03 16:58:03
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-04 12:10:44 * @Last Modified time: 2026-03-18 15:35:05
*/ */
/** /**
* Conversation Page * Conversation Page
@@ -14,13 +14,12 @@ import { type FC, useState, useEffect, useRef } from 'react'
import { useParams, useLocation } from 'react-router-dom' import { useParams, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import InfiniteScroll from 'react-infinite-scroll-component'; import InfiniteScroll from 'react-infinite-scroll-component';
import { Flex, Skeleton, Form, Dropdown, type MenuProps, App, Divider } from 'antd' import { Flex, Skeleton, App } from 'antd'
import { SettingOutlined } from '@ant-design/icons'
import clsx from 'clsx' import clsx from 'clsx'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { getConversationHistory, sendConversation, getConversationDetail, getShareToken, getExperienceConfig } from '@/api/application' import { getConversationHistory, sendConversation, getConversationDetail, getShareToken, getExperienceConfig } from '@/api/application'
import type { HistoryItem, QueryParams, UploadFileListModalRef } from './types' import type { HistoryItem } from './types'
import Empty from '@/components/Empty' import Empty from '@/components/Empty'
import { formatDateTime } from '@/utils/format'; import { formatDateTime } from '@/utils/format';
import { randomString } from '@/utils/common' import { randomString } from '@/utils/common'
@@ -34,20 +33,14 @@ import OnlineIcon from '@/assets/images/conversation/online.svg'
import OnlineCheckedIcon from '@/assets/images/conversation/onlineChecked.svg' import OnlineCheckedIcon from '@/assets/images/conversation/onlineChecked.svg'
import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFunctionChecked.svg' import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFunctionChecked.svg'
import { type SSEMessage } from '@/utils/stream' import { type SSEMessage } from '@/utils/stream'
import UploadFiles from './components/FileUpload'
import AudioRecorder from '@/components/AudioRecorder'
import { shareFileUploadUrlWithoutApiPrefix } from '@/api/fileStorage' import { shareFileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
import UploadFileListModal from './components/UploadFileListModal' import ChatToolbar, { type ChatToolbarRef } from '@/components/Chat/ChatToolbar'
import type { VariableConfigModalRef } from '@/views/Workflow/types'
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types' import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal'; import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
/**
* Conversation component for shared applications
*/
const Conversation: FC = () => { const Conversation: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { message: messageApi } = App.useApp() const { message: messageApi, modal } = App.useApp()
const { token } = useParams() const { token } = useParams()
const location = useLocation() const location = useLocation()
const searchParams = new URLSearchParams(location.search) const searchParams = new URLSearchParams(location.search)
@@ -63,35 +56,21 @@ const Conversation: FC = () => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<ChatToolbarRef>(null)
const [shareToken, setShareToken] = useState<string | null>(localStorage.getItem(`shareToken_${token}`)) const [shareToken, setShareToken] = useState<string | null>(localStorage.getItem(`shareToken_${token}`))
const [fileList, setFileList] = useState<any[]>([])
const [webSearch, setWebSearch] = useState(false)
const [isHasMemory, setIsHasMemory] = useState(false)
const [memory, setMemory] = useState(true)
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
const [form] = Form.useForm<QueryParams>()
const queryValues = Form.useWatch<QueryParams>([], form)
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
const [variables, setVariables] = useState<Variable[]>([]) // Workflow input variables
/**
* Opens the variable configuration modal
*/
const handleEditVariables = () => {
variableConfigModalRef.current?.handleOpen(variables)
}
/**
* Saves updated variable values from the modal
*/
const handleSave = (values: Variable[]) => {
setVariables([...values])
}
useEffect(() => { useEffect(() => {
const shareToken = localStorage.getItem(`shareToken_${token}`) const shareToken = localStorage.getItem(`shareToken_${token}`)
setShareToken(shareToken) setShareToken(shareToken)
if (shareToken && shareToken !== '') return if (shareToken && shareToken !== '') return
getShareToken(token as string, userId || randomString(12, false)) getShareToken(token as string, userId || randomString(12, false))
.then(res => { .then(res => {
const response = res as { access_token: string } || {} const response = res as { access_token: string } || {}
localStorage.setItem(`shareToken_${token}`, response.access_token ?? '') localStorage.setItem(`shareToken_${token}`, response.access_token ?? '')
setShareToken(response.access_token ?? '') setShareToken(response.access_token ?? '')
}) })
@@ -102,12 +81,15 @@ const Conversation: FC = () => {
getHistory() getHistory()
} }
}, [token, shareToken, page, hasMore, historyList]) }, [token, shareToken, page, hasMore, historyList])
useEffect(() => { useEffect(() => {
if (shareToken && token) { if (shareToken && token) {
getExperienceConfig(token) getExperienceConfig(token)
.then(res => { .then(res => {
const response = res as { variables: Variable[] } const response = res as { variables: Variable[]; features: FeaturesConfigForm; app_type: string; memory?: boolean; }
setVariables(response.variables || []) toolbarRef.current?.setVariables(response.variables || [])
setFeatures(response.features)
setIsHasMemory((response.app_type === 'workflow' && response.memory) || (response.app_type !== 'workflow'))
}) })
} else { } else {
setChatList([]) setChatList([])
@@ -129,9 +111,7 @@ const Conversation: FC = () => {
/** Fetch conversation history with pagination */ /** Fetch conversation history with pagination */
const getHistory = (flag: boolean = false) => { const getHistory = (flag: boolean = false) => {
if (!token || (pageLoading || !hasMore) && !flag) { if (!token || (pageLoading || !hasMore) && !flag) return
return
}
setPageLoading(true); setPageLoading(true);
getConversationHistory(token, { page: flag ? 1 : page, pagesize: 20 }) getConversationHistory(token, { page: flag ? 1 : page, pagesize: 20 })
.then(res => { .then(res => {
@@ -154,19 +134,14 @@ const Conversation: FC = () => {
setHasMore(response.page.hasnext); setHasMore(response.page.hasnext);
setLoading(false); setLoading(false);
}) })
.finally(() => { .finally(() => setPageLoading(false))
setPageLoading(false);
})
} }
/** Switch to different conversation or start new one */ /** Switch to different conversation or start new one */
const handleChangeHistory = (id: string | null) => { const handleChangeHistory = (id: string | null) => {
if (id !== conversation_id) { if (id !== conversation_id) setConversationId(id)
setConversationId(id) if (!id) setMessage('')
}
if (!id) {
setMessage('')
}
} }
useEffect(() => { useEffect(() => {
if (conversation_id) { if (conversation_id) {
getConversationDetail(token as string, conversation_id) getConversationDetail(token as string, conversation_id)
@@ -179,43 +154,38 @@ const Conversation: FC = () => {
} }
}, [conversation_id]) }, [conversation_id])
/** Add user message to chat */
const addUserMessage = (message: string = '', files?: any[]) => { const addUserMessage = (message: string = '', files?: any[]) => {
const newUserMessage: ChatItem = { setChatList(prev => [...prev, {
conversation_id, conversation_id,
role: 'user', role: 'user',
content: message, content: message,
created_at: Date.now(), created_at: Date.now(),
files files
}; }])
setChatList(prev => [...prev, newUserMessage])
} }
/** Add empty assistant message placeholder */
const addAssistantMessage = () => { const addAssistantMessage = () => {
const newAssistantMessage: ChatItem = { setChatList(prev => [...prev, {
created_at: Date.now(), created_at: Date.now(),
role: 'assistant', role: 'assistant',
content: '', content: ''
} }])
setChatList(prev => [...prev, newAssistantMessage])
} }
/** Update assistant message with streaming content */
const updateAssistantMessage = (content: string = '') => {
if (!content) return
if (streamLoading) {
setStreamLoading(false)
}
const updateAssistantMessage = (content: string = '', audio_url?: string) => {
if (!content && !audio_url) return
if (streamLoading) setStreamLoading(false)
setChatList(prev => { setChatList(prev => {
const lastList = [...prev] const lastList = [...prev]
const lastIndex = lastList.length - 1 const lastIndex = lastList.length - 1
const lastMsg = lastList[lastIndex] const lastMsg = lastList[lastIndex]
if (lastMsg?.role === 'assistant') { if (lastMsg?.role === 'assistant') {
return [ return [
...lastList.slice(0, lastList.length - 1), ...lastList.slice(0, lastIndex),
{ {
...lastMsg, ...lastMsg,
content: lastMsg.content + content content: lastMsg.content + content,
audioUrl: audio_url
} }
] ]
} }
@@ -223,22 +193,17 @@ const Conversation: FC = () => {
}) })
} }
const isNeedVariableConfig = variables.some(vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === ''))
/** Send message and handle streaming response */ /** Send message and handle streaming response */
const handleSend = () => { const handleSend = () => {
if (!token || !shareToken) { if (!token || !shareToken) return
return const files = toolbarRef.current?.getFiles() || []
} const variables = toolbarRef.current?.getVariables() || []
const { files = [], ...rest } = queryValues || {}
// Validate required variables before sending
let isCanSend = true let isCanSend = true
const params: Record<string, any> = {} const params: Record<string, any> = {}
if (variables.length > 0) { if (variables.length > 0) {
const needRequired: string[] = [] const needRequired: string[] = []
variables.forEach(vo => { variables.forEach(vo => {
params[vo.name] = vo.value ?? vo.defaultValue params[vo.name] = vo.value ?? vo.defaultValue
if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) { if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
isCanSend = false isCanSend = false
needRequired.push(vo.name) needRequired.push(vo.name)
@@ -249,33 +214,34 @@ const Conversation: FC = () => {
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`) messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
} }
} }
if (!isCanSend) { if (!isCanSend) return
return
}
setLoading(true) setLoading(true)
setStreamLoading(true) setStreamLoading(true)
addUserMessage(message, files) addUserMessage(message, files)
addAssistantMessage() addAssistantMessage()
toolbarRef.current?.setFiles([])
setFileList([])
let currentConversationId: string | null = null let currentConversationId: string | null = null
const handleStreamMessage = (data: SSEMessage[]) => { const handleStreamMessage = (data: SSEMessage[]) => {
data.forEach((item) => { data.forEach((item) => {
switch(item.event) { const { content, conversation_id: curId, audio_url } = item.data as { content: string; conversation_id: string; audio_url?: string; }
switch (item.event) {
case 'start': case 'start':
case 'node_start': case 'node_start':
const { conversation_id: newId } = item.data as { conversation_id: string } const { conversation_id: newId } = item.data as { conversation_id: string }
currentConversationId = newId currentConversationId = newId
break break
case 'message': case 'message':
const { content, conversation_id: curId } = item.data as { content: string; conversation_id: string; } updateAssistantMessage(content, audio_url)
updateAssistantMessage(content) if (curId) currentConversationId = curId;
if (curId) {
currentConversationId = curId;
}
break break
case 'end': case 'end':
case 'workflow_end': case 'workflow_end':
if (audio_url) {
updateAssistantMessage(content, audio_url)
}
setLoading(false) setLoading(false)
if (currentConversationId && currentConversationId !== conversation_id) { if (currentConversationId && currentConversationId !== conversation_id) {
setConversationId(currentConversationId) setConversationId(currentConversationId)
@@ -286,9 +252,9 @@ const Conversation: FC = () => {
}) })
}; };
form.setFieldValue('files', [])
sendConversation({ sendConversation({
...rest, web_search: webSearch,
memory,
message: message || '', message: message || '',
stream: true, stream: true,
conversation_id: conversation_id || null, conversation_id: conversation_id || null,
@@ -315,32 +281,18 @@ const Conversation: FC = () => {
}) })
} }
const fileChange = (file?: any) => { const handleChangeMemory = (value: boolean) => {
form.setFieldValue('files', [...(queryValues.files || []), file]) modal.confirm({
} title: value ? t('memoryConversation.memoryTipTitle') : t('memoryConversation.memoryCancelTipTitle'),
const handleRecordingComplete = async (file: any) => { okText: t('common.confirm'),
form.setFieldValue('files', [...(queryValues.files || []), { cancelText: t('common.cancel'),
uid: file.file_id, onOk: () => {
response: { data: file }, setMemory(value)
thumbUrl: file.url, },
type: file.type onCancel: () => {
}]) setMemory(!value)
} }
})
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
switch(key) {
case 'define':
uploadFileListModalRef.current?.handleOpen()
break
}
}
const addFileList = (fileList?: any[]) => {
if (!fileList || fileList.length <= 0) return
form.setFieldValue('files', [...(queryValues.files || []), ...fileList])
}
const updateFileList = (fileList?: any[]) => {
console.log('fileList', fileList)
form.setFieldValue('files', [...(fileList || [])])
} }
return ( return (
@@ -365,7 +317,6 @@ const Conversation: FC = () => {
next={getHistory} next={getHistory}
hasMore={hasMore} hasMore={hasMore}
loader={<Skeleton active />} loader={<Skeleton active />}
// endMessage={<Divider plain>It is all, nothing more 🤐</Divider>}
scrollableTarget="scrollableDiv" scrollableTarget="scrollableDiv"
> >
{Object.entries(groupHistoryList).map(([date, items]) => ( {Object.entries(groupHistoryList).map(([date, items]) => (
@@ -374,8 +325,8 @@ const Conversation: FC = () => {
{items.map(item => ( {items.map(item => (
<div key={item.updated_at} className="rb:mb-3"> <div key={item.updated_at} className="rb:mb-3">
<div className={clsx("rb:p-[8px_13px] rb:rounded-lg rb:leading-5 rb:cursor-pointer rb:hover:bg-[#F0F3F8]", { <div className={clsx("rb:p-[8px_13px] rb:rounded-lg rb:leading-5 rb:cursor-pointer rb:hover:bg-[#F0F3F8]", {
'rb:bg-[#FFFFFF] rb:shadow-[0px_2px_4px_0px_rgba(0,0,0,0.15)] rb:font-medium rb:hover:bg-[#FFFFFF]!': item.id === conversation_id, 'rb:bg-[#FFFFFF] rb:shadow-[0px_2px_4px_0px_rgba(0,0,0,0.15)] rb:font-medium rb:hover:bg-[#FFFFFF]!': item.id === conversation_id,
})} })}
onClick={() => handleChangeHistory(item.id)} onClick={() => handleChangeHistory(item.id)}
> >
{item.title} {item.title}
@@ -391,108 +342,61 @@ const Conversation: FC = () => {
</div> </div>
<div className="rb:relative rb:h-screen rb:px-4 rb:flex-[1_1_auto]"> <div className="rb:relative rb:h-screen rb:px-4 rb:flex-[1_1_auto]">
<div className='rb:w-190 rb:h-screen rb:mx-auto rb:pt-10'> <div className='rb:w-190 rb:h-screen rb:mx-auto rb:pt-10'>
<Chat <Chat
empty={<Empty url={ChatEmpty} className="rb:h-full" size={[320,180]} title={t('memoryConversation.chatEmpty')} subTitle={t('memoryConversation.emptyDesc')} />} empty={<Empty url={ChatEmpty} className="rb:h-full" size={[320, 180]} title={t('memoryConversation.chatEmpty')} subTitle={t('memoryConversation.emptyDesc')} />}
contentClassName={!queryValues?.files?.length ? "rb:h-[calc(100%-144px)]" : "rb:h-[calc(100%-208px)]"} contentClassName={!fileList.length ? "rb:h-[calc(100%-144px)]" : "rb:h-[calc(100%-208px)]"}
data={chatList} data={chatList}
streamLoading={streamLoading} streamLoading={streamLoading}
loading={loading} loading={loading}
onChange={setMessage} onChange={setMessage}
onSend={handleSend} onSend={handleSend}
labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')} labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
fileList={queryValues?.files || []} fileList={fileList}
fileChange={updateFileList} fileChange={(list) => {
> setFileList(list || [])
<Form form={form} initialValues={{ memory: false, web_search: false}}> toolbarRef.current?.setFiles(list || [])
<Flex justify="space-between" className="rb:flex-1"> }}
<Flex gap={8} align="center"> >
<Form.Item name="files" noStyle> <ChatToolbar
<Dropdown ref={toolbarRef}
menu={{ features={features}
items: [ onFilesChange={setFileList}
{ key: 'define', label: t('memoryConversation.addRemoteFile') }, uploadAction={shareFileUploadUrlWithoutApiPrefix}
{ uploadRequestConfig={{
key: 'upload', label: ( headers: {
<UploadFiles 'Content-Type': 'multipart/form-data',
action={shareFileUploadUrlWithoutApiPrefix} Authorization: `Bearer ${shareToken || ''}`,
onChange={fileChange} }
requestConfig={{ }}
headers: { extra={
'Content-Type': 'multipart/form-data', <>
Authorization: `Bearer ${shareToken || ''}`, {features?.web_search?.enabled &&
}}}
/>
)
},
],
onClick: handleShowUpload
}}
>
<div
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]"
></div>
</Dropdown>
</Form.Item>
<Form.Item name="web_search" valuePropName="checked" className="rb:mb-0!">
<ButtonCheckbox <ButtonCheckbox
icon={OnlineIcon} icon={OnlineIcon}
checkedIcon={OnlineCheckedIcon} checkedIcon={OnlineCheckedIcon}
checked={webSearch}
onChange={setWebSearch}
> >
{t(`memoryConversation.web_search`)} {t('memoryConversation.web_search')}
</ButtonCheckbox> </ButtonCheckbox>
</Form.Item> }
<Form.Item name="memory" valuePropName="checked" className="rb:mb-0!"> {isHasMemory &&
<ButtonCheckbox <ButtonCheckbox
icon={MemoryFunctionIcon} icon={MemoryFunctionIcon}
checkedIcon={MemoryFunctionCheckedIcon} checkedIcon={MemoryFunctionCheckedIcon}
checked={memory}
onChange={handleChangeMemory}
> >
{t(`memoryConversation.memory`)} {t('memoryConversation.memory')}
</ButtonCheckbox> </ButtonCheckbox>
</Form.Item> }
{variables.length > 0 && ( </>
<Form.Item name="variables" className="rb:mb-0!"> }
<div />
className={clsx("rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]", { </Chat>
'rb:border-[#FF5D34] rb:text-[#FF5D34]': isNeedVariableConfig,
'rb:border-[#DFE4ED]': !isNeedVariableConfig,
})}
onClick={handleEditVariables}
>
<SettingOutlined className="rb:mr-1" />
{t(`memoryConversation.variableConfig`)}
</div>
</Form.Item>
)}
</Flex>
<Flex align="center">
<AudioRecorder
action={shareFileUploadUrlWithoutApiPrefix}
requestConfig={{
headers: {
'Content-Type': 'multipart/form-data',
Authorization: `Bearer ${shareToken || ''}`,
}
}}
onRecordingComplete={handleRecordingComplete}
/>
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
</Flex>
</Flex>
</Form>
</Chat>
</div> </div>
</div> </div>
<UploadFileListModal
ref={uploadFileListModalRef}
refresh={addFileList}
/>
<VariableConfigModal
ref={variableConfigModalRef}
refresh={handleSave}
variables={variables}
/>
</Flex> </Flex>
) )
} }

View File

@@ -1,8 +1,8 @@
/* /*
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 18:32:23 * @Date: 2026-02-03 18:32:23
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 18:32:23 * @Last Modified time: 2026-03-17 17:36:49
*/ */
import { type FC, useEffect, useState } from 'react' import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -90,7 +90,7 @@ const PerceptualLastInfo: FC<{ type: 'last_visual' | 'last_listen' | 'last_text'
}) })
} }
const handleDownload = () => { const handleDownload = async () => {
if (!data.file_path) return if (!data.file_path) return
window.open(data.file_path, '_blank') window.open(data.file_path, '_blank')
} }

View File

@@ -10,10 +10,6 @@ interface CanvasToolbarProps {
isHandMode: boolean; isHandMode: boolean;
setIsHandMode: React.Dispatch<React.SetStateAction<boolean>>; setIsHandMode: React.Dispatch<React.SetStateAction<boolean>>;
zoomLevel: number; zoomLevel: number;
canUndo: boolean;
canRedo: boolean;
onUndo: () => void;
onRedo: () => void;
addNotes: () => void; addNotes: () => void;
} }

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-06 21:10:56 * @Date: 2026-02-06 21:10:56
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-04 18:51:48 * @Last Modified time: 2026-03-18 14:34:20
*/ */
/** /**
* Workflow Chat Component * Workflow Chat Component
@@ -21,50 +21,56 @@
* *
* @component * @component
*/ */
import { forwardRef, useImperativeHandle, useState, useRef } from 'react' import { forwardRef, useImperativeHandle, useState, useRef, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { App, Space, Button, Flex, Dropdown, type MenuProps, Divider } from 'antd' import { App } from 'antd'
import ChatIcon from '@/assets/images/application/chat.png' import ChatIcon from '@/assets/images/application/chat.png'
import RbDrawer from '@/components/RbDrawer'; import RbDrawer from '@/components/RbDrawer';
import VariableConfigModal from './VariableConfigModal'
import { draftRun } from '@/api/application'; import { draftRun } from '@/api/application';
import Empty from '@/components/Empty' import Empty from '@/components/Empty'
import ChatContent from '@/components/Chat/ChatContent' import ChatContent from '@/components/Chat/ChatContent'
import type { ChatItem } from '@/components/Chat/types' import type { ChatItem } from '@/components/Chat/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { ChatRef, VariableConfigModalRef, GraphRef } from '../../types' import type { ChatRef, GraphRef, WorkflowConfig } from '../../types'
import { type SSEMessage } from '@/utils/stream' import { type SSEMessage } from '@/utils/stream'
import type { Variable } from '../Properties/VariableList/types' import type { Variable } from '../Properties/VariableList/types'
import ChatInput from '@/components/Chat/ChatInput' import ChatInput from '@/components/Chat/ChatInput'
import UploadFiles from '@/views/Conversation/components/FileUpload' import ChatToolbar from '@/components/Chat/ChatToolbar'
import AudioRecorder from '@/components/AudioRecorder' import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar'
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
import type { UploadFileListModalRef } from '@/views/Conversation/types'
import Runtime from './Runtime'; import Runtime from './Runtime';
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId, graphRef }, ref) => { const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: WorkflowConfig | null }>(({ appId, graphRef, data }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { message: messageApi } = App.useApp() const { message: messageApi } = App.useApp()
const variableConfigModalRef = useRef<VariableConfigModalRef>(null) const toolbarRef = useRef<ChatToolbarRef>(null)
// State management const toolbarCallbackRef = useCallback((node: ChatToolbarRef | null) => {
const [open, setOpen] = useState(false) // Drawer visibility (toolbarRef as React.MutableRefObject<ChatToolbarRef | null>).current = node
const [loading, setLoading] = useState(false) // Send button loading state }, [])
const [chatList, setChatList] = useState<ChatItem[]>([]) // Chat message history const [open, setOpen] = useState(false)
const [variables, setVariables] = useState<Variable[]>([]) // Workflow input variables const [loading, setLoading] = useState(false)
const [streamLoading, setStreamLoading] = useState(false) // SSE streaming state const [chatList, setChatList] = useState<ChatItem[]>([])
const [conversationId, setConversationId] = useState<string | null>(null) // Current conversation ID const [variables, setVariables] = useState<Variable[]>([])
const [fileList, setFileList] = useState<any[]>([]) // Uploaded files const [streamLoading, setStreamLoading] = useState(false)
const [message, setMessage] = useState<string | undefined>(undefined) // Current input message const [conversationId, setConversationId] = useState<string | null>(null)
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null) const [fileList, setFileList] = useState<any[]>([])
const [message, setMessage] = useState<string | undefined>(undefined)
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
/** /**
* Opens the chat drawer and loads workflow variables from the start node * Opens the chat drawer and loads workflow variables from the start node
*/ */
const handleOpen = () => { const handleOpen = () => {
setOpen(true) setOpen(true)
getVariables() if (data?.features) setFeatures(data.features)
} }
useEffect(() => {
if (open && graphRef.current && toolbarRef.current) {
getVariables()
}
}, [open])
/** /**
* Extracts variables from the workflow's start node and merges with previous values * Extracts variables from the workflow's start node and merges with previous values
*/ */
@@ -84,7 +90,9 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
vo.value = lastVo.value vo.value = lastVo.value
} }
}) })
setVariables(curVariables) console.log('curVariables', curVariables)
setVariables([...curVariables])
toolbarRef.current?.setVariables([...curVariables])
} }
} }
/** /**
@@ -96,22 +104,12 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
setVariables([]) setVariables([])
setConversationId(null) setConversationId(null)
setMessage(undefined) setMessage(undefined)
toolbarRef.current?.setFiles([])
toolbarRef.current?.setVariables([])
setFileList([]) setFileList([])
setLoading(false) setLoading(false)
setStreamLoading(false) setStreamLoading(false)
} }
/**
* Opens the variable configuration modal
*/
const handleEditVariables = () => {
variableConfigModalRef.current?.handleOpen(variables)
}
/**
* Saves updated variable values from the modal
*/
const handleSave = (values: Variable[]) => {
setVariables([...values])
}
/** /**
* Sends a message to execute the workflow * Sends a message to execute the workflow
* *
@@ -337,14 +335,16 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
}) })
} }
const files = toolbarRef.current?.getFiles() || []
setMessage(undefined) setMessage(undefined)
toolbarRef.current?.setFiles([])
setFileList([]) setFileList([])
const data = { const data = {
message: message, message: message,
variables: params, variables: params,
stream: true, stream: true,
conversation_id: conversationId, conversation_id: conversationId,
files: fileList.map(file => { files: files.map(file => {
if (file.url) { if (file.url) {
return file return file
} else { } else {
@@ -359,7 +359,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
setStreamLoading(true) setStreamLoading(true)
draftRun(appId, data, handleStreamMessage) draftRun(appId, data, handleStreamMessage)
.catch((error) => { .catch((error) => {
console.log('draftRun error', error) const errorInfo = JSON.parse(error.message)
setChatList(prev => { setChatList(prev => {
const newList = [...prev] const newList = [...prev]
const lastIndex = newList.length - 1 const lastIndex = newList.length - 1
@@ -368,7 +368,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
...newList[lastIndex], ...newList[lastIndex],
status: 'failed', status: 'failed',
content: null, content: null,
subContent: error.error subContent: errorInfo.error
} }
} }
return newList return newList
@@ -379,65 +379,20 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
}) })
} }
/**
* Updates the current input message
*/
const handleMessageChange = (message: string) => {
setMessage(message)
}
/**
* Handles file upload from local device
*/
const fileChange = (file?: any) => {
setFileList([...fileList, file])
}
const handleRecordingComplete = async (file: any) => {
setFileList([...fileList, {
response: { data: file },
thumbUrl: file.url,
type: file.type
}])
}
/**
* Handles dropdown menu actions for file upload
*/
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
switch(key) {
case 'define':
uploadFileListModalRef.current?.handleOpen()
break
}
}
/**
* Adds files from remote URL modal
*/
const addFileList = (list?: any[]) => {
if (!list || list.length <= 0) return
setFileList([...fileList, ...(list || [])])
}
/**
* Updates the entire file list (used when removing files)
*/
const updateFileList = (list?: any[]) => { const updateFileList = (list?: any[]) => {
setFileList([...list || []]) setFileList([...list || []])
toolbarRef.current?.setFiles([...list || []])
} }
// Expose methods to parent component via ref
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
handleOpen, handleOpen,
handleClose handleClose
})); }));
console.log('fileList', fileList)
return ( return (
<RbDrawer <RbDrawer
title={<div className="rb:flex rb:items-center rb:gap-2.5"> title={<div className="rb:flex rb:items-center rb:gap-2.5">
{t('workflow.run')} {t('workflow.run')}
{variables.length > 0 && <Space>
<Button size="small" onClick={handleEditVariables}>{t('application.variable')}</Button>
</Space>}
</div>} </div>}
classNames={{ classNames={{
body: 'rb:p-0!' body: 'rb:p-0!'
@@ -466,48 +421,16 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
fileChange={updateFileList} fileChange={updateFileList}
fileList={fileList} fileList={fileList}
onSend={handleSend} onSend={handleSend}
onChange={handleMessageChange} onChange={(msg) => setMessage(msg)}
> >
<Flex justify="space-between" className="rb:flex-1"> <ChatToolbar
<Flex gap={8} align="center"> ref={toolbarCallbackRef}
<Dropdown features={features}
menu={{ onFilesChange={setFileList}
items: [ onVariablesChange={setVariables}
{ key: 'define', label: t('memoryConversation.addRemoteFile') }, />
{
key: 'upload', label: (
<UploadFiles
onChange={fileChange}
/>
)
},
],
onClick: handleShowUpload
}}
>
<div
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]"
></div>
</Dropdown>
</Flex>
<Flex align="center">
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
</Flex>
</Flex>
</ChatInput> </ChatInput>
</div> </div>
<VariableConfigModal
ref={variableConfigModalRef}
refresh={handleSave}
variables={variables}
/>
<UploadFileListModal
ref={uploadFileListModalRef}
refresh={addFileList}
/>
</RbDrawer> </RbDrawer>
) )
}) })

View File

@@ -1,8 +1,8 @@
/* /*
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-09 18:30:28 * @Date: 2026-02-09 18:30:28
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-09 18:30:28 * @Last Modified time: 2026-03-18 12:06:27
*/ */
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Popover } from 'antd'; import { Popover } from 'antd';
@@ -70,7 +70,6 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
// Get source port group information // Get source port group information
const sourcePortInfo = sourceNode.getPorts().find((p: any) => p.id === sourcePort); const sourcePortInfo = sourceNode.getPorts().find((p: any) => p.id === sourcePort);
const sourcePortGroup = sourcePortInfo?.group || sourcePort; const sourcePortGroup = sourcePortInfo?.group || sourcePort;
console.log('sourcePortGroup', sourcePortGroup, sourcePortInfo)
// If add-node position exists, use it; otherwise calculate new position // If add-node position exists, use it; otherwise calculate new position
let newX, newY; let newX, newY;
@@ -148,18 +147,23 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
if (sourcePortGroup === 'left') { if (sourcePortGroup === 'left') {
// Connect from left port to new node's right side // Connect from left port to new node's right side
targetPort = targetPorts.find((port: any) => port.group === 'right')?.id || 'right'; targetPort = targetPorts.find((port: any) => port.group === 'right')?.id || 'right';
graph.addEdge({
source: { cell: newNode.id, port: targetPort },
target: { cell: sourceNode.id, port: sourcePort },
...edgeAttrs
// zIndex: sourceNodeData.cycle && sourceNodeType == 'cycle-start' ? 1 : sourceNodeData.cycle ? 2 : 0
});
} else { } else {
// Connect from right port to new node's left side // Connect from right port to new node's left side
targetPort = targetPorts.find((port: any) => port.group === 'left')?.id || 'left'; targetPort = targetPorts.find((port: any) => port.group === 'left')?.id || 'left';
graph.addEdge({
source: { cell: sourceNode.id, port: sourcePort },
target: { cell: newNode.id, port: targetPort },
...edgeAttrs
// zIndex: sourceNodeData.cycle && sourceNodeType == 'cycle-start' ? 1 : sourceNodeData.cycle ? 2 : 0
});
} }
graph.addEdge({
source: { cell: sourceNode.id, port: sourcePort },
target: { cell: newNode.id, port: targetPort },
...edgeAttrs
// zIndex: sourceNodeData.cycle && sourceNodeType == 'cycle-start' ? 1 : sourceNodeData.cycle ? 2 : 0
});
// Adjust loop node size when child node is added via port within loop node // Adjust loop node size when child node is added via port within loop node
const cycleId = sourceNodeData.cycle; const cycleId = sourceNodeData.cycle;
if (cycleId) { if (cycleId) {
@@ -223,21 +227,28 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
const isChildOfLoop = sourceNodeData?.cycle && graph?.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle && n.getData()?.type === 'loop'); const isChildOfLoop = sourceNodeData?.cycle && graph?.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle && n.getData()?.type === 'loop');
const isChildOfIteration = sourceNodeData?.cycle && graph?.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle && n.getData()?.type === 'iteration'); const isChildOfIteration = sourceNodeData?.cycle && graph?.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle && n.getData()?.type === 'iteration');
const sourcePortInfo = sourceNode?.getPorts().find((p: any) => p.id === sourcePort);
const sourcePortGroup = sourcePortInfo?.group || sourcePort;
const isLeftPort = sourcePortGroup === 'left';
let filteredNodes; let filteredNodes;
if (isChildOfLoop) { if (isChildOfLoop) {
// Use same filtering as AddNode for child nodes of loop, but allow break // Use same filtering as AddNode for child nodes of loop, but allow break
filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'cycle-start', 'iteration'].includes(nodeType.type)); filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'cycle-start', 'iteration'].includes(nodeType.type));
} else if (isChildOfIteration) { } else if (isChildOfIteration) {
// Filter out loop and iteration nodes for children of iteration nodes, but allow break // Filter out loop and iteration nodes for children of iteration nodes, but allow break
filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'cycle-start', 'iteration'].includes(nodeType.type)); filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'cycle-start', 'iteration'].includes(nodeType.type));
} else { } else {
// Original filtering for non-loop child nodes // Original filtering for non-loop child nodes
filteredNodes = category.nodes.filter(nodeType => !['start', 'break', 'cycle-start'].includes(nodeType.type));
filteredNodes = category.nodes.filter(nodeType => filteredNodes = category.nodes.filter(nodeType =>
nodeType.type !== 'start' && nodeType.type !== 'cycle-start' && nodeType.type !== 'break' nodeType.type !== 'start' && nodeType.type !== 'cycle-start' && nodeType.type !== 'break'
); );
} }
if (isLeftPort) {
filteredNodes = filteredNodes.filter(nodeType => nodeType.type !== 'end');
}
if (filteredNodes.length === 0) return null; if (filteredNodes.length === 0) return null;
return ( return (

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 15:17:48 * @Date: 2026-02-03 15:17:48
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-17 10:00:10 * @Last Modified time: 2026-03-18 16:08:17
*/ */
import { useRef, useEffect, useState } from 'react'; import { useRef, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
@@ -12,10 +12,11 @@ import { Graph, Node, MiniMap, Snapline, Clipboard, Keyboard, type Edge } from '
import { register } from '@antv/x6-react-shape'; import { register } from '@antv/x6-react-shape';
import type { PortMetadata } from '@antv/x6/lib/model/port'; import type { PortMetadata } from '@antv/x6/lib/model/port';
import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode, noteNode, notesConfig } from '../constant'; import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode, notesConfig } from '../constant';
import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types'; import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types';
import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application' import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'
import { useUser } from '@/store/user'; import { useUser } from '@/store/user';
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
/** /**
* Props for useWorkflowGraph hook * Props for useWorkflowGraph hook
@@ -25,6 +26,8 @@ export interface UseWorkflowGraphProps {
containerRef: React.RefObject<HTMLDivElement>; containerRef: React.RefObject<HTMLDivElement>;
/** Reference to the minimap container element */ /** Reference to the minimap container element */
miniMapRef: React.RefObject<HTMLDivElement>; miniMapRef: React.RefObject<HTMLDivElement>;
/** Callback when features config is loaded */
onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void;
} }
/** /**
@@ -67,6 +70,7 @@ export interface UseWorkflowGraphReturn {
setChatVariables: React.Dispatch<React.SetStateAction<ChatVariable[]>>; setChatVariables: React.Dispatch<React.SetStateAction<ChatVariable[]>>;
handleAddNotes: () => void; handleAddNotes: () => void;
handleSaveFeaturesConfig: (value: FeaturesConfigForm) => void;
} }
/** /**
@@ -78,6 +82,7 @@ export interface UseWorkflowGraphReturn {
export const useWorkflowGraph = ({ export const useWorkflowGraph = ({
containerRef, containerRef,
miniMapRef, miniMapRef,
onFeaturesLoad,
}: UseWorkflowGraphProps): UseWorkflowGraphReturn => { }: UseWorkflowGraphProps): UseWorkflowGraphReturn => {
// Hooks // Hooks
const { id } = useParams(); const { id } = useParams();
@@ -115,6 +120,7 @@ export const useWorkflowGraph = ({
}) })
setChatVariables(initChatVariables) setChatVariables(initChatVariables)
setConfig({ ...rest, variables: initChatVariables }) setConfig({ ...rest, variables: initChatVariables })
onFeaturesLoad?.(rest.features)
}) })
} }
@@ -132,7 +138,7 @@ export const useWorkflowGraph = ({
if (nodes.length) { if (nodes.length) {
const nodeList = nodes.map(node => { const nodeList = nodes.map(node => {
const { id, type, name, position, config = {} } = node const { id, type, name, position, config = {} } = node
let nodeLibraryConfig = [...nodeLibrary, { nodes: [unknownNode, notesConfig] }] let nodeLibraryConfig: NodeProperties | undefined = [...nodeLibrary, { nodes: [unknownNode, notesConfig] }]
.flatMap(category => category.nodes) .flatMap(category => category.nodes)
.find(n => n.type === type) .find(n => n.type === type)
nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties
@@ -593,13 +599,6 @@ export const useWorkflowGraph = ({
if (!graphRef.current) return false; if (!graphRef.current) return false;
const selectedNodes = graphRef.current.getNodes().filter(node => node.getData()?.isSelected); const selectedNodes = graphRef.current.getNodes().filter(node => node.getData()?.isSelected);
if (selectedNodes.length) { if (selectedNodes.length) {
selectedNodes.forEach(node => {
const data = node.getData();
node.setData({
...data,
id: `${(data.type as string).replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
});
});
graphRef.current.copy(selectedNodes); graphRef.current.copy(selectedNodes);
} }
return false; return false;
@@ -610,7 +609,14 @@ export const useWorkflowGraph = ({
*/ */
const parseEvent = () => { const parseEvent = () => {
if (!graphRef.current?.isClipboardEmpty()) { if (!graphRef.current?.isClipboardEmpty()) {
graphRef.current?.paste({ offset: 32 }); const pastedNodes = graphRef.current?.paste({ offset: 32 }) ?? [];
pastedNodes.forEach(cell => {
if (cell.isNode()) {
const data = cell.getData();
const newId = `${(data.type as string).replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
cell.setData({ ...data, id: newId });
}
});
blankClick(); blankClick();
} }
return false; return false;
@@ -761,9 +767,24 @@ export const useWorkflowGraph = ({
createEdge() { createEdge() {
return graphRef.current?.createEdge(edgeAttrs); return graphRef.current?.createEdge(edgeAttrs);
}, },
validateConnection({ sourceCell, targetCell, targetMagnet }) { validateConnection({ sourceCell, targetCell, sourceMagnet, targetMagnet }) {
if (!targetMagnet) return false; if (!targetMagnet) return false;
// Only allow right port → left port connections
const getPortGroup = (magnet: Element) => {
let el: Element | null = magnet;
while (el) {
const group = el.getAttribute('port-group');
if (group) return group;
el = el.parentElement;
}
return null;
};
const sourceGroup = sourceMagnet ? getPortGroup(sourceMagnet) : null;
const targetGroup = targetMagnet ? getPortGroup(targetMagnet) : null;
if (sourceGroup === 'left' || targetGroup === 'right') return false;
// Node cannot connect to itself // Node cannot connect to itself
if (sourceCell?.id === targetCell?.id) return false; if (sourceCell?.id === targetCell?.id) return false;
@@ -979,6 +1000,9 @@ export const useWorkflowGraph = ({
}) || []; }) || [];
const edges = graphRef.current?.getEdges() || [] const edges = graphRef.current?.getEdges() || []
console.log('config', config)
const params = { const params = {
...config, ...config,
variables: chatVariables.map(v => { variables: chatVariables.map(v => {
@@ -1172,6 +1196,9 @@ export const useWorkflowGraph = ({
data: { ...cleanNodeData }, data: { ...cleanNodeData },
}); });
} }
const handleSaveFeaturesConfig = (value?: FeaturesConfigForm) => {
setConfig(prev => prev ? { ...prev, features: value } as WorkflowConfig : prev)
}
return { return {
config, config,
@@ -1191,6 +1218,7 @@ export const useWorkflowGraph = ({
handleSave, handleSave,
chatVariables, chatVariables,
setChatVariables, setChatVariables,
handleAddNotes handleAddNotes,
handleSaveFeaturesConfig
}; };
}; };

View File

@@ -6,13 +6,13 @@ import Properties from './components/Properties';
import CanvasToolbar from './components/CanvasToolbar'; import CanvasToolbar from './components/CanvasToolbar';
import PortClickHandler from './components/PortClickHandler'; import PortClickHandler from './components/PortClickHandler';
import { useWorkflowGraph } from './hooks/useWorkflowGraph'; import { useWorkflowGraph } from './hooks/useWorkflowGraph';
import type { WorkflowRef } from '@/views/ApplicationConfig/types' import type { WorkflowRef, FeaturesConfigForm } from '@/views/ApplicationConfig/types'
import Chat from './components/Chat/Chat'; import Chat from './components/Chat/Chat';
import type { ChatRef, AddChatVariableRef } from './types' import type { ChatRef, AddChatVariableRef } from './types'
import arrowIcon from '@/assets/images/workflow/arrow.png' import arrowIcon from '@/assets/images/workflow/arrow.png'
import AddChatVariable from './components/AddChatVariable'; import AddChatVariable from './components/AddChatVariable';
const Workflow = forwardRef<WorkflowRef>((_props, ref) => { const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void }>(({ onFeaturesLoad }, ref) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const miniMapRef = useRef<HTMLDivElement>(null); const miniMapRef = useRef<HTMLDivElement>(null);
const addChatVariableRef = useRef<AddChatVariableRef>(null) const addChatVariableRef = useRef<AddChatVariableRef>(null)
@@ -25,12 +25,8 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
selectedNode, selectedNode,
setSelectedNode, setSelectedNode,
zoomLevel, zoomLevel,
canUndo,
canRedo,
isHandMode, isHandMode,
setIsHandMode, setIsHandMode,
onUndo,
onRedo,
onDrop, onDrop,
blankClick, blankClick,
deleteEvent, deleteEvent,
@@ -39,8 +35,9 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
handleSave, handleSave,
chatVariables, chatVariables,
setChatVariables, setChatVariables,
handleAddNotes handleAddNotes,
} = useWorkflowGraph({ containerRef, miniMapRef }); handleSaveFeaturesConfig
} = useWorkflowGraph({ containerRef, miniMapRef, onFeaturesLoad });
const onDragOver = (event: React.DragEvent) => { const onDragOver = (event: React.DragEvent) => {
event.preventDefault(); event.preventDefault();
@@ -61,7 +58,8 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
graphRef, graphRef,
addVariable, addVariable,
config, config,
funConfig: config?.funConfig features: config?.features,
handleSaveFeaturesConfig
})) }))
return ( return (
<div className="rb:h-[calc(100vh-64px)] rb:relative"> <div className="rb:h-[calc(100vh-64px)] rb:relative">
@@ -93,10 +91,6 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
isHandMode={isHandMode} isHandMode={isHandMode}
setIsHandMode={setIsHandMode} setIsHandMode={setIsHandMode}
zoomLevel={zoomLevel} zoomLevel={zoomLevel}
canUndo={canUndo}
canRedo={canRedo}
onUndo={onUndo}
onRedo={onRedo}
addNotes={handleAddNotes} addNotes={handleAddNotes}
/> />
</div> </div>
@@ -115,6 +109,7 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
/> />
<Chat <Chat
ref={chatRef} ref={chatRef}
data={config}
graphRef={graphRef} graphRef={graphRef}
appId={config?.app_id as string} appId={config?.app_id as string}
/> />

View File

@@ -2,7 +2,7 @@
import { Graph } from '@antv/x6'; import { Graph } from '@antv/x6';
import type { KnowledgeConfig } from './components/Properties/Knowledge/types' import type { KnowledgeConfig } from './components/Properties/Knowledge/types'
import type { Variable } from './components/Properties/VariableList/types' import type { Variable } from './components/Properties/VariableList/types'
import type { FunConfigForm } from '@/views/ApplicationConfig/types' import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
export interface NodeConfig { export interface NodeConfig {
type: 'input' | 'textarea' | 'select' | 'inputNumber' | 'slider' | 'customSelect' | 'define' | 'knowledge' | 'variableList' | string; type: 'input' | 'textarea' | 'select' | 'inputNumber' | 'slider' | 'customSelect' | 'define' | 'knowledge' | 'variableList' | string;
placeholder?: string; placeholder?: string;
@@ -91,7 +91,7 @@ export interface WorkflowConfig {
created_at: number; created_at: number;
updated_at: number; updated_at: number;
funConfig?: FunConfigForm; features?: FeaturesConfigForm;
} }
export interface ChatRef { export interface ChatRef {