Merge branch 'refs/heads/release/v0.2.8' into fix/features_028

This commit is contained in:
Timebomb2018
2026-03-18 16:50:17 +08:00
47 changed files with 1621 additions and 1402 deletions

View File

@@ -663,6 +663,7 @@ async def config_query(
content = { content = {
"app_type": release.app.type, "app_type": release.app.type,
"variables": workflow_service.get_start_node_variables(release.config), "variables": workflow_service.get_start_node_variables(release.config),
"memory": workflow_service.is_memory_enable(release.config),
"features": release.config.get("features") "features": release.config.get("features")
} }
elif release.app.type == AppType.AGENT: elif release.app.type == AppType.AGENT:

View File

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

View File

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

View File

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

View File

@@ -1609,6 +1609,7 @@ class AppService:
variables=[var.model_dump() for var in data.variables] if data.variables else [], variables=[var.model_dump() for var in data.variables] if data.variables else [],
execution_config=data.execution_config.model_dump() if data.execution_config else {}, execution_config=data.execution_config.model_dump() if data.execution_config else {},
triggers=[trigger.model_dump() for trigger in data.triggers] if data.triggers else [], triggers=[trigger.model_dump() for trigger in data.triggers] if data.triggers else [],
features=data.features or {},
is_active=True, is_active=True,
created_at=now, created_at=now,
updated_at=now updated_at=now
@@ -1622,6 +1623,7 @@ class AppService:
workflow_cfg.variables = [var.model_dump() for var in data.variables] if data.variables else [] workflow_cfg.variables = [var.model_dump() for var in data.variables] if data.variables else []
workflow_cfg.execution_config = data.execution_config.model_dump() if data.execution_config else {} workflow_cfg.execution_config = data.execution_config.model_dump() if data.execution_config else {}
workflow_cfg.triggers = [trigger.model_dump() for trigger in data.triggers] if data.triggers else [] workflow_cfg.triggers = [trigger.model_dump() for trigger in data.triggers] if data.triggers else []
workflow_cfg.features = data.features or {}
workflow_cfg.updated_at = now workflow_cfg.updated_at = now
self.db.commit() self.db.commit()
@@ -1875,7 +1877,8 @@ class AppService:
"edges": workflow_cfg.edges, "edges": workflow_cfg.edges,
"variables": workflow_cfg.variables, "variables": workflow_cfg.variables,
"execution_config": workflow_cfg.execution_config, "execution_config": workflow_cfg.execution_config,
"triggers": workflow_cfg.triggers "triggers": workflow_cfg.triggers,
"features": workflow_cfg.features or {}
} }
is_valid, errors = WorkflowValidator.validate_for_publish(config) is_valid, errors = WorkflowValidator.validate_for_publish(config)

View File

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

View File

