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:
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ class EndUserRepository:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|
||||||
|
|
||||||
# ==================== 工作流执行 ====================
|
# ==================== 工作流执行 ====================
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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")):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
"""获取多模态服务实例(依赖注入)"""
|
"""获取多模态服务实例(依赖注入)"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ==================== 依赖注入函数 ====================
|
# ==================== 依赖注入函数 ====================
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
30
api/migrations/versions/f017efe4831c_202603181652.py
Normal file
30
api/migrations/versions/f017efe4831c_202603181652.py
Normal 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 ###
|
||||||
@@ -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')]`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' &&
|
||||||
|
|||||||
204
web/src/components/Chat/ChatToolbar.tsx
Normal file
204
web/src/components/Chat/ChatToolbar.tsx
Normal 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
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '红熊记忆科学',
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)]">
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
@@ -345,7 +362,7 @@ const Chat: FC<ChatProps> = ({
|
|||||||
|
|
||||||
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 } = item.data as { conversation_id: string, content: string, message_length: number };
|
||||||
|
|
||||||
switch (item.event) {
|
switch (item.event) {
|
||||||
case 'start':
|
case 'start':
|
||||||
if (conversation_id && conversationId !== conversation_id) {
|
if (conversation_id && conversationId !== conversation_id) {
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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} />}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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([])
|
||||||
@@ -118,7 +100,7 @@ const Conversation: FC = () => {
|
|||||||
const groupHistoryByDate = (items: HistoryItem[]): Record<string, HistoryItem[]> => {
|
const groupHistoryByDate = (items: HistoryItem[]): Record<string, HistoryItem[]> => {
|
||||||
return items.reduce((groups: Record<string, HistoryItem[]>, item) => {
|
return items.reduce((groups: Record<string, HistoryItem[]>, item) => {
|
||||||
const date = formatDateTime(item.created_at, 'YYYY-MM-DD')
|
const date = formatDateTime(item.created_at, 'YYYY-MM-DD')
|
||||||
|
|
||||||
if (!groups[date]) {
|
if (!groups[date]) {
|
||||||
groups[date] = [];
|
groups[date] = [];
|
||||||
}
|
}
|
||||||
@@ -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 (
|
||||||
@@ -349,8 +301,8 @@ const Conversation: FC = () => {
|
|||||||
<div className="rb:group rb:flex rb:items-center rb:justify-center rb:font-regular rb:cursor-pointer rb:mb-5 rb:border rb:border-[#DFE4ED] rb:hover:border-[#155EEF] rb:hover:text-[#155EEF] rb:rounded-lg rb:py-2.5"
|
<div className="rb:group rb:flex rb:items-center rb:justify-center rb:font-regular rb:cursor-pointer rb:mb-5 rb:border rb:border-[#DFE4ED] rb:hover:border-[#155EEF] rb:hover:text-[#155EEF] rb:rounded-lg rb:py-2.5"
|
||||||
onClick={() => handleChangeHistory(null)}
|
onClick={() => handleChangeHistory(null)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:mr-2 rb:bg-cover rb:bg-[url('@/assets/images/conversation/conversation.svg')] rb:group-hover:bg-[url('@/assets/images/conversation/conversation_hover.svg')]"
|
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:mr-2 rb:bg-cover rb:bg-[url('@/assets/images/conversation/conversation.svg')] rb:group-hover:bg-[url('@/assets/images/conversation/conversation_hover.svg')]"
|
||||||
></div>
|
></div>
|
||||||
{t('memoryConversation.startANewConversation')}
|
{t('memoryConversation.startANewConversation')}
|
||||||
</div>
|
</div>
|
||||||
@@ -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,109 +342,62 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default Conversation
|
export default Conversation
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,20 +227,27 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +767,23 @@ 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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user