Merge branch 'release/v0.2.8' into develop

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

View File

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

View File

@@ -15,7 +15,7 @@ import os
import uuid
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 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)
async def upload_file(
file: UploadFile = File(...),
@@ -280,6 +293,7 @@ async def upload_file_with_share_token(
@router.get("/files/{file_id}", response_model=Any)
async def download_file(
request: Request,
file_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
@@ -327,6 +341,7 @@ async def download_file(
else:
try:
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}")
return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND)
except FileNotFoundError:
@@ -400,6 +415,7 @@ async def delete_file(
@router.get("/files/{file_id}/url", response_model=ApiResponse)
async def get_file_url(
request: Request,
file_id: uuid.UUID,
expires: int = None,
permanent: bool = False,
@@ -463,6 +479,7 @@ async def get_file_url(
else:
# For remote storage (OSS/S3), get presigned URL
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}")
return success(
@@ -484,6 +501,7 @@ async def get_file_url(
@router.get("/public/{file_id}", response_model=Any)
async def public_download_file(
request: Request,
file_id: uuid.UUID,
expires: int = 0,
signature: str = "",
@@ -555,6 +573,7 @@ async def public_download_file(
# For remote storage, redirect to presigned URL
try:
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)
except Exception as 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)
async def permanent_download_file(
request: Request,
file_id: uuid.UUID,
db: Session = Depends(get_db),
storage_service: FileStorageService = Depends(get_file_storage_service),
@@ -625,6 +645,7 @@ async def permanent_download_file(
try:
# 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 = _match_scheme(request, presigned_url)
return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND)
except Exception as e:
api_logger.error(f"Failed to get presigned URL: {e}")

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.core.error_codes import BizCode
from app.schemas.tool_schema import (
ToolCreateRequest, ToolUpdateRequest, ToolExecuteRequest, ParseSchemaRequest,
CustomToolTestRequest, ToolActiveUpdate
@@ -250,8 +252,10 @@ async def sync_mcp_tools(
try:
result = await service.sync_mcp_tools(tool_id, current_user.tenant_id)
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工具列表同步完成")
except BusinessException:
raise
except Exception as 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)
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="连接测试完成")
except BusinessException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

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

View File

@@ -5,7 +5,7 @@
import re
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.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:
self._SCOPE = SCOPE_PATTERN.findall(self.literal)[0]
def get_scope(self) -> str | None:
matches = SCOPE_PATTERN.findall(self.literal)
self._SCOPE = matches[0] if matches else None
return self._SCOPE
def depends_on_scope(self, scope: str) -> bool:
@@ -68,6 +69,8 @@ class OutputContent(BaseModel):
Returns:
bool: True if this segment references the given scope.
"""
if not self.is_variable:
return False
if self._SCOPE:
return self._SCOPE == scope
return self.get_scope() == scope
@@ -152,7 +155,7 @@ class StreamOutputConfig(BaseModel):
"""
# Case 1: resolve control branch dependency
if scope in self.control_nodes.keys():
if scope in self.control_nodes:
if status is None:
raise RuntimeError("[Stream Output] Control node activation status not provided")
if status in self.control_nodes[scope]:

View File

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

View File

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

View File

@@ -66,7 +66,8 @@ class EndUserRepository:
raise
def get_or_create_end_user(
self,
self,
app_id: uuid.UUID,
workspace_id: uuid.UUID,
other_id: str,
original_user_id: Optional[str] = None
@@ -74,6 +75,7 @@ class EndUserRepository:
"""获取或创建终端用户
Args:
app_id: 应用ID
workspace_id: 工作空间ID
other_id: 第三方ID
original_user_id: 原始用户ID (存储到 other_id)
@@ -92,10 +94,14 @@ class EndUserRepository:
if end_user:
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
# 创建新用户
end_user = EndUser(
app_id=app_id,
workspace_id=workspace_id,
other_id=other_id
)

View File

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

View File

@@ -1609,6 +1609,7 @@ class AppService:
variables=[var.model_dump() for var in data.variables] if data.variables else [],
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 [],
features=data.features or {},
is_active=True,
created_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.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.features = data.features or {}
workflow_cfg.updated_at = now
self.db.commit()
@@ -1875,7 +1877,8 @@ class AppService:
"edges": workflow_cfg.edges,
"variables": workflow_cfg.variables,
"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)
@@ -2062,7 +2065,8 @@ class AppService:
)
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(
f"回滚时更新终端用户记忆配置: app_id={app_id}, version={version}, "
f"memory_config_id={memory_config_id}, updated_count={updated_count}"

View File

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

View File

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

View File

@@ -14,9 +14,13 @@ import uuid
from abc import ABC, abstractmethod
from typing import List, Dict, Any, Optional
import csv
import json
import PyPDF2
import httpx
import magic
import openpyxl
from docx import Document
from sqlalchemy.orm import Session
@@ -39,6 +43,13 @@ DOC_MIME = [
'application/msword',
'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):
@@ -48,22 +59,22 @@ class MultimodalFormatStrategy(ABC):
self.file = file
@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
@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
@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
@abstractmethod
async def format_video(self, url: str) -> Dict[str, Any]:
async def format_video(self, url: str) -> tuple[bool, Dict[str, Any]]:
"""格式化视频"""
pass
@@ -71,16 +82,16 @@ class MultimodalFormatStrategy(ABC):
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"}"""
return {
return True, {
"type": "image",
"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",
"text": f"<document name=\"{file_name}\">\n{text}\n</document>"
}
@@ -91,26 +102,26 @@ class DashScopeFormatStrategy(MultimodalFormatStrategy):
url: str,
content: bytes | None = None,
transcription: Optional[str] = None
) -> Dict[str, Any]:
) -> tuple[bool, Dict[str, Any]]:
"""
通义千问音频格式
- 原生支持: qwen-audio 系列
- 其他模型: 需要转录为文本
"""
if transcription:
return {
return True, {
"type": "text",
"text": f"<audio url=\"{url}\">\ntext_transcription:{transcription}\n</audio>"
}
# 通义千问音频格式:{"type": "audio", "audio": "url"}
return {
return True, {
"type": "audio",
"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 系列原生支持)"""
return {
return True, {
"type": "video",
"video": url
}
@@ -119,7 +130,7 @@ class DashScopeFormatStrategy(MultimodalFormatStrategy):
class BedrockFormatStrategy(MultimodalFormatStrategy):
"""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 编码
{"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)}")
return {
return True, {
"type": "image",
"source": {
"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 文档需要 base64 编码
text_bytes = text.encode('utf-8')
base64_text = base64.b64encode(text_bytes).decode('utf-8')
return {
return True, {
"type": "document",
"source": {
"type": "base64",
@@ -171,24 +182,24 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
url: str,
content: bytes | None = None,
transcription: Optional[str] = None
) -> Dict[str, Any]:
) -> tuple[bool, Dict[str, Any]]:
"""
Bedrock/Anthropic 音频格式
不支持原生音频,必须转录为文本
"""
if transcription:
return {
return True, {
"type": "text",
"text": f"[音频转录]\n{transcription}"
}
return {
return False, {
"type": "text",
"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 视频格式"""
return {
return False, {
"type": "text",
"text": f"<video url=\"{url}\">\n[视频文件,当前 provider 暂不支持]\n</video>"
}
@@ -197,18 +208,18 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
class OpenAIFormatStrategy(MultimodalFormatStrategy):
"""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": "..."}}"""
return {
return True, {
"type": "image_url",
"image_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 文档格式"""
return {
return True, {
"type": "text",
"text": f"<document name=\"{file_name}\">\n{text}\n</document>"
}
@@ -219,14 +230,14 @@ class OpenAIFormatStrategy(MultimodalFormatStrategy):
url: str,
content: bytes | None = None,
transcription: Optional[str] = None
) -> Dict[str, Any]:
) -> tuple[bool, Dict[str, Any]]:
"""
OpenAI 音频格式
- gpt-4o-audio 系列支持原生音频(需要 base64 编码)
- 其他模型使用转录文本
"""
if transcription:
return {
return True, {
"type": "text",
"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"}
file_ext = "wav" if not file_ext else file_ext
return {
return True, {
"type": "input_audio",
"input_audio": {
"data": f"data:;base64,{base64_audio}",
@@ -264,14 +275,14 @@ class OpenAIFormatStrategy(MultimodalFormatStrategy):
}
except Exception as e:
logger.error(f"下载音频失败: {e}")
return {
return False, {
"type": "text",
"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 视频格式"""
return {
return True, {
"type": "video_url",
"video_url": {
"url": url
@@ -366,21 +377,25 @@ class MultimodalService:
file.url = await self.get_file_url(file)
try:
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)
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:
content = await self._process_document(file, strategy)
is_support, content = await self._process_document(file, strategy)
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:
content = await self._process_audio(file, strategy)
is_support, content = await self._process_audio(file, strategy)
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:
content = await self._process_video(file, strategy)
is_support, content = await self._process_video(file, strategy)
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:
logger.warning(f"不支持的文件类型: {file.type}")
except Exception as e:
@@ -413,7 +428,7 @@ class MultimodalService:
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)
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 返回不同格式的图片内容
"""
try:
url = await self.get_file_url(file)
return await strategy.format_image(url, content=file.get_content())
# url = await self.get_file_url(file)
return await strategy.format_image(file.url, content=file.get_content())
except Exception as e:
logger.error(f"处理图片失败: {e}", exc_info=True)
return {
return False, {
"type": "text",
"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 等)
@@ -446,7 +461,7 @@ class MultimodalService:
Dict: 根据 provider 返回不同格式的文档内容
"""
if file.transfer_method == TransferMethod.REMOTE_URL:
return {
return True, {
"type": "text",
"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)
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 返回不同格式的音频内容
"""
try:
url = await self.get_file_url(file)
# url = await self.get_file_url(file)
# 如果启用音频转文本且有 API Key
transcription = None
if self.enable_audio_transcription and self.audio_api_key:
logger.info(f"开始音频转文本: {url}")
logger.info(f"开始音频转文本: {file.url}")
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":
transcription = await AudioTranscriptionService.transcribe_openai(url, self.audio_api_key)
transcription = await AudioTranscriptionService.transcribe_openai(file.url, self.audio_api_key)
else:
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:
logger.error(f"处理音频失败: {e}", exc_info=True)
return {
return False, {
"type": "text",
"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 返回不同格式的视频内容
"""
try:
url = await self.get_file_url(file)
return await strategy.format_video(url)
# url = await self.get_file_url(file)
return await strategy.format_video(file.url)
except Exception as e:
logger.error(f"处理视频失败: {e}", exc_info=True)
return {
return False, {
"type": "text",
"text": f"[视频处理失败: {str(e)}]"
}
@@ -577,6 +592,12 @@ class MultimodalService:
return await self._extract_pdf_text(file_content)
elif file_mime_type in DOC_MIME:
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:
return f"[Unsupported file type: {file_mime_type}]"
except Exception as e:
@@ -602,7 +623,6 @@ class MultimodalService:
async def _extract_word_text(file_content: bytes) -> str:
"""提取 Word 文档文本"""
try:
# 使用 BytesIO 读取 Word 文档
word_file = io.BytesIO(file_content)
doc = Document(word_file)
text_parts = [paragraph.text for paragraph in doc.paragraphs]
@@ -611,6 +631,42 @@ class MultimodalService:
logger.error(f"提取 Word 文本失败: {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:
"""获取多模态服务实例(依赖注入)"""

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 15:01:59
* @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"
justify={cicle ? 'center' : 'start'}
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: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
"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

View File

@@ -2,13 +2,14 @@
* @Author: ZhaoYing
* @Date: 2025-12-10 16:46:17
* @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 Markdown from '@/components/Markdown'
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
@@ -28,7 +29,25 @@ const ChatContent: FC<ChatContentProps> = ({
// Scroll container reference for controlling auto-scroll to bottom
const scrollContainerRef = useRef<(HTMLDivElement | null)>(null)
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
useEffect(() => {
@@ -101,6 +120,19 @@ const ChatContent: FC<ChatContentProps> = ({
{item.subContent && renderRuntime && renderRuntime(item, index)}
{/* Render message content using Markdown component */}
<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>
{/* Bottom label (such as timestamp, username, etc.) */}
{labelPosition === 'bottom' &&

View File

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

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2025-12-10 16:45:54
* @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'
@@ -24,6 +24,7 @@ export interface ChatItem {
subContent?: Record<string, any>[];
files?: any[];
error?: string;
audioUrl?: string;
}
/**

View File

@@ -4,10 +4,10 @@
* @Author: yujiangping
* @Date: 2026-03-16 19:01:12
* @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 { Spin, Alert, Button, Table, InputNumber } from 'antd';
import { Spin, Alert, Button, Table, InputNumber, Image } from 'antd';
import {
ReloadOutlined,
DownloadOutlined,
@@ -21,12 +21,10 @@ import { cookieUtils } from '@/utils/request';
import mammoth from 'mammoth';
import * as XLSX from 'xlsx';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.mjs?url';
// 设置 pdf.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.mjs',
import.meta.url,
).toString();
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
interface DocumentPreviewProps {
fileUrl: string;
@@ -65,9 +63,12 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
const [pptCurrentPage, setPptCurrentPage] = useState(1);
const [pptTotalPages, setPptTotalPages] = useState(0);
// 图片状态
const [imageBlobUrl, setImageBlobUrl] = useState<string>('');
// 支持预览的文件类型
const previewableTypes = [
'.pdf', '.txt', '.md',
'.pdf', '.txt', '.md', '.csv',
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp',
'.doc', '.docx', '.xls', '.xlsx',
'.ppt', '.pptx',
@@ -90,7 +91,7 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
};
const isPdfFile = () => getFileExtension() === '.pdf';
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 isPreviewable = () => previewableTypes.includes(getFileExtension());
@@ -227,6 +228,28 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
}
}, [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 加载逻辑 ==========
const loadTextFile = async () => {
setLoading(true);
@@ -274,12 +297,42 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
}
};
const isCsvFile = () => getFileExtension() === '.csv';
const loadExcelFile = async () => {
setLoading(true);
setError(false);
setErrorMessage('');
try {
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 sheets = workbook.SheetNames.map(sheetName => {
const worksheet = workbook.Sheets[sheetName];
@@ -311,7 +364,7 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
else if (isExcelFile()) loadExcelFile();
else if (isPdfFile()) loadPdfFile();
else if (isPptFile()) loadPptFile();
else if (isImageFile()) setLoading(false);
else if (isImageFile()) loadImageFile();
}, [fileUrl]);
// PDF 翻页/缩放后重新渲染
@@ -412,11 +465,11 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
{/* 图片预览 */}
{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">
<img
src={fileUrl}
<Image
src={imageBlobUrl}
alt={fileName || '图片预览'}
className="rb:max-w-full rb:max-h-full rb:object-contain"
onError={() => handleError('图片加载失败')}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
onError={() => handleError('图片渲染失败')}
/>
</div>
)}

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 16:35:43
* @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
@@ -176,17 +176,23 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe
case 500:
case 502:
const errorData = await response.json();
let errorInfo = errorData.error || i18n.t('common.serviceUpgrading')
const errorInfo = errorData.error || i18n.t('common.serviceUpgrading');
message.warning(errorInfo);
throw errorInfo;
throw new Error(errorData);
case 400:
const error = await response.json();
message.warning(error.error);
throw error.error || 'Bad Request';
const error400 = 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:
const errorJson = await response.json();
message.warning(errorJson.error || i18n.t('common.serverError'));
throw errorData.error;
const errorMsg = errorJson.error || i18n.t('common.serverError');
message.warning(errorMsg);
throw new Error(errorJson);
case 401:
if (url?.includes('/public')) {
return message.warning(i18n.t('common.publicApiCannotRefreshToken'));

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:29:21
* @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 clsx from 'clsx'
@@ -24,7 +24,7 @@ import type {
AiPromptModalRef,
Source,
ChatVariableConfigModalRef,
FunConfigForm
FeaturesConfigForm
} from './types'
import type { Variable } from './components/VariableList/types'
import type { KnowledgeConfig } from './components/Knowledge/types'
@@ -42,7 +42,7 @@ import ToolList from './components/ToolList/ToolList'
import SkillList from './components/Skill'
import ChatVariableConfigModal from './components/ChatVariableConfigModal';
import type { Skill } from '@/views/Skills/types'
import FunConfig from './components/FunConfig'
import FeaturesConfig from './components/FeaturesConfig'
/**
* Description wrapper component
@@ -129,7 +129,7 @@ const SelectWrapper: FC<{ title: string, desc: string, name: string | string[],
* Agent configuration component
* 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 { id } = useParams();
const { message } = App.useApp()
@@ -200,6 +200,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
...response,
tools: allTools
})
onFeaturesLoad?.(response.features)
}).finally(() => {
setLoading(false)
})
@@ -356,7 +357,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
useImperativeHandle(ref, () => ({
handleSave,
funConfig: values?.funConfig
features: values?.features
}))
const aiPromptModalRef = useRef<AiPromptModalRef>(null)
@@ -411,8 +412,8 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
setChatVariables(values?.variables || [])
}, [values?.variables])
const handleSaveFunConfig = (value: FunConfigForm) => {
form.setFieldValue('funConfig', value)
const handleSaveFeaturesConfig = (value: FeaturesConfigForm) => {
form.setFieldValue('features', value)
}
console.log('agent', values)
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 || t('application.chooseModel')}
</Button>
{/* <FunConfig value={values?.funConfig as FunConfigForm} refresh={handleSaveFunConfig} /> */}
<FeaturesConfig value={values?.features as FeaturesConfigForm} refresh={handleSaveFeaturesConfig} />
<Button type="primary" onClick={() => handleSave()}>
{t('common.save')}
</Button>
@@ -435,7 +436,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
<Form form={form}>
<Form.Item name="default_model_config_id" 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%' }}>
<Card title={t('application.promptConfiguration')}>
<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>
<RbCard height="calc(100vh - 160px)" bodyClassName="rb:p-[0]! rb:h-full rb:overflow-hidden">
<Chat
data={data as Config}
data={values as Config}
chatList={chatList}
updateChatList={setChatList}
handleSave={handleSave}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:29:33
* @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 { useTranslation } from 'react-i18next'
@@ -19,7 +19,8 @@ import type {
ChatData,
SubAgentItem,
ClusterRef,
ModelConfigModalRef
ModelConfigModalRef,
FeaturesConfigForm
} from './types'
import Chat from './components/Chat'
import RbCard from '@/components/RbCard/Card'
@@ -29,7 +30,7 @@ import RadioGroupCard from '@/components/RadioGroupCard'
import { getModelListUrl } from '@/api/models'
import ModelConfigModal from './components/ModelConfigModal'
import type { Application } from '@/views/ApplicationManagement/types'
import FeaturesConfig from './components/FeaturesConfig'
const tagColors = ['processing', 'warning', 'default']
const MAX_LENGTH = 5;
@@ -37,7 +38,7 @@ const MAX_LENGTH = 5;
* Multi-agent cluster configuration component
* 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 { message } = App.useApp()
const [form] = Form.useForm()
@@ -130,6 +131,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
} else {
setSubAgents(sub_agents)
}
onFeaturesLoad?.(response.features)
})
}
/**
@@ -166,7 +168,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
}
useImperativeHandle(ref, () => ({
handleSave,
funConfig: data?.funConfig
features: data?.features
}))
const modelConfigModalRef = useRef<ModelConfigModalRef>(null)
@@ -185,16 +187,21 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
model_parameters: values
})
}
const handleSaveFeaturesConfig = (value: FeaturesConfigForm) => {
form.setFieldValue('features', value)
}
return (
<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]">
<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()}>
{t('common.save')}
</Button>
</div>
</Flex>
<Form form={form} layout="vertical">
<Form.Item name="features" hidden noStyle></Form.Item>
<Space size={20} direction="vertical" style={{width: '100%'}}>
<Card title={t('application.collaboration')}>
<Form.Item

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:29:41
* @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 { useTranslation } from 'react-i18next';
@@ -70,7 +70,8 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
})
}
const handleExport = () => {
appExport(data.id, data.name)
if (!selectedVersion) return
appExport(data.id, data.name, {release_version: selectedVersion.id})
}
return (
<div className="rb:flex rb:h-[calc(100vh-64px)]">

View File

@@ -1,36 +1,25 @@
import { type FC, useState, useRef, useEffect, useMemo } from 'react'
import { type FC, useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { App, Flex, Dropdown, type MenuProps, Divider, Form, Space } from 'antd'
import { SettingOutlined } from '@ant-design/icons'
import { App } from 'antd'
import clsx from 'clsx'
import dayjs from 'dayjs'
import ChatIcon from '@/assets/images/application/chat.png'
import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal'
import { draftRun } from '@/api/application';
import { draftRun } from '@/api/application'
import Empty from '@/components/Empty'
import Chat from '@/components/Chat'
import AudioRecorder from '@/components/AudioRecorder'
import RbCard from '@/components/RbCard/Card'
import UploadFiles from '@/views/Conversation/components/FileUpload'
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
import Runtime from '@/views/Workflow/components/Chat/Runtime';
import ChatToolbar, { type ChatToolbarRef } from '@/components/Chat/ChatToolbar'
import Runtime from '@/views/Workflow/components/Chat/Runtime'
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 { 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 { TestChatProps } from './type';
import type { UploadFileListModalRef } from '@/views/Conversation/types'
import type { TestChatProps } from './type'
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>) => {
return {
@@ -65,29 +54,25 @@ interface NodeData {
elapsed_time?: string;
error?: any;
state: Record<string, any>;
status?: 'completed' | 'failed'
status?: 'completed' | 'failed';
audio_url?: string;
}
interface FormData {
files: any[];
variables: Variable[]
}
const TestChat: FC<TestChatProps> = ({
application,
config
}) => {
const { t } = useTranslation()
const { message: messageApi } = App.useApp()
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
const toolbarRef = useRef<ChatToolbarRef>(null)
const [loading, setLoading] = useState(false) // Send button loading state
const [chatList, setChatList] = useState<ChatItem[]>([]) // Chat message history
const [streamLoading, setStreamLoading] = useState(false) // SSE streaming state
const [conversationId, setConversationId] = useState<string | null>(null) // Current conversation ID
const [message, setMessage] = useState<string | undefined>(undefined) // Current input message
const [form] = Form.useForm<FormData>()
const queryValues = Form.useWatch([], form)
const [loading, setLoading] = useState(false)
const [chatList, setChatList] = useState<ChatItem[]>([])
const [streamLoading, setStreamLoading] = useState(false)
const [conversationId, setConversationId] = useState<string | null>(null)
const [message, setMessage] = useState<string | undefined>(undefined)
const [fileList, setFileList] = useState<any[]>([])
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
useEffect(() => {
getVariables()
@@ -96,6 +81,8 @@ const TestChat: FC<TestChatProps> = ({
const getVariables = () => {
if (!application || !config) return
setFeatures(config?.features || {} as FeaturesConfigForm)
let initVariables: Variable[] = []
switch (application.type) {
@@ -104,85 +91,35 @@ const TestChat: FC<TestChatProps> = ({
const startNodes = nodes.filter(vo => vo.type === 'start')
if (startNodes.length) {
const curVariables = startNodes[0].config.variables as Variable[]
curVariables.forEach((vo) => {
if (typeof vo.default !== 'undefined') {
vo.value = vo.default
}
const lastVo = curVariables.find(item => item.name === vo.name)
if (lastVo?.value) {
vo.value = lastVo.value
}
})
initVariables = curVariables
}
curVariables.forEach((vo) => {
if (typeof vo.default !== 'undefined') {
vo.value = vo.default
}
const lastVo = curVariables.find(item => item.name === vo.name)
if (lastVo?.value) {
vo.value = lastVo.value
}
})
initVariables = curVariables
}
break
case 'agent':
initVariables = config.variables as Variable[]
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 newUserMessage: ChatItem = {
setChatList(prev => [...prev, {
role: 'user',
content: message,
created_at: Date.now(),
files
};
setChatList(prev => [...prev, newUserMessage])
}])
}
const addAssistantMessage = () => {
const { type } = application || {}
setChatList(prev => [...prev, {
@@ -193,20 +130,22 @@ const TestChat: FC<TestChatProps> = ({
}])
}
const updateAssistantMessage = (content: string) => {
const updateAssistantMessage = (content: string, audio_url?: string) => {
setChatList(prev => {
let newList = [...prev]
const newList = [...prev]
const lastMsg = newList[newList.length - 1]
if (lastMsg.role === 'assistant') {
lastMsg.content += content
lastMsg.content += content;
lastMsg.audioUrl = audio_url
}
return newList
})
}
const updateErrorAssistantMessage = (message_length: number) => {
if (message_length > 0) return
setChatList(prev => {
let newList = [...prev]
const newList = [...prev]
const lastMsg = newList[newList.length - 1]
if (lastMsg.role === 'assistant') {
lastMsg.content = null
@@ -214,34 +153,37 @@ const TestChat: FC<TestChatProps> = ({
return newList
})
}
const handleSend = () => {
if (loading || !application || !message || !message?.trim()) return
// Validate required variables before sending
const { variables, files } = queryValues;
const buildVariableParams = (variables: Variable[]) => {
let isCanSend = true
const params: Record<string, any> = {}
if (variables && variables.length > 0) {
if (variables?.length > 0) {
const needRequired: string[] = []
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] === '')) {
isCanSend = false
needRequired.push(vo.name)
}
})
if (needRequired.length) {
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
}
}
if (!isCanSend) {
setLoading(false)
return
}
return { isCanSend, params }
}
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)
setMessage(undefined)
form.setFieldValue('files', [])
toolbarRef.current?.setFiles([])
setFileList([])
addAssistantMessage()
setStreamLoading(true)
setLoading(true)
@@ -252,6 +194,7 @@ const TestChat: FC<TestChatProps> = ({
handleStreamMessage
)
.catch(() => {
updateErrorAssistantMessage(0)
setLoading(false)
})
.finally(() => {
@@ -259,105 +202,77 @@ const TestChat: FC<TestChatProps> = ({
setStreamLoading(false)
})
}
const handleStreamMessage = (data: SSEMessage[]) => {
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) {
case 'start':
if (conversation_id && conversationId !== conversation_id) {
setConversationId(conversation_id);
}
if (conversation_id && conversationId !== conversation_id) setConversationId(conversation_id)
break
case 'message':
updateAssistantMessage(content)
if (conversation_id && conversationId !== conversation_id) {
setConversationId(conversation_id);
}
break;
if (conversation_id && conversationId !== conversation_id) setConversationId(conversation_id)
break
case 'end':
if (audio_url) {
updateAssistantMessage(content, audio_url)
}
updateErrorAssistantMessage(message_length)
setStreamLoading(false)
break;
break
}
})
};
}
const handleWorkflowSend = () => {
if (loading || !application || !message || !message?.trim()) return
// Validate required variables before sending
const { variables, files } = queryValues;
let isCanSend = true
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
}
const files = toolbarRef.current?.getFiles() || []
const variables = toolbarRef.current?.getVariables() || []
const { isCanSend, params } = buildVariableParams(variables)
if (!isCanSend) return
setLoading(true)
addUserMessage(message, files)
addAssistantMessage()
form.setFieldsValue({
files: [],
})
toolbarRef.current?.setFiles([])
setFileList([])
setMessage(undefined)
setStreamLoading(true)
draftRun(
application.id,
formatParams(message, conversationId, files, params),
handleWorkflowStreamMessage
)
.catch((error) => {
console.log('draftRun error', error)
const errorInfo = JSON.parse(error.message)
setChatList(prev => {
const newList = [...prev]
const lastIndex = newList.length - 1
if (lastIndex >= 0) {
newList[lastIndex] = {
...newList[lastIndex],
status: 'failed',
content: null,
subContent: error.error
}
newList[lastIndex] = { ...newList[lastIndex], status: 'failed', content: null, subContent: errorInfo.error }
}
return newList
})
}).finally(() => {
})
.finally(() => {
setLoading(false)
setStreamLoading(false)
})
}
const handleWorkflowStreamMessage = (data: SSEMessage[]) => {
data.forEach(item => {
const { content, conversation_id } = item.data as NodeData;
switch (item.event) {
// Append streaming text chunks to assistant message
// Append streaming text chunks to assistant message
case 'message':
setChatList(prev => {
const newList = [...prev]
const lastIndex = newList.length - 1
if (lastIndex >= 0) {
newList[lastIndex] = {
...newList[lastIndex],
content: newList[lastIndex].content + content
}
newList[lastIndex] = { ...newList[lastIndex], content: newList[lastIndex].content + content }
}
return newList
})
@@ -388,10 +303,10 @@ const TestChat: FC<TestChatProps> = ({
}
})
}
const addWorkflowNodeStartMessage = (data: NodeData) => {
const { node_id } = data;
const { nodes } = config as WorkflowConfig
const node = nodes.find(n => n.id === node_id);
const { name, type } = node || {}
const icon = nodeLibrary.flatMap(g => g.nodes).find(n => n.type === type)?.icon
@@ -428,6 +343,7 @@ const TestChat: FC<TestChatProps> = ({
return newList
})
}
const updateWorkflowNodeEndMessage = (data: NodeData) => {
const { node_id, input, output, error, elapsed_time, status } = data;
setChatList(prev => {
@@ -456,10 +372,10 @@ const TestChat: FC<TestChatProps> = ({
return newList
})
}
const updateWorkflowCycleMessage = (data: NodeData) => {
const { node_id, cycle_id, cycle_idx, input, output, error, elapsed_time, status } = data;
const { nodes } = config as WorkflowConfig
const node = nodes.find(n => n.id === node_id);
const { name, type } = node || {}
const icon = nodeLibrary.flatMap(g => g.nodes).find(n => n.type === type)?.icon
@@ -500,22 +416,9 @@ const TestChat: FC<TestChatProps> = ({
return newList
})
}
const updateWorkflowEndMessage = (data: NodeData) => {
const { error, status } = data as {
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'
};
const { error, status, audio_url } = data;
setChatList(prev => {
const newList = [...prev]
const lastIndex = newList.length - 1
@@ -525,13 +428,13 @@ const TestChat: FC<TestChatProps> = ({
status,
error,
content: newList[lastIndex].content === '' ? null : newList[lastIndex].content,
audioUrl: audio_url
}
}
return newList
})
}
console.log('queryValues', queryValues)
return (
<div className="rb:w-250 rb:p-3 rb:mx-auto">
<RbCard
@@ -543,97 +446,29 @@ const TestChat: FC<TestChatProps> = ({
<Chat
empty={<Empty url={ChatIcon} title={t('application.testChatEmpty')} isNeedSubTitle={false} size={[240, 200]} />}
contentClassName={clsx(`rb:mx-[16px] rb:pt-[24px]`, {
'rb:h-[calc(100%-140px)]': !queryValues?.files?.length,
'rb:h-[calc(100%-208px)]': !!queryValues?.files?.length,
'rb:h-[calc(100%-140px)]': !fileList.length,
'rb:h-[calc(100%-208px)]': !!fileList.length,
})}
data={chatList}
streamLoading={streamLoading}
loading={loading}
onChange={setMessage}
onSend={application?.type === 'workflow' ? handleWorkflowSend : handleSend}
fileList={queryValues?.files || []}
fileChange={updateFileList}
fileList={fileList}
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')}
errorDesc={t('application.ReplyException')}
renderRuntime={application?.type === 'workflow' ? (item, index) => {
return <Runtime item={item} index={index} />
} : undefined}
renderRuntime={application?.type === 'workflow' ? (item, index) => <Runtime item={item} index={index} /> : undefined}
>
<Form form={form}>
<Flex justify="space-between" className="rb:flex-1">
<Space size={8} align="center">
<Form.Item name="files" noStyle>
<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>
<ChatToolbar
ref={toolbarRef}
features={features}
onFilesChange={setFileList}
/>
</Chat>
<VariableConfigModal
ref={variableConfigModalRef}
refresh={handleSave}
/>
<UploadFileListModal
ref={uploadFileListModalRef}
refresh={addFileList}
/>
</RbCard>
</div>
)

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-03-13 17:19:13
* @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 { Checkbox, App, Form } from 'antd';
@@ -78,7 +78,7 @@ const AppSharingModal = forwardRef<AppSharingModalRef, AppSharingModalProps>(({
*/
const handleToggle = (id: string, isShared: boolean) => {
if (isShared) return;
const prev = form.getFieldValue('target_workspace_ids') as string[] ?? [];
const prev: string[] = form.getFieldValue('target_workspace_ids') ?? [];
form.setFieldValue(
'target_workspace_ids',
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 */}
<Form.Item
name="target_workspace_ids"
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">
{spaceList.map(space => {
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)}>
<Checkbox
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)}
/>
<span className="rb:flex-1 rb:text-sm">{space.name}</span>
{/* Badge shown when the app is already shared with this workspace */}
{isShared && (
<span className="rb:text-xs rb:text-[#5B6167]">{t('application.alreadyShared')}</span>
)}

View File

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

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:27:52
* @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 { 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 type { Application, ApplicationModalRef } from '@/views/ApplicationManagement/types';
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 CopyModal from './CopyModal'
import FunConfig from './FunConfig'
import FeaturesConfig from './FeaturesConfig'
const { Header } = Layout;
@@ -61,6 +61,10 @@ interface ConfigHeaderProps {
workflowRef: React.RefObject<WorkflowRef>
/** App component ref (Agent/Cluster/Workflow) */
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,
workflowRef,
appRef,
features,
onFeaturesChange,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -173,14 +179,10 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
return items
}, [t, handleClick, application])
const funConfig = useMemo(() => {
return (appRef?.current?.funConfig || { file_type: [] }) as FunConfigForm
}, [appRef])
const handleSaveFunConfig = useCallback((value: FunConfigForm) => {
appRef?.current?.handleSaveFunConfig?.(value)
}, [appRef])
console.log('formatMenuItems', formatMenuItems)
const handleSaveFeaturesConfig = useCallback((value: FeaturesConfigForm) => {
appRef?.current?.handleSaveFeaturesConfig?.(value)
onFeaturesChange?.(value)
}, [appRef, onFeaturesChange])
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">
@@ -211,7 +213,7 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
</div>
{application?.type === 'workflow'
? <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={addvariable}>{t('workflow.addvariable')}</Button>
<Button onClick={run}>{t('workflow.run')}</Button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:29:49
* @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 { Variable } from './components/VariableList/types'
@@ -78,7 +78,7 @@ export interface Config extends MultiAgentConfig {
updated_at: number;
skills?: SkillConfigForm | null;
funConfig?: FunConfigForm;
features?: FeaturesConfigForm;
}
/**
@@ -129,8 +129,8 @@ export interface AgentRef {
* @param flag - Whether to show success message
*/
handleSave: (flag?: boolean) => Promise<unknown>;
funConfig: Config['funConfig'];
handleSaveFunConfig?: (value: FunConfigForm) => void;
features: Config['features'];
handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
}
/**
@@ -142,8 +142,8 @@ export interface ClusterRef {
* @param flag - Whether to show success message
*/
handleSave: (flag?: boolean) => Promise<unknown>;
funConfig: Config['funConfig'];
handleSaveFunConfig?: (value: FunConfigForm) => void;
features: Config['features'];
handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
}
/**
@@ -162,8 +162,8 @@ export interface WorkflowRef {
/** Add variable */
addVariable: () => void;
config: WorkflowConfig | null;
funConfig: WorkflowConfig['funConfig'];
handleSaveFunConfig?: (value: FunConfigForm) => void;
features: WorkflowConfig['features'];
handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
}
/**
@@ -416,17 +416,55 @@ export interface FileTypeConfig {
maxCount: number;
maxSize: number;
}
export interface FunConfigForm {
enabled: boolean;
fileTypes: FileTypeConfig[]
uploadType: 'local' | 'url' | 'both';
interface FileSetttings {
image_enabled: boolean;
image_max_size_mb: number;
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
*/
export interface FunConfigModalRef {
export interface FeaturesConfigModalRef {
/** Open function config modal */
handleOpen: (value: FunConfigForm) => void;
handleOpen: (value: FeaturesConfigForm) => void;
}
/**

View File

@@ -2,15 +2,16 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:34:12
* @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 { Button, App, Flex, Row, Col, Collapse } from 'antd';
import clsx from 'clsx';
import type { MySharedOutItem } from './types';
import { mySharedOutList, cancelShare, cancelSpaceShare } from '@/api/application'
import BodyWrapper from '@/components/Empty/BodyWrapper'
const MySharing: React.FC = () => {
const { t } = useTranslation();
@@ -20,7 +21,8 @@ const MySharing: React.FC = () => {
useEffect(() => { getList() }, [])
const getList = () => {
mySharedOutList().then(res => setData(res as MySharedOutItem[]))
mySharedOutList()
.then(res => setData(res as MySharedOutItem[]))
}
/** 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({
title: t('application.confirmAppCancelShareDesc', { app: item.source_app_name, workspace: item.target_workspace_name }),
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 (
<Flex vertical gap={12}>
{grouped.map(({ workspace, items }) => (
<Collapse
key={workspace.target_workspace_id}
defaultActiveKey={[workspace.target_workspace_id]}
items={[{
key: workspace.target_workspace_id,
label: (
<Flex align="center" gap={12}>
{workspace.target_workspace_icon
? <img src={workspace.target_workspace_icon} className="rb:w-8 rb:h-8 rb:rounded-lg rb:object-cover" />
: <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">
{workspace.target_workspace_name[0]}
</div>
}
<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]}
<Flex vertical gap={12} className="rb:h-[calc(100vh-148px)]! rb:overflow-y-auto!">
<BodyWrapper loading={false} empty={data.length === 0}>
{grouped.map(({ workspace, items }) => (
<Collapse
key={workspace.target_workspace_id}
defaultActiveKey={[workspace.target_workspace_id]}
items={[{
key: workspace.target_workspace_id,
label: (
<Flex align="center" gap={12}>
{workspace.target_workspace_icon
? <img src={workspace.target_workspace_icon} className="rb:w-8 rb:h-8 rb:rounded-lg rb:object-cover" />
: <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">
{workspace.target_workspace_name[0]}
</div>
<div className="rb:font-medium">{item.source_app_name}</div>
</Flex>
<Flex vertical gap={4} className="rb:mt-3! rb:text-[12px]!">
<Flex gap={5} justify="space-between">
<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>
}
<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 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 gap={5} justify="space-between">
<span className="rb:text-[#5B6167]">{t('application.version')}</span>
<span>{item.source_app_version}</span>
<Flex vertical gap={4} className="rb:mt-3! rb:text-[12px]!">
<Flex gap={5} justify="space-between">
<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 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>
</Col>
))}
</Row>
),
}]}
/>
))}
</Flex>
</Col>
))}
</Row>
),
}]}
/>
))}
</BodyWrapper>
</Flex>
);
};

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:34:12
* @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
@@ -185,7 +185,7 @@ const ApplicationManagement: React.FC = () => {
<PageScrollList<Application, Query>
ref={scrollListRef}
url={getApplicationListUrl}
query={{ ...query, shared_only: activeTab === 'sharing' }}
query={{ ...query, shared_only: activeTab === 'sharing', include_shared: activeTab !== 'apps' }}
renderItem={(item) => (
<RbCard
title={item.name}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:34:15
* @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
@@ -16,6 +16,7 @@ export interface Query {
search: string;
type?: string;
shared_only?: boolean;
include_shared?: boolean;
}
/**

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 18:32:23
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 18:32:23
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-17 17:36:49
*/
import { type FC, useEffect, useState } from 'react'
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
window.open(data.file_path, '_blank')
}

View File

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

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-06 21:10:56
* @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
@@ -21,50 +21,56 @@
*
* @component
*/
import { forwardRef, useImperativeHandle, useState, useRef } from 'react'
import { forwardRef, useImperativeHandle, useState, useRef, useEffect, useCallback } from 'react'
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 RbDrawer from '@/components/RbDrawer';
import VariableConfigModal from './VariableConfigModal'
import { draftRun } from '@/api/application';
import Empty from '@/components/Empty'
import ChatContent from '@/components/Chat/ChatContent'
import type { ChatItem } from '@/components/Chat/types'
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 { Variable } from '../Properties/VariableList/types'
import ChatInput from '@/components/Chat/ChatInput'
import UploadFiles from '@/views/Conversation/components/FileUpload'
import AudioRecorder from '@/components/AudioRecorder'
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
import type { UploadFileListModalRef } from '@/views/Conversation/types'
import ChatToolbar from '@/components/Chat/ChatToolbar'
import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar'
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 { message: messageApi } = App.useApp()
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
// State management
const [open, setOpen] = useState(false) // Drawer visibility
const [loading, setLoading] = useState(false) // Send button loading state
const [chatList, setChatList] = useState<ChatItem[]>([]) // Chat message history
const [variables, setVariables] = useState<Variable[]>([]) // Workflow input variables
const [streamLoading, setStreamLoading] = useState(false) // SSE streaming state
const [conversationId, setConversationId] = useState<string | null>(null) // Current conversation ID
const [fileList, setFileList] = useState<any[]>([]) // Uploaded files
const [message, setMessage] = useState<string | undefined>(undefined) // Current input message
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
const toolbarRef = useRef<ChatToolbarRef>(null)
const toolbarCallbackRef = useCallback((node: ChatToolbarRef | null) => {
(toolbarRef as React.MutableRefObject<ChatToolbarRef | null>).current = node
}, [])
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [chatList, setChatList] = useState<ChatItem[]>([])
const [variables, setVariables] = useState<Variable[]>([])
const [streamLoading, setStreamLoading] = useState(false)
const [conversationId, setConversationId] = useState<string | null>(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
*/
const handleOpen = () => {
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
*/
@@ -84,7 +90,9 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
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([])
setConversationId(null)
setMessage(undefined)
toolbarRef.current?.setFiles([])
toolbarRef.current?.setVariables([])
setFileList([])
setLoading(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
*
@@ -337,14 +335,16 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
})
}
const files = toolbarRef.current?.getFiles() || []
setMessage(undefined)
toolbarRef.current?.setFiles([])
setFileList([])
const data = {
message: message,
variables: params,
stream: true,
conversation_id: conversationId,
files: fileList.map(file => {
files: files.map(file => {
if (file.url) {
return file
} else {
@@ -359,7 +359,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
setStreamLoading(true)
draftRun(appId, data, handleStreamMessage)
.catch((error) => {
console.log('draftRun error', error)
const errorInfo = JSON.parse(error.message)
setChatList(prev => {
const newList = [...prev]
const lastIndex = newList.length - 1
@@ -368,7 +368,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
...newList[lastIndex],
status: 'failed',
content: null,
subContent: error.error
subContent: errorInfo.error
}
}
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[]) => {
setFileList([...list || []])
toolbarRef.current?.setFiles([...list || []])
}
// Expose methods to parent component via ref
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
console.log('fileList', fileList)
return (
<RbDrawer
title={<div className="rb:flex rb:items-center rb:gap-2.5">
{t('workflow.run')}
{variables.length > 0 && <Space>
<Button size="small" onClick={handleEditVariables}>{t('application.variable')}</Button>
</Space>}
</div>}
classNames={{
body: 'rb:p-0!'
@@ -466,48 +421,16 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
fileChange={updateFileList}
fileList={fileList}
onSend={handleSend}
onChange={handleMessageChange}
onChange={(msg) => setMessage(msg)}
>
<Flex justify="space-between" className="rb:flex-1">
<Flex gap={8} align="center">
<Dropdown
menu={{
items: [
{ 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>
<ChatToolbar
ref={toolbarCallbackRef}
features={features}
onFilesChange={setFileList}
onVariablesChange={setVariables}
/>
</ChatInput>
</div>
<VariableConfigModal
ref={variableConfigModalRef}
refresh={handleSave}
variables={variables}
/>
<UploadFileListModal
ref={uploadFileListModalRef}
refresh={addFileList}
/>
</RbDrawer>
)
})

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-09 18:30:28
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-09 18:30:28
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-18 12:06:27
*/
import { useEffect, useState } from 'react';
import { Popover } from 'antd';
@@ -70,7 +70,6 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
// Get source port group information
const sourcePortInfo = sourceNode.getPorts().find((p: any) => p.id === sourcePort);
const sourcePortGroup = sourcePortInfo?.group || sourcePort;
console.log('sourcePortGroup', sourcePortGroup, sourcePortInfo)
// If add-node position exists, use it; otherwise calculate new position
let newX, newY;
@@ -148,18 +147,23 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
if (sourcePortGroup === 'left') {
// Connect from left port to new node's right side
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 {
// Connect from right port to new node's left side
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
const cycleId = sourceNodeData.cycle;
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 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;
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));
} else if (isChildOfIteration) {
// 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));
} else {
// Original filtering for non-loop child nodes
filteredNodes = category.nodes.filter(nodeType => !['start', 'break', 'cycle-start'].includes(nodeType.type));
filteredNodes = category.nodes.filter(nodeType =>
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;

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 15:17:48
* @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 { 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 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 { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'
import { useUser } from '@/store/user';
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
/**
* Props for useWorkflowGraph hook
@@ -25,6 +26,8 @@ export interface UseWorkflowGraphProps {
containerRef: React.RefObject<HTMLDivElement>;
/** Reference to the minimap container element */
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[]>>;
handleAddNotes: () => void;
handleSaveFeaturesConfig: (value: FeaturesConfigForm) => void;
}
/**
@@ -78,6 +82,7 @@ export interface UseWorkflowGraphReturn {
export const useWorkflowGraph = ({
containerRef,
miniMapRef,
onFeaturesLoad,
}: UseWorkflowGraphProps): UseWorkflowGraphReturn => {
// Hooks
const { id } = useParams();
@@ -115,6 +120,7 @@ export const useWorkflowGraph = ({
})
setChatVariables(initChatVariables)
setConfig({ ...rest, variables: initChatVariables })
onFeaturesLoad?.(rest.features)
})
}
@@ -132,7 +138,7 @@ export const useWorkflowGraph = ({
if (nodes.length) {
const nodeList = nodes.map(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)
.find(n => n.type === type)
nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties
@@ -593,13 +599,6 @@ export const useWorkflowGraph = ({
if (!graphRef.current) return false;
const selectedNodes = graphRef.current.getNodes().filter(node => node.getData()?.isSelected);
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);
}
return false;
@@ -610,7 +609,14 @@ export const useWorkflowGraph = ({
*/
const parseEvent = () => {
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();
}
return false;
@@ -761,8 +767,23 @@ export const useWorkflowGraph = ({
createEdge() {
return graphRef.current?.createEdge(edgeAttrs);
},
validateConnection({ sourceCell, targetCell, targetMagnet }) {
validateConnection({ sourceCell, targetCell, sourceMagnet, targetMagnet }) {
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
if (sourceCell?.id === targetCell?.id) return false;
@@ -979,6 +1000,9 @@ export const useWorkflowGraph = ({
}) || [];
const edges = graphRef.current?.getEdges() || []
console.log('config', config)
const params = {
...config,
variables: chatVariables.map(v => {
@@ -1172,6 +1196,9 @@ export const useWorkflowGraph = ({
data: { ...cleanNodeData },
});
}
const handleSaveFeaturesConfig = (value?: FeaturesConfigForm) => {
setConfig(prev => prev ? { ...prev, features: value } as WorkflowConfig : prev)
}
return {
config,
@@ -1191,6 +1218,7 @@ export const useWorkflowGraph = ({
handleSave,
chatVariables,
setChatVariables,
handleAddNotes
handleAddNotes,
handleSaveFeaturesConfig
};
};

View File

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

View File

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