@@ -59,22 +59,22 @@ class MultimodalFormatStrategy(ABC):
self.file = file self.file = file
@abstractmethod @abstractmethod
async def format_image(self, url: str, content: bytes | None = None) -> Dict[str, Any]: async def format_image(self, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
"""格式化图片""" """格式化图片"""
pass pass
@abstractmethod @abstractmethod
async def format_document(self, file_name: str, text: str) -> Dict[str, Any]: async def format_document(self, file_name: str, text: str) -> tuple[bool, Dict[str, Any]]:
"""格式化文档""" """格式化文档"""
pass pass
@abstractmethod @abstractmethod
async def format_audio(self, file_type: str, url: str, content: bytes | None = None) -> Dict[str, Any]: async def format_audio(self, file_type: str, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
"""格式化音频""" """格式化音频"""
pass pass
@abstractmethod @abstractmethod
async def format_video(self, url: str) -> Dict[str, Any]: async def format_video(self, url: str) -> tuple[bool, Dict[str, Any]]:
"""格式化视频""" """格式化视频"""
pass pass
@@ -82,16 +82,16 @@ class MultimodalFormatStrategy(ABC):
class DashScopeFormatStrategy(MultimodalFormatStrategy): class DashScopeFormatStrategy(MultimodalFormatStrategy):
"""通义千问策略""" """通义千问策略"""
async def format_image(self, url: str, content: bytes | None = None) -> Dict[str, Any]: async def format_image(self, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
"""通义千问图片格式:{"type": "image", "image": "url"}""" """通义千问图片格式:{"type": "image", "image": "url"}"""
return { return True, {
"type": "image", "type": "image",
"image": url "image": url
} }
async def format_document(self, file_name: str, text: str) -> Dict[str, Any]: async def format_document(self, file_name: str, text: str) -> tuple[bool, Dict[str, Any]]:
"""通义千问文档格式""" """通义千问文档格式"""
return { return True, {
"type": "text", "type": "text",
"text": f"<document name=\"{file_name}\">\n{text}\n</document>" "text": f"<document name=\"{file_name}\">\n{text}\n</document>"
} }
@@ -102,26 +102,26 @@ class DashScopeFormatStrategy(MultimodalFormatStrategy):
url: str, url: str,
content: bytes | None = None, content: bytes | None = None,
transcription: Optional[str] = None transcription: Optional[str] = None
) -> Dict[str, Any]: ) -> tuple[bool, Dict[str, Any]]:
""" """
通义千问音频格式 通义千问音频格式
- 原生支持: qwen-audio 系列 - 原生支持: qwen-audio 系列
- 其他模型: 需要转录为文本 - 其他模型: 需要转录为文本
""" """
if transcription: if transcription:
return { return True, {
"type": "text", "type": "text",
"text": f"<audio url=\"{url}\">\ntext_transcription:{transcription}\n</audio>" "text": f"<audio url=\"{url}\">\ntext_transcription:{transcription}\n</audio>"
} }
# 通义千问音频格式:{"type": "audio", "audio": "url"} # 通义千问音频格式:{"type": "audio", "audio": "url"}
return { return True, {
"type": "audio", "type": "audio",
"audio": url "audio": url
} }
async def format_video(self, url: str) -> Dict[str, Any]: async def format_video(self, url: str) -> tuple[bool, Dict[str, Any]]:
"""通义千问视频格式qwen-vl 系列原生支持)""" """通义千问视频格式qwen-vl 系列原生支持)"""
return { return True, {
"type": "video", "type": "video",
"video": url "video": url
} }
@@ -130,7 +130,7 @@ class DashScopeFormatStrategy(MultimodalFormatStrategy):
class BedrockFormatStrategy(MultimodalFormatStrategy): class BedrockFormatStrategy(MultimodalFormatStrategy):
"""Bedrock/Anthropic 策略""" """Bedrock/Anthropic 策略"""
async def format_image(self, url: str, content: bytes | None = None) -> Dict[str, Any]: async def format_image(self, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
""" """
Bedrock/Anthropic 格式: base64 编码 Bedrock/Anthropic 格式: base64 编码
{"type": "image", "source": {"type": "base64", "media_type": "...", "data": "..."}} {"type": "image", "source": {"type": "base64", "media_type": "...", "data": "..."}}
@@ -153,7 +153,7 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
logger.info(f"图片编码完成: media_type={media_type}, size={len(base64_data)}") logger.info(f"图片编码完成: media_type={media_type}, size={len(base64_data)}")
return { return True, {
"type": "image", "type": "image",
"source": { "source": {
"type": "base64", "type": "base64",
@@ -162,13 +162,13 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
} }
} }
async def format_document(self, file_name: str, text: str) -> Dict[str, Any]: async def format_document(self, file_name: str, text: str) -> tuple[bool, Dict[str, Any]]:
"""Bedrock/Anthropic 文档格式(需要 base64 编码)""" """Bedrock/Anthropic 文档格式(需要 base64 编码)"""
# Bedrock 文档需要 base64 编码 # Bedrock 文档需要 base64 编码
text_bytes = text.encode('utf-8') text_bytes = text.encode('utf-8')
base64_text = base64.b64encode(text_bytes).decode('utf-8') base64_text = base64.b64encode(text_bytes).decode('utf-8')
return { return True, {
"type": "document", "type": "document",
"source": { "source": {
"type": "base64", "type": "base64",
@@ -182,24 +182,24 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
url: str, url: str,
content: bytes | None = None, content: bytes | None = None,
transcription: Optional[str] = None transcription: Optional[str] = None
) -> Dict[str, Any]: ) -> tuple[bool, Dict[str, Any]]:
""" """
Bedrock/Anthropic 音频格式 Bedrock/Anthropic 音频格式
不支持原生音频,必须转录为文本 不支持原生音频,必须转录为文本
""" """
if transcription: if transcription:
return { return True, {
"type": "text", "type": "text",
"text": f"[音频转录]\n{transcription}" "text": f"[音频转录]\n{transcription}"
} }
return { return False, {
"type": "text", "type": "text",
"text": "[音频文件Bedrock 不支持原生音频,请启用音频转文本功能]" "text": "[音频文件Bedrock 不支持原生音频,请启用音频转文本功能]"
} }
async def format_video(self, url: str) -> Dict[str, Any]: async def format_video(self, url: str) -> tuple[bool, Dict[str, Any]]:
"""Bedrock/Anthropic 视频格式""" """Bedrock/Anthropic 视频格式"""
return { return False, {
"type": "text", "type": "text",
"text": f"<video url=\"{url}\">\n[视频文件,当前 provider 暂不支持]\n</video>" "text": f"<video url=\"{url}\">\n[视频文件,当前 provider 暂不支持]\n</video>"
} }
@@ -208,18 +208,18 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
class OpenAIFormatStrategy(MultimodalFormatStrategy): class OpenAIFormatStrategy(MultimodalFormatStrategy):
"""OpenAI 策略""" """OpenAI 策略"""
async def format_image(self, url: str, content: bytes | None = None) -> Dict[str, Any]: async def format_image(self, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
"""OpenAI 格式: {"type": "image_url", "image_url": {"url": "..."}}""" """OpenAI 格式: {"type": "image_url", "image_url": {"url": "..."}}"""
return { return True, {
"type": "image_url", "type": "image_url",
"image_url": { "image_url": {
"url": url "url": url
} }
} }
async def format_document(self, file_name: str, text: str) -> Dict[str, Any]: async def format_document(self, file_name: str, text: str) -> tuple[bool, Dict[str, Any]]:
"""OpenAI 文档格式""" """OpenAI 文档格式"""
return { return True, {
"type": "text", "type": "text",
"text": f"<document name=\"{file_name}\">\n{text}\n</document>" "text": f"<document name=\"{file_name}\">\n{text}\n</document>"
} }
@@ -230,14 +230,14 @@ class OpenAIFormatStrategy(MultimodalFormatStrategy):
url: str, url: str,
content: bytes | None = None, content: bytes | None = None,
transcription: Optional[str] = None transcription: Optional[str] = None
) -> Dict[str, Any]: ) -> tuple[bool, Dict[str, Any]]:
""" """
OpenAI 音频格式 OpenAI 音频格式
- gpt-4o-audio 系列支持原生音频(需要 base64 编码) - gpt-4o-audio 系列支持原生音频(需要 base64 编码)
- 其他模型使用转录文本 - 其他模型使用转录文本
""" """
if transcription: if transcription:
return { return True, {
"type": "text", "type": "text",
"text": f"<audio url=\"{url}\">\n{transcription}\n</audio>" "text": f"<audio url=\"{url}\">\n{transcription}\n</audio>"
} }
@@ -266,7 +266,7 @@ class OpenAIFormatStrategy(MultimodalFormatStrategy):
# supported_ext = {"wav", "mp3", "mp4", "ogg", "flac", "webm", "m4a", "wave", "x-m4a"} # supported_ext = {"wav", "mp3", "mp4", "ogg", "flac", "webm", "m4a", "wave", "x-m4a"}
file_ext = "wav" if not file_ext else file_ext file_ext = "wav" if not file_ext else file_ext
return { return True, {
"type": "input_audio", "type": "input_audio",
"input_audio": { "input_audio": {
"data": f"data:;base64,{base64_audio}", "data": f"data:;base64,{base64_audio}",
@@ -275,14 +275,14 @@ class OpenAIFormatStrategy(MultimodalFormatStrategy):
} }
except Exception as e: except Exception as e:
logger.error(f"下载音频失败: {e}") logger.error(f"下载音频失败: {e}")
return { return False, {
"type": "text", "type": "text",
"text": f"[音频处理失败: {str(e)}]" "text": f"[音频处理失败: {str(e)}]"
} }
async def format_video(self, url: str) -> Dict[str, Any]: async def format_video(self, url: str) -> tuple[bool, Dict[str, Any]]:
"""OpenAI 视频格式""" """OpenAI 视频格式"""
return { return True, {
"type": "video_url", "type": "video_url",
"video_url": { "video_url": {
"url": url "url": url
@@ -377,21 +377,25 @@ class MultimodalService:
file.url = await self.get_file_url(file) file.url = await self.get_file_url(file)
try: try:
if file.type == FileType.IMAGE and "vision" in self.capability: if file.type == FileType.IMAGE and "vision" in self.capability:
content = await self._process_image(file, strategy) is_support, content = await self._process_image(file, strategy)
result.append(content) result.append(content)
self.write_perceptual_memory(end_user_id, file.type, file.url, content) if is_support:
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
elif file.type == FileType.DOCUMENT: elif file.type == FileType.DOCUMENT:
content = await self._process_document(file, strategy) is_support, content = await self._process_document(file, strategy)
result.append(content) result.append(content)
self.write_perceptual_memory(end_user_id, file.type, file.url, content) if is_support:
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
elif file.type == FileType.AUDIO and "audio" in self.capability: elif file.type == FileType.AUDIO and "audio" in self.capability:
content = await self._process_audio(file, strategy) is_support, content = await self._process_audio(file, strategy)
result.append(content) result.append(content)
self.write_perceptual_memory(end_user_id, file.type, file.url, content) if is_support:
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
elif file.type == FileType.VIDEO and "video" in self.capability: elif file.type == FileType.VIDEO and "video" in self.capability:
content = await self._process_video(file, strategy) is_support, content = await self._process_video(file, strategy)
result.append(content) result.append(content)
self.write_perceptual_memory(end_user_id, file.type, file.url, content) if is_support:
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
else: else:
logger.warning(f"不支持的文件类型: {file.type}") logger.warning(f"不支持的文件类型: {file.type}")
except Exception as e: except Exception as e:
@@ -424,7 +428,7 @@ class MultimodalService:
if end_user_id and self.api_config: if end_user_id and self.api_config:
write_perceptual_memory.delay(end_user_id, self.api_config.model_dump(), file_type, file_url, file_message) write_perceptual_memory.delay(end_user_id, self.api_config.model_dump(), file_type, file_url, file_message)
async def _process_image(self, file: FileInput, strategy) -> Dict[str, Any]: async def _process_image(self, file: FileInput, strategy) -> tuple[bool, Dict[str, Any]]:
""" """
处理图片文件 处理图片文件
@@ -440,12 +444,12 @@ class MultimodalService:
return await strategy.format_image(file.url, content=file.get_content()) return await strategy.format_image(file.url, content=file.get_content())
except Exception as e: except Exception as e:
logger.error(f"处理图片失败: {e}", exc_info=True) logger.error(f"处理图片失败: {e}", exc_info=True)
return { return False, {
"type": "text", "type": "text",
"text": f"[图片处理失败: {str(e)}]" "text": f"[图片处理失败: {str(e)}]"
} }
async def _process_document(self, file: FileInput, strategy) -> Dict[str, Any]: async def _process_document(self, file: FileInput, strategy) -> tuple[bool, Dict[str, Any]]:
""" """
处理文档文件PDF、Word 等) 处理文档文件PDF、Word 等)
@@ -457,7 +461,7 @@ class MultimodalService:
Dict: 根据 provider 返回不同格式的文档内容 Dict: 根据 provider 返回不同格式的文档内容
""" """
if file.transfer_method == TransferMethod.REMOTE_URL: if file.transfer_method == TransferMethod.REMOTE_URL:
return { return True, {
"type": "text", "type": "text",
"text": f"<document url=\"{file.url}\">\n{await self._extract_document_text(file)}\n</document>" "text": f"<document url=\"{file.url}\">\n{await self._extract_document_text(file)}\n</document>"
} }
@@ -475,7 +479,7 @@ class MultimodalService:
# 使用策略格式化文档 # 使用策略格式化文档
return await strategy.format_document(file_name, text) return await strategy.format_document(file_name, text)
async def _process_audio(self, file: FileInput, strategy) -> Dict[str, Any]: async def _process_audio(self, file: FileInput, strategy) -> tuple[bool, Dict[str, Any]]:
""" """
处理音频文件 处理音频文件
@@ -503,12 +507,12 @@ class MultimodalService:
return await strategy.format_audio(file.file_type, file.url, file.get_content(), transcription) return await strategy.format_audio(file.file_type, file.url, file.get_content(), transcription)
except Exception as e: except Exception as e:
logger.error(f"处理音频失败: {e}", exc_info=True) logger.error(f"处理音频失败: {e}", exc_info=True)
return { return False, {
"type": "text", "type": "text",
"text": f"[音频处理失败: {str(e)}]" "text": f"[音频处理失败: {str(e)}]"
} }
async def _process_video(self, file: FileInput, strategy) -> Dict[str, Any]: async def _process_video(self, file: FileInput, strategy) -> tuple[bool, Dict[str, Any]]:
""" """
处理视频文件 处理视频文件
@@ -524,7 +528,7 @@ class MultimodalService:
return await strategy.format_video(file.url) return await strategy.format_video(file.url)
except Exception as e: except Exception as e:
logger.error(f"处理视频失败: {e}", exc_info=True) logger.error(f"处理视频失败: {e}", exc_info=True)
return { return False, {
"type": "text", "type": "text",
"text": f"[视频处理失败: {str(e)}]" "text": f"[视频处理失败: {str(e)}]"
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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